Перейти к содержимому

Язык Python

Как устроен сам язык: имена и объекты вместо переменных-ячеек, функции как объекты, классы с утиной типизацией и аннотациями, память и GIL. Это база, без которой не объяснить ни asyncio, ни поведение Python-сервиса под нагрузкой; где Python ведёт себя иначе, чем C#, это сказано прямо.

Junior+

Какие встроенные типы есть в 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 False
print([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]
Middle+
Ответ

Что это

Функция объявляется через 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}, затем 30
print(scale.__name__) # scale
Генератор и контекстный менеджер
from 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, затем 0
print(sum(gen)) # yield 1, yield 2, затем 3
@contextmanager
def 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
Middle+
Ответ

Что это

  • Класс — шаблон объекта, как в 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 dataclass
from 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) True
print(Circle(1).diameter) # 2
print(total([Rect(2, 3), Circle(1)])) # 9.14
MRO и super()
class 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, Base
print([c.__name__ for c in Repo.__mro__]) # ['Repo', 'Audit', 'Cache', 'Base', 'object']
Senior+
Ответ

Что это

  • Управление памятью. Явно освобождать память не нужно. У каждого объекта есть счётчик ссылок: имена, элементы списков, атрибуты. Когда он падает до нуля, объект удаляется сразу.
  • GIL (Global Interpreter Lock) — блокировка в CPython, стандартной реализации Python. Байт-код в каждый момент выполняет только один поток.

Зачем это знать

  • Потоки (threading) полезны для ожидания: сеть, диск, база. Пока поток ждёт, GIL свободен для других.
  • Вычисления на CPU потоки не ускоряют. Для них берут процессы или библиотеки на C вроде NumPy.

Частая ошибка

Запускать тяжёлые вычисления в ThreadPoolExecutor и ждать ускорения по числу ядер. С GIL время останется прежним или даже вырастет.

Как устроено

  1. Присваивание, передача в функцию, добавление в список увеличивают счётчик ссылок объекта, удаление ссылки — уменьшает.
  2. При нуле CPython сразу вызывает __del__, если он есть, и освобождает память.
  3. Циклы (a.peer = b, b.peer = a) счётчик не освободит. Их находит модуль gc: он периодически обходит объекты-контейнеры по трём поколениям и удаляет недостижимые группы.
  4. Поток, держащий 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 gc
import 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, b
print(gc.collect() > 0) # True: циклический сборщик нашёл цикл
print(int("256") is int("256")) # True: кеш малых int от -5 до 256
print(int("257") is int("257")) # False: два разных объекта
s1 = "".join(["py", "thon"])
s2 = "".join(["py", "thon"])
print(s1 == s2, s1 is s2) # True False
print(sys.intern(s1) is sys.intern(s2)) # True