Roman Kryvolapov Engineering Blog

Python для Java-розробників

Ця стаття для тих, хто вже знає Java і хоче писати на Python. Майже всюди спочатку йде код на Java, а одразу під ним те саме на Python, тому пояснення здебільшого написані просто в коментарях до коду.

Порядок такий: спочатку синтаксис, потім основні типи та їхні методи, потім те, що влаштовано не так, як у Java: змінні, декоратори, генератори, потоки та asyncio. Наприкінці з усього цього зібрано мікросервіс. Стаття лише про мову та стандартну бібліотеку, FastAPI, Django і Flask будуть окремими статтями.

Коротко про сам Python. Його створив Гвідо ван Россум, перша версія вийшла 1991 року, тобто він старший за Java. Версія 3.0 у 2008 році зламала сумісність із другою, і зараз живе лише третя гілка. Приклади запускалися на Python 3.14 і 3.15 та на Java 26, крім тих, де потрібні сторонні бібліотеки Java. Там, де можливість з'явилася нещодавно, версію вказано.

Плюси: коду набагато менше, ніж на Java, швидкий старт програми та величезна екосистема, особливо для даних і машинного навчання.

Мінуси: обчислення помітно повільніші, ніж на JVM, потоки зазвичай не пришвидшують обчислення, а помилки типів без окремого інструмента видно лише під час запуску.

Синтаксис: Java і Python поруч

Який вигляд має програма

У Java будь-який код живе всередині класу, а запуск починається з методу main. У Python файл просто виконується згори донизу.

Java
public class Main {
public static void main(String[] args) {
System.out.println("Hello");
}
}
Python
# Увесь файл: ні класу, ні main, ні крапки з комою.
print("Hello") # Hello

Якщо файл можна і запустити, і імпортувати з іншого файлу, код запуску ховають у перевірку. Це найближче до main у Java.

Python
def main():
print("Hello") # Hello
# __name__ — ім'я поточного модуля. Якщо файл запустили напряму,
# там рядок "__main__". Якщо його імпортували — ім'я файлу,
# і main() не викликається.
if __name__ == "__main__":
main()

Як записуються блоки та коментарі

Замість фігурних дужок у Python відступи. Це не оформлення, а синтаксис: неправильний відступ не дасть програмі запуститися.

Java
int price = 150;
// Однорядковий коментар
if (price > 100) {
System.out.println("expensive"); // expensive
} else {
System.out.println("cheap");
}
/* Багаторядкового коментаря в Python немає */
Python
price = 150
# Однорядковий коментар
# Дужки навколо умови не потрібні, наприкінці рядка двокрапка,
# а тіло блоку зсунуте на 4 пробіли.
if price > 100:
print("expensive") # expensive
else:
print("cheap")

Довгий вираз переносять усередині дужок. Замість Javadoc пишуть рядок документації одразу під оголошенням, він називається docstring.

Java
/**
* Рахує суму з податком.
*/
int total(int price, int tax) {
return price
+ tax;
}
System.out.println(total(100, 20)); // 120
Python
def total(price, tax):
"""Рахує суму з податком."""
# Усередині дужок перенесення рядка дозволене.
return (price
+ tax)
print(total(100, 20)) # 120
# Docstring доступна і під час роботи програми.
print(total.__doc__) # Рахує суму з податком.

Заглушка для порожнього блоку в Java — це {}. У Python порожній блок записати не можна, тому є слово pass, яке нічого не робить.

Java
void todo() {
}
class Empty {
}
Python
def todo():
pass
class Empty:
pass

Як оголосити змінну

У Python немає оголошення. Змінна з'являється під час першого присвоювання, і тип є не в неї, а в значення, яке в ній лежить. Це називають динамічною типізацією. Докладніше про те, що таке змінна, розказано нижче.

Java, Python
int age = 30; # Java
age = 30 # Python
var name = "Ann"; # Java
name = "Ann" # Python
# Слова final немає. ВЕРХНІЙ_РЕГІСТР означає «не змінюйте», але це домовленість.
final int MAX_SIZE = 100; # Java
MAX_SIZE = 100 # Python
# У Java це помилка компіляції, у Python тепер в age лежить рядок.
age = "thirty" # Python
# Присвоїти одразу кілька значень.
int a = 1, b = 2; # Java
a, b = 1, 2 # Python
# Поміняти місцями: у Java потрібна тимчасова змінна.
int tmp = a; a = b; b = tmp; # Java
a, b = b, a # Python
a + " " + b # Java: 2 1
f"{a} {b}" # Python: 2 1

Якщо тип усе ж хочеться вказати, для цього є підказка типу. Python її не перевіряє, але її перевіряють IDE та окремий інструмент mypy.

Java, Python
int count = 0; # Java
count: int = 0 # Python
String title = "Report"; # Java
title: str = "Report" # Python
# Python не заперечує: підказка — лише позначка для інструментів.
count = "many" # Python

Як прийнято називати

Правила описані в документі PEP 8, це аналог Java Code Conventions. Головна відмінність — слова в іменах змінних і функцій розділяються підкресленням.

ЩоJavaPython
ЗміннаuserNameuser_name
Метод, функціяfindUser()find_user()
КласOrderServiceOrderService
КонстантаMAX_SIZEMAX_SIZE
Пакетcom.shop.ordersorders
ФайлOrderService.javaorder_service.py
Закрите полеprivate int size_size
Відступ4 пробіли4 пробіли

Які є базові типи

Примітивів у Python немає, усе є об'єктом, навіть число. Тому немає і пари int та Integer. Різних цілих типів теж немає, один int зберігає число будь-якого розміру.

JavaPythonПриклад
byte, short, int, long, BigIntegerint42
float, doublefloat3.14
booleanboolTrue, False
char, Stringstr"a", "Ann"
nullNoneNone
byte[]bytesb"abc"
Objectobjectobject()
BigDecimaldecimal.DecimalDecimal("9.99")

Зверніть увагу: True, False і None пишуться з великої літери. Окремого типу для символу немає, символ — це рядок довжини 1.

Java, Python
int number = 42; # Java
number = 42 # Python
Object text = "Ann"; # Java
text = "Ann" # Python
# Дізнатися клас значення. У числа в Python він теж є.
text.getClass().getSimpleName() # Java: String
type(text).__name__ # Python: str
type(number).__name__ # Python: int
# Перевірити тип.
text instanceof String # Java: true
isinstance(text, str) # Python: True
# Число має методи, дужки потрібні лише в літерала.
Integer.bitCount(255) # Java: 8
(255).bit_count() # Python: 8

Як звести один тип до іншого

Зведення в дужках немає. Замість нього викликають клас потрібного типу як функцію.

Java, Python
(int) 3.7 # Java: 3
int(3.7) # Python: 3
(double) 3 # Java: 3.0
float(3) # Python: 3.0
Integer.parseInt("42") # Java: 42
int("42") # Python: 42
Double.parseDouble("3.5") # Java: 3.5
float("3.5") # Python: 3.5
String.valueOf(42) # Java: 42
str(42) # Python: 42
# Символ і його код.
(int) 'A' # Java: 65
ord("A") # Python: 65
(char) 65 # Java: A
chr(65) # Python: A
# Пастка: у Python непорожній рядок завжди істинний, навіть "false".
Boolean.parseBoolean("false") # Java: false
bool("false") # Python: True

Рядок і число самі не склеюються. Там, де Java мовчки викличе String.valueOf, Python видасть помилку.

Java, Python
int age = 30; # Java
age = 30 # Python
"Age: " + age # Java: Age: 30
# У Python "Age: " + age дасть TypeError. Про f-рядки розказано нижче.
"Age: " + str(age) # Python: Age: 30
f"Age: {age}" # Python: Age: 30

Які є арифметичні оператори

Найчастіша помилка тут — ділення. У Python / завжди дає дробове число, а цілочисельне ділення записується як //. Операторів ++ і -- немає.

Java, Python
7 / 2 # Java: 3
7 // 2 # Python: 3
7.0 / 2 # Java: 3.5
7 / 2 # Python: 3.5
7 % 2 # Java: 1
7 % 2 # Python: 1
# У Python для степеня є оператор, а результат лишається цілим.
Math.pow(2, 10) # Java: 1024.0
2 ** 10 # Python: 1024
int counter = 0; # Java
counter = 0 # Python
# counter++ у Python не спрацює.
counter++; # Java
counter += 1 # Python

Як порівнювати значення

Тут у Python усе навпаки. == порівнює вміст, як equals у Java. А is перевіряє, що це той самий об'єкт, як == для посилань у Java.

Java, Python
var first = new ArrayList<>(List.of(1, 2)); # Java
first = [1, 2] # Python
var second = new ArrayList<>(List.of(1, 2)); # Java
second = [1, 2] # Python
# Чи рівний вміст.
first.equals(second) # Java: true
first == second # Python: True
# Чи це той самий об'єкт.
first == second # Java: false
first is second # Python: False
# Порожнє значення. None перевіряють лише через is.
String name = null; # Java
name = None # Python
name == null # Java: true
name is None # Python: True
name != null # Java: false
name is not None # Python: False
# Порівняння можна писати ланцюжком, як у математиці.
int age = 30; # Java
age = 30 # Python
age >= 18 && age < 65 # Java: true
18 <= age < 65 # Python: True

Як записати логічні умови

Замість &&, || і ! використовуються слова. Крім того, умовою може бути будь-яке значення, а не лише boolean: порожні колекції, порожній рядок, нуль і None вважаються хибою.

Java, Python
boolean active = true; # Java
active = True # Python
boolean admin = false; # Java
admin = False # Python
active && !admin # Java: true
active and not admin # Python: True
active || admin # Java: true
active or admin # Python: True
Python
active = True
admin = False
print(active and not admin) # True
print(active or admin) # True
# Звичайний спосіб перевірити, що колекція порожня.
items = []
if not items:
print("no items") # no items
# Одна умова замінює перевірку і на None, і на порожнечу.
name = None
if name:
print(name)

and і or повертають не True чи False, а один зі своїх операндів. На цьому тримається короткий запис значення за замовчуванням.

Java, Python
String name = ""; # Java
name = "" # Python
# or поверне перше істинне значення, тут це "guest".
name == null || name.isEmpty() ? "guest" : name # Java: guest
name or "guest" # Python: guest
# Обережно: 0 теж хиба, і справжнє значення буде втрачено.
0 or 100 # Python: 100

Як записати умову й тернарний оператор

else if скорочується до elif. Тернарний оператор читається як звичайна фраза: спочатку значення, потім умова.

Java
int score = 75;
String grade;
if (score >= 90) {
grade = "A";
} else if (score >= 60) {
grade = "B";
} else {
grade = "C";
}
System.out.println(grade); // B
String result = score >= 60 ? "pass" : "fail";
System.out.println(result); // pass
Python
score = 75
if score >= 90:
grade = "A"
elif score >= 60:
grade = "B"
else:
grade = "C"
print(grade) # B
# значення_якщо_так if умова else значення_якщо_ні
result = "pass" if score >= 60 else "fail"
print(result) # pass

Чим замінити switch

Замість нього є match, він з'явився в Python 3.10. Як і новий switch у Java, він порівнює не лише з константами, а й зі зразками. Провалювання з гілки в гілку немає, тому break не потрібен.

Java
String httpName(int code) {
return switch (code) {
case 200 -> "OK";
case 301, 302 -> "Redirect";
case 404 -> "Not Found";
default -> "Unknown";
};
}
System.out.println(httpName(302)); // Redirect
System.out.println(httpName(500)); // Unknown
Python
def http_name(code):
match code:
case 200:
return "OK"
# Вертикальна риска означає «або».
case 301 | 302:
return "Redirect"
case 404:
return "Not Found"
# Підкреслення підходить під усе, це default.
case _:
return "Unknown"
print(http_name(302)) # Redirect
print(http_name(500)) # Unknown

Зіставлення за типом працює так само, як pattern matching у Java 21, тільки зразок уміє заразом розбирати значення на частини. Приклади з dataclass і словниками будуть далі.

Java
String describe(Object value) {
return switch (value) {
case Integer n when n > 100 -> "big number";
case Integer n -> "number " + n;
case String s -> "text " + s;
case List<?> l -> "list of " + l.size();
default -> "other";
};
}
System.out.println(describe(500)); // big number
System.out.println(describe(List.of(1, 2))); // list of 2
Python
def describe(value):
match value:
# int(n) перевіряє тип і кладе значення в n, if — це when.
case int(n) if n > 100:
return "big number"
case int(n):
return f"number {n}"
case str(s):
return f"text {s}"
case list(items):
return f"list of {len(items)}"
case _:
return "other"
print(describe(500)) # big number
print(describe([1, 2])) # list of 2
# А так зразок розбирає значення: список рівно з двох елементів.
match ["move", 10]:
case ["move", distance]:
print(distance) # 10

Як написати цикл for

У Python є лише один вид for, це аналог for (x : items) з Java. Там, де в Java потрібен лічильник, використовується range: функція, яка видає числа по порядку.

Java
var names = List.of("Ann", "Bob");
for (String name : names) {
System.out.print(name + " "); // Ann Bob
}
for (int i = 0; i < 3; i++) {
System.out.print(i + " "); // 0 1 2
}
for (int i = 10; i > 0; i -= 3) {
System.out.print(i + " "); // 10 7 4 1
}
Python
names = ["Ann", "Bob"]
for name in names:
print(name) # Ann Bob
# range(3) видає 0, 1, 2. Права межа не входить.
for i in range(3):
print(i) # 0 1 2
# range(від, до, крок)
for i in range(10, 0, -3):
print(i) # 10 7 4 1

Індекс потрібен рідше, ніж здається. Для частих випадків є готові функції, а for i in range(len(names)) вважається поганим стилем.

Java
var names = List.of("Ann", "Bob");
var ages = List.of(30, 25);
// Значення та індекс
for (int i = 0; i < names.size(); i++) {
System.out.print(i + ":" + names.get(i) + " "); // 0:Ann 1:Bob
}
// Два списки паралельно
for (int i = 0; i < names.size(); i++) {
System.out.print(names.get(i) + "=" + ages.get(i) + " "); // Ann=30 Bob=25
}
// У зворотному порядку
for (int i = names.size() - 1; i >= 0; i--) {
System.out.print(names.get(i) + " "); // Bob Ann
}
Python
names = ["Ann", "Bob"]
ages = [30, 25]
# enumerate видає пари (індекс, значення).
for i, name in enumerate(names):
print(f"{i}:{name}") # 0:Ann 1:Bob
# zip іде двома списками одразу й видає пари.
for name, age in zip(names, ages):
print(f"{name}={age}") # Ann=30 Bob=25
for name in reversed(names):
print(name) # Bob Ann

Як написати while і вийти з циклу

while, break і continue працюють так само, як у Java. Циклу do-while немає, і міток для виходу із вкладених циклів теж немає.

Java
int n = 0;
while (n < 10) {
n++;
if (n % 2 == 0) {
continue;
}
if (n > 5) {
break;
}
System.out.print(n + " "); // 1 3 5
}
int tries = 0;
do {
tries++;
} while (tries < 3);
System.out.println(tries); // 3
Python
n = 0
while n < 10:
n += 1
if n % 2 == 0:
continue
if n > 5:
break
print(n) # 1 3 5
# do-while записують як вічний цикл із перевіркою наприкінці.
tries = 0
while True:
tries += 1
if tries >= 3:
break
print(tries) # 3

У циклу може бути гілка else, якої немає в Java. Вона виконується, якщо цикл дійшов до кінця й не вийшов через break. Зазвичай це замінює прапорець found.

Java
var ids = List.of(1, 2, 3);
boolean found = false;
for (int id : ids) {
if (id == 7) {
found = true;
break;
}
}
if (!found) {
System.out.println("not found"); // not found
}
Python
ids = [1, 2, 3]
for id_ in ids:
if id_ == 7:
break
else:
# Сюди потрапляємо, лише якщо break не спрацював.
print("not found") # not found

Оператор := присвоює значення просто всередині виразу. У Java присвоювання і так є виразом, а в Python для цього потрібен окремий оператор.

Java
var names = List.of("Ann", "Bob");
int count;
if ((count = names.size()) > 1) {
System.out.println(count); // 2
}
Python
names = ["Ann", "Bob"]
# Порахували й одразу перевірили. Звичайне = тут дало б SyntaxError.
if (count := len(names)) > 1:
print(count) # 2

Як оголосити функцію

У Java метод завжди лежить у класі. У Python функція може лежати просто у файлі, тому класи зі статичними методами тут не потрібні. Тип результату й параметрів можна вказати підказками, але це необов'язково.

Java
int add(int a, int b) {
return a + b;
}
void greet(String name) {
System.out.println("Hi, " + name); // Hi, Ann
}
System.out.println(add(2, 3)); // 5
greet("Ann");
Python
def add(a, b):
return a + b
# Те саме з підказками типів. None тут означає void.
def greet(name: str) -> None:
print(f"Hi, {name}") # Hi, Ann
print(add(2, 3)) # 5
greet("Ann")
# Функція без return насправді повертає None.
print(greet("Ann")) # None

Чим замінити перевантаження методів

Перевантаження в Python немає: другий def з тим самим іменем просто замінить перший. Замість нього використовують значення параметрів за замовчуванням. Під час виклику аргументи можна називати за іменем, що замінює і патерн Builder.

Java
String connect(String host, int port, boolean secure) {
return host + ":" + port + " " + secure;
}
String connect(String host, int port) {
return connect(host, port, false);
}
String connect(String host) {
return connect(host, 80);
}
System.out.println(connect("a")); // a:80 false
System.out.println(connect("a", 443, true)); // a:443 true
Python
def connect(host, port=80, secure=False):
return f"{host}:{port} {secure}"
print(connect("a")) # a:80 False
# За порядком, як у Java.
print(connect("a", 443, True)) # a:443 True
# За іменем: порт пропущено, і зрозуміло, що означає True.
print(connect("a", secure=True)) # a:80 True

Як передати довільну кількість аргументів

int... numbers з Java записується як *numbers. У Python є й те, чого немає в Java: **options збирає іменовані аргументи у словник.

Java
int sum(int... numbers) {
return Arrays.stream(numbers).sum();
}
System.out.println(sum(1, 2, 3)); // 6
int[] values = {1, 2, 3};
System.out.println(sum(values)); // 6
Python
# У numbers приходить кортеж (1, 2, 3). Кортеж — це незмінний список.
def total(*numbers):
return sum(numbers)
print(total(1, 2, 3)) # 6
# Зірочка під час виклику, навпаки, розкладає список на аргументи.
values = [1, 2, 3]
print(total(*values)) # 6
# В options приходить словник {"port": 80, "secure": True}.
def connect(host, **options):
print(host, options) # a {'port': 80, 'secure': True}
connect("a", port=80, secure=True)

Зірочкою у списку параметрів можна заборонити передачу за порядком. Усе, що стоїть після неї, можна передати лише за іменем. Так роблять для прапорців, щоб не з'являлися виклики на кшталт send(msg, True, False).

Python
def send(message, *, retry=False):
return f"{message} {retry}"
print(send("hi", retry=True)) # hi True
# TypeError: так передавати не можна.
# send("hi", True)

Як повернути кілька значень

У Java для цього потрібен окремий клас або record. У Python функція просто повертає кілька значень через кому, і вони пакуються в кортеж.

Java
record MinMax(int min, int max) {
}
MinMax minMax(List<Integer> items) {
return new MinMax(Collections.min(items), Collections.max(items));
}
var result = minMax(List.of(3, 1, 2));
System.out.println(result.min() + " " + result.max()); // 1 3
Python
def min_max(items):
return min(items), max(items)
# Кортеж одразу розкладається по двох змінних.
low, high = min_max([3, 1, 2])
print(low, high) # 1 3
# Зайве значення прийнято складати у змінну _ .
low, _ = min_max([3, 1, 2])
# Зірочка забирає «усе інше».
head, *rest = [1, 2, 3]
print(head, rest) # 1 [2, 3]

Як записати лямбду й посилання на метод

Функція в Python — звичайний об'єкт: її можна покласти у змінну й передати в іншу функцію. Тому функціональні інтерфейси на кшталт Function і Comparator не потрібні, а замість .apply() функцію просто викликають. Лямбда може містити лише один вираз.

Java, Python
Function<Integer, Integer> twice = x -> x * 2; # Java
twice = lambda x: x * 2 # Python
twice.apply(4) # Java: 8
twice(4) # Python: 8
# Посилання на метод — це просто ім'я функції без дужок.
Function<String, Integer> size = String::length; # Java
size = len # Python
size.apply("Ann") # Java: 3
size("Ann") # Python: 3
# Кілька параметрів.
BinaryOperator<Integer> plus = (a, b) -> a + b; # Java
plus = lambda a, b: a + b # Python
plus.apply(2, 3) # Java: 5
plus(2, 3) # Python: 5

Найчастіше лямбду застосовують як ключ сортування. Замість Comparator, який порівнює два об'єкти, у Python передають функцію, яка за одним об'єктом повертає те, за чим сортувати.

Java, Python
var names = new ArrayList<>(List.of("Bob", "Al", "Cindy")); # Java
names = ["Bob", "Al", "Cindy"] # Python
names.sort(Comparator.comparingInt(String::length)); # Java
names.sort(key=len) # Python
names # Java: [Al, Bob, Cindy]
names # Python: ['Al', 'Bob', 'Cindy']
names.sort(Comparator.comparing(s -> s.charAt(s.length() - 1))); # Java
names.sort(key=lambda s: s[-1]) # Python
names # Java: [Bob, Al, Cindy]
names # Python: ['Bob', 'Al', 'Cindy']

Як оголосити клас

Конструктор називається __init__. Поля заздалегідь не оголошуються: вони з'являються, коли їм присвоюють значення в конструкторі. Слова new немає, об'єкт створюється викликом класу. Першим параметром кожного методу йде self. Це те саме, що this у Java, тільки записується явно.

Java
class User {
private String name;
private int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
String greet() {
return "Hi, " + this.name;
}
}
User user = new User("Ann", 30);
System.out.println(user.greet()); // Hi, Ann
Python
class User:
def __init__(self, name, age):
self.name = name
self.age = age
# self під час виклику передавати не потрібно, Python підставить його сам.
def greet(self):
return f"Hi, {self.name}"
user = User("Ann", 30)
print(user.greet()) # Hi, Ann
# Точно те саме, тільки self передано вручну.
print(User.greet(user)) # Hi, Ann

Метод із подвійними підкресленнями з боків, такий як __init__, називають dunder-методом (скорочення від double underscore). Його не викликають напряму: його викликає сам Python, коли потрібно створити об'єкт, надрукувати його чи порівняти. Далі таких методів буде багато.

Як зробити статичні поля й методи

Змінна, оголошена в тілі класу, належить класу, як static поле в Java. Для методів є два декоратори. @staticmethod — це звичайний статичний метод. @classmethod отримує першим параметром сам клас, і на ньому зазвичай роблять фабричні методи. Декоратор — це позначка над функцією, схожа на анотацію в Java. Як він працює, розказано нижче.

Java
class Money {
static final String CURRENCY = "USD";
int cents;
Money(int cents) {
this.cents = cents;
}
static boolean isValid(int cents) {
return cents >= 0;
}
static Money ofDollars(int dollars) {
return new Money(dollars * 100);
}
}
System.out.println(Money.CURRENCY); // USD
System.out.println(Money.isValid(-1)); // false
System.out.println(Money.ofDollars(2).cents); // 200
Python
class Money:
CURRENCY = "USD"
def __init__(self, cents):
self.cents = cents
@staticmethod
def is_valid(cents):
return cents >= 0
# cls — це сам клас Money. У нащадка тут буде нащадок.
@classmethod
def of_dollars(cls, dollars):
return cls(dollars * 100)
print(Money.CURRENCY) # USD
print(Money.is_valid(-1)) # False
print(Money.of_dollars(2).cents) # 200

Як зробити поле закритим і що замість гетерів

Модифікаторів доступу в Python немає. Одне підкреслення на початку імені означає «це внутрішнє, ззовні не чіпайте», але заборони немає, це домовленість.

JavaPythonЩо означає
public int sizesizeвідкрите
protected int size_sizeвнутрішнє, за домовленістю
private int size_sizeте саме, окремого рівня немає

Гетери й сетери заздалегідь не пишуть, поле просто роблять відкритим. Якщо пізніше знадобиться перевірка, поле замінюють на @property: ззовні код лишається тим самим, account.balance, але викликається метод.

Java
class Account {
private int balance;
int getBalance() {
return balance;
}
void setBalance(int balance) {
if (balance < 0) {
throw new IllegalArgumentException("negative");
}
this.balance = balance;
}
}
var account = new Account();
account.setBalance(100);
System.out.println(account.getBalance()); // 100
Python
class Account:
def __init__(self):
self._balance = 0
# Гетер: читається як поле, account.balance, без дужок.
@property
def balance(self):
return self._balance
# Сетер: спрацьовує на account.balance = 100.
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("negative")
self._balance = value
account = Account()
account.balance = 100
print(account.balance) # 100

Чим замінити toString, equals і hashCode

Це теж dunder-методи. Є й відмінність від Java: рядкових представлень два. __str__ — для користувача, його використовує print. __repr__ — для розробника, його показують налагоджувач і контейнери. Якщо оголошено лише __repr__, він працює і за __str__, тому зазвичай пишуть його.

Java
class Point {
int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
@Override
public boolean equals(Object other) {
return other instanceof Point p && p.x == x && p.y == y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
System.out.println(new Point(1, 2)); // Point(1, 2)
System.out.println(new Point(1, 2).equals(new Point(1, 2))); // true
Python
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
# NotImplemented означає «я не вмію порівнювати з цим типом».
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
print(Point(1, 2)) # Point(1, 2)
print(Point(1, 2) == Point(1, 2)) # True

Чим замінити record

Попередній клас майже ніхто так не пише: для нього є dataclass. Цей декоратор сам створює __init__, __repr__ і __eq__ за списком полів. З параметром frozen=True об'єкт стає незмінним, як record, і отримує __hash__.

Java
record Point(int x, int y) {
public Point {
if (x < 0) {
throw new IllegalArgumentException("x");
}
}
Point withX(int newX) {
return new Point(newX, y);
}
}
var point = new Point(1, 2);
System.out.println(point); // Point[x=1, y=2]
System.out.println(point.x()); // 1
System.out.println(point.withX(5)); // Point[x=5, y=2]
Python
from dataclasses import dataclass, field, replace
# Тут підказки типів обов'язкові: за ними декоратор знаходить поля.
@dataclass(frozen=True)
class Point:
x: int
y: int = 0
# Компактний конструктор record: викликається після __init__.
def __post_init__(self):
if self.x < 0:
raise ValueError("x")
point = Point(1, 2)
print(point) # Point(x=1, y=2)
print(point.x) # 1
# replace робить копію зі зміненим полем, це аналог withX.
print(replace(point, x=5)) # Point(x=5, y=2)
# point.x = 5 дав би FrozenInstanceError.
# Змінюване значення за замовчуванням задають через field: список на об'єкт свій.
@dataclass
class Cart:
items: list = field(default_factory=list)
print(Cart()) # Cart(items=[])

Як успадкувати клас

Батьківський клас вказується в дужках. Виклик батьківського конструктора необов'язковий і сам не відбувається, тому super().__init__() легко забути. Усі методи можна перевизначати, слова @Override немає. Батьківських класів може бути кілька, про це нижче.

Java
class Animal {
String name;
Animal(String name) {
this.name = name;
}
String speak() {
return name + " makes a sound";
}
}
class Dog extends Animal {
Dog(String name) {
super(name);
}
@Override
String speak() {
return super.speak() + ": woof";
}
}
Animal pet = new Dog("Rex");
System.out.println(pet.speak()); // Rex makes a sound: woof
System.out.println(pet instanceof Animal); // true
Python
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def __init__(self, name):
super().__init__(name)
def speak(self):
return f"{super().speak()}: woof"
pet = Dog("Rex")
print(pet.speak()) # Rex makes a sound: woof
print(isinstance(pet, Animal)) # True

Чим замінити абстрактний клас та інтерфейс

Абстрактний клас оголошується через ABC. Створити його екземпляр не можна, а нащадок мусить реалізувати абстрактні методи. Відмінність від Java в тому, що помилка з'явиться не під час компіляції, а під час створення об'єкта.

Java
abstract class Shape {
abstract double area();
String describe() {
return "area " + area();
}
}
class Square extends Shape {
double side = 2;
@Override
double area() {
return side * side;
}
}
System.out.println(new Square().describe()); // area 4.0
Python
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
def describe(self):
return f"area {self.area()}"
class Square(Shape):
side = 2.0
def area(self):
return self.side * self.side
print(Square().describe()) # area 4.0
# Shape() дав би TypeError: абстрактний клас створити не можна.

Інтерфейсу найближчий Protocol. Він відрізняється важливою річчю: класу не потрібно писати implements. Підходить будь-який клас, у якого є такі самі методи. Це називають качиною типізацією: якщо об'єкт уміє те, що потрібно, його тип неважливий. Перевіряє відповідність не Python, а mypy.

Java
interface Notifier {
String send(String text);
}
class Email implements Notifier {
public String send(String text) {
return "email: " + text;
}
}
String alert(Notifier notifier) {
return notifier.send("down");
}
System.out.println(alert(new Email())); // email: down
Python
from typing import Protocol
class Notifier(Protocol):
def send(self, text: str) -> str:
...
# Ні implements, ні успадкування: збігу методів досить.
class Email:
def send(self, text: str) -> str:
return f"email: {text}"
def alert(notifier: Notifier) -> str:
return notifier.send("down")
print(alert(Email())) # email: down

Як оголосити enum

Перелік — це клас-нащадок Enum. Як і в Java, кожен елемент існує в одному екземплярі, тому їх можна порівнювати через is. Якщо значення — рядки, зручний StrEnum: його елементи і є рядками.

Java
enum Status {
NEW("new"),
PAID("paid");
final String code;
Status(String code) {
this.code = code;
}
}
System.out.println(Status.PAID.name()); // PAID
System.out.println(Status.PAID.code); // paid
System.out.println(Status.valueOf("NEW").code); // new
System.out.println(Status.values().length); // 2
Python
from enum import Enum, StrEnum
class Status(Enum):
NEW = "new"
PAID = "paid"
print(Status.PAID.name) # PAID
print(Status.PAID.value) # paid
# Знайти за іменем і за значенням.
print(Status["NEW"].value) # new
print(Status("paid").name) # PAID
print(len(Status)) # 2
print(Status.PAID is Status("paid")) # True
# Елемент StrEnum дорівнює рядку, це зручно для JSON і бази.
class Color(StrEnum):
RED = "red"
print(Color.RED == "red") # True

Чим замінити анонімні та вкладені класи

Анонімних класів немає, і зазвичай вони не потрібні, бо можна передати функцію. Вкладений клас буває, але він схожий на static class у Java: доступу до об'єкта зовнішнього класу він не має.

Java
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("run"); // run
}
};
task.run();
Python
def run_task(task):
task()
# Замість анонімного класу — функція, оголошена там, де потрібна.
def task():
print("run") # run
run_task(task)

Як написати дженерик

Підказка типу колекції пишеться у квадратних дужках. Власні узагальнені класи з Python 3.12 оголошуються так само, як у Java, тільки дужки інші. Як і в Java, під час роботи від параметра типу нічого не лишається. Докладніше в розділі про підказки типів.

Java, Python
List<String> names = new ArrayList<>(); # Java
names: list[str] = [] # Python
Map<String, Integer> ages = new HashMap<>(); # Java
ages: dict[str, int] = {} # Python
# Значення або null.
String nickname = null; # Java
nickname: str | None = None # Python
Java
class Box<T> {
private final T value;
Box(T value) {
this.value = value;
}
T get() {
return value;
}
}
<T extends Comparable<T>> T maxOf(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}
System.out.println(new Box<>("x").get()); // x
System.out.println(maxOf(1, 2)); // 2
Python
class Box[T]:
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
# T: int | str — це обмеження, як T extends у Java.
def max_of[T: int | str](a: T, b: T) -> T:
return a if a > b else b
print(Box("x").get()) # x
print(max_of(1, 2)) # 2

Як обробляти винятки

catch називається except, а throw називається raise. Перевірюваних винятків немає: слова throws не існує, і перехоплювати нічого не обов'язково.

Java
int parse(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException | NullPointerException e) {
System.out.println("bad: " + text); // bad: abc
return 0;
} finally {
System.out.println("done"); // done
}
}
parse("abc");
Python
def parse(text):
try:
return int(text)
# Кілька типів перелічуються в дужках.
except (ValueError, TypeError) as e:
print(f"bad: {text}") # bad: abc
return 0
finally:
print("done") # done
parse("abc")

Власний виняток оголошують як клас. Причина, cause з Java, вказується через from. Ще є гілка else: вона виконується, лише якщо в try не було помилки.

Java
class OrderException extends RuntimeException {
OrderException(String message, Throwable cause) {
super(message, cause);
}
}
try {
try {
Integer.parseInt("abc");
} catch (NumberFormatException e) {
throw new OrderException("bad id", e);
}
} catch (OrderException e) {
System.out.println(e.getMessage()); // bad id
System.out.println(e.getCause() instanceof NumberFormatException); // true
}
Python
class OrderError(Exception):
pass
try:
try:
int("abc")
except ValueError as e:
raise OrderError("bad id") from e
except OrderError as e:
print(e) # bad id
# __cause__ — те саме, що getCause().
print(isinstance(e.__cause__, ValueError)) # True
try:
value = int("42")
except ValueError:
print("bad")
else:
# Лише якщо помилки не було.
print(value) # 42

Назви стандартних винятків інші, але відповідність проста:

JavaPython
ThrowableBaseException
ExceptionException
IllegalArgumentException, NumberFormatExceptionValueError
ClassCastExceptionTypeError
NullPointerExceptionAttributeError або TypeError
IndexOutOfBoundsExceptionIndexError
немає ключа в MapKeyError
IOExceptionOSError
FileNotFoundExceptionFileNotFoundError
ArithmeticExceptionZeroDivisionError
UnsupportedOperationExceptionNotImplementedError
StackOverflowErrorRecursionError
TimeoutExceptionTimeoutError

Чим замінити try-with-resources

Замість нього є оператор with. Об'єкт для нього називають менеджером контексту. Замість методу close з AutoCloseable у нього два методи: __enter__ викликається на вході, а __exit__ на виході, навіть у разі помилки. Порядок виконання показано цифрами.

Java
class Resource implements AutoCloseable {
Resource() {
System.out.println("open"); // 1. open
}
@Override
public void close() {
System.out.println("close"); // 3. close
}
}
try (var resource = new Resource()) {
System.out.println("work"); // 2. work
}
Python
class Resource:
def __enter__(self):
print("open") # 1. open
# Те, що повертає __enter__, потрапляє у змінну після as.
return self
def __exit__(self, exc_type, exc, tb):
print("close") # 3. close
with Resource() as resource:
print("work") # 2. work

Найчастіше with трапляється під час роботи з файлами:

Java
Path path = Files.createTempFile("demo", ".txt");
try (var writer = Files.newBufferedWriter(path)) {
writer.write("hello");
}
System.out.println(Files.readString(path)); // hello
Python
import tempfile
from pathlib import Path
path = Path(tempfile.mkdtemp()) / "demo.txt"
# Файл закриється сам. Кодування вказуйте завжди: інакше береться системне.
with open(path, "w", encoding="utf-8") as file:
file.write("hello")
print(path.read_text(encoding="utf-8")) # hello

Як влаштовані імпорти та пакети

Рядка package у Python немає: файл сам є модулем, а його ім'я дорівнює імені файлу. Каталог із модулями називається пакетом. В один файл можна покласти скільки завгодно класів і функцій, а окремий файл на кожен клас тут не прийнятий. Імпортувати можна як сам модуль, так і окреме ім'я з нього.

Java, Python
# Імпорт одного імені.
import java.time.LocalDate; # Java
from datetime import date # Python
# Статичний імпорт.
import static java.lang.Math.sqrt; # Java
from math import sqrt # Python
# Імпорт усього підряд. У Python так робити не прийнято.
import java.util.*; # Java
from math import * # Python
# Лише в Python: увесь модуль, а потім звернення через його ім'я.
import math # Python
math.floor(2.5) # Python: 2
# Лише в Python: власне коротке ім'я.
import datetime as dt # Python
dt.date(2026, 1, 2).year # Python: 2026
orders/ пакет, як package orders
├── __init__.py ознака пакета, зазвичай порожній
├── domain.py модуль orders.domain
└── api/
├── __init__.py
└── routes.py модуль orders.api.routes
Python
# З іншого файлу проєкту.
from orders.domain import Order
# Із сусіднього файлу того самого пакета: крапка означає «цей пакет».
from .domain import Order
# На рівень вище.
from ..domain import Order

Шпаргалка із синтаксису

JavaPython
int a = 1;a = 1
final int MAX = 1;MAX = 1
nullNone
true, falseTrue, False
a.equals(b)a == b
a == b для посиланьa is b
&&, ||, !and, or, not
x > 0 ? a : ba if x > 0 else b
7 / 27 // 2
Math.pow(2, 3)2 ** 3
i++i += 1
else ifelif
switchmatch
for (int i = 0; i < n; i++)for i in range(n):
for (T x : items)for x in items:
do { } while (x);while True: і break
void f(int x)def f(x):
int... xs*xs
x -> x * 2lambda x: x * 2
String::lengthlen
new User("Ann")User("Ann")
thisself
class B extends Aclass B(A):
super.run()super().run()
static int f()@staticmethod
record Point(int x)@dataclass(frozen=True)
interfaceProtocol
abstract classclass A(ABC):
List<String>list[str]
throwraise
catch (E e)except E as e:
try (var r = ...)with ... as r:
toString()__repr__, __str__
equals(), hashCode()__eq__, __hash__
System.out.println(x)print(x)

Числа: основні операції

Чим цілі числа відрізняються від Java

int у Python не переповнюється, він росте, поки вистачає пам'яті. Ділення з мінусом теж працює інакше: Java відкидає дробову частину, а Python округлює вниз.

Java, Python
Integer.MAX_VALUE + 1 # Java: -2147483648
2147483647 + 1 # Python: 2147483648
BigInteger.TWO.pow(70) # Java: 1180591620717411303424
2 ** 70 # Python: 1180591620717411303424
# Ділення з мінусом: результати різні.
-7 / 2 # Java: -3
-7 // 2 # Python: -4
-7 % 3 # Java: -1
-7 % 3 # Python: 2
# Підкреслення в числах, двійковий і шістнадцятковий запис — як у Java.
1_000_000 # Java: 1000000
1_000_000 # Python: 1000000
0xFF # Java: 255
0xFF # Python: 255
0b101 # Java: 5
0b101 # Python: 5
# Переведення в іншу систему числення. Python додає префікс.
Integer.toBinaryString(10) # Java: 1010
bin(10) # Python: 0b1010
Integer.toHexString(255) # Java: ff
hex(255) # Python: 0xff
Integer.parseInt("ff", 16) # Java: 255
int("ff", 16) # Python: 255
# Бітові операції ті самі, тільки >>> немає: число не має фіксованої довжини.
5 & 3 # Java: 1
5 & 3 # Python: 1
1 << 4 # Java: 16
1 << 4 # Python: 16

Де шукати Math

Найчастіші функції вбудовані в мову й доступні без імпорту, а решта лежить у модулі math.

Java, Python
import math # Python
Math.abs(-5) # Java: 5
abs(-5) # Python: 5
Math.max(1, 2) # Java: 2
max(1, 2) # Python: 2
# min і max у Python приймають і кілька значень, і список.
Collections.min(List.of(3, 1)) # Java: 1
min(3, 1) # Python: 1
# floor і ceil у Python повертають ціле, а не дробове.
Math.floor(2.7) # Java: 2.0
math.floor(2.7) # Python: 2
Math.ceil(2.1) # Java: 3.0
math.ceil(2.1) # Python: 3
Math.sqrt(16) # Java: 4.0
math.sqrt(16) # Python: 4.0
Math.PI # Java: 3.141592653589793
math.pi # Python: 3.141592653589793
# Пастка: round у Python округлює половину до парного.
Math.round(2.5) # Java: 3
round(2.5) # Python: 2
Math.round(3.5) # Java: 4
round(3.5) # Python: 4
# Округлити до двох знаків.
Math.round(3.14159 * 100) / 100.0 # Java: 3.14
round(3.14159, 2) # Python: 3.14
# Частка й остача одразу.
divmod(7, 2) # Python: (3, 1)
Double.isNaN(0.0 / 0) # Java: true
math.isnan(float("nan")) # Python: True
# У Java ділення дробового на нуль дає Infinity, у Python ZeroDivisionError.
1.0 / 0 # Java: Infinity
float("inf") # Python: inf

Як рахувати гроші

float у Python — це той самий double, з тими самими помилками округлення. Для грошей є Decimal, аналог BigDecimal. Створювати його потрібно з рядка. Звичайні оператори працюють, тому add і multiply не потрібні.

Java, Python
from decimal import Decimal, ROUND_HALF_UP # Python
0.1 + 0.2 # Java: 0.30000000000000004
0.1 + 0.2 # Python: 0.30000000000000004
new BigDecimal("0.1").add(new BigDecimal("0.2")) # Java: 0.3
Decimal("0.1") + Decimal("0.2") # Python: 0.3
new BigDecimal("19.99").multiply(new BigDecimal("3")) # Java: 59.97
Decimal("19.99") * 3 # Python: 59.97
# Округлити до копійок.
new BigDecimal("2.675").setScale(2, RoundingMode.HALF_UP) # Java: 2.68
Decimal("2.675").quantize(Decimal("0.01"), ROUND_HALF_UP) # Python: 2.68
# Порівняння: у Java equals враховує масштаб, у Python == лише значення.
new BigDecimal("1.0").equals(new BigDecimal("1.00")) # Java: false
Decimal("1.0") == Decimal("1.00") # Python: True

Як отримати випадкове число

Зверніть увагу: у randint увімкнено обидві межі. Модуль random не підходить для паролів і токенів, для них є secrets, як SecureRandom у Java.

Java, Python
import random # Python
import secrets # Python
# Кубик: від 1 до 6.
int dice = ThreadLocalRandom.current().nextInt(1, 7); # Java
dice = random.randint(1, 6) # Python
# Дробове від 0 до 1.
double share = Math.random(); # Java
share = random.random() # Python
# Випадковий елемент і перемішування.
var cards = new ArrayList<>(List.of(1, 2, 3)); # Java
cards = [1, 2, 3] # Python
Collections.shuffle(cards); # Java
random.shuffle(cards) # Python
int card = cards.get(new Random().nextInt(cards.size())); # Java
card = random.choice(cards) # Python
# Токен для безпеки.
var token = new java.security.SecureRandom().nextLong(); # Java
token = secrets.token_hex(16) # Python

Рядки: основні методи

Як записати рядок

Як і String у Java, str незмінний і зберігає текст в Unicode. Одинарні й подвійні лапки означають те саме. Крім звичайного рядка є кілька видів літералів: літера перед лапкою змінює сенс.

ЗаписЩо цеАналог у Java
"text", 'text'звичайний рядок"text"
"""text"""багаторядковий рядок"""text"""
f"{name}"рядок із підстановкою значеньString.format
r"\d+"сирий рядок: зворотна коса риска не екрануєнемає
b"abc"байти, а не текстbyte[]
t"{name}"шаблон: значення окремо від тексту (3.14)немає

Літери можна поєднувати: rf"\d{count}" або rb"\x00".

Java, Python
"it's" # Java: it's
'it\'s' # Python: it's
# Зручно, коли всередині є лапки іншого виду.
"say \"hi\"" # Java: say "hi"
'say "hi"' # Python: say "hi"
# Сирий рядок заощаджує зворотні косі риски в регулярних виразах.
"\\d+" # Java: \d+
r"\d+" # Python: \d+
# Символ Unicode за кодом.
"é" # Java: é
"é" # Python: é

Багаторядковий рядок записується так само, як текстовий блок у Java, але відступи Python сам не прибирає. Для цього є textwrap.dedent.

Java
String query = """
SELECT *
FROM users
""";
System.out.print(query);
Python
from textwrap import dedent
# Зворотна коса риска після лапок прибирає перше перенесення рядка.
query = dedent("""\
SELECT *
FROM users
""")
print(query)

У сусідніх літералів є пастка. Python склеює їх без жодного плюса. Це зручно для довгих рядків, але якщо забути кому у списку, помилки не буде.

Python
message = (
"first part, "
"second part"
)
print(message) # first part, second part
# Забута кома: у списку два елементи, а не три.
names = [
"Ann",
"Bob"
"Cid",
]
print(names) # ['Ann', 'BobCid']

Які методи є в рядка

Тут зібрано ті методи, що потрібні щодня. Рядок незмінний, тому всі вони, як і в Java, повертають новий рядок.

Java, Python
String s = "Hello World"; # Java
s = "Hello World" # Python
# Довжина — функція, а не метод.
s.length() # Java: 11
len(s) # Python: 11
s.charAt(0) # Java: H
s[0] # Python: H
# Від'ємний індекс рахує з кінця.
s.charAt(s.length() - 1) # Java: d
s[-1] # Python: d
s.toUpperCase() # Java: HELLO WORLD
s.upper() # Python: HELLO WORLD
s.toLowerCase() # Java: hello world
s.lower() # Python: hello world
s.contains("World") # Java: true
"World" in s # Python: True
s.startsWith("He") # Java: true
s.startswith("He") # Python: True
s.endsWith("ld") # Java: true
s.endswith("ld") # Python: True
s.indexOf("o") # Java: 4
s.find("o") # Python: 4
s.lastIndexOf("o") # Java: 7
s.rfind("o") # Python: 7
# Не знайшли: -1. Метод s.index() у цьому разі дасть ValueError.
s.indexOf("z") # Java: -1
s.find("z") # Python: -1
s.replace("World", "Ann") # Java: Hello Ann
s.replace("World", "Ann") # Python: Hello Ann
# Замінити лише перше входження.
s.replaceFirst("l", "L") # Java: HeLlo World
s.replace("l", "L", 1) # Python: HeLlo World
# Скільки разів трапляється. Готового методу в Java немає.
s.chars().filter(c -> c == 'l').count() # Java: 3
s.count("l") # Python: 3
Java, Python
# Пробіли по краях.
" a ".strip() # Java: a
" a ".strip() # Python: a
" a".stripLeading() # Java: a
" a".lstrip() # Python: a
"a ".stripTrailing() # Java: a
"a ".rstrip() # Python: a
# У Python strip уміє прибирати і задані символи.
"--a--".strip("-") # Python: a
# Прибрати префікс, лише якщо він є.
"v1.2".removeprefix("v") # Python: 1.2
"ab".repeat(2) # Java: abab
"ab" * 2 # Python: abab
# Перевернути рядок.
new StringBuilder("abc").reverse() # Java: cba
"abc"[::-1] # Python: cba
# Чи порожній рядок.
"".isEmpty() # Java: true
not "" # Python: True
" ".isBlank() # Java: true
not " ".strip() # Python: True
# Перевірки символів. У Python вони працюють і для всього рядка.
Character.isDigit('5') # Java: true
"5".isdigit() # Python: True
Character.isLetter('a') # Java: true
"a".isalpha() # Python: True
"123".chars().allMatch(Character::isDigit) # Java: true
"123".isdigit() # Python: True
# Перша літера велика.
"ann".substring(0, 1).toUpperCase() + "ann".substring(1) # Java: Ann
"ann".capitalize() # Python: Ann
# Порівняння: compareTo у Python замінюють звичайні оператори.
"a".compareTo("b") < 0 # Java: true
"a" < "b" # Python: True
"ANN".equalsIgnoreCase("ann") # Java: true
"ANN".casefold() == "ann".casefold() # Python: True

Що таке зріз рядка

Замість substring використовується зріз, записується він як s[початок:кінець:крок]. Права межа не входить, як і в substring. Будь-яку частину можна пропустити. Індекси зручно уявляти як номери під літерами. Так само зрізи працюють зі списками.

рядок: H e l l o
індекс: 0 1 2 3 4
з кінця: -5 -4 -3 -2 -1
Java, Python
String s = "Hello"; # Java
s = "Hello" # Python
s.substring(1, 3) # Java: el
s[1:3] # Python: el
# Без початку: від першого символу.
s.substring(0, 2) # Java: He
s[:2] # Python: He
# Без кінця: до останнього символу.
s.substring(2) # Java: llo
s[2:] # Python: llo
# Останні три символи.
s.substring(s.length() - 3) # Java: llo
s[-3:] # Python: llo
# Кожен другий символ. У Java це робиться циклом.
s[::2] # Python: Hlo
# На відміну від substring, вихід за межі не дає помилки.
s[3:100] # Python: lo

Як розбити й склеїти рядок

Тут найбільше відмінностей. split у Java приймає регулярний вираз і викидає порожні рядки наприкінці, а split у Python нічого не викидає й шукає звичайний текст. Без аргументів він ділить за будь-якими пробілами. join у Python викликають у роздільника.

Java, Python
Arrays.toString("a,b,,".split(",")) # Java: [a, b]
"a,b,,".split(",") # Python: ['a', 'b', '', '']
# У Java крапка — регулярний вираз, а в Python звичайний символ.
Arrays.toString("a.b".split("\\.")) # Java: [a, b]
"a.b".split(".") # Python: ['a', 'b']
Arrays.toString(" a b ".trim().split("\\s+")) # Java: [a, b]
" a b ".split() # Python: ['a', 'b']
# Обмежити кількість частин. У Python рахуються розрізи, а не частини.
Arrays.toString("a=b=c".split("=", 2)) # Java: [a, b=c]
"a=b=c".split("=", 1) # Python: ['a', 'b=c']
# За рядками.
"a\nb".lines().toList() # Java: [a, b]
"a\nb".splitlines() # Python: ['a', 'b']
# Розділити на три частини: до, роздільник, після.
"key=value".partition("=") # Python: ('key', '=', 'value')
String.join(", ", List.of("a", "b")) # Java: a, b
", ".join(["a", "b"]) # Python: a, b
# У Python join приймає лише рядки, числа треба перетворити.
", ".join(str(n) for n in [1, 2]) # Python: 1, 2
# Перебрати символи й отримати їхній список.
"abc".toCharArray().length # Java: 3
list("abc") # Python: ['a', 'b', 'c']

Як підставити значення в рядок

Основний спосіб — f-рядок: літера f перед лапкою та будь-які вирази у фігурних дужках. Після двокрапки йде формат, дуже схожий на той, що в String.format.

Java, Python
String name = "Ann"; # Java
name = "Ann" # Python
int n = 42; # Java
n = 42 # Python
double x = 3.14159; # Java
x = 3.14159 # Python
String.format("%s is %d", name, n) # Java: Ann is 42
f"{name} is {n}" # Python: Ann is 42
# У дужках можна писати будь-який вираз.
String.format("%d", n * 2) # Java: 84
f"{n * 2}" # Python: 84
# Знаки після коми.
String.format(Locale.US, "%.2f", x) # Java: 3.14
f"{x:.2f}" # Python: 3.14
# Ширина й нулі.
String.format("%05d", n) # Java: 00042
f"{n:05d}" # Python: 00042
# Вирівнювання: > праворуч, < ліворуч, ^ по центру.
String.format("[%5s]", name) # Java: [ Ann]
f"[{name:>5}]" # Python: [ Ann]
String.format("[%-5s]", name) # Java: [Ann ]
f"[{name:<5}]" # Python: [Ann ]
f"[{name:^5}]" # Python: [ Ann ]
# Роздільник тисяч.
String.format(Locale.US, "%,d", 1234567) # Java: 1,234,567
f"{1234567:,}" # Python: 1,234,567
# Шістнадцятковий запис і відсотки.
String.format("%x", 255) # Java: ff
f"{255:x}" # Python: ff
f"{0.25:.0%}" # Python: 25%
# Лише в Python: знак = друкує і вираз, і значення. Зручно для налагодження.
f"{n=}" # Python: n=42
# Фігурна дужка як символ пишеться двічі.
f"{{{n}}}" # Python: {42}

У старому коді трапляються ще два способи, про них потрібно знати, щоб читати такий код. Формат % ще й живе в логуванні: log.info("user %s", name). Так повідомлення збирається, лише якщо запис реально потрапить у лог, як і з {} у SLF4J. Тому f-рядки у викликах логера не використовують.

Python
name = "Ann"
# Як printf. Найстаріший спосіб.
print("Hi, %s" % name) # Hi, Ann
# Як MessageFormat. Потрібен, коли шаблон задано заздалегідь, у налаштуваннях.
print("Hi, {}".format(name)) # Hi, Ann
print("Hi, {who}".format(who=name)) # Hi, Ann

Що таке t-рядок

f-рядок одразу склеює все у звичайний текст, тому підставляти в нього дані від користувача в SQL чи HTML небезпечно: так з'являються ін'єкції. t-рядок з'явився в Python 3.14. Він нічого не склеює, а віддає текст і значення окремо, і ви самі вирішуєте, як їх з'єднати.

Python
name = "x' OR '1'='1"
# f-рядок: дані стали частиною SQL.
print(f"name = '{name}'") # name = 'x' OR '1'='1'
# t-рядок: це об'єкт, а не рядок.
template = t"name = {name}"
print(template.strings) # ('name = ', '')
print(template.values) # ("x' OR '1'='1",)
# Текст іде в запит, значення окремо, як у PreparedStatement.
def to_sql(query):
return "?".join(query.strings), query.values
print(to_sql(template)) # ('name = ?', ("x' OR '1'='1",))

Як зібрати рядок із частин

Рядок незмінний, тому склеювання в циклі щоразу створює новий рядок. Як і в Java, на великому обсязі це повільно. Окремого StringBuilder у Python немає: частини складають у список і один раз склеюють через join.

Java
var builder = new StringBuilder();
for (int i = 0; i < 3; i++) {
builder.append(i).append(",");
}
System.out.println(builder); // 0,1,2,
Python
parts = []
for i in range(3):
parts.append(f"{i},")
print("".join(parts)) # 0,1,2,
# Якщо текст збирається як потік, є io.StringIO, це StringWriter.
import io
buffer = io.StringIO()
buffer.write("a")
buffer.write("b")
print(buffer.getvalue()) # ab

Чим __str__ відрізняється від __repr__ під час друку

Різницю добре видно на самому рядку. Функція str дає текст як є, а repr показує, як значення записане в коді: з лапками й керівними символами. Контейнери друкують свої елементи через repr, тому у списку рядки видно з лапками.

Python
text = "a\tb"
print(str(text)) # a b
print(repr(text)) # 'a\tb'
# В f-рядку !r означає «виклич repr». Зручно в повідомленнях про помилки.
print(f"bad value: {text!r}") # bad value: 'a\tb'
print(["a", "b"]) # ['a', 'b']

Чим текст відрізняється від байтів

str зберігає текст, bytes зберігає байти. Python їх ніколи не змішує: додати їх не можна, а перехід завжди йде явно, через кодування. Для роботи з байтами є три типи.

PythonЗмінюванийЩо цеАналог у Java
bytesнінезмінні байтиbyte[], який ніхто не змінює
bytearrayтакзмінюваний буферbyte[]
memoryviewяк у джерелавікно в чужі байти без копіюванняByteBuffer.wrap
Java, Python
import base64 # Python
byte[] data = "héllo".getBytes(StandardCharsets.UTF_8); # Java
data = "héllo".encode("utf-8") # Python
new String(data, StandardCharsets.UTF_8) # Java: héllo
data.decode("utf-8") # Python: héllo
# Довжина в байтах: é займає два.
data.length # Java: 6
len(data) # Python: 6
# Байт: у Java він зі знаком, а в Python від 0 до 255.
data[1] # Java: -61
data[1] # Python: 195
HexFormat.of().formatHex(data) # Java: 68c3a96c6c6f
data.hex() # Python: 68c3a96c6c6f
Base64.getEncoder().encodeToString("hi".getBytes()) # Java: aGk=
base64.b64encode(b"hi").decode() # Python: aGk=
Python
data = b"abc"
# Друкується з літерою b, щоб текст і байти не плуталися.
print(data) # b'abc'
# "abc" + b"abc" дасть TypeError.
# bytes не можна змінювати, bytearray можна.
buffer = bytearray(b"abc")
buffer[0] = 65
print(buffer) # bytearray(b'Abc')
# memoryview дивиться на ті самі байти: запис видно в джерелі.
view = memoryview(buffer)
view[1:2] = b"B"
print(buffer) # bytearray(b'ABc')

Чим довжина рядка відрізняється від Java

String у Java рахує одиниці UTF-16, тому емодзі займає два char. У Python len рахує символи Unicode. Сам Python вибирає, скільки байтів відвести на символ, тому індекс завжди працює швидко. Але один видимий символ усе одно може складатися з кількох символів Unicode, і перед порівнянням такий текст нормалізують.

Java, Python
import unicodedata # Python
"😀".length() # Java: 2
len("😀") # Python: 1
"😀".codePointCount(0, 2) # Java: 1
len("😀") # Python: 1
# é як один символ і як e плюс знак наголосу.
"é".equals("é") # Java: false
"é" == "é" # Python: False
Normalizer.normalize("é", Normalizer.Form.NFC).equals("é") # Java: true
unicodedata.normalize("NFC", "é") == "é" # Python: True

Як працювати з регулярними виразами

Синтаксис виразів майже той самий, а методи лежать у модулі re. Шаблон прийнято писати сирим рядком. Якщо збігу немає, методи повертають None.

Java, Python
import re # Python
# Чи підходить увесь рядок.
"123".matches("\\d+") # Java: true
bool(re.fullmatch(r"\d+", "123")) # Python: True
"a1b2".replaceAll("\\d", "#") # Java: a#b#
re.sub(r"\d", "#", "a1b2") # Python: a#b#
Arrays.toString("a1b2c".split("\\d")) # Java: [a, b, c]
re.split(r"\d", "a1b2c") # Python: ['a', 'b', 'c']
# Усі збіги.
var found = Pattern.compile("\\d").matcher("a1b2").results(); # Java
found.map(r -> r.group()).toList() # Java: [1, 2]
re.findall(r"\d", "a1b2") # Python: ['1', '2']
Java
Pattern pattern = Pattern.compile("(?<key>\\w+)=(?<value>\\d+)");
Matcher matcher = pattern.matcher("size=42");
if (matcher.find()) {
System.out.println(matcher.group("key")); // size
System.out.println(matcher.group("value")); // 42
}
Python
import re
# Заздалегідь скомпільований шаблон, як Pattern. Ім'я групи: ?P<ім'я>.
pattern = re.compile(r"(?P<key>\w+)=(?P<value>\d+)")
# search шукає в будь-якому місці рядка, як find().
if match := pattern.search("size=42"):
print(match["key"]) # size
print(match["value"]) # 42

Колекції: основні методи

Які колекції чому відповідають

Основні колекції вбудовані в мову й мають власний синтаксис, тому new та імпорти для них не потрібні. Інтерфейсу List з різними реалізаціями теж немає: є один list, і всередині це динамічний масив, як ArrayList.

JavaPythonЗапис
ArrayListlist[1, 2]
List.oftuple(1, 2)
LinkedHashMapdict{"a": 1}
HashSetset{1, 2}
Set.offrozensetfrozenset({1, 2})
ArrayDeque, LinkedListcollections.dequedeque([1, 2])
PriorityQueueheapqфункції над списком
int[]list або array.array[0] * 3
TreeMapнемає, зазвичай sorted(d)
ConcurrentHashMapdict і Lock

Як працювати зі списком

Java, Python
var items = new ArrayList<>(List.of(3, 1, 2)); # Java
items = [3, 1, 2] # Python
items.size() # Java: 3
len(items) # Python: 3
items.get(0) # Java: 3
items[0] # Python: 3
# Останній елемент.
items.getLast() # Java: 2
items[-1] # Python: 2
items.set(0, 9); # Java
items[0] = 9 # Python
items.add(4); # Java
items.append(4) # Python
# Вставити за індексом.
items.add(0, 7); # Java
items.insert(0, 7) # Python
items.addAll(List.of(5, 6)); # Java
items.extend([5, 6]) # Python
items # Java: [7, 9, 1, 2, 4, 5, 6]
items # Python: [7, 9, 1, 2, 4, 5, 6]
# Видалити за індексом і отримати значення.
items.remove(0) # Java: 7
items.pop(0) # Python: 7
# Видалити за значенням.
items.remove(Integer.valueOf(9)); # Java
items.remove(9) # Python
items.contains(1) # Java: true
1 in items # Python: True
# Якщо значення немає, index дає ValueError, а не -1.
items.indexOf(2) # Java: 1
items.index(2) # Python: 1
items.isEmpty() # Java: false
not items # Python: False
Collections.max(items) # Java: 6
max(items) # Python: 6
items.stream().mapToInt(i -> i).sum() # Java: 18
sum(items) # Python: 18
# Перевернути на місці.
Collections.reverse(items); # Java
items.reverse() # Python
items # Java: [6, 5, 4, 2, 1]
items # Python: [6, 5, 4, 2, 1]
# Частина списку. У Java це вигляд на вихідний список, у Python копія.
items.subList(0, 2) # Java: [6, 5]
items[:2] # Python: [6, 5]
# Копія всього списку.
var copy = new ArrayList<>(items); # Java
copy = items.copy() # Python
# Склеювання й повторення.
[1] + [2] # Python: [1, 2]
[0] * 3 # Python: [0, 0, 0]
items.clear(); # Java
items.clear() # Python

Зріз можна використовувати й ліворуч від присвоювання, а множення приховує пастку на вкладених списках.

Python
items = [1, 2, 3, 4]
# Замінити частину списку.
items[1:3] = [20, 30]
print(items) # [1, 20, 30, 4]
# Видалити частину списку.
del items[1:3]
print(items) # [1, 4]
# Пастка: два посилання на той самий внутрішній список.
grid = [[0] * 2] * 2
grid[0][0] = 1
print(grid) # [[1, 0], [1, 0]]
# Правильно: кожен внутрішній список створюється окремо.
grid = [[0] * 2 for _ in range(2)]
grid[0][0] = 1
print(grid) # [[1, 0], [0, 0]]

Що таке кортеж

tuple — це незмінний список, найближчий аналог List.of. Його використовують для фіксованого набору значень, таких як координати чи рядок із бази. А ще його можна зробити ключем словника, а список не можна.

Java, Python
var point = List.of(10, 20); # Java
point = (10, 20) # Python
point.get(0) # Java: 10
point[0] # Python: 10
# point.set(0, 1) у Java і point[0] = 1 у Python дадуть помилку.
# Кортеж з одного елемента: кома обов'язкова.
var single = List.of(1); # Java
single = (1,) # Python
# Без коми це просто число в дужках.
type((1)).__name__ # Python: int
type((1,)).__name__ # Python: tuple
# Розкладання по змінних.
var x = point.get(0); # Java
x, y = point # Python
x # Java: 10
y # Python: 20
# Кортежі порівнюються поелементно.
(1, 2) < (1, 3) # Python: True

Як працювати зі словником

dict — це Map. Він зберігає порядок додавання, як LinkedHashMap. Найважливіша відмінність від Java: ages["Bob"] за відсутності ключа дає помилку KeyError, а не null. Поведінку як у get у Java дає метод get.

Java, Python
var ages = new LinkedHashMap<String, Integer>(); # Java
ages = {} # Python
ages.put("Ann", 30); # Java
ages["Ann"] = 30 # Python
ages.put("Bob", 25); # Java
ages["Bob"] = 25 # Python
ages.get("Ann") # Java: 30
ages["Ann"] # Python: 30
# Ключа немає.
ages.get("Cid") # Java: null
ages.get("Cid") # Python: None
ages.getOrDefault("Cid", 0) # Java: 0
ages.get("Cid", 0) # Python: 0
ages.containsKey("Ann") # Java: true
"Ann" in ages # Python: True
ages.containsValue(25) # Java: true
25 in ages.values() # Python: True
ages.size() # Java: 2
len(ages) # Python: 2
ages.keySet() # Java: [Ann, Bob]
list(ages) # Python: ['Ann', 'Bob']
ages.values() # Java: [30, 25]
list(ages.values()) # Python: [30, 25]
# Записати, лише якщо ключа ще немає.
ages.putIfAbsent("Cid", 40); # Java
ages.setdefault("Cid", 40) # Python: 40
# Видалити й отримати значення.
ages.remove("Cid") # Java: 40
ages.pop("Cid") # Python: 40
# Видалити, а якщо ключа немає, повернути значення за замовчуванням.
ages.pop("Cid", None) # Python: None
# Ключі по порядку, замість TreeMap.
new TreeMap<>(ages).firstKey() # Java: Ann
sorted(ages)[0] # Python: Ann

Перебір записується через items, він віддає пари ключ і значення. Він замінює entrySet.

Java
var ages = new LinkedHashMap<>(Map.of("Ann", 30));
for (var entry : ages.entrySet()) {
System.out.print(entry.getKey() + "=" + entry.getValue()); // Ann=30
}
for (String name : ages.keySet()) {
System.out.print(name); // Ann
}
ages.forEach((name, age) -> System.out.print(name + age));
Python
ages = {"Ann": 30}
for name, age in ages.items():
print(f"{name}={age}") # Ann=30
# Перебір самого словника йде за ключами.
for name in ages:
print(name) # Ann
# Об'єднання: за збігу ключів перемагає правий.
print({"a": 1} | {"a": 2, "b": 3}) # {'a': 2, 'b': 3}
# Дві зірочки розгортають словник усередині іншого.
print({**{"a": 1}, "b": 2}) # {'a': 1, 'b': 2}

Ключем може бути лише незмінне значення: рядок, число, кортеж, заморожений dataclass. Формально потрібен метод __hash__, це той самий hashCode. Список дав би помилку TypeError: unhashable type.

Як накопичувати значення за ключем

У Java для цього є computeIfAbsent і merge. У Python є setdefault і два спеціальні словники. defaultdict сам створює значення для нового ключа. Counter рахує, скільки разів трапляється кожен елемент.

Java
var byCity = new TreeMap<String, List<String>>();
byCity.computeIfAbsent("Kyiv", k -> new ArrayList<>()).add("Ann");
byCity.computeIfAbsent("Kyiv", k -> new ArrayList<>()).add("Bob");
System.out.println(byCity); // {Kyiv=[Ann, Bob]}
var counts = new TreeMap<String, Integer>();
for (String word : List.of("a", "b", "a")) {
counts.merge(word, 1, Integer::sum);
}
System.out.println(counts); // {a=2, b=1}
Python
from collections import Counter, defaultdict
# list тут — функція, яка створює значення для нового ключа.
by_city = defaultdict(list)
by_city["Kyiv"].append("Ann")
by_city["Kyiv"].append("Bob")
print(dict(by_city)) # {'Kyiv': ['Ann', 'Bob']}
counts = Counter(["a", "b", "a"])
print(dict(counts)) # {'a': 2, 'b': 1}
print(counts["a"]) # 2
# Найчастіші елементи: топ-1.
print(counts.most_common(1)) # [('a', 2)]

Як працювати з множиною

Методи схожі на Java, а замість retainAll і removeAll є звичайні оператори. Вони повертають нову множину й не змінюють вихідні.

Java, Python
var seen = new TreeSet<>(List.of(1, 2)); # Java
seen = {1, 2} # Python
seen.add(3); # Java
seen.add(3) # Python
seen.contains(3) # Java: true
3 in seen # Python: True
seen.remove(3); # Java
seen.remove(3) # Python
# У Python remove дасть KeyError, якщо значення немає. discard промовчить.
seen.discard(100) # Python
# Порожня множина: {} створить словник.
var empty = new HashSet<Integer>(); # Java
empty = set() # Python
# Об'єднання, перетин, різниця.
{1, 2} | {2, 3} # Python: {1, 2, 3}
{1, 2} & {2, 3} # Python: {2}
{1, 2} - {2, 3} # Python: {1}
# Чи входять усі елементи, як containsAll.
seen.containsAll(List.of(1)) # Java: true
{1} <= seen # Python: True

Як зробити чергу й пріоритетну чергу

list.pop(0) працює за O(n), бо зсуває весь масив, тому для черги використовують deque. Пріоритетна черга зроблена не класом, а функціями з модуля heapq. Вони працюють зі звичайним списком, а нагорі завжди лежить мінімальний елемент.

Java, Python
from collections import deque # Python
import heapq # Python
var queue = new ArrayDeque<>(List.of(1, 2)); # Java
queue = deque([1, 2]) # Python
queue.addLast(3); # Java
queue.append(3) # Python
queue.addFirst(0); # Java
queue.appendleft(0) # Python
queue.pollFirst() # Java: 0
queue.popleft() # Python: 0
queue.pollLast() # Java: 3
queue.pop() # Python: 3
# Подивитися, не видаляючи.
queue.peekFirst() # Java: 1
queue[0] # Python: 1
# Пріоритетна черга.
var tasks = new PriorityQueue<>(List.of(3, 1)); # Java
tasks = [1, 3] # Python
tasks.add(2); # Java
heapq.heappush(tasks, 2) # Python
tasks.poll() # Java: 1
heapq.heappop(tasks) # Python: 1
# З пріоритетом: кортеж порівнюється за першим елементом.
jobs = [(2, "later"), (1, "urgent")]; heapq.heapify(jobs) # Python
heapq.heappop(jobs) # Python: (1, 'urgent')
# N найбільших одразу.
heapq.nlargest(2, [1, 5, 3]) # Python: [5, 3]

Як сортувати

list.sort() змінює сам список, а sorted() повертає новий і приймає будь-яку колекцію. Правила сортування задає функція key. Для сортування за кількома полями вона просто повертає кортеж.

Java
record User(String name, int age) {
}
var users = new ArrayList<>(List.of(
new User("Ann", 30),
new User("Bob", 25),
new User("Cid", 30)
));
// За спаданням віку, потім за іменем.
users.sort(
Comparator.comparingInt(User::age).reversed()
.thenComparing(User::name)
);
System.out.println(users.getFirst().name()); // Ann
Python
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
users = [
User("Ann", 30),
User("Bob", 25),
User("Cid", 30),
]
# За спаданням віку, потім за іменем. Мінус перевертає порядок для чисел.
users.sort(key=lambda u: (-u.age, u.name))
print(users[0].name) # Ann
# sorted не змінює вихідний список, а reverse=True перевертає порядок.
print(sorted([2, 3, 1], reverse=True)) # [3, 2, 1]
print(sorted("bca")) # ['a', 'b', 'c']

Що буде, якщо змінювати колекцію під час перебору

У Java це дає ConcurrentModificationException. У словника й множини в Python поведінка така сама, а от список мовчки пропускає елементи.

Python
items = [1, 2, 2, 3]
# Помилки немає, але друга двійка залишилася.
for item in items:
if item == 2:
items.remove(item)
print(items) # [1, 2, 3]
# Правильно: зібрати новий список. Запис пояснюється в наступному розділі.
items = [1, 2, 2, 3]
items = [item for item in items if item != 2]
print(items) # [1, 3]
ages = {"a": 1}
try:
for key in ages:
ages["b"] = 2
except RuntimeError as e:
print(e) # dictionary changed size during iteration

Яка складність операцій

Значення ті самі, що й у відповідних колекцій у Java.

Операціяlistdict, setdeque
Читання за індексом чи ключемO(1)O(1)O(n)
x in cO(n)O(1)O(n)
Додати в кінецьO(1)O(1)O(1)
Додати чи прибрати на початкуO(n)–O(1)
sortedO(n log n)O(n log n)O(n log n)

Stream API: що замість нього

Що таке спискове включення

Замість ланцюжка filter і map у Python є особливий запис, який називається списковим включенням (list comprehension). Читається він як звичайна фраза: що покласти, звідки взяти, за якої умови. Цей запис замінює більшу частину Stream API.

Python
numbers = [1, 2, 3, 4]
# [ що покласти звідки взяти умова ]
squares = [n * n for n in numbers if n % 2 == 0]
print(squares) # [4, 16]
# Те саме звичайним циклом.
squares = []
for n in numbers:
if n % 2 == 0:
squares.append(n * n)
print(squares) # [4, 16]

Чим замінити операції зі стрімами

Основні операції з результатом видно одразу. Зверніть увагу: комбінацію map і filter записано одним виразом, без окремих викликів.

Java, Python
import functools # Python
var items = List.of(3, 1, 2, 3); # Java
items = [3, 1, 2, 3] # Python
# filter
items.stream().filter(i -> i > 1).toList() # Java: [3, 2, 3]
[i for i in items if i > 1] # Python: [3, 2, 3]
# map
items.stream().map(i -> i * 2).toList() # Java: [6, 2, 4, 6]
[i * 2 for i in items] # Python: [6, 2, 4, 6]
# sum, count, min, max
items.stream().mapToInt(i -> i).sum() # Java: 9
sum(items) # Python: 9
items.stream().filter(i -> i > 1).count() # Java: 3
sum(1 for i in items if i > 1) # Python: 3
items.stream().max(Integer::compare).get() # Java: 3
max(items) # Python: 3
# Середнє значення.
items.stream().mapToInt(i -> i).average().getAsDouble() # Java: 2.25
sum(items) / len(items) # Python: 2.25
# anyMatch, allMatch, noneMatch
items.stream().anyMatch(i -> i > 2) # Java: true
any(i > 2 for i in items) # Python: True
items.stream().allMatch(i -> i > 2) # Java: false
all(i > 2 for i in items) # Python: False
items.stream().noneMatch(i -> i > 5) # Java: true
not any(i > 5 for i in items) # Python: True
# findFirst
items.stream().filter(i -> i < 3).findFirst().orElse(null) # Java: 1
next((i for i in items if i < 3), None) # Python: 1
# sorted, limit, skip
items.stream().sorted().toList() # Java: [1, 2, 3, 3]
sorted(items) # Python: [1, 2, 3, 3]
items.stream().limit(2).toList() # Java: [3, 1]
items[:2] # Python: [3, 1]
items.stream().skip(2).toList() # Java: [2, 3]
items[2:] # Python: [2, 3]
# distinct зі збереженням порядку: у словника ключі унікальні.
items.stream().distinct().toList() # Java: [3, 1, 2]
list(dict.fromkeys(items)) # Python: [3, 1, 2]
# Без порядку досить множини.
items.stream().collect(Collectors.toCollection(TreeSet::new)) # Java: [1, 2, 3]
{i for i in items} # Python: {1, 2, 3}
# flatMap
Stream.of(List.of(1), List.of(2)).flatMap(List::stream).toList() # Java: [1, 2]
[x for row in [[1], [2]] for x in row] # Python: [1, 2]
# joining
var words = items.stream().map(String::valueOf); # Java
words.collect(Collectors.joining(",")) # Java: 3,1,2,3
",".join(str(i) for i in items) # Python: 3,1,2,3
# reduce живе у functools.
items.stream().reduce(1, (a, b) -> a * b) # Java: 18
functools.reduce(lambda a, b: a * b, items) # Python: 18

Круглі дужки замість квадратних у виразах на кшталт sum(1 for i in items if i > 1) створюють генератор. Він не будує список у пам'яті, а видає значення по одному, як лінивий стрім. Докладніше про це розказано нижче.

Як зібрати словник і згрупувати значення

Є включення і для словника, і для множини. А от у groupingBy простого аналога немає: групування зазвичай пишуть циклом.

Java
record User(String name, String city, int age) {
}
var users = List.of(
new User("Ann", "Kyiv", 30),
new User("Bob", "Lviv", 25),
new User("Cid", "Kyiv", 40)
);
var agesByName = users.stream()
.collect(Collectors.toMap(User::name, User::age));
System.out.println(agesByName.get("Ann")); // 30
var byCity = users.stream()
.collect(Collectors.groupingBy(User::city));
System.out.println(byCity.get("Kyiv").size()); // 2
var countByCity = users.stream()
.collect(Collectors.groupingBy(User::city, Collectors.counting()));
System.out.println(countByCity.get("Kyiv")); // 2
var adults = users.stream()
.collect(Collectors.partitioningBy(u -> u.age() >= 30));
System.out.println(adults.get(true).size()); // 2
Python
from collections import Counter, defaultdict
from dataclasses import dataclass
@dataclass
class User:
name: str
city: str
age: int
users = [
User("Ann", "Kyiv", 30),
User("Bob", "Lviv", 25),
User("Cid", "Kyiv", 40),
]
# toMap
ages_by_name = {u.name: u.age for u in users}
print(ages_by_name["Ann"]) # 30
# groupingBy
by_city = defaultdict(list)
for user in users:
by_city[user.city].append(user)
print(len(by_city["Kyiv"])) # 2
# groupingBy + counting
count_by_city = Counter(u.city for u in users)
print(count_by_city["Kyiv"]) # 2
# partitioningBy
adults = [u for u in users if u.age >= 30]
print(len(adults)) # 2

Чим замінити Optional

Optional у Python не використовують. Відсутність значення — це None, а тип записують як User | None. Ланцюжки замінюються звичайними перевірками.

Java
record User(String name) {
}
Optional<User> findUser(int id) {
return id == 1 ? Optional.of(new User("Ann")) : Optional.empty();
}
String name = findUser(2)
.map(User::name)
.orElse("guest");
System.out.println(name); // guest
Python
from dataclasses import dataclass
@dataclass
class User:
name: str
def find_user(user_id: int) -> User | None:
return User("Ann") if user_id == 1 else None
user = find_user(2)
name = user.name if user is not None else "guest"
print(name) # guest

Часто потрібні класи стандартної бібліотеки

Як працювати з датою та часом

Замість java.time є модуль datetime. Важлива деталь: datetime може бути з часовим поясом або без нього. Без поясу це аналог LocalDateTime, із поясом — ZonedDateTime. Порівняти такі значення між собою не можна, а виклик datetime.now() без аргументів віддає значення без поясу. У сервісах завжди пишіть datetime.now(UTC).

Java, Python
from datetime import UTC, date, datetime, timedelta # Python
from zoneinfo import ZoneInfo # Python
import java.time.format.DateTimeFormatter; # Java
import java.time.*; # Java
var day = LocalDate.of(2026, 11, 6); # Java
day = date(2026, 11, 6) # Python
# Додати й відняти: замість plusDays — звичайні оператори.
day.plusDays(30) # Java: 2026-12-06
day + timedelta(days=30) # Python: 2026-12-06
day.getDayOfWeek().getValue() # Java: 5
day.isoweekday() # Python: 5
# Різниця між датами.
Duration.ofHours(36).toDays() # Java: 1
timedelta(hours=36).days # Python: 1
# У рядок і назад.
day.toString() # Java: 2026-11-06
day.isoformat() # Python: 2026-11-06
LocalDate.parse("2026-01-02").getYear() # Java: 2026
date.fromisoformat("2026-01-02").year # Python: 2026
day.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) # Java: 06.11.2026
day.strftime("%d.%m.%Y") # Python: 06.11.2026
# Поточний момент із поясом.
var now = ZonedDateTime.now(ZoneOffset.UTC); # Java
now = datetime.now(UTC) # Python
var kyiv = ZoneId.of("Europe/Kyiv"); # Java
var at = ZonedDateTime.of(2026, 1, 2, 12, 0, 0, 0, kyiv); # Java
at = datetime(2026, 1, 2, 12, tzinfo=ZoneInfo("Europe/Kyiv")) # Python
at.toEpochSecond() # Java: 1767348000
int(at.timestamp()) # Python: 1767348000

Як працювати з файлами

Path і Files у Python об'єднані в одному класі pathlib.Path. Частини шляху з'єднуються оператором /.

Java, Python
import tempfile # Python
from pathlib import Path # Python
Path dir = Files.createTempDirectory("demo"); # Java
dir = Path(tempfile.mkdtemp()) # Python
Path file = dir.resolve("logs").resolve("a.txt"); # Java
file = dir / "logs" / "a.txt" # Python
file.getFileName() # Java: a.txt
file.name # Python: a.txt
file.getParent().getFileName() # Java: logs
file.parent.name # Python: logs
Files.exists(file) # Java: false
file.exists() # Python: False
# Створити каталоги разом із батьківськими.
Files.createDirectories(file.getParent()); # Java
file.parent.mkdir(parents=True, exist_ok=True) # Python
Files.writeString(file, "one\ntwo\n"); # Java
file.write_text("one\ntwo\n", encoding="utf-8") # Python
Files.readAllLines(file) # Java: [one, two]
file.read_text(encoding="utf-8").splitlines() # Python: ['one', 'two']
# Обійти каталог з усіма підкаталогами.
Files.walk(dir).filter(Files::isRegularFile).count() # Java: 1
sum(1 for f in dir.rglob("*.txt")) # Python: 1
Files.delete(file); # Java
file.unlink() # Python

Великий файл читають порядково. Об'єкт файлу в Python можна перебирати в циклі, а в пам'ять при цьому потрапляє один рядок за раз.

Java
Path path = Files.createTempFile("big", ".txt");
Files.writeString(path, "1\n2\n");
try (var lines = Files.lines(path)) {
System.out.println(lines.mapToInt(Integer::parseInt).sum()); // 3
}
Python
import tempfile
from pathlib import Path
path = Path(tempfile.mkdtemp()) / "big.txt"
path.write_text("1\n2\n", encoding="utf-8")
with open(path, encoding="utf-8") as file:
print(sum(int(line) for line in file)) # 3

Як працювати з JSON

Модуль json входить до стандартної бібліотеки. На відміну від Jackson, він працює лише зі словниками й списками, а у ваші класи не перетворює. Для цього в реальних проєктах беруть Pydantic. Об'єкт dataclass можна перетворити на словник функцією asdict.

Java
ObjectMapper mapper = new ObjectMapper();
String text = mapper.writeValueAsString(new User("Ann", 30));
User user = mapper.readValue(text, User.class);
Map<String, Object> raw = mapper.readValue(text, Map.class);
Python
import json
from dataclasses import asdict, dataclass
@dataclass
class User:
name: str
age: int
# Об'єкт у JSON: спочатку словник, потім рядок.
text = json.dumps(asdict(User("Ann", 30)))
print(text) # {"name": "Ann", "age": 30}
# JSON у словник.
raw = json.loads(text)
print(raw["name"]) # Ann
# Словник в об'єкт: дві зірочки розкладають його на іменовані аргументи.
print(User(**raw)) # User(name='Ann', age=30)
# Типи на кшталт datetime і Decimal json не знає, потрібна функція default.
from decimal import Decimal
print(json.dumps({"sum": Decimal("1.5")}, default=str)) # {"sum": "1.5"}

Як писати логи

Модуль logging — це SLF4J і Logback в одному флаконі. Логер створюють у кожному модулі, а налаштовують один раз під час запуску. Змінна __name__ містить ім'я поточного модуля. Повідомлення пишуть через %s, як {} у SLF4J.

Java
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("order {} paid", id);
try {
pay(id);
} catch (Exception e) {
log.error("payment failed", e);
}
Python
import logging
log = logging.getLogger(__name__)
# Налаштування під час запуску програми, замість logback.xml.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log.info("order %s paid", 42)
try:
1 / 0
except ZeroDivisionError:
# exception пише повідомлення й весь стек, як log.error("...", e).
log.exception("payment failed")

Де шукати решту

Java, Python
import hashlib # Python
import os # Python
import sys # Python
import time # Python
import uuid # Python
# Змінна оточення або значення за замовчуванням.
String port = System.getenv().getOrDefault("NO_SUCH_VAR", "80"); # Java
port = os.environ.get("NO_SUCH_VAR", "80") # Python
# Аргументи командного рядка. sys.argv[0] — ім'я файлу.
String[] args = new String[0]; # Java
args = sys.argv[1:] # Python
# Виміряти час.
long start = System.nanoTime(); # Java
start = time.perf_counter() # Python
Thread.sleep(1); # Java
time.sleep(0.001) # Python
# Унікальний ідентифікатор.
UUID.randomUUID().toString().length() # Java: 36
len(str(uuid.uuid4())) # Python: 36
# Перші 8 символів хешу SHA-256.
var sha = java.security.MessageDigest.getInstance("SHA-256"); # Java
HexFormat.of().formatHex(sha.digest(new byte[0])).substring(0, 8) # Java: e3b0c442
hashlib.sha256(b"").hexdigest()[:8] # Python: e3b0c442
JavaPython
System.exit(1)sys.exit(1)
System.err.println(x)print(x, file=sys.stderr)
new Scanner(System.in)input()
HttpClienturllib.request, на практиці httpx
URLEncoder.encodeurllib.parse.quote
Propertiesconfigparser, tomllib
@Cacheablefunctools.lru_cache
Thread, ExecutorServicethreading, concurrent.futures
CompletableFutureasyncio
JUnitunittest, на практиці pytest

Чим Python влаштований інакше, ніж Java

Як виконується код

Основна реалізація мови називається CPython. Як і javac з JVM, вона компілює вихідний код у байткод і виконує його на віртуальній машині, тільки обидва кроки йдуть однією командою. Байткод кешується в каталозі __pycache__, як файли .class. Головна відмінність від JVM у тому, що байткод зазвичай інтерпретується. JIT-компілятор, який на JVM перетворює гарячий код на машинний, у CPython лише з'являється і поки експериментальний. Звідси й різниця у швидкості.

JavaPython
Компіляціяjavac заздалегідьпід час запуску
Помилку типу виднопід час компіляціїпід час виконання рядка
JITC2, Graalекспериментальний
Старт програмидовшийшвидший
Збирання сміттятрасувальнелічильник посилань і збирач циклів
Паралельні потокитакзазвичай ні, через GIL

Оголошення класу чи функції в Python — це звичайна команда, яка виконується, коли до неї дійшла черга. У Java класи завантажує JVM, а тут файл просто йде згори донизу. Цифри показують порядок.

Python
print("start") # 1. start
# Тут функція лише створюється. Тіло не виконується.
def greet():
print("hi") # 4. hi
# А тіло класу виконується одразу.
class Config:
print("class body") # 2. class body
print("end") # 3. end
greet()

Це пояснює багато що з подальшого. Імпорт файлу теж просто виконує його згори донизу, тому побічний код на верхньому рівні модуля спрацює під час першого імпорту. Тому й потрібна перевірка if __name__ == "__main__".

Що таке змінна

Усі змінні в Python влаштовані як посилальні змінні в Java. Примітивів немає, тому змінна завжди зберігає посилання на об'єкт, навіть для числа. Присвоювання b = a копіює лише посилання. Зручно уявляти змінну як ярлик, приклеєний до об'єкта.

a = [1, 2] a ──► [1, 2]
b = a a ──► [1, 2] ◄── b
b.append(3) a ──► [1, 2, 3] ◄── b
b = [9] a ──► [1, 2, 3]
b ──► [9]
Java
var a = new ArrayList<>(List.of(1, 2));
var b = a;
b.add(3);
System.out.println(a); // [1, 2, 3]
b = new ArrayList<>(List.of(9));
System.out.println(a); // [1, 2, 3]
Python
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3]
# Ярлик b перевішено на інший об'єкт. Об'єкт a не змінився.
b = [9]
print(a) # [1, 2, 3]
# id() — це адреса об'єкта, як System.identityHashCode.
c = a
print(id(c) == id(a)) # True

Як передаються аргументи у функцію

Так само, як об'єкти в Java: функція отримує копію посилання. Вміст об'єкта вона змінити може, а замінити змінну у викликального коду не може.

Java
void change(List<Integer> items) {
items.add(1);
items = new ArrayList<>();
items.add(99);
}
var data = new ArrayList<Integer>();
change(data);
System.out.println(data); // [1]
Python
def change(items):
# 1. Змінюємо спільний об'єкт: це видно ззовні.
items.append(1)
# 2. Перевішуємо локальний ярлик: ззовні не видно.
items = []
items.append(99)
data = []
change(data)
print(data) # [1]

Які типи змінювані, а які ні

НезмінніЗмінювані
int, float, bool, Nonelist
str, bytesdict
tuple, frozensetset, bytearray
@dataclass(frozen=True)звичайні об'єкти

Звідси важлива пастка з +=. Для змінюваного об'єкта він змінює сам об'єкт, а для незмінного створює новий і перевішує ярлик.

Python
# Список: один об'єкт на двох, зміну видно через обидва ярлики.
a = [1]
b = a
a += [2]
print(b) # [1, 2]
# Рядок: a тепер вказує на новий рядок, а b на старий.
a = "x"
b = a
a += "y"
print(b) # x

Як зробити копію об'єкта

list(x) або x.copy() — це поверхнева копія, як new ArrayList<>(x): копіюється сам список, а вкладені об'єкти лишаються спільними. Модуль copy уміє копіювати все дерево об'єктів. Для цього в Java довелося б писати код або серіалізувати об'єкт.

Python
import copy
original = [[1], [2]]
# Поверхнева копія: новий зовнішній список, ті самі внутрішні.
shallow = original.copy()
shallow[0].append(9)
print(original) # [[1, 9], [2]]
# Глибока копія: усе нове.
deep = copy.deepcopy(original)
deep[0].append(7)
print(original) # [[1, 9], [2]]

Чому числа не можна порівнювати через is

Це та сама історія, що з Integer у Java. Там кешуються значення від -128 до 127, тому == іноді працює, а іноді ні. CPython теж кешує невеликі числа, і межа залежить від версії. Тому is використовують лише з None, True, False і елементами enum.

Java
Integer small1 = 127, small2 = 127;
System.out.println(small1 == small2); // true
Integer big1 = 1000, big2 = 1000;
System.out.println(big1 == big2); // false
System.out.println(big1.equals(big2)); // true
Python
small1 = int("256")
small2 = int("256")
print(small1 is small2) # True
big1 = int("100000")
big2 = int("100000")
print(big1 is big2) # False
print(big1 == big2) # True

Де видно змінну

У Java змінна живе всередині свого блоку у фігурних дужках. У Python блоків видимості немає: if і for їх не створюють. Межі проводять лише функції, класи й модулі. Змінна шукається по черзі в чотирьох місцях, цей порядок називають LEGB: Local (своя функція), Enclosing (зовнішня функція), Global (файл), Built-in (вбудовані імена).

Java
for (int i = 0; i < 3; i++) {
int square = i * i;
}
// Помилка компіляції: i та square тут не існують.
// System.out.println(i);
Python
for i in range(3):
square = i * i
# Обидві змінні живі: цикл не створює своєї області.
print(i, square) # 2 4
# Виняток: змінну всередині включення ззовні не видно.
squares = [n * n for n in range(3)]

Читання зовнішньої змінної працює саме. А от присвоювання всередині функції створює локальну змінну. Щоб записати в зовнішню, потрібно сказати про це словом global або nonlocal.

Python
count = 0
def broken():
# Присвоювання нижче робить count локальною в усій функції,
# тому читання падає: UnboundLocalError.
count += 1
def works():
global count
count += 1
works()
print(count) # 1
def counter():
total = 0
def add():
# nonlocal — змінна зовнішньої функції.
nonlocal total
total += 1
return total
return add
add = counter()
add()
print(add()) # 2

Що вважається істинним в умові

Хибою вважаються порожні й нульові значення, решта вважається істиною. Власний клас керує цим через методи __bool__ або __len__.

Python
print(bool(0), bool(0.0), bool(""), bool(None)) # False False False False
print(bool([]), bool({}), bool(set()), bool(())) # False False False False
print(bool(1), bool("0"), bool([0])) # True True True
# bool — нащадок int, тому True дорівнює 1. Так рахують збіги.
print(True + True) # 2
print(sum(n > 1 for n in [1, 2, 3])) # 2

Що означає «просити вибачення, а не дозволу»

У Java прийнято спочатку перевірити, а потім робити. У Python частіше роблять одразу й обробляють помилку. Такий стиль називають EAFP (easier to ask forgiveness than permission). Він ще й прибирає гонку між перевіркою та дією. Винятки тут не вважаються дорогою рідкістю, ними користуються звично.

Java
var config = new HashMap<>(Map.of("port", "80"));
int port = 0;
if (config.containsKey("port")) {
port = Integer.parseInt(config.get("port"));
}
System.out.println(port); // 80
Python
config = {"port": "80"}
try:
port = int(config["port"])
except (KeyError, ValueError):
port = 0
print(port) # 80

Як власний клас вбудовується в мову

У Java for працює з Iterable, а + працює лише з рядками й числами. У Python в усіх операторів і вбудованих функцій є відповідний dunder-метод. Оголосіть його у своєму класі, і оператор запрацює. Цей набір правил називають моделлю даних.

Що пишетеЩо викликаєтьсяАналог у Java
Cart()__init__конструктор
len(x)__len__size()
x[i]__getitem__get(i)
x[i] = v__setitem__set(i, v)
v in x__contains__contains(v)
for v in x__iter__iterator()
a + b__add__метод add
a == b__eq__equals
a < b__lt__compareTo
hash(x)__hash__hashCode
str(x), repr(x)__str__, __repr__toString
x()__call__метод функціонального інтерфейсу
with x:__enter__, __exit__AutoCloseable
Java
class Cart implements Iterable<String> {
private final List<String> items = new ArrayList<>();
void add(String item) {
items.add(item);
}
int size() {
return items.size();
}
String get(int index) {
return items.get(index);
}
public Iterator<String> iterator() {
return items.iterator();
}
}
var cart = new Cart();
cart.add("tea");
System.out.println(cart.size()); // 1
System.out.println(cart.get(0)); // tea
Python
class Cart:
def __init__(self):
self._items = []
def add(self, item):
self._items.append(item)
def __len__(self):
return len(self._items)
def __getitem__(self, index):
return self._items[index]
def __iter__(self):
return iter(self._items)
def __contains__(self, item):
return item in self._items
cart = Cart()
cart.add("tea")
# Об'єкт поводиться як вбудована колекція.
print(len(cart)) # 1
print(cart[0]) # tea
print("tea" in cart) # True
print(list(cart)) # ['tea']
# Порожній кошик хибний, бо len дорівнює 0.
print(bool(Cart())) # False

Так само перевантажують оператори. Порівняння зручно отримати з одного методу: __lt__ замінює Comparable, а декоратор total_ordering добудує <=, > і >=.

Java
record Money(int cents) implements Comparable<Money> {
Money add(Money other) {
return new Money(cents + other.cents);
}
public int compareTo(Money other) {
return Integer.compare(cents, other.cents);
}
}
var a = new Money(100);
var b = new Money(50);
System.out.println(a.add(b)); // Money[cents=150]
System.out.println(a.compareTo(b) > 0); // true
Python
from dataclasses import dataclass
from functools import total_ordering
@total_ordering
@dataclass(frozen=True)
class Money:
cents: int
def __add__(self, other):
return Money(self.cents + other.cents)
def __lt__(self, other):
return self.cents < other.cents
a = Money(100)
b = Money(50)
print(a + b) # Money(cents=150)
print(a > b) # True
# min, max і sorted тепер працюють самі.
print(min(a, b)) # Money(cents=50)

Функції: чого немає в Java

Чому не можна писати список як значення за замовчуванням

Значення за замовчуванням обчислюється один раз, під час оголошення функції, і зберігається в ній. Якщо це список, усі виклики ділять той самий об'єкт. Це найвідоміша пастка Python.

Python
# 1. Тут один раз створюється список.
def add_bad(item, items=[]):
items.append(item)
return items
# 2. Перший виклик кладе в нього 1.
print(add_bad(1)) # [1]
# 3. Другий виклик отримує той самий список.
print(add_bad(2)) # [1, 2]
# Правильно: None за замовчуванням, а новий список усередині.
def add_good(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_good(1)) # [1]
print(add_good(2)) # [2]

Що таке замикання

Замикання — це функція, яка запам'ятала змінні з місця, де її створено. Java-лямбди теж так уміють, але захоплюють лише final значення. Python захоплює саму змінну, тому її можна змінювати, а видно буде її останнє значення.

Java
Function<Integer, Integer> makeAdder(int base) {
return x -> x + base;
}
var addTen = makeAdder(10);
System.out.println(addTen.apply(5)); // 15
var tasks = new ArrayList<Supplier<Integer>>();
for (int i = 0; i < 3; i++) {
// Без цієї копії не скомпілюється: i змінюється.
int copy = i;
tasks.add(() -> copy);
}
System.out.println(tasks.get(0).get()); // 0
Python
def make_adder(base):
return lambda x: x + base
add_ten = make_adder(10)
print(add_ten(5)) # 15
# Пастка: усі лямбди бачать одну змінну i, а цикл уже закінчився.
tasks = [lambda: i for i in range(3)]
print(tasks[0]()) # 2
# Рішення: значення за замовчуванням обчислюється одразу й запам'ятовується.
tasks = [lambda i=i: i for i in range(3)]
print(tasks[0]()) # 0

Що таке декоратор

Запис @something над функцією схожий на анотацію в Java, але працює інакше. Анотація — це позначка, яку потім читає хтось інший, наприклад Spring. Декоратор — це звичайна функція. Вона отримує вашу функцію й повертає іншу, яка замінить вихідну. Це схоже на проксі, які Spring створює для @Transactional. Ось як це влаштовано по кроках.

Python
def logged(func):
# 1. Під час оголошення add викликається logged(add).
# 2. Він створює wrapper і повертає його замість add.
def wrapper(*args, **kwargs):
print("before") # 3. before
result = func(*args, **kwargs)
print("after") # 5. after
return result
return wrapper
@logged
def add(a, b):
print("add") # 4. add
return a + b
# Тепер ім'я add вказує на wrapper.
print(add(2, 3)) # 6. 5

Запис @logged — це лише коротка форма присвоювання. Ті самі два варіанти поруч:

Python
@logged
def add(a, b):
return a + b
# Точно те саме:
def add(a, b):
return a + b
add = logged(add)

У справжнього декоратора є ще одна деталь. wrapper підміняє функцію, і ім'я з документацією губляться. Їх повертає functools.wraps. Параметри в декоратора теж можливі, для цього потрібен ще один рівень вкладеності: @retry(times=3) спочатку викликає retry(times=3), і вже результат працює декоратором.

Python
from functools import wraps
# 1. retry(times=3) створює й повертає decorator.
def retry(times):
# 2. decorator(flaky) створює й повертає wrapper.
def decorator(func):
# 3. Виклик flaky() тепер виконує wrapper.
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except ConnectionError:
if attempt == times - 1:
raise
return wrapper
return decorator
calls = []
@retry(times=3)
def flaky():
calls.append(1)
if len(calls) < 3:
raise ConnectionError
return "ok"
print(flaky()) # ok
print(len(calls)) # 3
# Без wraps тут було б wrapper.
print(flaky.__name__) # flaky

Які декоратори є готові

Кеш результатів, як @Cacheable у Spring, є у стандартній бібліотеці. Розбір за типом першого аргументу частково замінює перевантаження.

Python
from functools import lru_cache, singledispatch
# Зберігає результати викликів за аргументами: максимум 128 останніх.
@lru_cache(maxsize=128)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(50)) # 12586269025
print(fib.cache_info().hits) # 48
# Вибирає реалізацію за типом першого аргументу.
@singledispatch
def show(value):
return "other"
@show.register
def _(value: int):
return "number"
@show.register
def _(value: str):
return "text"
print(show(1)) # number
print(show("a")) # text
print(show(1.5)) # other

Класи: що ще потрібно знати

Чим поле класу відрізняється від поля об'єкта

Під час читання user.role Python спочатку шукає атрибут у самого об'єкта, а потім у класу. Під час запису атрибут завжди створюється в об'єкта. Через це поле класу зі списком виявляється спільним на всі об'єкти.

Python
class User:
role = "guest"
tags = []
a = User()
b = User()
# Читаємо поле класу через об'єкт.
print(a.role) # guest
# Запис створив поле в a, а в класу і в b усе як раніше.
a.role = "admin"
print(b.role) # guest
# Пастка: це не запис, а зміна спільного списку.
a.tags.append("x")
print(b.tags) # ['x']
# Поля об'єкта лежать у словнику. Тому їх можна додавати на льоту.
a.city = "Kyiv"
print(a.__dict__) # {'role': 'admin', 'city': 'Kyiv'}

Словник на кожен об'єкт займає пам'ять. Якщо об'єктів мільйони, поля оголошують заздалегідь через __slots__. Заразом стає неможливо додати випадкове поле з друкарською помилкою. У dataclass для цього є параметр slots=True.

Python
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
point = Point(1, 2)
try:
point.z = 3
except AttributeError as e:
print(type(e).__name__) # AttributeError

Як працює множинне успадкування

У Java успадкування класів одиночне, а спільна поведінка виноситься в default-методи інтерфейсів. У Python батьківських класів може бути кілька. Порядок, у якому шукається метод, називається MRO (Method Resolution Order). Він лінійний, і його можна подивитися. Важливий момент: super() викликає не батьківський клас, а наступний клас у цьому порядку.

Python
class Base:
def run(self):
print("Base") # 3. Base
class Left(Base):
def run(self):
print("Left") # 1. Left
super().run()
class Right(Base):
def run(self):
print("Right") # 2. Right
super().run()
class Both(Left, Right):
pass
# Порядок: Both, Left, Right, Base, object. Після Left іде Right, а не Base.
names = [c.__name__ for c in Both.__mro__]
print(names) # ['Both', 'Left', 'Right', 'Base', 'object']
Both().run()

На практиці множинне успадкування використовують для міксинів. Міксин — це маленький клас з однією можливістю, який підмішують до основного. Це найближчий аналог default-методів інтерфейсу.

Java
interface Jsonable {
Map<String, Object> fields();
default String toJson() {
return fields().toString();
}
}
record User(String name) implements Jsonable {
public Map<String, Object> fields() {
return Map.of("name", name);
}
}
System.out.println(new User("Ann").toJson()); // {name=Ann}
Python
import json
class JsonMixin:
def to_json(self):
return json.dumps(self.__dict__)
class User(JsonMixin):
def __init__(self, name):
self.name = name
print(User("Ann").to_json()) # {"name": "Ann"}

Як звернутися до поля за іменем

Рефлексія в Python проста й вбудована в мову. Для звернення до атрибута за іменем є функції getattr, setattr і hasattr.

Java
class User {
public String name = "Ann";
public String greet() {
return "Hi, " + name;
}
}
var user = new User();
var field = User.class.getField("name");
System.out.println(field.get(user)); // Ann
var method = User.class.getMethod("greet");
System.out.println(method.invoke(user)); // Hi, Ann
Python
class User:
name = "Ann"
def greet(self):
return f"Hi, {self.name}"
user = User()
print(getattr(user, "name")) # Ann
print(getattr(user, "age", 0)) # 0
print(hasattr(user, "greet")) # True
# Метод — звичайний атрибут, його можна отримати й викликати.
print(getattr(user, "greet")()) # Hi, Ann
# Методи можна замінити просто під час роботи. Це monkey patching.
User.greet = lambda self: "Hello"
print(user.greet()) # Hello

Метод __getattr__ викликається, якщо атрибут не знайдено звичайним шляхом. З його допомогою за кілька рядків пишуть проксі, для чого в Java потрібен java.lang.reflect.Proxy з інтерфейсом.

Python
class Logged:
def __init__(self, target):
self._target = target
# 1. Атрибута append у Logged немає, тому викликано __getattr__.
# 2. Він бере атрибут у справжнього об'єкта.
def __getattr__(self, name):
print(f"access {name}") # access append
return getattr(self._target, name)
items = Logged([])
# 3. Отриманий метод викликається з аргументом 1.
items.append(1)

Що ще трапиться в чужому коді

Ці речі потрібні рідко, але в коді фреймворків вони є.

ЩоЩо робитьАналог у Java
__new__створює об'єкт, а __init__ його заповнюєнемає
Дескриптороб'єкт із методами __get__ і __set__ у полі класу. Так влаштовані property і методинемає
Метакласклас, який створює класи. Так Django збирає моделі за їхніми полямиобробка анотацій і генерація байткоду
__init_subclass__викликається під час оголошення нащадка, зручний для реєстраціїServiceLoader
__del__фіналізатор, покладатися на нього не можна, замість нього withfinalize

Підказки типів

Чи перевіряє Python типи

Ні. Анотації зберігаються, але під час запуску ні на що не впливають. Перевіряє їх окрема програма, найчастіше mypy або pyright, яку запускають так само, як лінтер у CI. Виходить компілятор Java, тільки необов'язковий. У сервісах анотації пишуть усюди, а перевірку вмикають у строгому режимі.

Python
def double(value: int) -> int:
return value * 2
# Python виконає це без помилок. Помилку покаже лише mypy.
print(double("ab")) # abab
# Анотації можна прочитати. На цьому побудовані Pydantic і FastAPI.
print(double.__annotations__["value"].__name__) # int

Як записуються типи

JavaPython
int, Stringint, str
List<String>list[str]
Map<String, Integer>dict[str, int]
Set<Long>set[int]
int[]list[int]
пара значеньtuple[str, int]
незмінний списокtuple[str, ...]
@Nullable Stringstr | None
Objectobject
немає перевірки взагаліAny
Collection<? extends T>Iterable[T], Sequence[T]
Function<Integer, String>Callable[[int], str]
Class<T>type[T]
voidNone
sealed interface з варіантамиA | B
обмежений набір рядківLiteral["asc", "desc"]
повертає thisSelf
ніколи не повертаєNoReturn
typealias: немає в Javatype UserId = int

У старому коді типи мають інший вигляд. Зустрінете таке — знайте, що це те саме.

Старий записНовий записЗ якої версії
List[str], Dict[str, int]list[str], dict[str, int]3.9
Optional[str]str | None3.10
Union[int, str]int | str3.10
T = TypeVar("T"), Generic[T]class Box[T]:3.12

Параметр Sequence[T] приймає і список, і кортеж, а змінювати його не можна. Це та сама ідея, що List<? extends T>: щоб приймати підтипи, параметр має бути лише для читання. Тому для параметрів функцій краще широкі типи на кшталт Iterable і Sequence, а для результату конкретний list.

Python
from collections.abc import Callable, Iterable
def total(items: Iterable[int]) -> int:
return sum(items)
# Підходить будь-яка колекція, що перебирається.
print(total([1, 2])) # 3
print(total({3, 4})) # 7
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
print(apply(lambda x: x + 1, 1)) # 2

Ітератори та генератори

Як влаштований цикл for

Це ті самі Iterable та Iterator, що в Java. Метод __iter__ видає ітератор. Метод __next__ видає наступне значення, а кінець повідомляє не через hasNext, а винятком StopIteration.

Java
var iterator = List.of("a", "b").iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next()); // ab
}
Python
# 1. for викликає iter(items).
iterator = iter(["a", "b"])
# 2. На кожному кроці він викликає next(iterator).
print(next(iterator)) # a
print(next(iterator)) # b
# 3. StopIteration завершує цикл for, ніхто її не бачить.
print(next(iterator, "end")) # end

Що таке генератор

Написати ітератор у Java — це клас із полями, що зберігають стан, і двома методами. У Python для цього є генератор: функція, у якій замість return стоїть yield. Вона видає значення й завмирає на цьому рядку, зберігши всі змінні, а за наступного запиту продовжує з того самого місця. Цифри показують порядок виконання.

Python
def numbers():
print("start") # 2. start
yield 1
print("resume") # 4. resume
yield 2
print("end") # 6. end
# 1. Виклик лише створює генератор, тіло ще не виконується.
gen = numbers()
# 2. next запускає тіло й зупиняється на першому yield.
print(next(gen)) # 3. 1
# 4. Наступний next продовжує з місця зупинки.
print(next(gen)) # 5. 2
# 6. Тіло дійшло до кінця: це StopIteration, отже, значень більше немає.
print(next(gen, "no more")) # 7. no more

Порівняйте те саме завдання: видати числа від from до to.

Java
class Range implements Iterable<Integer> {
final int from, to;
Range(int from, int to) {
this.from = from;
this.to = to;
}
public Iterator<Integer> iterator() {
return new Iterator<>() {
int current = from;
public boolean hasNext() {
return current < to;
}
public Integer next() {
return current++;
}
};
}
}
for (int n : new Range(1, 3)) {
System.out.print(n); // 12
}
Python
def count_range(start, end):
current = start
while current < end:
yield current
current += 1
for n in count_range(1, 3):
print(n) # 1 2

Навіщо потрібні генератори

Вони ліниві, як стріми в Java: значення з'являється, лише коли його запросили. Тому можна описати нескінченну послідовність, і тому великий файл проходить через ланцюжок функцій, не завантажуючись у пам'ять.

Java
var firstFive = Stream.iterate(0, n -> n + 2)
.limit(5)
.toList();
System.out.println(firstFive); // [0, 2, 4, 6, 8]
Python
from itertools import islice
# Нескінченний цикл тут безпечний: код стоїть на yield, поки не попросять.
def evens():
n = 0
while True:
yield n
n += 2
# islice — це limit.
print(list(islice(evens(), 5))) # [0, 2, 4, 6, 8]
Java
var lines = Stream.of("1", "x", "2");
int total = lines
.filter(line -> line.matches("\\d+"))
.mapToInt(Integer::parseInt)
.sum();
System.out.println(total); // 3
Python
lines = ["1", "x", "2"]
# Круглі дужки: це вираз-генератор. Кожен крок лінивий, як у стрімі.
digits = (line for line in lines if line.isdigit())
numbers = (int(line) for line in digits)
# Робота починається лише тут.
print(sum(numbers)) # 3

Різниця між квадратними й круглими дужками — головне, що тут треба запам'ятати. Перший варіант одразу будує весь список у пам'яті, а другий не будує нічого.

Python
import sys
as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list) > 1_000_000) # True
print(sys.getsizeof(as_gen) < 1_000) # True

Генератор, як і стрім, можна пройти лише один раз. Другий прохід не дасть помилки, а просто буде порожнім. Те саме стосується результатів map, filter і zip.

Java
var stream = Stream.of(1, 2);
stream.toList();
// Повторно: IllegalStateException.
// stream.toList();
Python
gen = (n for n in [1, 2])
print(list(gen)) # [1, 2]
# Помилки немає, просто порожньо.
print(list(gen)) # []
doubled = map(lambda n: n * 2, [1, 2])
print(sum(doubled)) # 6
print(sum(doubled)) # 0

Що ще вміють генератори

yield from передає значення іншого генератора. Метод send надсилає значення всередину, у місце зупинки на yield. На цьому механізмі раніше будувалася асинхронність, а сьогодні він трапляється рідко.

Python
def chain_all(first, second):
# Замінює цикл із yield на кожне значення.
yield from first
yield from second
print(list(chain_all([1], [2]))) # [1, 2]
def averager():
total = 0
count = 0
average = None
while True:
# Спочатку віддає значення назовні, потім отримує нове із send.
value = yield average
total += value
count += 1
average = total / count
avg = averager()
# Перший next доводить генератор до першого yield.
next(avg)
print(avg.send(10)) # 10.0
print(avg.send(20)) # 15.0

Готові ліниві операції лежать у модулі itertools.

Java, Python
from itertools import batched, chain, groupby, islice, takewhile # Python
Stream.of(1, 2, 3).limit(2).toList() # Java: [1, 2]
list(islice([1, 2, 3], 2)) # Python: [1, 2]
Stream.concat(Stream.of(1), Stream.of(2)).toList() # Java: [1, 2]
list(chain([1], [2])) # Python: [1, 2]
Stream.of(1, 2, 3).takeWhile(i -> i < 3).toList() # Java: [1, 2]
list(takewhile(lambda i: i < 3, [1, 2, 3])) # Python: [1, 2]
# Розбити на пачки по 2.
list(batched([1, 2, 3], 2)) # Python: [(1, 2), (3,)]
# Групує лише значення, що йдуть підряд, тому спочатку сортують.
[k for k, _ in groupby(sorted("abab"))] # Python: ['a', 'b']

Потоки та процеси

Що таке GIL

GIL (Global Interpreter Lock) — це одне спільне блокування на весь процес. Виконувати код на Python може лише той потік, який його тримає, тому в один момент працює лише один потік, скільки б ядер не було. Потоки при цьому справжні, системні, як у Java. На час очікування мережі, диска чи sleep потік відпускає блокування, і працюють інші.

Висновок простий: потоки пришвидшують очікування, але не пришвидшують обчислення. Для обчислень потрібні процеси, у кожного з яких свій GIL.

ЗавданняJavaPython
Багато очікування мережі чи базипотоки, віртуальні потокиasyncio або потоки
Обчислення на всіх ядрахпотокипроцеси
Блокувальна бібліотекапотокипотоки

Як запустити потік

Клас Thread майже такий самий. Замість реалізації Runnable у нього передають функцію та її аргументи.

Java
void work(String name) {
System.out.println("work " + name); // work a
}
var thread = new Thread(() -> work("a"), "worker");
thread.setDaemon(true);
thread.start();
thread.join();
System.out.println(thread.getName()); // worker
System.out.println(thread.isAlive()); // false
Python
import threading
def work(name):
print(f"work {name}") # work a
# args — кортеж аргументів, тому кома обов'язкова.
thread = threading.Thread(target=work, args=("a",), name="worker", daemon=True)
thread.start()
thread.join()
print(thread.name) # worker
print(thread.is_alive()) # False

Ось що означає «потоки пришвидшують очікування». Чотири потоки сплять по 0,1 секунди, а загальний час теж близько 0,1 секунди. Якщо замінити sleep на підрахунок у циклі, вони виконуватимуться строго по черзі.

Python
import threading
import time
def wait():
time.sleep(0.1)
threads = [threading.Thread(target=wait) for _ in range(4)]
start = time.perf_counter()
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(time.perf_counter() - start < 0.3) # True

Чи потрібна синхронізація, якщо є GIL

Так. GIL захищає нутрощі самого інтерпретатора, а не ваш код. Операція counter += 1 складається з кількох кроків, і потік може втратити GIL між ними. Як і в Java, для цього є блокування. Запису with lock: відповідає synchronized: блокування знімається під час виходу з блоку, навіть у разі помилки.

Потік A Потік B
1. прочитав counter = 5
2. прочитав counter = 5
3. записав counter = 6
4. записав counter = 6
Два додавання, а лічильник виріс на 1.
Java
class Counter {
private int value;
synchronized void add() {
value++;
}
synchronized int get() {
return value;
}
}
var counter = new Counter();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int j = 0; j < 10_000; j++) {
counter.add();
}
}));
}
for (var thread : threads) {
thread.join();
}
System.out.println(counter.get()); // 40000
Python
import threading
class Counter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def add(self):
with self._lock:
self.value += 1
def run(counter):
for _ in range(10_000):
counter.add()
counter = Counter()
threads = [threading.Thread(target=run, args=(counter,)) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(counter.value) # 40000

Обережно: Lock у Python не реентерабельний, тобто той самий потік не може взяти його вдруге. synchronized і ReentrantLock у Java це дозволяють. Точний аналог — RLock.

Python
import threading
# Звичайний Lock: другий acquire у тому самому потоці завис би назавжди.
lock = threading.Lock()
lock.acquire()
print(lock.acquire(blocking=False)) # False
lock.release()
# RLock можна брати повторно.
rlock = threading.RLock()
with rlock:
with rlock:
print("inside") # inside

Чим замінити ExecutorService

Пул потоків працює так само. submit повертає Future, а result() — це get(). Замість try із shutdown використовується with: під час виходу з блоку пул дочекається всіх завдань і закриється.

Java
int square(int n) {
return n * n;
}
try (var pool = Executors.newFixedThreadPool(4)) {
Future<Integer> future = pool.submit(() -> square(3));
System.out.println(future.get()); // 9
var tasks = new ArrayList<Callable<Integer>>();
for (int n : List.of(1, 2)) {
tasks.add(() -> square(n));
}
for (var done : pool.invokeAll(tasks)) {
System.out.print(done.get() + " "); // 1 4
}
}
Python
from concurrent.futures import ThreadPoolExecutor, as_completed
def square(n):
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
future = pool.submit(square, 3)
print(future.result()) # 9
# map: результати в порядку аргументів.
print(list(pool.map(square, [1, 2]))) # [1, 4]
# as_completed: у порядку готовності, як CompletionService.
futures = [pool.submit(square, n) for n in [1, 2]]
print(sorted(f.result() for f in as_completed(futures))) # [1, 4]

Виняток із завдання поводиться як у Java: він зберігається у Future і викидається під час виклику result(). Якщо результат ніхто не запросив, помилка загубиться мовчки.

Що чому відповідає в java.util.concurrent

JavaPython
Threadthreading.Thread
synchronized, ReentrantLockthreading.RLock
Semaphorethreading.Semaphore
CountDownLatchthreading.Event, Barrier
CyclicBarrierthreading.Barrier
Conditionthreading.Condition
BlockingQueuequeue.Queue
ConcurrentHashMapdict і Lock
AtomicIntegerint і Lock
ThreadLocalthreading.local
ExecutorServiceThreadPoolExecutor
ForkJoinPoolProcessPoolExecutor
Futureconcurrent.futures.Future
CompletableFutureasyncio.Task
volatileне потрібен

Як зробити виробника і споживача

queue.Queue — це BlockingQueue: put чекає, якщо черга повна, а get чекає, якщо вона порожня. Цифри показують порядок роботи. Для завершення в чергу прийнято класти особливе значення, у Java його називають poison pill.

Java
var tasks = new ArrayBlockingQueue<Integer>(10);
var results = new ArrayList<Integer>();
var consumer = Thread.ofPlatform().start(() -> {
try {
int item;
while ((item = tasks.take()) != -1) {
results.add(item * 2);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
tasks.put(1);
tasks.put(2);
tasks.put(-1);
consumer.join();
System.out.println(results); // [2, 4]
Python
import queue
import threading
tasks = queue.Queue(maxsize=10)
results = []
def consume():
# 2. Беремо із черги; якщо порожньо, потік чекає.
while (item := tasks.get()) is not None:
results.append(item * 2)
consumer = threading.Thread(target=consume)
consumer.start()
# 1. Кладемо завдання.
tasks.put(1)
tasks.put(2)
# 3. None повідомляє, що роботи більше немає.
tasks.put(None)
consumer.join()
print(results) # [2, 4]

Як зупинити потік

Методу interrupt у Python немає, ззовні потік зупинити не можна. Потік сам перевіряє прапорець, і для цього є Event. Метод wait заразом замінює sleep, який прокидається одразу, щойно прапорець виставлено.

Java
var worker = Thread.ofPlatform().start(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
return;
}
}
});
worker.interrupt();
worker.join();
System.out.println(worker.isAlive()); // false
Python
import threading
stop = threading.Event()
def worker():
# Чекаємо 10 секунд або сигналу зупинки, залежно від того, що настане раніше.
while not stop.wait(timeout=10):
pass
thread = threading.Thread(target=worker)
thread.start()
stop.set()
thread.join()
print(thread.is_alive()) # False

Як рахувати на всіх ядрах

ProcessPoolExecutor має вигляд пулу потоків, але запускає окремі процеси. У кожного свій інтерпретатор, своя пам'ять і свій GIL, тому обчислення йдуть паралельно. За це доводиться платити: аргументи й результати копіюються між процесами через серіалізацію (вона називається pickle), тому лямбду передати не можна, а спільних змінних немає. Код запуску мусить лежати всередині if __name__ == "__main__", бо новий процес імпортує файл заново.

Java
var result = IntStream.of(1, 2, 3, 4)
.parallel()
.map(n -> n * n)
.sum();
System.out.println(result); // 30
Python
from concurrent.futures import ProcessPoolExecutor
def square(n):
return n * n
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
print(sum(pool.map(square, [1, 2, 3, 4]))) # 30

GIL поступово зникає. З версії 3.13 є окрема збірка інтерпретатора без GIL, з 3.14 вона офіційно підтримується, але типово не ввімкнена, і частина бібліотек до неї ще не готова. У ній потоки працюють паралельно, як у Java. З 3.14 є і InterpreterPoolExecutor, де в кожного потоку свій ізольований інтерпретатор і свій GIL.

Асинхронність: asyncio

Що таке asyncio

Це спосіб обслуговувати тисячі операцій, що чекають, в одному потоці. Потік у Python дорогий і впирається в GIL, тому сервіси зазвичай будують саме на ньому. У Java те саме завдання розв'язують Netty і WebFlux, а тепер і віртуальні потоки. Знадобляться чотири терміни:

  • Корутина (coroutine) — функція, оголошена через async def. Вона вміє призупинятися й потім продовжувати роботу.
  • await — точка призупинення. Корутина каже: «я чекаю, займіться поки іншими».
  • Цикл подій (event loop) — диспетчер, один на потік. Він вибирає, яка корутина працює далі.
  • Завдання (task) — корутина, поставлена в цикл подій на виконання. Це аналог CompletableFuture.

Порівняйте з віртуальними потоками. Код робить те саме: дві операції очікування йдуть одночасно.

Java
String fetch(String name) throws InterruptedException {
Thread.sleep(100);
return name + " done";
}
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
var user = pool.submit(() -> fetch("user"));
var orders = pool.submit(() -> fetch("orders"));
System.out.println(user.get()); // user done
System.out.println(orders.get()); // orders done
}
Python
import asyncio
async def fetch(name):
await asyncio.sleep(0.1)
return f"{name} done"
async def main():
# gather запускає обидві корутини й чекає на обидві.
user, orders = await asyncio.gather(fetch("user"), fetch("orders"))
print(user) # user done
print(orders) # orders done
# asyncio.run створює цикл подій і запускає в ньому main.
asyncio.run(main())

Як це працює по кроках

Потік один, і вся робота йде по черзі. Перемикання відбувається лише на await. Цифри показують реальний порядок виведення.

Python
import asyncio
async def first():
print("first starts") # 1. first starts
# first віддає керування циклу, і він запускає second.
await asyncio.sleep(0.2)
print("first ends") # 4. first ends
async def second():
print("second starts") # 2. second starts
# Тепер чекають обидві, і потік вільний.
await asyncio.sleep(0.1)
print("second ends") # 3. second ends
async def main():
await asyncio.gather(first(), second())
asyncio.run(main())

Чому виклик async def нічого не робить

Виклик fetch() не запускає код, а лише створює об'єкт корутини. Код запускається через await або через create_task. Друга пастка в тому, що сам по собі await не створює одночасності: два await підряд виконуються по черзі. Щоб операції йшли одночасно, потрібні завдання.

Java
CompletableFuture<String> fetch(String name) {
return CompletableFuture.supplyAsync(() -> name);
}
var user = fetch("user");
var orders = fetch("orders");
System.out.println(user.join() + " " + orders.join()); // user orders
Python
import asyncio
import time
async def fetch(name):
await asyncio.sleep(0.1)
return name
async def one_by_one():
# Друга операція почнеться лише після першої: 0,2 секунди.
start = time.perf_counter()
await fetch("user")
await fetch("orders")
print(round(time.perf_counter() - start, 1)) # 0.2
async def at_once():
start = time.perf_counter()
# create_task одразу ставить корутину в чергу, як supplyAsync.
user = asyncio.create_task(fetch("user"))
orders = asyncio.create_task(fetch("orders"))
# await — це join: дочекатися результату.
print(await user, await orders) # user orders
print(round(time.perf_counter() - start, 1)) # 0.1
asyncio.run(one_by_one())
asyncio.run(at_once())

Що буде, якщо викликати блокувальну функцію

Це головна помилка в асинхронному коді. Звичайний time.sleep, драйвер бази чи requests не містять await, тому не віддають керування. Цикл подій стоїть, і всі інші запити чекають. Те саме у WebFlux буває за блокувального виклику на потоці Netty. Такі виклики потрібно відправляти в окремий потік через asyncio.to_thread.

Python
import asyncio
import time
async def bad():
# Увесь цикл подій стоїть: інші завдання не працюють 0,1 секунди.
time.sleep(0.1)
async def good():
# Функція йде в інший потік, а цикл продовжує працювати.
await asyncio.to_thread(time.sleep, 0.1)
async def measure(work):
start = time.perf_counter()
await asyncio.gather(work(), work())
return round(time.perf_counter() - start, 1)
print(asyncio.run(measure(bad))) # 0.2
print(asyncio.run(measure(good))) # 0.1

Як правильно запускати групу завдань

TaskGroup з'явився в 3.11, це аналог StructuredTaskScope у Java. Правило просте: завдання не переживають блок async with. Якщо одне впало, інші скасовуються, а помилки приходять разом у групі винятків. Для їх обробки є особлива форма except*. З gather цього немає: у разі помилки інші завдання продовжать працювати самі по собі.

Python
import asyncio
async def ok():
await asyncio.sleep(0.2)
print("never printed")
async def fail():
await asyncio.sleep(0.1)
raise ValueError("boom")
async def main():
try:
async with asyncio.TaskGroup() as tasks:
# 1. Запускаємо два завдання.
tasks.create_task(ok())
tasks.create_task(fail())
# 2. fail упала, тому ok скасовано.
# 3. except* ловить потрібний тип із групи помилок.
except* ValueError as group:
print(group.exceptions) # (ValueError('boom'),)
asyncio.run(main())

Як задати таймаут і скасувати завдання

Скасування працює так: task.cancel() кидає CancelledError усередині корутини в точці await. Це схоже на InterruptedException. Ловити й ковтати її не можна, інакше завдання не зупинити, тому очищення роблять у finally. CancelledError не успадковує Exception, тому звичайний except Exception її не зловить.

Java
var slow = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
return "done";
});
try {
slow.orTimeout(50, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
System.out.println(e.getCause() instanceof TimeoutException); // true
}
Python
import asyncio
async def slow():
try:
await asyncio.sleep(1)
finally:
print("cleanup") # 2. cleanup
async def main():
try:
# 1. Через 0,05 секунди завдання скасують.
async with asyncio.timeout(0.05):
await slow()
# 3. Ззовні скасування перетворюється на TimeoutError.
except TimeoutError:
print("timed out") # 3. timed out
asyncio.run(main())

Чому завдання може зникнути

Цикл подій зберігає на завдання лише слабкі посилання. Якщо ви запустили завдання й не зберегли результат create_task, збирач сміття може його видалити просто під час роботи. Крім того, помилку в такому завданні ніхто не побачить. Тому посилання зберігають або завдання запускають у TaskGroup.

Python
import asyncio
background = set()
def fire_and_forget(coroutine):
task = asyncio.create_task(coroutine)
# Тримаємо посилання, поки завдання працює, потім прибираємо самі.
background.add(task)
task.add_done_callback(background.discard)
async def notify():
print("sent") # sent
async def main():
fire_and_forget(notify())
await asyncio.sleep(0)
asyncio.run(main())

Як обмежити кількість одночасних завдань

Сто тисяч завдань створити легко, але стільки запитів база не витримає. Як і з віртуальними потоками в Java, обмеження роблять семафором.

Java
var limit = new Semaphore(10);
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
limit.acquire();
try {
return 1;
} finally {
limit.release();
}
});
}
}
Python
import asyncio
async def fetch(limit, n):
# Не більше 10 завдань одночасно всередині блоку.
async with limit:
await asyncio.sleep(0)
return n
async def main():
limit = asyncio.Semaphore(10)
results = await asyncio.gather(*(fetch(limit, n) for n in range(100)))
print(len(results)) # 100
asyncio.run(main())

Чи потрібні блокування в asyncio

Між двома await код виконується безперервно, бо потік один. Тому counter += 1 тут безпечний. Гонка з'являється, лише якщо між читанням і записом є await. Для цього випадку є asyncio.Lock, але звичайний threading.Lock тут використовувати не можна: він зупинить увесь цикл подій.

Python
import asyncio
balance = 100
async def withdraw(lock, amount):
global balance
async with lock:
# 1. Прочитали баланс.
current = balance
# 2. Без блокування сюди встигне зайти друге завдання з тим самим балансом.
await asyncio.sleep(0)
# 3. Записали новий баланс.
balance = current - amount
async def main():
lock = asyncio.Lock()
await asyncio.gather(withdraw(lock, 10), withdraw(lock, 10))
print(balance) # 80
asyncio.run(main())

Що таке async with і async for

Це ті самі with і for, тільки їхні внутрішні кроки самі містять await: наприклад, відкрити з'єднання чи отримати наступну порцію з мережі. Асинхронний генератор, як і звичайний, пишеться через yield, тільки всередині async def. Такий генератор схожий на Flux у WebFlux.

Python
import asyncio
class Connection:
async def __aenter__(self):
await asyncio.sleep(0)
print("open") # 1. open
return self
async def __aexit__(self, exc_type, exc, tb):
await asyncio.sleep(0)
print("close") # 3. close
async def rows():
for row in [1, 2]:
await asyncio.sleep(0)
yield row
async def main():
async with Connection():
async for row in rows():
print(row) # 2. 1 2
asyncio.run(main())

Чим замінити ThreadLocal

Звичайний threading.local тут не підійде: потік один, і всі завдання побачили б одне значення. Для цього є ContextVar. Кожне завдання отримує свою копію контексту. Це аналог ScopedValue у Java та MDC у логуванні: ідентифікатор запиту не треба передавати через усі виклики.

Java
var requestId = new ThreadLocal<String>();
requestId.set("a");
System.out.println(requestId.get());
Python
import asyncio
from contextvars import ContextVar
request_id = ContextVar("request_id", default="-")
def log(message):
print(request_id.get(), message) # a work b work
async def handle(rid):
request_id.set(rid)
await asyncio.sleep(0)
log("work")
async def main():
await asyncio.gather(handle("a"), handle("b"))
# У самій main значення не змінювалося.
print(request_id.get()) # -
asyncio.run(main())

Як зв'язати потоки та asyncio

ЗвідкиКудиЯк
Звичайний кодкорутинаasyncio.run(main())
Корутинаблокувальна функціяawait asyncio.to_thread(func)
Інший потіккорутинаasyncio.run_coroutine_threadsafe(coro, loop)
Інший потікзвичайна функція в цикліloop.call_soon_threadsafe(func)

Чим asyncio відрізняється від віртуальних потоків

Мета та сама: дешеве очікування. Різниця в тому, що віртуальні потоки ховають це всередині JVM, а звичайний блокувальний код продовжує працювати. В asyncio асинхронність видно в коді: функцію з async можна викликати лише з іншої такої функції. Це називають «розфарбовуванням функцій».

Віртуальні потокиasyncio
Ядра процесораусіодне
Де перемикаютьсяза будь-якого блокуваннялише на await
Блокувальний кодпрацюєзупиняє все
Гонки в кодіяк у звичайних потоківлише між await
СкасуванняinterruptCancelledError
Бібліотекизвичайніпотрібні асинхронні

Залежності та тести

Чим замінити Maven і Gradle

У Java залежності лежать у спільному кеші ~/.m2, а потрібні версії передаються кожному проєкту через classpath. У Python встановлена бібліотека видна всій копії інтерпретатора, тому в кожного проєкту своя копія в каталозі .venv. Такий каталог називають віртуальним оточенням. Опис проєкту лежить у файлі pyproject.toml, а точні версії в lock-файлі. Зараз усім цим зазвичай керує інструмент uv.

JavaPython
pom.xml, build.gradlepyproject.toml
gradle.lockfileuv.lock
Maven CentralPyPI
mvn dependency:treeuv tree
gradle dependencies adduv add httpx
gradle builduv build
gradle testuv run pytest
Checkstyle, Spotlessruff
javac як перевірка типівmypy
.jar.whl

Імпорт у Python шукає модулі за списком каталогів sys.path, як classpath у Java. Циклічний імпорт тут небезпечний, на відміну від Java: імпорт виконує файл, і якщо a.py імпортує b.py, а той знову a.py, другий побачить наполовину виконаний модуль і впаде з ImportError. Лікується це винесенням спільного коду в третій модуль.

Чим замінити JUnit

Стандарт на практиці — pytest. Тест — це звичайна функція з іменем на test_, а замість набору assertEquals використовується звичайний assert. У разі падіння pytest сам показує, що з чим порівнювалося. Замість @BeforeEach є фікстура: функція, чиє ім'я тест указує як параметр. Усе, що стоїть після yield у фікстурі, виконується після тесту, як @AfterEach.

Java
class CartTest {
private List<String> cart;
@BeforeEach
void setUp() {
cart = new ArrayList<>();
}
@Test
void addsItem() {
cart.add("tea");
assertEquals(1, cart.size());
}
@ParameterizedTest
@CsvSource({"2, 4", "3, 9"})
void squares(int input, int expected) {
assertEquals(expected, input * input);
}
@Test
void rejectsBadNumber() {
assertThrows(NumberFormatException.class, () -> Integer.parseInt("x"));
}
}
Python
import pytest
# Замінює @BeforeEach: pytest викличе її перед кожним тестом.
@pytest.fixture
def cart():
return []
# Ім'я параметра збігається з іменем фікстури.
def test_adds_item(cart):
cart.append("tea")
assert len(cart) == 1
@pytest.mark.parametrize("value, expected", [(2, 4), (3, 9)])
def test_squares(value, expected):
assert value * value == expected
def test_rejects_bad_number():
with pytest.raises(ValueError):
int("x")

Чим замінити Mockito

Заглушки є у стандартній бібліотеці, у модулі unittest.mock. Mock приймає будь-який виклик і запам'ятовує його. Функція patch замінює ім'я у вказаному модулі на час тесту. Важливе правило: підміняти потрібно там, де ім'я використовується, а не там, де оголошене.

Java
var repository = mock(UserRepository.class);
when(repository.find(1)).thenReturn("Ann");
assertEquals("Ann", repository.find(1));
verify(repository).find(1);
Python
from unittest.mock import Mock
repository = Mock()
repository.find.return_value = "Ann"
print(repository.find(1)) # Ann
repository.find.assert_called_once_with(1)
# Змусити метод кинути виняток: thenThrow.
repository.save.side_effect = ConnectionError("down")

Хоча підміна працює, код зручніше тестувати так само, як у Java: через залежності, передані в конструктор. Так написано сервіс наприкінці статті.

Пам'ять і продуктивність

Як Python керує пам'яттю

У JVM об'єкти живуть, поки їх не знайде збирач сміття. У CPython кожен об'єкт має лічильник посилань, і об'єкт видаляється одразу, щойно лічильник став нулем. Циклічні посилання так не виявити, тому додатково є збирач циклів із модуля gc.

JVMCPython
Коли видаляється об'єктколи дійде збирачодразу, коли немає посилань
Переміщення об'єктівтак, купа стискаєтьсяні
Налаштування розміру-Xmxнемає, обмежує контейнер
Розмір int4 байтиблизько 28 байтів

Що робити, якщо повільно

Спочатку виміряти. cProfile — це простий профайлер, а для працюючого сервісу є py-spy, який підключається ззовні. Зазвичай найшвидше допомагає вибір правильної структури даних і вбудованих функцій, написаних на C: sum, sorted, set. Важкі обчислення роблять у бібліотеках на C на кшталт NumPy, які відпускають GIL на час роботи.

Архітектура сервісу на Python

Що замінює Tomcat і Servlet API

Схема та сама: є сервер, який приймає з'єднання, і стандартний інтерфейс, через який він викликає ваш застосунок. Інтерфейсу два: WSGI для синхронного коду та ASGI для асинхронного.

JavaPythonЩо це
Servlet APIWSGIсинхронний інтерфейс сервера й застосунку
WebFlux HttpHandlerASGIасинхронний інтерфейс
Tomcat, Jettygunicornсервер для WSGI
Nettyuvicornсервер для ASGI
Spring MVCDjango, Flaskсинхронні фреймворки
WebFluxFastAPIасинхронний фреймворк

Чим модель роботи відрізняється від Java

Java-сервіс — це один процес із пулом потоків і спільною пам'яттю. Через GIL сервіс на Python — це кілька процесів, їх називають воркерами. Звідси випливає ось що:

  • Масштабування. У Kubernetes зазвичай тримають один воркер на под і додають репліки.
  • Кеш у пам'яті у кожного воркера свій. Спільні дані зберігають у Redis або базі.
  • Пул з'єднань теж у кожного свій: 4 воркери по 10 з'єднань — це 40 з'єднань із базою.
  • Планувальник запуститься в кожному воркері, тому його виносять в окремий процес.
  • Довгі обчислення в обробнику запиту неприпустимі. Їх відправляють у чергу завдань, наприклад Celery.

Як обійтися без Spring

Контейнер залежностей майже ніколи не потрібен. Залежності передають через конструктор, а замість контексту Spring є одна функція під час запуску, яка створює об'єкти й передає їх одне одному. Таке місце називають composition root. Шари при цьому ті самі, тільки проходять не пакетами з класами, а модулями.

SpringМодуль у PythonЩо знає
@Entitydomain.pyлише стандартну бібліотеку
інтерфейси репозиторіївports.pyдомен
@Serviceservice.pyдомен і порти
@Repositorystorage.pyдомен і базу
@RestControllerhttp_api.pyсервіс і HTTP
@Configuration, main__main__.pyусе

Які патерни з Java тут не потрібні

Багато патернів у Java обходять обмеження мови, яких у Python немає. Наприклад, стратегія в Java — це інтерфейс і клас, а в Python просто функція.

Java
interface Discount {
double apply(double price);
}
class Seasonal implements Discount {
public double apply(double price) {
return price * 0.9;
}
}
double checkout(double price, Discount discount) {
return discount.apply(price);
}
System.out.println(checkout(100, new Seasonal())); // 90.0
Python
def seasonal(price):
return price * 0.9
def checkout(price, discount):
return discount(price)
print(checkout(100, seasonal)) # 90.0
JavaPython
Singletonоб'єкт на рівні модуля
Factoryфункція або сам клас
Builderіменовані аргументи та replace
Strategy, Commandпередати функцію
Observerсписок функцій
Visitormatch, singledispatch
Decorator@decorator
Iteratorгенератор
Proxy__getattr__
Клас-утилітафункції в модулі
Гетери й сетериполе, потім @property

Код, написаний із Java-звичками, видно одразу. Ось що видає такий код:

Python
# Погано
for i in range(len(items)):
print(items[i])
# Добре
for item in items:
print(item)
# Погано
if len(items) == 0:
pass
# Добре
if not items:
pass
# Погано
if value == None or flag == True:
pass
# Добре
if value is None or flag:
pass
# Погано
user.get_name()
# Добре
user.name

Мікросервіс на стандартній бібліотеці

Що ми будуємо

Зберемо все разом і напишемо сервіс замовлень. Сторонніх бібліотек у ньому немає, щоб було видно саму мову. У справжньому проєкті HTTP-сервер і маршрути ви віддасте FastAPI, а SQLite замінить PostgreSQL, але заміняться лише файли на краях. Неоплачені замовлення старші за годину сервіс скасовує у фоні.

ЗапитЩо робитьВідповідь
GET /healthперевірка, що сервіс живий200
POST /ordersстворити замовлення201, 422
GET /orders?status=paidсписок замовлень200
GET /orders/{id}показати замовлення200, 404
POST /orders/{id}/payоплатити200, 409
POST /orders/{id}/cancelскасувати200, 409

Запит проходить через файли згори донизу, і кожен файл знає лише про нижнього сусіда. Логіка в центрі не залежить від зовнішнього світу. Вона спілкується з ним через інтерфейси, їх називають портами, а їхні реалізації для конкретної технології адаптерами. У Spring це сервіс, який працює з інтерфейсом репозиторію й не знає, що за ним JDBC.

http_server.py читає байти, будує об'єкт Request
│
▼
http_api.py вибирає маршрут, розбирає JSON
│
▼
service.py створює, оплачує, скасовує замовлення
│ │
▼ ▼
domain.py storage.py
правила запити до SQLite
orders-service/
├── pyproject.toml
├── Dockerfile
├── src/orders/
│ ├── __init__.py порожній файл, ознака пакета
│ ├── __main__.py запуск і збирання об'єктів
│ ├── config.py налаштування з оточення
│ ├── domain.py замовлення та його правила
│ ├── ports.py що сервісу потрібно ззовні
│ ├── service.py бізнес-логіка
│ ├── storage.py адаптер SQLite
│ ├── http_server.py HTTP-сервер на asyncio
│ ├── http_api.py маршрути й помилки
│ └── logs.py логи в JSON
└── tests/
├── test_service.py
└── test_api.py

Як описати проєкт

Файл pyproject.toml — це аналог pom.xml.

TOML · pyproject.toml
[project]
name = "orders-service"
version = "0.1.0"
requires-python = ">=3.12"
# Сторонніх бібліотек для роботи немає.
dependencies = []
# Створює команду orders-service, яка викликає функцію main.
[project.scripts]
orders-service = "orders.__main__:main"
# Як testImplementation у Gradle: в образ не потраплять.
[dependency-groups]
dev = ["mypy>=1.13", "pytest>=8", "ruff>=0.8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orders"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
strict = true
files = ["src", "tests"]
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "G"]

Як описати замовлення

Почнемо з центру. Цей файл замінює клас Order із бізнес-методами. Він не знає ні про HTTP, ні про базу.

Python · src/orders/domain.py
from dataclasses import dataclass, replace
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Self
class OrderStatus(StrEnum):
NEW = "new"
PAID = "paid"
CANCELLED = "cancelled"
# Спільний батько: веб-шар зловить усі помилки одним except.
class DomainError(Exception):
pass
class OrderNotFound(DomainError):
pass
class InvalidOrder(DomainError):
pass
class IllegalTransition(DomainError):
pass
# Незмінний запис: pay і cancel повертають нове замовлення.
@dataclass(frozen=True, slots=True)
class Order:
id: str
customer: str
amount: Decimal
status: OrderStatus
created_at: datetime
# Перевірка під час створення. NaN і Infinity — теж допустимі Decimal.
def __post_init__(self) -> None:
if not self.customer.strip():
raise InvalidOrder("customer must not be empty")
if not self.amount.is_finite() or self.amount <= 0:
raise InvalidOrder("amount must be a positive number")
def pay(self) -> Self:
if self.status is not OrderStatus.NEW:
raise IllegalTransition(f"cannot pay an order that is {self.status}")
return replace(self, status=OrderStatus.PAID)
def cancel(self) -> Self:
if self.status is not OrderStatus.NEW:
raise IllegalTransition(f"cannot cancel an order that is {self.status}")
return replace(self, status=OrderStatus.CANCELLED)

Як відокремити логіку від бази

Це аналог інтерфейсу OrderRepository. Реалізації не потрібно писати implements, тому в тестах можна використовувати просту підробку, яка ні від чого не успадковується.

Python · src/orders/ports.py
from collections.abc import Sequence
from typing import Protocol
from .domain import Order, OrderStatus
# Методи async: поки база відповідає, цикл зайнятий іншими запитами.
class OrderRepository(Protocol):
async def add(self, order: Order) -> None: ...
async def get(self, order_id: str) -> Order | None: ...
async def find(
self, status: OrderStatus | None, limit: int
) -> Sequence[Order]: ...
# Зберігає новий статус, лише якщо в базі все ще expected.
async def update_status(self, order: Order, expected: OrderStatus) -> bool: ...

Як написати бізнес-логіку

Це аналог класу з @Service. У Java для годинника завели б Clock, а для ідентифікаторів інтерфейс із реалізацією. Тут досить передати функцію, а в тесті легко підставити іншу.

Python · src/orders/service.py
from collections.abc import Callable, Sequence
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from uuid import uuid4
from .domain import IllegalTransition, Order, OrderNotFound, OrderStatus
from .ports import OrderRepository
def _utc_now() -> datetime:
return datetime.now(UTC)
def _new_id() -> str:
return uuid4().hex
class OrderService:
# Параметри після * передаються лише за іменем.
def __init__(
self,
repository: OrderRepository,
*,
clock: Callable[[], datetime] = _utc_now,
new_id: Callable[[], str] = _new_id,
) -> None:
self._repository = repository
self._clock = clock
self._new_id = new_id
async def create(self, customer: str, amount: Decimal) -> Order:
order = Order(
self._new_id(), customer, amount, OrderStatus.NEW, self._clock()
)
await self._repository.add(order)
return order
async def get(self, order_id: str) -> Order:
order = await self._repository.get(order_id)
if order is None:
raise OrderNotFound(f"no order {order_id}")
return order
async def find(
self, status: OrderStatus | None, limit: int = 100
) -> Sequence[Order]:
return await self._repository.find(status, limit)
# Order.pay — звичайна функція, у якої перший параметр self.
async def pay(self, order_id: str) -> Order:
return await self._change(order_id, Order.pay)
async def cancel(self, order_id: str) -> Order:
return await self._change(order_id, Order.cancel)
async def expire_unpaid(self, older_than: timedelta) -> int:
cutoff = self._clock() - older_than
expired = 0
for order in await self._repository.find(OrderStatus.NEW, limit=1000):
if order.created_at < cutoff:
# bool — це int, тому True додає 1.
expired += await self._repository.update_status(
order.cancel(), OrderStatus.NEW
)
return expired
async def _change(
self, order_id: str, transition: Callable[[Order], Order]
) -> Order:
# 1. Прочитали замовлення.
order = await self.get(order_id)
# 2. Перевірили правила й отримали нове замовлення.
changed = transition(order)
# 3. Умовний запис: поки йшов await, інший запит міг змінити замовлення.
if not await self._repository.update_status(changed, expected=order.status):
raise IllegalTransition(f"order {order_id} was changed concurrently")
return changed

Крок 3 методу _change називають оптимістичним блокуванням: запис заздалегідь не блокується, а під час збереження перевіряється, що він не змінився. У JPA те саме завдання розв'язує @Version. Ось що без нього сталося б за двох одночасних оплат:

Запит A Запит B
1. читає замовлення: new
2. читає замовлення: new
3. UPDATE ... WHERE status='new'
оновлено 1 рядок, успіх
4. UPDATE ... WHERE status='new'
оновлено 0 рядків, помилка 409

Як звернутися до бази з асинхронного коду

Це аналог класу з @Repository. Драйвер sqlite3 блокувальний, тому кожен виклик іде в окремий потік. Для PostgreSQL ви візьмете асинхронний драйвер, і to_thread зникне, а порт не зміниться.

Python · src/orders/storage.py
import asyncio
import sqlite3
from collections.abc import Callable, Sequence
from contextlib import closing
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from .domain import Order, OrderStatus
_SCHEMA = """
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
customer TEXT NOT NULL,
amount TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS orders_status ON orders (status);
"""
def _to_order(row: sqlite3.Row) -> Order:
return Order(
id=row["id"],
customer=row["customer"],
amount=Decimal(row["amount"]),
status=OrderStatus(row["status"]),
created_at=datetime.fromisoformat(row["created_at"]),
)
class SqliteOrderRepository:
def __init__(self, path: Path) -> None:
self._path = path
async def migrate(self) -> None:
await self._run(lambda c: c.executescript(_SCHEMA))
async def add(self, order: Order) -> None:
values = (
order.id,
order.customer,
str(order.amount),
order.status,
order.created_at.isoformat(),
)
query = "INSERT INTO orders VALUES (?, ?, ?, ?, ?)"
await self._run(lambda c: c.execute(query, values))
async def get(self, order_id: str) -> Order | None:
query = "SELECT * FROM orders WHERE id = ?"
row = await self._run(lambda c: c.execute(query, (order_id,)).fetchone())
return _to_order(row) if row else None
async def find(self, status: OrderStatus | None, limit: int) -> Sequence[Order]:
where, args = ("WHERE status = ?", [status]) if status else ("", [])
query = f"SELECT * FROM orders {where} ORDER BY created_at LIMIT ?"
rows = await self._run(lambda c: c.execute(query, (*args, limit)).fetchall())
return [_to_order(row) for row in rows]
async def update_status(self, order: Order, expected: OrderStatus) -> bool:
query = "UPDATE orders SET status = ? WHERE id = ? AND status = ?"
values = (order.status, order.id, expected)
return await self._run(lambda c: c.execute(query, values).rowcount) == 1
# Як TransactionTemplate: відкрити, виконати, зафіксувати чи відкотити.
async def _run[T](self, work: Callable[[sqlite3.Connection], T]) -> T:
def in_thread() -> T:
connection = sqlite3.connect(self._path, timeout=5.0)
connection.row_factory = sqlite3.Row
# with connection робить commit або rollback, а closing закриває.
with closing(connection), connection:
return work(connection)
return await asyncio.to_thread(in_thread)

Як додати ідентифікатор запиту в логи

Це роль Logback і MDC. Кожен запит обробляється у своєму завданні, тому в нього своє значення ContextVar. Код, який пише в лог, про ідентифікатор нічого не знає.

Python · src/orders/logs.py
import json
import logging
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id", default="-")
# Один запис лога — один рядок JSON. Такі зручно розбирати збирачам логів.
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
entry = {
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"request_id": request_id.get(),
"message": record.getMessage(),
}
if record.exc_info:
entry["exception"] = self.formatException(record.exc_info)
return json.dumps(entry, ensure_ascii=False)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=level, handlers=[handler], force=True)

Як написати HTTP-сервер на asyncio

У справжньому проєкті цю роботу робить uvicorn. Тут вона написана, щоб показати asyncio на реальному завданні. HTTP-запит — це звичайний текст: рядок запиту, заголовки, порожній рядок і тіло.

HTTP, JSON
POST /orders HTTP/1.1
Host: localhost:8080
Content-Length: 38
{"customer": "ann", "amount": "19.99"}
Python · src/orders/http_server.py
import asyncio
import json
import logging
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass
from http import HTTPStatus
from typing import Any
from urllib.parse import parse_qsl, urlsplit
log = logging.getLogger(__name__)
MAX_BODY = 1024 * 1024
@dataclass(frozen=True, slots=True)
class Request:
method: str
path: str
query: dict[str, str]
headers: dict[str, str]
body: bytes
@dataclass(frozen=True, slots=True)
class Response:
status: HTTPStatus
payload: Any = None
def encode(self) -> bytes:
body = (
b"" if self.payload is None else json.dumps(self.payload).encode("utf-8")
)
head = (
f"HTTP/1.1 {self.status.value} {self.status.phrase}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
)
# Протокол вимагає для заголовків кодування latin-1.
return head.encode("latin-1") + body
# Псевдонім типу: будь-яка асинхронна функція від Request до Response.
type Handler = Callable[[Request], Awaitable[Response]]
async def read_request(reader: asyncio.StreamReader) -> Request:
# 1. Читаємо байти до порожнього рядка: це заголовки.
head = await reader.readuntil(b"\r\n\r\n")
request_line, *header_lines = head.decode("latin-1").split("\r\n")
method, target, _version = request_line.split(" ")
# 2. Розбираємо заголовки у словник.
headers = {}
for line in header_lines:
name, separator, value = line.partition(":")
if separator:
headers[name.strip().lower()] = value.strip()
# 3. З Content-Length дізнаємося довжину тіла й читаємо його.
length = int(headers.get("content-length", "0"))
if length > MAX_BODY:
raise ValueError("body is too large")
body = await reader.readexactly(length)
url = urlsplit(target)
return Request(method, url.path, dict(parse_qsl(url.query)), headers, body)
class HttpServer:
def __init__(
self,
handler: Handler,
host: str,
port: int,
*,
max_concurrent: int = 100,
read_timeout: float = 5.0,
handler_timeout: float = 30.0,
) -> None:
self._handler = handler
self._host = host
self._port = port
self._slots = asyncio.Semaphore(max_concurrent)
self._read_timeout = read_timeout
self._handler_timeout = handler_timeout
# Цикл зберігає на завдання лише слабкі посилання, тому тримаємо свої.
self._connections: set[asyncio.Task[Any]] = set()
self._server: asyncio.Server | None = None
# На кожне з'єднання asyncio створює завдання з _on_connect.
async def start(self) -> int:
self._server = await asyncio.start_server(
self._on_connect, self._host, self._port
)
return int(self._server.sockets[0].getsockname()[1])
async def stop(self, grace: float) -> None:
assert self._server is not None
# 1. Перестаємо приймати нові з'єднання.
self._server.close()
# 2. Даємо поточним час закінчити, решту скасовуємо.
if self._connections:
_, unfinished = await asyncio.wait(self._connections, timeout=grace)
for task in unfinished:
task.cancel()
await asyncio.gather(*unfinished, return_exceptions=True)
await self._server.wait_closed()
async def _on_connect(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
task = asyncio.current_task()
assert task is not None
self._connections.add(task)
task.add_done_callback(self._connections.discard)
try:
response = await self._respond(reader)
if response is not None:
writer.write(response.encode())
# Якщо клієнт читає повільно, завдання тут почекає.
await writer.drain()
except ConnectionError:
log.info("client disconnected")
finally:
writer.close()
with suppress(ConnectionError):
await writer.wait_closed()
async def _respond(self, reader: asyncio.StreamReader) -> Response | None:
# Таймаут захищає від клієнта, який підключився й нічого не надсилає.
try:
async with asyncio.timeout(self._read_timeout):
request = await read_request(reader)
except asyncio.IncompleteReadError:
return None
except (ValueError, asyncio.LimitOverrunError):
return Response(HTTPStatus.BAD_REQUEST, {"error": "malformed request"})
except TimeoutError:
return Response(
HTTPStatus.REQUEST_TIMEOUT, {"error": "request not read in time"}
)
# Семафор захищає базу від перевантаження, а таймаут від завислого запиту.
async with self._slots:
try:
async with asyncio.timeout(self._handler_timeout):
return await self._handler(request)
except TimeoutError:
log.warning("timed out: %s %s", request.method, request.path)
return Response(HTTPStatus.GATEWAY_TIMEOUT, {"error": "timed out"})
# CancelledError сюди не потрапить: скасування має пройти наскрізь.
except Exception:
log.exception("failed: %s %s", request.method, request.path)
return Response(
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal error"}
)

Як зробити маршрути й обробку помилок

Це роль @RestController і @ControllerAdvice. Маршрути зручно вибирати через match: зразок "GET", ["orders", order_id] підходить для GET /orders/42 і одразу кладе 42 у змінну order_id.

Python · src/orders/http_api.py
import json
import logging
from decimal import Decimal, InvalidOperation
from http import HTTPStatus
from uuid import uuid4
from .domain import (
DomainError,
IllegalTransition,
InvalidOrder,
Order,
OrderNotFound,
OrderStatus,
)
from .http_server import Request, Response
from .logs import request_id
from .service import OrderService
log = logging.getLogger(__name__)
_ERROR_STATUS: dict[type[DomainError], HTTPStatus] = {
OrderNotFound: HTTPStatus.NOT_FOUND,
InvalidOrder: HTTPStatus.UNPROCESSABLE_ENTITY,
IllegalTransition: HTTPStatus.CONFLICT,
}
def _to_json(order: Order) -> dict[str, str]:
return {
"id": order.id,
"customer": order.customer,
"amount": str(order.amount),
"status": order.status,
"created_at": order.created_at.isoformat(),
}
def _parse_new_order(body: bytes) -> tuple[str, Decimal]:
try:
payload = json.loads(body)
except ValueError as e:
raise InvalidOrder("body is not valid JSON") from e
# Зразок перевіряє і наявність полів, і їхні типи.
# Сума — рядок: число в JSON втрачає точність.
match payload:
case {"customer": str(customer), "amount": str(amount)}:
try:
return customer, Decimal(amount)
except InvalidOperation as e:
raise InvalidOrder(f"bad amount: {amount!r}") from e
case _:
raise InvalidOrder("expected strings: customer, amount")
class OrdersApi:
def __init__(self, service: OrderService) -> None:
self._service = service
# Об'єкт можна викликати як функцію, тому він підходить під Handler.
async def __call__(self, request: Request) -> Response:
request_id.set(request.headers.get("x-request-id") or uuid4().hex)
try:
response = await self._route(request)
# Як @ControllerAdvice: помилка бізнес-логіки стає кодом відповіді.
except DomainError as e:
response = Response(_ERROR_STATUS[type(e)], {"error": str(e)})
log.info("%s %s -> %d", request.method, request.path, response.status)
return response
async def _route(self, request: Request) -> Response:
match request.method, request.path.strip("/").split("/"):
case "GET", ["health"]:
return Response(HTTPStatus.OK, {"status": "ok"})
case "POST", ["orders"]:
customer, amount = _parse_new_order(request.body)
order = await self._service.create(customer, amount)
return Response(HTTPStatus.CREATED, _to_json(order))
case "GET", ["orders"]:
return await self._list(request)
case "GET", ["orders", order_id]:
return Response(
HTTPStatus.OK, _to_json(await self._service.get(order_id))
)
case "POST", ["orders", order_id, "pay"]:
return Response(
HTTPStatus.OK, _to_json(await self._service.pay(order_id))
)
case "POST", ["orders", order_id, "cancel"]:
return Response(
HTTPStatus.OK, _to_json(await self._service.cancel(order_id))
)
case _:
return Response(HTTPStatus.NOT_FOUND, {"error": "no such route"})
async def _list(self, request: Request) -> Response:
raw = request.query.get("status")
try:
status = OrderStatus(raw) if raw else None
except ValueError as e:
raise InvalidOrder(f"unknown status: {raw!r}") from e
orders = await self._service.find(status)
return Response(HTTPStatus.OK, [_to_json(order) for order in orders])

Як читати налаштування

Це роль @ConfigurationProperties: незмінний об'єкт, один раз прочитаний зі змінних оточення. У тесті замість оточення можна передати звичайний словник.

Python · src/orders/config.py
import os
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
@dataclass(frozen=True)
class Settings:
host: str = "0.0.0.0"
port: int = 8080
database: Path = Path("orders.db")
log_level: str = "INFO"
unpaid_ttl: timedelta = timedelta(hours=1)
shutdown_grace: float = 10.0
@classmethod
def from_env(cls, env: Mapping[str, str] = os.environ) -> "Settings":
return cls(
host=env.get("ORDERS_HOST", cls.host),
port=int(env.get("ORDERS_PORT", cls.port)),
database=Path(env.get("ORDERS_DATABASE", cls.database)),
log_level=env.get("ORDERS_LOG_LEVEL", cls.log_level),
)

Де збираються всі об'єкти

Цей файл і є весь контейнер залежностей. Його можна прочитати згори донизу й побачити, що з чим пов'язано. Цифри показують, як сервіс завершується: Docker надсилає сигнал SIGTERM, а сервіс допрацьовує поточні запити.

Python · src/orders/__main__.py
import asyncio
import logging
import signal
from contextlib import suppress
from datetime import timedelta
from .config import Settings
from .http_api import OrdersApi
from .http_server import HttpServer
from .logs import configure_logging
from .service import OrderService
from .storage import SqliteOrderRepository
log = logging.getLogger("orders")
# Фонове завдання, як @Scheduled(fixedDelay = ...).
async def expire_loop(
service: OrderService, stop: asyncio.Event, ttl: timedelta, every: float = 60.0
) -> None:
while not stop.is_set():
try:
if expired := await service.expire_unpaid(ttl):
log.info("expired %d unpaid orders", expired)
# Один збій не повинен зупинити завдання назавжди.
except Exception:
log.exception("expiration job failed")
# Чекаємо хвилину або сигналу зупинки.
with suppress(TimeoutError):
async with asyncio.timeout(every):
await stop.wait()
async def run(settings: Settings) -> None:
repository = SqliteOrderRepository(settings.database)
await repository.migrate()
service = OrderService(repository)
server = HttpServer(OrdersApi(service), settings.host, settings.port)
# 1. Docker надсилає SIGTERM, і обробник виставляє подію stop.
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda *_: loop.call_soon_threadsafe(stop.set))
port = await server.start()
log.info("listening on %s:%d", settings.host, port)
# TaskGroup гарантує, що фонове завдання не переживе run.
async with asyncio.TaskGroup() as tasks:
tasks.create_task(expire_loop(service, stop, settings.unpaid_ttl))
# 2. Тут run прокидається.
await stop.wait()
log.info("shutting down")
# 3. Сервер допрацьовує поточні запити й закривається.
await server.stop(settings.shutdown_grace)
log.info("stopped")
def main() -> None:
settings = Settings.from_env()
configure_logging(settings.log_level)
asyncio.run(run(settings))
if __name__ == "__main__":
main()

Як протестувати сервіс

Юніт-тести працюють із підробкою замість бази. Клас InMemoryOrders зберігає замовлення у словнику. Він ніде не успадковує OrderRepository, але mypy перевірить, що він підходить.

Python · tests/test_service.py
import asyncio
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from orders.domain import (
IllegalTransition,
InvalidOrder,
Order,
OrderNotFound,
OrderStatus,
)
from orders.service import OrderService
NOW = datetime(2026, 11, 5, 12, 0, tzinfo=UTC)
class InMemoryOrders:
def __init__(self) -> None:
self.orders: dict[str, Order] = {}
async def add(self, order: Order) -> None:
self.orders[order.id] = order
async def get(self, order_id: str) -> Order | None:
return self.orders.get(order_id)
async def find(self, status: OrderStatus | None, limit: int) -> Sequence[Order]:
found = [
o for o in self.orders.values() if status is None or o.status is status
]
return found[:limit]
async def update_status(self, order: Order, expected: OrderStatus) -> bool:
if self.orders[order.id].status is not expected:
return False
self.orders[order.id] = order
return True
# Годинник заморожено, тому тести не залежать від поточного часу.
@pytest.fixture
def service() -> OrderService:
return OrderService(InMemoryOrders(), clock=lambda: NOW, new_id=lambda: "id-1")
def test_new_order_can_be_paid(service: OrderService) -> None:
async def scenario() -> None:
order = await service.create("ann", Decimal("10.50"))
assert (await service.pay(order.id)).status is OrderStatus.PAID
assert (await service.get(order.id)).status is OrderStatus.PAID
asyncio.run(scenario())
def test_paid_order_cannot_be_cancelled(service: OrderService) -> None:
async def scenario() -> None:
order = await service.create("ann", Decimal("1"))
await service.pay(order.id)
with pytest.raises(IllegalTransition):
await service.cancel(order.id)
asyncio.run(scenario())
@pytest.mark.parametrize(
"customer, amount",
[("", "1"), ("ann", "0"), ("ann", "-1"), ("ann", "NaN"), ("ann", "Infinity")],
)
def test_invalid_orders_are_rejected(
service: OrderService, customer: str, amount: str
) -> None:
with pytest.raises(InvalidOrder):
asyncio.run(service.create(customer, Decimal(amount)))
def test_missing_order(service: OrderService) -> None:
with pytest.raises(OrderNotFound):
asyncio.run(service.get("nope"))
def test_old_unpaid_orders_expire() -> None:
now = [NOW]
service = OrderService(
InMemoryOrders(), clock=lambda: now[0], new_id=lambda: "id-1"
)
async def scenario() -> None:
order = await service.create("ann", Decimal("1"))
assert await service.expire_unpaid(timedelta(hours=1)) == 0
now[0] += timedelta(hours=2)
assert await service.expire_unpaid(timedelta(hours=1)) == 1
assert (await service.get(order.id)).status is OrderStatus.CANCELLED
asyncio.run(scenario())

Інтеграційний тест піднімає справжній сервер на вільному порту (порт 0 означає «вибери сам») зі справжньою базою в тимчасовому каталозі.

Python · tests/test_api.py
import asyncio
import json
from pathlib import Path
from typing import Any
from orders.http_api import OrdersApi
from orders.http_server import HttpServer
from orders.service import OrderService
from orders.storage import SqliteOrderRepository
async def call(
port: int, method: str, path: str, body: Any = None
) -> tuple[int, Any]:
payload = b"" if body is None else json.dumps(body).encode()
head = (
f"{method} {path} HTTP/1.1\r\n"
f"Host: test\r\nContent-Length: {len(payload)}\r\n\r\n"
)
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.write(head.encode() + payload)
await writer.drain()
response = await reader.read()
writer.close()
await writer.wait_closed()
status_line, _, content = response.partition(b"\r\n\r\n")
return int(status_line.split()[1]), json.loads(content) if content else None
def test_order_lifecycle_over_http(tmp_path: Path) -> None:
async def scenario() -> None:
repository = SqliteOrderRepository(tmp_path / "test.db")
await repository.migrate()
server = HttpServer(OrdersApi(OrderService(repository)), "127.0.0.1", 0)
port = await server.start()
try:
assert (await call(port, "GET", "/health"))[0] == 200
order = {"customer": "ann", "amount": "19.99"}
status, created = await call(port, "POST", "/orders", order)
assert status == 201
assert created["amount"] == "19.99"
assert created["status"] == "new"
pay = f"/orders/{created['id']}/pay"
status, paid = await call(port, "POST", pay)
assert (status, paid["status"]) == (200, "paid")
assert (await call(port, "POST", pay))[0] == 409
_, listed = await call(port, "GET", "/orders?status=paid")
assert [o["id"] for o in listed] == [created["id"]]
assert (await call(port, "GET", "/orders?status=lost"))[0] == 422
assert (await call(port, "GET", "/orders/nope"))[0] == 404
assert (await call(port, "POST", "/orders", {"amount": 5}))[0] == 422
assert (await call(port, "DELETE", "/orders"))[0] == 404
finally:
await server.stop(grace=1.0)
asyncio.run(scenario())
def test_malformed_request_gets_400(tmp_path: Path) -> None:
async def scenario() -> None:
repository = SqliteOrderRepository(tmp_path / "test.db")
await repository.migrate()
server = HttpServer(OrdersApi(OrderService(repository)), "127.0.0.1", 0)
port = await server.start()
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.write(b"nonsense\r\n\r\n")
await writer.drain()
assert b"400 Bad Request" in await reader.read()
writer.close()
await writer.wait_closed()
finally:
await server.stop(grace=1.0)
asyncio.run(scenario())

Як запакувати сервіс у Docker

Збірка двоетапна, як із Gradle і JRE. Пояснення до кожного кроку в коментарях.

Dockerfile
# Етап 1: uv ставить лише робочі залежності за lock-файлом.
FROM python:3.15-slim AS build
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
RUN uv sync --frozen --no-dev --no-editable
# Етап 2: готове оточення копіюється в чистий образ.
FROM python:3.15-slim
RUN useradd --create-home app && mkdir /data && chown app /data
COPY --from=build /app/.venv /app/.venv
USER app
ENV ORDERS_DATABASE=/data/orders.db PATH=/app/.venv/bin:$PATH
EXPOSE 8080
VOLUME /data
HEALTHCHECK CMD python -c "import urllib.request as r; \
r.urlopen('http://127.0.0.1:8080/health', timeout=2)"
# Форма масиву: сервіс стане PID 1, і SIGTERM прийде просто йому.
CMD ["orders-service"]

Як запустити сервіс

Shell
# Створити оточення й uv.lock
uv sync
# Тести
uv run pytest
# Типи в суворому режимі
uv run mypy
# Лінтер
uv run ruff check
# Запуск сервісу
uv run python -m orders
# Або в контейнері
docker build -t orders-service .
docker run --rm -p 8080:8080 orders-service

Створимо замовлення, оплатимо його, спробуємо оплатити ще раз і надішлемо неправильні дані.

Shell, JSON
$ curl -s -X POST localhost:8080/orders \
-d '{"customer": "ann", "amount": "19.99"}' | python -m json.tool
{
"id": "2165d2a6a1ee4cbb966203d6099d49c9",
"customer": "ann",
"amount": "19.99",
"status": "new",
"created_at": "2026-11-05T18:44:05.717023+00:00"
}
$ ID=2165d2a6a1ee4cbb966203d6099d49c9
$ curl -s -X POST localhost:8080/orders/$ID/pay | python -m json.tool
{
"id": "2165d2a6a1ee4cbb966203d6099d49c9",
"customer": "ann",
"amount": "19.99",
"status": "paid",
"created_at": "2026-11-05T18:44:05.717023+00:00"
}
$ curl -s -X POST localhost:8080/orders/$ID/pay
{"error": "cannot pay an order that is paid"}
$ curl -s -X POST localhost:8080/orders \
-d '{"customer": "a", "amount": "NaN"}'
{"error": "amount must be a positive number"}

Що з цього замінять фреймворки

Цей код написано руками лише для того, щоб побачити механізми. У справжньому проєкті більшу частину візьмуть на себе бібліотеки. Написаний тут service.py при цьому не зміниться.

Тут написаноЩо візьмуть у проєктіАналог у Java
http_server.pyuvicorn, gunicornTomcat, Netty
http_api.pyFastAPI, Flask, DjangoSpring MVC
розбір JSONPydanticJackson, Bean Validation
storage.pySQLAlchemy, asyncpgHibernate, JDBC
CREATE TABLEAlembicFlyway, Liquibase
Settingspydantic-settings@ConfigurationProperties
expire_loopCelery, APScheduler@Scheduled, Quartz
retrytenacityResilience4j
logs.pystructlogLogback, MDC
немаєprometheus-client, OpenTelemetryMicrometer
немаєhttpxWebClient
немаєaiokafkaSpring Kafka

Що читати далі

Першоджерела: модель даних, документація asyncio, розділ про збірку без GIL, документація mypy і що нового в кожній версії. Для порівняння з Java на цьому сайті є статті про основи Java, багатопотоковість, Project Loom і Spring.