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
# Хэш SHA-256 первые 8 символов.
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.