Язык Python
Как устроен сам язык: имена и объекты вместо переменных-ячеек, функции как объекты, классы с утиной типизацией и аннотациями, память и GIL. Это база, без которой не объяснить ни asyncio, ни поведение Python-сервиса под нагрузкой; где Python ведёт себя иначе, чем C#, это сказано прямо.
Какие встроенные типы есть в Python и чем изменяемые отличаются от неизменяемых?
Заголовок раздела «Какие встроенные типы есть в Python и чем изменяемые отличаются от неизменяемых?»Что это
| Тип | Изменяемый | Пример |
|---|---|---|
int, float, bool |
нет | 42, 3.14, True |
str, tuple |
нет | "text", (1, "a") |
list |
да | [1, 2] |
dict |
да | {"id": 1} |
set |
да | {1, 2} |
None — единственный объект своего типа, аналог null.
Как пользоваться
list— последовательность, которую будут менять.tuple— запись фиксированной длины: координаты, пара значений.dict— поиск по ключу,set— уникальные элементы и быстрая проверкаx in s.- Comprehension собирает коллекцию одним выражением:
[x * x for x in items if x > 0].
Частая ошибка
Изменяемое значение по умолчанию: def add(item, items=[]). Список создаётся один раз и копит элементы между вызовами. Правильно — items=None и внутри if items is None: items = [].
Как устроено
- Имена и объекты.
b = aпривязывает второе имя к тому же объекту. Копии не создаётся ни для списка, ни для числа. - Передача аргументов. Функция получает ссылку на тот же объект.
items.append(x)изменит список вызывающего, аitems = [x]перепривяжет только локальное имя. isи==.isсравнивает идентичность,==вызывает__eq__и сравнивает значения. СNoneсравнивают черезis.- Копирование.
copy.copy,list(a)и срезa[:]дают поверхностную копию: новый внешний контейнер с теми же вложенными объектами.copy.deepcopyкопирует рекурсивно.
Когда что выбирать
| Задача | Тип |
|---|---|
| Последовательность, которую меняют | list |
| Запись фиксированной длины, составной ключ | tuple |
| Поиск по ключу | dict |
| Уникальность, проверка вхождения за O(1) | set, frozenset |
Ловушки
- Значение по умолчанию вычисляется один раз при выполнении
def, поэтому[]или{}в нём общий для всех вызовов. [[0] * 3] * 3— три ссылки на один внутренний список: изменение одной строки видно во всех.- Кортеж с изменяемым внутри. В
t = ([1],)нельзя заменитьt[0], ноt[0].append(2)работает, и такой кортеж нехешируемый.
Правило
Значения по умолчанию — неизменяемые или None. Если функция меняет аргумент, это должно быть видно из имени; иначе она работает с копией.
Под капотом
- Каждое значение — объект в куче с указателем на свой тип. Переменная хранит только ссылку, поэтому значимых типов в смысле C# нет: даже
int— объект. - Изменяемость задаёт тип.
intиstrнеизменяемы, поэтомуx += 1создаёт новый объект и перепривязывает имя. Дляlistтот же+=вызывает__iadd__и меняет список на месте. dictиset— хеш-таблицы. Если бы ключ менялся, его хеш устарел бы и элемент «потерялся». Поэтомуlist,dictиsetнехешируемы, аtupleхешируем, только если хешируемо всё внутри.
Цена
- Каждое «изменение» неизменяемого объекта — новая аллокация. Склейка строк через
+=в цикле в худшем случае квадратична,"".join(parts)— линейна. listхранит ссылки на объекты, а не сами числа: миллион разныхintв списке — миллион объектов. Для числовых данных естьarrayи NumPy.deepcopyобходит весь граф объектов и запоминает уже скопированные — на больших структурах это дорого.
Компромиссы
- Неизменяемые объекты безопасно делить между функциями и потоками, но каждое изменение — копия.
- Неизменяемые аналоги:
tuple,frozenset,@dataclass(frozen=True),types.MappingProxyType; с Python 3.15 есть встроенныйfrozendict. - Comprehension быстрее цикла с
appendи не выпускает переменную цикла наружу, но три уровня вложенности читаются хуже обычного цикла.
Как проверить в проде
Изменяемые значения по умолчанию ловит линтер: правило B006 в Ruff (из flake8-bugbear). Общий вложенный объект в отладчике выдаёт одинаковый id().
Итог: изменяемость — свойство типа, и от неё зависят хешируемость, безопасность разделения и цена изменений.
import copy
a = [1, 2]b = a # второе имя для того же спискаb.append(3)print(a) # [1, 2, 3]
def add(items, x): items.append(x) # меняет объект вызывающего items = [x] # перепривязывает только локальное имя
add(a, 4)print(a) # [1, 2, 3, 4]
grid = [[0], [0]]shallow, deep = copy.copy(grid), copy.deepcopy(grid)grid[0].append(1)print(shallow[0], deep[0]) # [0, 1] [0]print([1, 2] == [1, 2], [1, 2] is [1, 2]) # True Falseprint([x * x for x in range(5) if x % 2 == 0]) # [0, 4, 16]
def bad(item, bucket=[]): # список создан один раз, при def bucket.append(item) return bucket
print(bad(1), bad(2)) # [1, 2] [1, 2]- Почему
tupleможет быть ключомdict, аlistнет? —tupleнеизменяем и хешируем, если хешируемо его содержимое. - Что выведет
a = [[0] * 2] * 2; a[0][0] = 1; print(a)? —[[1, 0], [1, 0]]: обе строки — один и тот же список. - Чем
x += 1дляintотличается от+=дляlist? — Дляintсоздаётся новый объект,listменяется на месте через__iadd__. - Почему с
Noneсравнивают черезis? —None— единственный объект, а==можно переопределить в__eq__. - Чем
copy.copyотличается отcopy.deepcopy? — Первый копирует только внешний контейнер, второй — весь граф с учётом циклов.
Как устроены функции в Python?
Заголовок раздела «Как устроены функции в Python?»Что это
Функция объявляется через def и возвращает значение через return; без него она вернёт None. Функция — обычный объект: её можно передать в sorted(users, key=get_age).
Как пользоваться
- Значение по умолчанию:
def connect(host, port=5432). - Вызов с именами:
connect(port=6543, host="db")— порядок не важен. *argsсобирает лишние позиционные аргументы в кортеж,**kwargs— именованные в словарь.- Распаковка при вызове:
f(*items)иf(**options). lambda— анонимная функция из одного выражения:key=lambda u: u.age.
Частая ошибка
Присвоить глобальной переменной внутри функции без global. Присваивание делает имя локальным для всей функции, и чтение до него падает с UnboundLocalError.
Как устроено
- Параметры. В
def f(a, /, b, *, c)aпередаётся только по позиции,b— как угодно,c— только по имени. Именованные-только параметры спасают от путаницы флагов:send(msg, retry=True). - Области видимости. Имя ищется в локальной, объемлющей, глобальной и встроенной области (правило LEGB).
nonlocalиglobalразрешают перепривязать внешнее имя. - Замыкание. Вложенная функция помнит переменные объемлющей и после её завершения.
- Декоратор.
@loggedнадdef f— то же, чтоf = logged(f).functools.wrapsкопирует в обёртку__name__и__doc__и ставит__wrapped__. - Генератор. Вызов функции с
yieldне выполняет тело, а возвращает итератор; каждыйnext()идёт до следующегоyield. - Контекстный менеджер.
withвызывает__enter__, а в конце —__exit__, даже при исключении.@contextlib.contextmanagerстроит менеджер из генератора.
Ловушки
- Позднее связывание. Лямбды из
[lambda: i for i in range(3)]все вернут2: замыкание видит переменную, а не значение. Лечится параметромlambda i=i: i. - Исчерпанный генератор. Второй
forпо нему не выполнится ни разу. - Декоратор без
wraps. В логах и трассировках все функции называютсяwrapper.
Правило
Ресурсы — через with, большие потоки данных — через генераторы, сквозную логику — через декораторы с functools.wraps.
Под капотом
- Объект функции хранит
__code__(байт-код),__defaults__и__closure__— кортеж ячеек (cell) захваченных переменных. Ячейка общая для всех вложенных функций, поэтомуnonlocalв одной видно в другой. - Значения по умолчанию вычисляются один раз при выполнении
defи лежат в__defaults__. - Генератор хранит собственный кадр с локальными переменными и позицией.
yieldприостанавливает кадр,send()возобновляет его и передаёт значение внутрь — на этом исторически построили корутины. - Если
__exit__возвращает истину, исключение из блокаwithподавляется.
Цена
- Вызов Python-функции намного дороже вызова метода в C#: интерпретатор создаёт кадр и разбирает аргументы, встраивания (inlining), как у JIT .NET, нет.
- Каждый слой декоратора добавляет вызов и упаковку
*argsи**kwargs. - Генератор держит в памяти один элемент, а не весь список — так обрабатывают файлы и выборки, которые не помещаются в память.
Компромиссы
- Декоратор меняет поведение незаметно из места вызова:
@retryнад неидемпотентной операцией может повторить списание. - Генераторы ленивы: ошибка источника всплывёт там, где генератор читают, а не где создали.
contextlib.ExitStackуправляет переменным числом ресурсов. Асинхронные версии (async with,asynccontextmanager) — в теме asyncio и FastAPI.
Как проверить в проде
inspect.signature и __wrapped__ показывают исходную функцию за декораторами. Число и стоимость вызовов видны в cProfile или в py-spy без перезапуска процесса.
Итог: гибкость функций Python держится на объектах и протоколах, а платой за неё становятся дорогие вызовы.
import functools
def counter(): count = 0 def inc(): nonlocal count # перепривязать переменную внешней функции count += 1 return count return inc
c = counter()c()print(c()) # 2
def logged(func): @functools.wraps(func) # сохранить __name__, __doc__, __wrapped__ def wrapper(*args, **kwargs): print(f"call {func.__name__} {args} {kwargs}") return func(*args, **kwargs) return wrapper
@logged # то же, что scale = logged(scale)def scale(x, /, *, factor=2): # x — только позиционный, factor — только именованный return x * factor
print(scale(3, factor=10)) # call scale (3,) {'factor': 10}, затем 30print(scale.__name__) # scalefrom contextlib import contextmanager
def numbers(n): for i in range(n): print(f"yield {i}") yield i # пауза до следующего next()
gen = numbers(3) # тело ещё не выполнялосьprint(next(gen)) # yield 0, затем 0print(sum(gen)) # yield 1, yield 2, затем 3
@contextmanagerdef connection(name): print(f"open {name}") try: yield name.upper() # значение для as finally: print(f"close {name}") # выполнится и при исключении
with connection("db") as conn: print(conn) # open db, DB, close db- Что вернёт
[f() for f in [lambda: i for i in range(3)]]? —[2, 2, 2]: замыкания видят переменную, а не значение на момент создания. - Как написать декоратор с параметрами, например
@retry(times=3)? — Функция, которая возвращает декоратор: три уровня вложенности. - Чем
yield fromотличается от цикла сyield? — Делегирует подгенератору, пробрасываяsend(),throw()и возвращаемое значение. - Чем генератор отличается от итератора? — Генератор — итератор, который Python строит из функции с
yield. - Что будет, если
__exit__вернётTrue? — Исключение из блокаwithбудет подавлено.
Как устроены классы и типизация в Python?
Заголовок раздела «Как устроены классы и типизация в Python?»Что это
- Класс — шаблон объекта, как в C#. Экземпляр создаётся вызовом класса без
new:user = User("Ann"). __init__— инициализатор,self— первый параметр каждого метода экземпляра.- Модификаторов доступа нет:
_name— договорённость «внутреннее».
Как пользоваться
@propertyделает метод вычисляемым атрибутом:circle.diameterбез скобок, как свойство в C#.@classmethodполучает классcls— так пишут альтернативные конструкторы:Rect.square(2).@staticmethodне получает ниself, ниcls.- Аннотации:
def total(items: list[float]) -> float; «может бытьNone» —str | None(с 3.10).
Частая ошибка
Объявить изменяемый атрибут на уровне класса: class Cart: items = []. Такой список общий для всех экземпляров; поля экземпляра задают в __init__.
Как устроено
- Dunder-методы. Операторы и встроенные функции вызывают специальные методы:
a + b—a.__add__(b),len(x)—x.__len__(). Это аналог перегрузки операторов иIEquatableв C#. - Наследование. Разрешено множественное. Порядок поиска методов — MRO (
Cls.__mro__), иsuper()вызывает следующий класс в нём, не обязательно родителя. - Duck typing и Protocol. Функции важны методы объекта, а не имя его типа.
Protocolописывает такой контракт структурно: класс совместим без наследования. - Аннотации. Хранятся в
__annotations__; проверяют их mypy и pyright, а не интерпретатор.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Данные с равенством по значению | @dataclass, неизменяемые — frozen=True |
| Контракт без наследования | typing.Protocol |
| Общая реализация, обязательные методы | базовый класс, abc.ABC |
| Проверка внешних данных | Pydantic — в теме asyncio и FastAPI |
Ловушки
__eq__без__hash__. Определив__eq__, вы делаете класс нехешируемым: Python ставит__hash__ = None. Так же ведёт себя@dataclassбезfrozen=True.isinstanceс Protocol работает только с@runtime_checkableи проверяет лишь наличие методов, не сигнатуры.Optional[X]значит «может бытьNone», а не «необязательный параметр». Современная запись —X | None.
Правило
Публичные функции аннотируйте полностью и запускайте mypy или pyright в CI. Данные — @dataclass, контракты — Protocol.
Под капотом
- Атрибуты экземпляра лежат в словаре
__dict__.__slots__или@dataclass(slots=True)(с 3.10) заменяют его фиксированными слотами: меньше памяти и быстрее доступ, но новые атрибуты добавить нельзя. @property,@classmethodи@staticmethod— дескрипторы: объекты с__get__, которые перехватывают доступ к атрибуту класса.- MRO строится C3-линеаризацией: потомок раньше предков, порядок базовых классов из объявления сохраняется. Несогласуемая иерархия —
TypeErrorпри объявлении класса. - С 3.14 аннотации вычисляются лениво (PEP 649): ссылки на ещё не объявленные классы не нужно брать в кавычки.
Сравнение с C#
| C# | Python |
|---|---|
| Номинальные интерфейсы | Protocol — структурный, abc.ABC — номинальный |
| Generics существуют в рантайме | параметры стираются: list[int] в рантайме — просто list |
where T : IComparable<T> |
def f[T: Comparable] (PEP 695, 3.12+) |
| Ошибку типов отклоняет компилятор | ошибку находит mypy или pyright, рантайм её пропустит |
До 3.12 параметры типа объявляли через TypeVar и Generic[T]. Синоним типа — type Pair[T] = tuple[T, T], значения по умолчанию для параметров типа — с 3.13 (PEP 696).
Компромиссы
- Слоты экономят память на каждом объекте; на миллионах мелких объектов это заметно, но выигрыш стоит измерить (
tracemalloc). - Глубокие иерархии с
super()по MRO трудно читать. Композиция иProtocolобычно проще.
Как проверить в проде
mypy --strict или pyright в режиме strict в CI; reveal_type(x) показывает выведенный тип. В рантайме типы проверяет только явная валидация: Pydantic или isinstance.
Итог: Python даёт типизацию уровня C# как внешний инструмент поверх динамической модели объектов — гарантии есть ровно там, где работает проверщик.
from dataclasses import dataclassfrom typing import Protocol
class Shape(Protocol): def area(self) -> float: ...
@dataclass(frozen=True, slots=True)class Rect: w: float h: float
def area(self) -> float: return self.w * self.h
class Circle: # не наследует Shape, но подходит по структуре def __init__(self, r: float) -> None: self.r = r
@property def diameter(self) -> float: # вычисляемый атрибут без скобок return self.r * 2
def area(self) -> float: return 3.14 * self.r ** 2
def total(shapes: list[Shape]) -> float: return sum(s.area() for s in shapes)
print(Rect(2, 3), Rect(2, 3) == Rect(2, 3)) # Rect(w=2, h=3) Trueprint(Circle(1).diameter) # 2print(total([Rect(2, 3), Circle(1)])) # 9.14class Base: def save(self): print("Base")
class Audit(Base): def save(self): print("Audit") super().save() # следующий класс в MRO, не обязательно родитель
class Cache(Base): def save(self): print("Cache") super().save()
class Repo(Audit, Cache): pass
Repo().save() # Audit, Cache, Baseprint([c.__name__ for c in Repo.__mro__]) # ['Repo', 'Audit', 'Cache', 'Base', 'object']- Чем
@classmethodотличается от@staticmethod? — Первый получает классclsи подходит для фабрик с наследованием, второй не получает ничего. - Почему объект с
__eq__нельзя положить вset? — Python обнуляет__hash__; нужно определить его согласованно с__eq__или взятьfrozen=True. - Чем
Protocolотличается отabc.ABC? —Protocolпроверяет структуру,ABCтребует наследования и не даёт создать объект без реализации абстрактных методов. - Как работает
super()при множественном наследовании? — Вызывает следующий класс в MRO экземпляра, поэтому вызыватьsuper()должны все классы цепочки. - Что такое
TypeVarи чем его заменили в 3.12? — Параметр типа для generics; в 3.12 появился синтаксисdef f[T]иclass Box[T].
Как Python управляет памятью и что такое GIL?
Заголовок раздела «Как Python управляет памятью и что такое GIL?»Что это
- Управление памятью. Явно освобождать память не нужно. У каждого объекта есть счётчик ссылок: имена, элементы списков, атрибуты. Когда он падает до нуля, объект удаляется сразу.
- GIL (Global Interpreter Lock) — блокировка в CPython, стандартной реализации Python. Байт-код в каждый момент выполняет только один поток.
Зачем это знать
- Потоки (
threading) полезны для ожидания: сеть, диск, база. Пока поток ждёт, GIL свободен для других. - Вычисления на CPU потоки не ускоряют. Для них берут процессы или библиотеки на C вроде NumPy.
Частая ошибка
Запускать тяжёлые вычисления в ThreadPoolExecutor и ждать ускорения по числу ядер. С GIL время останется прежним или даже вырастет.
Как устроено
- Присваивание, передача в функцию, добавление в список увеличивают счётчик ссылок объекта, удаление ссылки — уменьшает.
- При нуле CPython сразу вызывает
__del__, если он есть, и освобождает память. - Циклы (
a.peer = b,b.peer = a) счётчик не освободит. Их находит модульgc: он периодически обходит объекты-контейнеры по трём поколениям и удаляет недостижимые группы. - Поток, держащий GIL, отдаёт его ожидающим примерно раз в 5 мс (
sys.getswitchinterval()) и всегда — на блокирующем вводе-выводе.
Когда что выбирать
| Нагрузка | Решение |
|---|---|
| Ожидание: HTTP, база, файлы | потоки или asyncio |
| Вычисления на чистом Python | процессы: ProcessPoolExecutor |
| Числовые массивы | NumPy: цикл идёт в C |
| Тяжёлое ядро алгоритма | C-расширение, Cython, Rust через PyO3 |
Ловушки
isдля чисел и строк. CPython кеширует целые от −5 до 256 и интернирует часть строк, поэтомуisдля них иногда «работает». Это деталь реализации — значения сравнивают через==.- GIL не делает код потокобезопасным.
counter += 1— несколько инструкций байт-кода, и поток может переключиться между ними. Нуженthreading.Lock. - Объекты в цикле живут до прохода сборщика, и момент вызова их
__del__непредсказуем.
Правило
Сначала выясните, во что упирается задача — в ожидание или в CPU. Подробное сравнение потоков, процессов и asyncio — в теме asyncio и FastAPI.
Под капотом
- С 3.12 (PEP 683)
None,True, малые целые и другие служебные объекты бессмертны: их счётчик ссылок не меняется. - GIL защищает счётчики и внутренние структуры интерпретатора одной блокировкой. Это дёшево для однопоточного кода и упрощает C-расширения.
- Free-threaded сборка заменяет GIL смещённым подсчётом ссылок (biased reference counting) и блокировками на уровне объектов. Статус: 3.13 — экспериментальная, 3.14 — официально поддерживается (PEP 779), 3.15 — по-прежнему отдельная сборка
python3.15t. Импорт C-расширения без пометки о поддержке снова включает GIL. - JIT-компилятор (PEP 744) появился в 3.13 как экспериментальный. С 3.14 он есть в официальных сборках для Windows и macOS, но включается вручную (
PYTHON_JIT=1) и ускоряет код на проценты, а не в разы.
Цена
- Объект тяжёлый:
sys.getsizeof(1)— 28 байт, плюс 8 байт на ссылку в списке. Массив NumPy хранит голые значения подряд. - Однопоточный оверхед free-threaded сборки — по документации от ~1 % до ~8 % в зависимости от платформы.
- Цикл на чистом Python часто в десятки раз медленнее аналога на C#.
Компромиссы
- Процессы обходят GIL, но платят сериализацией данных (pickle) и памятью на копию интерпретатора.
- NumPy и многие C-расширения отпускают GIL внутри тяжёлых операций, поэтому потоки поверх них масштабируются.
gc.freeze()передforkубирает существующие объекты из обхода сборщика, и дочерние процессы дольше делят страницы памяти.
Как проверить в проде
- py-spy снимает профиль работающего процесса без перезапуска; флаг
--gilоставляет только потоки, держащие GIL. tracemallocпоказывает, где выделялась память;sys._is_gil_enabled()(3.13+) — включён ли GIL.
Итог: CPython платит за простоту подсчёта ссылок и GIL медленным однопоточным кодом; ускоряют его, вынося горячий цикл из интерпретатора, а free-threading и JIT пока меняют картину лишь частично.
import gcimport sys
data = []before = sys.getrefcount(data)alias = data # ещё одна ссылка на тот же объектprint(sys.getrefcount(data) - before) # 1
class Node: def __init__(self): self.peer = None
a, b = Node(), Node()a.peer, b.peer = b, a # цикл: счётчики не упадут до нуляdel a, bprint(gc.collect() > 0) # True: циклический сборщик нашёл цикл
print(int("256") is int("256")) # True: кеш малых int от -5 до 256print(int("257") is int("257")) # False: два разных объекта
s1 = "".join(["py", "thon"])s2 = "".join(["py", "thon"])print(s1 == s2, s1 is s2) # True Falseprint(sys.intern(s1) is sys.intern(s2)) # True- Почему
counter += 1в нескольких потоках теряет инкременты даже с GIL? — Это несколько инструкций байт-кода, и переключение возможно между чтением и записью. - Когда освободится объект, который ссылается сам на себя? — Только при проходе циклического сборщика.
- Что изменилось с GIL в Python 3.13–3.15? — Появилась free-threaded сборка: экспериментальная в 3.13, официально поддерживаемая с 3.14, но не по умолчанию.
- Почему NumPy быстрее цикла на Python? — Цикл идёт в C над непрерывным массивом, без объекта на каждый элемент и без диспетчеризации интерпретатора.
- Отпускает ли поток GIL на
time.sleepи сетевом вызове? — Да, блокирующие вызовы отпускают GIL.