Мова Python
Як влаштована сама мова: імена й об’єкти замість змінних-комірок, функції як об’єкти, класи з качиною типізацією та анотаціями, пам’ять і GIL. Це база, без якої не пояснити ні asyncio, ні поведінку Python-сервісу під навантаженням; де Python поводиться інакше, ніж C#, це сказано прямо.
Які вбудовані типи є в Python і чим змінювані відрізняються від незмінних?
Section titled “Які вбудовані типи є в 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?
Section titled “Як влаштовані функції в 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?
Section titled “Як влаштовані класи й типізація в 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?
Section titled “Як 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.