Перейти до вмісту

Мова Python

Як влаштована сама мова: імена й об’єкти замість змінних-комірок, функції як об’єкти, класи з качиною типізацією та анотаціями, пам’ять і GIL. Це база, без якої не пояснити ні asyncio, ні поведінку Python-сервісу під навантаженням; де Python поводиться інакше, ніж C#, це сказано прямо.

Junior+

Які вбудовані типи є в 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 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+

Як влаштовані функції в 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}, потім 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+

Як влаштовані класи й типізація в 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 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+

Як Python керує пам’яттю і що таке GIL?

Section titled “Як Python керує пам’яттю і що таке GIL?”
Відповідь

Що це

  • Керування пам’яттю. Явно звільняти пам’ять не треба. Кожен об’єкт має лічильник посилань: імена, елементи списків, атрибути. Коли він падає до нуля, об’єкт видаляється одразу.
  • 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