Классы
Класс связывает данные с операциями, определёнными над ними, и вместо разрозненных переменных и обрабатывающих их функций получается один объект, отвечающий за своё состояние. От Java и C++ Python отличается тем, что не скрывает механику: ссылка на экземпляр передаётся методу явным первым аргументом self, а не появляется в теле сама собой.
ООП в Python не является обязательным. Модуль, составленный из десятка функций, представляет собой полноценную программу, и оборачивать её в класс только ради класса не требуется. Классы применяются тогда, когда у данных появляется состояние, которое необходимо поддерживать согласованным, или когда одну и ту же операцию требуется выполнять по-разному для объектов разного вида, обрабатываемых единым кодом.
Слайды к главе. Материал главы изложен также в лекции «Классы в Python» (классы и экземпляры, методы и свойства, наследование, магические методы, дескрипторы, метаклассы) с демонстрациями в интерактивной оболочке; слайды лекции доступны на сайте книги и в PDF.
Базовые понятия
Определение класса
Метод __init__ вызывается сразу после создания объекта и задаёт начальное состояние; всё, записанное в self, становится атрибутом конкретного экземпляра, которым и пользуются остальные методы.
class Counter:
"""I count. That is all."""
def __init__(self, initial=0): # начальное состояние
self.value = initial # запись атрибута
def increment(self):
self.value += 1
def get(self):
return self.value # чтение атрибута
# Использование
c = Counter(42)
c.increment()
print(c.get()) # 43
Тело класса выполняется вместе с инструкцией class, обычно один раз, при импорте модуля: функции, объявленные в нём, становятся методами, а остальные присвоенные имена — атрибутами класса. Имена классов по PEP 8 записываются в стиле CapWords (Counter, MeasurementSeries).
Создание экземпляра: __new__ и __init__
Название «конструктор», закрепившееся за __init__, строго говоря неверно, поскольку объект к этому моменту уже создан. Вызов класса выполняется в два шага: метод __new__ получает те же аргументы, что и вызов, и создаёт объект, а затем __init__ заполняет заготовку, полученную от него. Разница проявляется при наследовании от неизменяемого типа наподобие int, float или tuple, у которого изменять уже нечего, и вся работа переносится в __new__.
class PositiveFloat(float):
def __new__(cls, value):
if value <= 0:
raise ValueError("нужно > 0")
return super().__new__(cls, value)
PositiveFloat(2.5) + 1 # 3.5
PositiveFloat(-1) # ValueError: нужно > 0
Объект проверен при создании и участвует в арифметике как обычное число, но результат арифметики — уже обычный float, и проверка на него не действует: PositiveFloat(2.5) - 3 даёт -0.5. Аргументы передаются в super().__new__ только тогда, когда их потребляет базовый класс: float.__new__ принимает значение, а object.__new__ с лишними аргументами завершается TypeError.
Специфика Python
Первым параметром обычного метода следует self, то есть экземпляр, на котором метод вызван, причём указывать его приходится вручную, в соответствии с правилом «явное лучше неявного». Методы класса и статические методы, рассматриваемые ниже, получают вместо экземпляра класс или ничего.
Модификаторов доступа наподобие private и protected в Python нет, вместо них действует соглашение об именовании.
public_attributeсоставляет часть публичного интерфейса, и его использование допустимо;_internal_attributeявляется внутренним, извне к нему не обращаются;__private_attributeозначает то же самое, но с дополнительной защитой от случайного переопределения, выполненного в подклассе.
Только за двойным подчёркиванием стоит механизм, встроенный в язык. Компилятор переписывает имя, помеченное таким образом, добавляя к нему имя класса, вследствие чего __private внутри класса Counter превращается в _Counter__private. Искажение лексическое: внутри тела класса self.__private и Counter.__private одинаково становятся _Counter__private. Механизм предназначен для предотвращения коллизий, а не для сокрытия данных, так что базовый класс и подкласс, объявив по атрибуту __cache, не столкнутся. Доступ к «приватному» атрибуту при этом сохраняется через полное имя. Имена с двумя подчёркиваниями с обеих сторон, такие как __init__, зарезервированы для специальных методов и не искажаются.
Атрибуты классов и экземпляров
Атрибуты в Python бывают двух видов, и путаница между ними приводит к трудноуловимым ошибкам.
Атрибуты экземпляра
Атрибут экземпляра появляется в результате присваивания, выполненного к self, и принадлежит только этому объекту. Создавать его не обязательно в __init__, поскольку присвоить атрибут можно и извне, уже созданному объекту.
class Noop:
def __init__(self):
self.some_attribute = 42
noop = Noop()
noop.other_attribute = 100500 # динамическое добавление
Эта свобода следует из того, что атрибуты хранятся в обычном словаре, и у неё есть оборотная сторона: опечатка в имени не вызовет ошибки. Строка noop.som_attribute = 0 без предупреждения создаст новый атрибут, а старый останется прежним.
Атрибуты класса
Атрибут, объявленный в теле класса, принадлежит самому классу и является одним на все его экземпляры. Это подходит для общих сущностей: настроек, констант, реестра созданных объектов.
class Counter:
all_counters = [] # атрибут класса
def __init__(self, initial=0):
Counter.all_counters.append(self)
self.value = initial
# Также можно добавлять атрибуты после определения
Counter.some_other_attribute = 42
Ловушка та же, что с изменяемыми аргументами по умолчанию: список all_counters создаётся один раз, при выполнении инструкции class. Если бы он был задуман как список, заведённый у каждого счётчика отдельно, все экземпляры разделяли бы один. Здесь общий список необходим, однако при объявлении изменяемого атрибута класса проверяется, должен ли он быть общим. Кроме того, такой реестр удерживает созданные счётчики, и они не освобождаются никогда; реестр, не удерживающий объекты, строится на weakref.WeakSet.
При чтении c.value интерпретатор ищет имя сначала в словаре экземпляра, затем в словаре класса, затем в базовых классах в порядке разрешения методов; исключения из этой схемы составляют дескрипторы, рассматриваемые в конце главы. Присваивание через экземпляр, напротив, всегда пишет в словарь экземпляра: c.all_counters = [] создаст у c собственный атрибут, заслоняющий атрибут класса, а del c.all_counters снова откроет атрибут класса.
Словарь атрибутов
Все атрибуты экземпляра хранятся в обычном словаре __dict__, благодаря которому и работает динамическое добавление. В него можно заглянуть и просмотреть поля, имеющиеся у объекта.
noop = Noop()
noop.some_attribute = 42
print(noop.__dict__) # {'some_attribute': 42}
print(vars(noop)) # тот же словарь
# Имя атрибута, вычисляемое во время выполнения
cfg = {"gain": 2.5, "threshold": 0.1}
for name, value in cfg.items():
setattr(noop, name, value)
getattr(noop, "gain") # 2.5
getattr(noop, "offset", 0.0) # значение по умолчанию вместо AttributeError
hasattr(noop, "threshold") # True
Встроенная vars() с аргументом возвращает __dict__ объекта, тот же словарь, а не копию, а без аргументов — словарь текущего локального пространства имён. Для имени атрибута, вычисляемого во время выполнения, применяются getattr, setattr, hasattr и delattr; прямая запись в __dict__ обходит свойства и __setattr__ и нужна редко, например внутри самого __setattr__. Параметры моделирования, прочитанные из файла настроек, переносятся в объект именно так, и имена при этом сверяются со списком допустимых: опечатка в файле создала бы лишний атрибут без сообщения об ошибке.
__slots__ для оптимизации
За гибкость приходится платить памятью: словарь атрибутов на объекте с двумя полями занимает больше самих полей. С Python 3.11 он создаётся лишь при первом обращении к __dict__, поэтому плата меньше, чем в прежних версиях, но при миллионах объектов, по одному на событие, зарегистрированное в эксперименте, она по-прежнему ощутима.
__slots__ фиксирует набор допустимых атрибутов заранее, благодаря чему интерпретатор обходится без словаря и размещает значения по ячейкам, отведённым при создании объекта.
class Event:
__slots__ = ("time", "energy") # фиксирует набор атрибутов
def __init__(self, time, energy):
self.time = time
self.energy = energy
e = Event(0.1, 511.0)
e.charge = -1 # AttributeError: 'Event' object has no attribute 'charge'
Замер модулем tracemalloc для 100 тысяч объектов с двумя полями, сложенных в список, вместе с 8 байтами ссылки из списка даёт в Python 3.12 88 байт на обычный объект, 152 байта после обращения к его __dict__ и 56 байт со __slots__; в 3.11 обычный объект занимает 96 байт. Скорость доступа при этом почти не меняется: в измерении из главы «Оптимизация средствами самого Python» разница оказалась в пределах шума. Взамен теряется динамика: атрибут, отсутствующий в списке, добавить не удастся, слабые ссылки работают, только если '__weakref__' указан в слотах, а подкласс без собственного __slots__ снова получает словарь. Начинать с __slots__ нецелесообразно; его добавляют тогда, когда профилировщик покажет, что память расходуется именно здесь. Для массовых числовых данных объекты заменяются массивами NumPy: событие из двух float64 в структурированном массиве занимает 16 байт вместе со значениями.
Методы
Методы и связывание
Метод представляет собой функцию, хранящуюся в атрибуте класса, и получить её можно двумя путями. Через класс получается обычная функция, требующая экземпляр, переданный явно. Через экземпляр получается связанный метод, в котором объект уже подставлен в self, и передавать его повторно не требуется.
class SomeClass:
def do_something(self):
print("Doing something.")
# Через класс — обычная функция (в Python 2 — несвязанный метод)
function = SomeClass.do_something
instance = SomeClass()
function(instance) # экземпляр передаётся явно
# Через экземпляр — связанный метод
bound_method = instance.do_something
bound_method() # self уже привязан
bound_method.__self__ is instance # True
Связанный метод представляет собой полноценный объект: его помещают в переменную, передают в map или регистрируют как обработчик события, а ссылку на свой экземпляр (__self__) и функцию (__func__) он хранит в себе. Создаётся он при каждом обращении: instance.do_something is instance.do_something даёт False. В Python 2 обращение через класс давало особый тип, проверявший тип первого аргумента и не пропускавший посторонние объекты; в Python 3 это обычная функция. Само связывание выполняет протокол дескрипторов, рассматриваемый ниже.
Методы класса и статические методы
Помимо обычных методов, класс может содержать методы, связанные не с экземпляром, а с самим классом, и методы, не связанные ни с чем.
class Particle:
def __init__(self, mass, v=0.0):
self.mass, self.v = mass, v
@classmethod
def from_momentum(cls, mass, p):
return cls(mass, v=p / mass) # нерелятивистский случай, v << c
@staticmethod
def gamma(v, c=299_792_458.0):
return 1 / (1 - (v / c) ** 2) ** 0.5
class Electron(Particle):
pass
e = Electron.from_momentum(9.109e-31, 2.7e-24)
type(e).__name__ # 'Electron'
Декоратор classmethod делает метод, первым аргументом которого приходит класс, а не экземпляр. Типичное применение — альтернативные конструкторы, как dict.fromkeys и datetime.fromtimestamp: поскольку cls — фактический класс вызова, подкласс получает объект своего типа. Декоратор staticmethod убирает привязку совсем: метод получает только переданные аргументы и является функцией, помещённой в пространство имён класса, потому что относится к нему по смыслу. Альтернативные конструкторы удобны для чтения данных разных форматов: Spectrum.from_csv(path) и Spectrum.from_hdf5(path) разбирают каждый свой формат и вызывают общий __init__.
Свойства (Properties)
Публичный атрибут иногда необходимо защитить проверкой или вычислять при обращении. В языках со строгой инкапсуляцией на этот случай заранее пишутся геттеры и сеттеры для всех полей. В Python атрибут остаётся атрибутом, а когда потребуется, за ним размещаются методы без изменения кода, использующего его.
Это обеспечивает декоратор @property: метод, помещённый под ним, вызывается при чтении, метод под @имя.setter при записи, метод под @имя.deleter при удалении. Извне всё выглядит как обычное присваивание.
class BigDataModel:
def __init__(self):
self._params = []
@property
def params(self):
return self._params
@params.setter
def params(self, new_params):
if any(p <= 0 for p in new_params):
raise ValueError("параметры должны быть положительны")
self._params = new_params
@params.deleter
def params(self):
del self._params
model = BigDataModel()
model.params = [0.1, 0.5, 0.4]
print(model.params) # [0.1, 0.5, 0.4]
Присваивание model.params = [...] выглядит как запись в обычное поле, но проходит через проверку в сеттере. assert для такой проверки не годится: при запуске интерпретатора с ключом -O он удаляется вместе с проверкой. Свойство без сеттера доступно только для чтения, и присваивание завершается AttributeError; с Python 3.11 сообщение звучит как property 'params' of 'BigDataModel' object has no setter. Интерфейс не меняется, а поведение за ним может меняться произвольно. Производные величины — kT, плотность, давление — удобно оформлять свойствами: они вычисляются из состояния и всегда с ним согласованы. Значение, которое трудоёмко вычислять и которое после этого не меняется, вычисляется один раз декоратором functools.cached_property (Python 3.8): результат первого обращения сохраняется в словаре экземпляра. Внутренне свойства построены на дескрипторах, рассматриваемых ниже.
Классы данных
Значительная часть классов в расчётном коде — записи с именованными полями: результат измерения, параметры прогона, конфигурация детектора. Для них __init__, __repr__ и __eq__ пишутся однообразно, и эту работу выполняет декоратор dataclass (Python 3.7, PEP 557), строящий методы по аннотированным полям.
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Measurement:
value: float
error: float = 0.0
unit: str = "м"
@dataclass
class Run:
number: int
events: list[int] = field(default_factory=list)
m = Measurement(9.81, 0.02)
m # Measurement(value=9.81, error=0.02, unit='м')
m == Measurement(9.81, 0.02) # True
m.value = 10 # FrozenInstanceError: cannot assign to field 'value'
Run(1).events is Run(2).events # False
| Параметр | Что даёт |
|---|---|
| по умолчанию | __init__, __repr__, __eq__ по полям |
frozen=True | запрет присваивания полям и __hash__ |
order=True | сравнения <, <=, >, >= по кортежу полей |
slots=True (3.10) | __slots__: без словаря и без новых атрибутов |
field(default_factory=list) | свой изменяемый объект у каждого экземпляра |
Изменяемое значение по умолчанию запрещено: объявление events: list = [] завершается ValueError с подсказкой применить default_factory. Аннотации при этом не проверяются: Measurement("9.81") создаётся без ошибки. От опечатки в имени поля защищает не всякий класс данных: лишний именованный аргумент при создании объекта даёт TypeError, но присваивание run.evnets = [1] у обычного @dataclass создаёт новый атрибут, и ошибку дают только slots=True или frozen=True. dataclass является декоратором класса, а не метаклассом: методы дописываются к готовому классу, а при slots=True декоратор возвращает новый класс со __slots__. Для неизменяемых записей без методов есть также typing.NamedTuple.
Наследование
Базовое наследование
Подкласс получает всё, объявленное у родителя, и добавляет своё. Если метода нет у подкласса, интерпретатор поднимается по цепочке наследования и ищет его выше. OtherCounter не определяет __init__, поэтому при создании работает родительский.
class Counter:
def __init__(self, initial=0):
self.value = initial
class OtherCounter(Counter):
def get(self):
return self.value
oc = OtherCounter() # вызывает Counter.__init__
print(oc.get()) # вызывает OtherCounter.get
Принадлежность объекта классу проверяется функцией isinstance(oc, Counter), учитывающей наследование, а не сравнением type(oc) == Counter, которое для экземпляра подкласса даёт False; issubclass(OtherCounter, Counter) проверяет отношение самих классов. Родителей, в отличие от многих языков, может быть несколько, и чем сложнее иерархия, тем труднее определить, чей метод, найденный по цепочке, будет вызван.
Переопределение методов и super()
Чаще подкласс не заменяет метод родителя целиком, а надстраивает над ним своё и передаёт управление выше, а ссылку на реализацию, унаследованную от родителя, предоставляет super().
class Counter:
all_counters = []
def __init__(self, initial=0):
self.__class__.all_counters.append(self)
self.value = initial
def increment(self):
self.value += 1
def get(self):
return self.value
class OtherCounter(Counter):
def __init__(self, initial=0):
self.initial = initial
super().__init__(initial) # вызов родительского __init__
Запись Counter.__init__(self, initial) вместо super() является распространённой ошибкой, и разница проявится при множественном наследовании. super() обращается не к родителю, а к следующему классу в порядке разрешения методов класса того объекта, у которого вызван метод (type(self).__mro__): у ThreadSafeCounter из раздела о примесях за примесью следует Counter, хотя примесь о нём не знает. Имя родителя, указанное явно, эту цепочку разрывает. Форма без аргументов работает благодаря неявной ячейке __class__, которую компилятор создаёт для функций, объявленных в теле класса; ручная запись super(self.__class__, self) при трёх уровнях наследования уходит в бесконечную рекурсию.
Множественное наследование
Порядок, в котором интерпретатор перебирает классы в поисках метода, называется MRO, то есть method resolution order, и вычисленный порядок доступен у любого класса.
class A:
def f(self):
print("A.f")
class B:
def f(self):
print("B.f")
class C(A, B):
pass
print(C.mro()) # порядок разрешения методов
C().f() # A.f (согласно MRO)
Здесь f находится в A, поскольку A стоит первым в списке родителей, перечисленных в скобках. Порядок, выстраиваемый здесь, вычисляет алгоритм C3, предложенный для языка Dylan и используемый в Python с версии 2.3, и он обеспечивает три гарантии. Подкласс всегда следует раньше своих родителей; порядок, в котором родители перечислены, сохраняется; каждый класс иерархии встречается в списке единожды. MRO вычисляется при создании класса, и если требования противоречат друг другу, Python откажется создавать класс.
class D(A, C): # C — подкласс A, но указан после него
pass
# TypeError: Cannot create a consistent method resolution
# order (MRO) for bases A, C
Классы-примеси
Примесью (mixin) называют класс, не предназначенный для самостоятельного использования и не описывающий сущность, а только добавляющий к чужому поведению одну черту, будь то блокировка, журналирование или кеширование. Ниже примесь оборачивает операции счётчика в блокировку, захватываемую на время вызова.
import threading
class ThreadSafeMixin:
def __init__(self, *args, **kwargs):
self._lock = threading.Lock()
super().__init__(*args, **kwargs)
def get_lock(self):
return self._lock
def increment(self):
with self.get_lock():
super().increment()
def get(self):
with self.get_lock():
return super().get()
class ThreadSafeCounter(ThreadSafeMixin, Counter):
pass
Всё это работает благодаря super() и порядку разрешения методов. Примесь стоит перед Counter в списке родителей, поэтому её increment перехватывает вызов первым, а super().increment(), расположенный внутри, передаётся дальше по цепочке, в настоящий счётчик. ThreadSafeMixin не наследуется от Counter и ничего о нём не знает: следующий класс в MRO определяется в момент, когда собирается ThreadSafeCounter. Замок при этом необходим и при GIL: self.value += 1 выполняется несколькими инструкциями байт-кода, и переключение потоков между ними возможно. Аналогичным образом устроены socketserver.ThreadingMixIn и значительная часть Django.
Композиция вместо наследования
Наследование выражает отношение «является»: электрон — частица, и любой код, работающий с частицей, обязан работать и с электроном (принцип подстановки Лисков). Если же объект лишь использует другой — детектор состоит из каналов, ускоритель — из магнитов и резонаторов, — это композиция: объект хранит другие объекты в атрибутах и поручает им работу.
class Channel:
def __init__(self, number, gain):
self.number, self.gain = number, gain
class Detector:
def __init__(self, channels):
self.channels = list(channels) # содержит, а не является
def total_gain(self):
return sum(c.gain for c in self.channels)
Композицию проще понимать и тестировать, её части заменяются независимо, а глубокие иерархии наследования связывают классы жёстко. Признак неудачного наследования — подкласс, отключающий унаследованные методы или меняющий их смысл, как квадрат, унаследованный от прямоугольника с независимо изменяемыми сторонами.
Декораторы классов
Декоратор применяется не только к функциям: класс также является объектом, и его можно пропустить через функцию, возвращающую нечто другое. Классическим примером служит синглтон, где класс подменяется функцией, создающей экземпляр при первом вызове и в дальнейшем возвращающей тот же самый.
import functools
def singleton(cls):
instance = None
@functools.wraps(cls)
def inner(*args, **kwargs):
nonlocal instance
if instance is None:
instance = cls(*args, **kwargs)
return instance
return inner
@singleton
class Noop:
"I do nothing at all."
print(Noop() is Noop()) # True
Проверка Noop() is Noop() сравнивает два живых объекта. Распространённая запись id(Noop()) == id(Noop()) ничего не доказывает: первый временный объект уничтожается до создания второго, его адрес переиспользуется, и для обычного класса такое сравнение тоже даёт True. Цена синглтона в таком виде — класс подменён функцией, поэтому isinstance(x, Noop) и наследование от Noop больше не работают.
Декораторы классов появились в Python 2.6 и 3.0 (PEP 3129) и потеснили метаклассы: многое, ради чего раньше писался метакласс, теперь выполняется одной функцией. Наиболее известным примером является @dataclass, рассмотренный выше.
Магические методы
Магическими (или специальными) методами называют методы, чьи имена окружены двойными подчёркиваниями. Напрямую их не вызывают, поскольку за каждым стоит синтаксическая конструкция или встроенная функция. Определение нужного метода позволяет объекту вести себя как встроенный, то есть складываться, сравниваться, печататься, попадать в множество.
| Запись | Вызов |
|---|---|
len(x) | x.__len__() |
x + y | x.__add__(y), иначе y.__radd__(x) |
x == y, x < y | x.__eq__(y), x.__lt__(y) |
x[i], x[i] = v | x.__getitem__(i), x.__setitem__(i, v) |
for v in x | iter(x), то есть x.__iter__() |
x(...) | x.__call__(...) |
repr(x), str(x), f"{x:.2f}" | x.__repr__(), x.__str__(), x.__format__(".2f") |
if x | x.__bool__(), иначе x.__len__() |
hash(x) | x.__hash__() |
Неявный вызов ищет специальный метод у типа объекта, минуя словарь экземпляра: метод, присвоенный экземпляру (obj.__len__ = ...), на len(obj) не влияет.
Управление атрибутами
Первая группа перехватывает работу с атрибутами. __getattr__ вызывается только тогда, когда обычный поиск завершился AttributeError, и в нём подставляется значение вместо ошибки. __setattr__ и __delattr__, напротив, перехватывают любую запись и любое удаление.
class Settings:
def __init__(self, **values):
object.__setattr__(self, "_values", values)
def __getattr__(self, name):
# Вызывается, если обычный поиск не нашёл атрибут
values = self.__dict__.get("_values", {})
if name in values:
return values[name]
raise AttributeError(name)
def __setattr__(self, name, value):
# Вызывается при записи любого атрибута
raise AttributeError("настройки только для чтения")
def __delattr__(self, name):
# Вызывается при удалении любого атрибута
raise AttributeError("настройки только для чтения")
cfg = Settings(gain=2.5, threshold=0.1)
print(cfg.gain) # 2.5
hasattr(cfg, "offset") # False
cfg.gain = 3 # AttributeError: настройки только для чтения
Для неизвестного имени __getattr__ обязан бросать AttributeError, а не возвращать значение и не бросать другое исключение: на этом основаны hasattr, getattr со значением по умолчанию, копирование copy и сериализация pickle. Поскольку __getattr__ вызывается и тогда, когда AttributeError возникла внутри геттера свойства, он может маскировать ошибку в свойстве. Запись внутри __setattr__ выполняется через object.__setattr__ или super().__setattr__, иначе self.name = value снова вызовет метод и приведёт к бесконечной рекурсии; по той же причине __getattr__ читает служебное поле через self.__dict__: у объекта, созданного копированием без вызова __init__, этого поля ещё нет. Переопределять __getattribute__, перехватывающий доступ ко всем атрибутам, а не только к отсутствующим, не рекомендуется. Готовый объект с доступом к значениям через точку — types.SimpleNamespace.
Операторы сравнения
Вторая группа отвечает за сравнение, и методов в ней шесть, но записывать все шесть не требуется: декоратор @functools.total_ordering достроит остальные по __eq__ и любому одному из методов, задающих упорядочивание.
import functools
@functools.total_ordering
class Counter:
def __init__(self, value):
self.value = value
def __eq__(self, other):
if not isinstance(other, Counter):
return NotImplemented
return self.value == other.value
def __lt__(self, other):
if not isinstance(other, Counter):
return NotImplemented
return self.value < other.value
c1, c2 = Counter(1), Counter(2)
print(c1 < c2) # True
print(c1 >= c2) # False (достроено из __lt__ и __eq__)
print(c1 == 1) # False: сравнение с объектом другого типа
Для объекта чужого типа методы возвращают NotImplemented, и интерпретатор пробует отражённую операцию второго операнда, а при неудаче сравнивает объекты на равенство по идентичности; без этой проверки Counter(1) == 1 завершилось бы AttributeError. __ne__ по умолчанию обращает результат __eq__, а при отсутствии __gt__ интерпретатор пробует отражённое c2 < c1, поэтому без total_ordering не выводятся только <= и >=.
Определение __eq__ отключает хеширование: интерпретатор присваивает __hash__ = None, и объект не попадёт ни в множество, ни в ключи словаря. Причина та же, что у изменяемых коллекций: равные объекты обязаны иметь равные хеши, а хеш по умолчанию основан на идентичности объекта и с новым равенством не согласован. Если объект должен оставаться хешируемым, __hash__ определяется явно и вычисляется из тех же полей, что участвуют в сравнении, причём только для объектов, которые после создания не меняются: объект, изменившийся после помещения в множество, поиск в нём не находит.
Строковое представление
Третья группа отвечает за текстовое представление. __repr__ предназначен для программиста: он должен быть однозначным и в идеале выглядеть как выражение, воссоздающее объект. __str__ предназначен для человека, просматривающего вывод программы. Если __str__ не определён, Python использует __repr__, поэтому начинают именно с него.
class Counter:
def __init__(self, initial=0):
self.value = initial
def __repr__(self):
return f"Counter({self.value})"
def __str__(self):
return f"Counted to {self.value}"
def __format__(self, format_spec):
return self.value.__format__(format_spec)
c = Counter(42)
print(repr(c)) # Counter(42)
print(str(c)) # Counted to 42
print(f"{c:b}") # 101010 (бинарное представление)
Третий метод, __format__, отвечает за подстановку в f-строку со спецификатором, а здесь он передаёт полученный спецификатор дальше в целое число, поэтому {c:b} печатает значение счётчика в двоичном виде.
__repr__ имеет смысл определять у любого класса, существующего дольше пары строк, иначе при отладке объекты печатаются как <__main__.Counter object at 0x7f3a…>.
Арифметические операторы и NotImplemented
Арифметические операторы вызывают методы __add__, __sub__, __mul__, __truediv__, __pow__ и другие. Класс величины с погрешностью ниже складывает погрешности независимых величин в квадратуре и масштабирует погрешность при умножении на точное число.
import math
class Measured:
def __init__(self, value, error):
self.value, self.error = value, error
def __repr__(self):
return f"Measured({self.value!r}, {self.error!r})"
def __add__(self, other):
if not isinstance(other, Measured):
return NotImplemented
error = math.hypot(self.error, other.error)
return Measured(self.value + other.value, error)
def __mul__(self, k):
if not isinstance(k, (int, float)):
return NotImplemented
return Measured(self.value * k, abs(k) * self.error)
__rmul__ = __mul__
a, b = Measured(1.5, 0.3), Measured(2.5, 0.4)
a + b # Measured(4.0, 0.5)
2 * a # Measured(3.0, 0.6): отражённый __rmul__
a + 1 # TypeError: unsupported operand type(s) for +: 'Measured' and 'int'
Если левый операнд не умеет работать с правым, метод возвращает специальное значение NotImplemented, и интерпретатор пробует отражённый метод правого операнда: для a + b это b.__radd__(a). Если и он вернул NotImplemented, возникает TypeError. Поэтому для неподходящего типа возвращается NotImplemented, а не бросается исключение: другой тип сохраняет возможность обработать операцию. Отражённый метод вызывается, только если типы операндов различны; если правый операнд — экземпляр подкласса и переопределяет отражённый метод, этот метод вызывается первым. __rmul__ нужен, чтобы работало 2 * a: умножение int на Measured не определено. Для составного присваивания a += b ищется __iadd__, а без него выполняется __add__ с созданием нового объекта; унарные операции — __neg__, __pos__, __abs__.
Зависимость величин класс не учитывает: a + a даёт погрешность σ√2, хотя верно 2σ, как у 2 * a. Учёт корреляций и производных для нелинейных функций реализован в пакете uncertainties.
Протоколы коллекций
Коллекции определяются протоколами — наборами методов. Спектр ниже определяет два метода и уже ведёт себя как последовательность.
class Spectrum:
def __init__(self, counts):
self._counts = list(counts)
def __len__(self):
return len(self._counts)
def __getitem__(self, i):
if isinstance(i, slice):
return type(self)(self._counts[i])
return self._counts[i]
s = Spectrum([3, 5, 8, 2])
len(s), s[2] # (4, 8)
list(s), 8 in s # ([3, 5, 8, 2], True)
list(reversed(s)) # [2, 8, 5, 3]
type(s[1:3]).__name__ # 'Spectrum'
__len__ даёт функцию len и, при отсутствии __bool__, истинность: пустая коллекция ложна. __getitem__ даёт индексирование, а получив объект slice, — срезы; здесь срез возвращает новый спектр, а не список. Двух методов достаточно, чтобы объект перебирался в цикле for (интерпретатор вызывает __getitem__ с индексами 0, 1, 2 и так далее до IndexError), проверялся оператором in и разворачивался функцией reversed. Современный протокол перебора — метод __iter__, возвращающий итератор (глава «Итераторы, генераторы и корутины»); __contains__ ускоряет проверку in, если ответ получается без перебора, а __setitem__ и __delitem__ дают запись и удаление элементов. Наследование от collections.abc.Sequence добавит index, count и __reversed__ по тем же двум методам и сделает объект последовательностью для isinstance; без него isinstance(s, Sequence) даёт False.
Другие полезные магические методы
__call__ делает экземпляр вызываемым: g(x) выполняет g.__call__(x).
import math
class Gaussian:
def __init__(self, mu, sigma):
self.mu, self.sigma = mu, sigma
def __call__(self, x):
z = (x - self.mu) / self.sigma
return math.exp(-z * z / 2)
g = Gaussian(0.0, 2.0)
g(2.0) # 0.6065306597126334
callable(g) # True
Такой объект подходит везде, где ожидается функция, — в интеграторе scipy.integrate.quad, оптимизаторе или в sorted как ключ, — и, в отличие от замыкания, хранит параметры в именованных атрибутах, имеет repr и может иметь другие методы. На __call__ основаны и декораторы, реализованные классами. __bool__ задаёт, что объект означает в условии if; без него Python обращается к __len__, и объект нулевой длины оказывается ложным, а объект без обоих методов истинен всегда. __hash__ определяет, как объект попадает в множества и словари; хеш по умолчанию выводится из идентичности объекта, и писать его вручную для этого не требуется.
Дескрипторы
Свойство решает задачу для одного атрибута одного класса, а проверка на положительность требуется десяткам полей в разных классах. Дескриптор выносит логику доступа в отдельный класс, откуда её можно переиспользовать.
Базовый дескриптор
Дескриптор представляет собой объект, класс которого определяет хотя бы один из методов __get__, __set__ или __delete__. Помещённый в атрибут класса, он перехватывает обращение к этому атрибуту у всех экземпляров: чтение obj.x вызывает __get__(obj, type(obj)), обращение через класс — __get__(None, cls), запись — __set__, удаление — __delete__. Метод __set_name__ (Python 3.6, PEP 487) вызывается при создании класса и сообщает дескриптору имя, под которым он записан.
class Positive:
def __set_name__(self, owner, name):
self.public, self.name = name, "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.public}: ожидается > 0")
setattr(obj, self.name, value)
class Particle:
mass = Positive()
lifetime = Positive()
def __init__(self, mass, lifetime):
self.mass, self.lifetime = mass, lifetime
muon = Particle(0.1057, 2.2e-6)
muon.lifetime = -1 # ValueError: lifetime: ожидается > 0
vars(muon) # {'_mass': 0.1057, '_lifetime': 2.2e-06}
Проверка написана один раз, защищённых ею полей два, и добавление третьего требует одной строки. Обращение через класс, Particle.mass, передаёт obj = None, и по соглашению возвращается сам дескриптор.
Дескрипторы данных определяют __set__ или __delete__, дескрипторы не данных — только __get__. От этого зависит порядок поиска при чтении атрибута:
- дескриптор данных в классе или его базовых классах;
- запись в словаре экземпляра;
- дескриптор не данных или обычный атрибут класса;
- метод
__getattr__, если он определён, иначеAttributeError.
Поэтому дескриптор данных, например свойство, нельзя заслонить записью в словарь экземпляра, а дескриптор не данных — можно. Через дескрипторы в Python реализовано почти всё, связанное с атрибутами: и @property, и @staticmethod, и @classmethod, и сама привязка методов к экземпляру. Обычная функция — дескриптор не данных, её __get__ возвращает связанный метод, поэтому функция, хранящаяся в классе, при обращении через экземпляр оказывается связанным методом, а атрибут экземпляра с тем же именем её заслоняет. Встроенные функции вроде len дескрипторами не являются: после A.length = len вызов A().length() метода не даёт.
Хранение данных в дескрипторах
Хранить значение в самом дескрипторе нельзя: он один на весь класс, а экземпляров много, и значения, записанные ими, затирали бы друг друга. Дескриптор Positive хранит значение в атрибуте экземпляра с подчёркиванием; другой вариант — запись в словаре экземпляра под меткой поля.
class Proxy:
def __init__(self, label):
self.label = label
def __get__(self, instance, owner):
if instance is None:
return self
try:
return instance.__dict__[self.label]
except KeyError:
raise AttributeError(self.label) from None
def __set__(self, instance, value):
instance.__dict__[self.label] = value
def __delete__(self, instance):
del instance.__dict__[self.label]
class Something:
attr = Proxy("attr")
some = Something()
some.attr = 42
print(some.attr) # 42
У каждого экземпляра значение своё, и существует оно столько же, сколько сам экземпляр. Отсутствующее значение даёт AttributeError, а не KeyError, иначе hasattr и getattr со значением по умолчанию завершались бы ошибкой. Вариант со словарём «экземпляр → значение», заведённым внутри дескриптора, хуже по двум причинам: он требует хешируемых объектов и удерживает их, не позволяя сборщику мусора удалить, если не применить weakref.WeakKeyDictionary. Ключ id(instance) вместо самого экземпляра не помогает: после удаления объекта тот же адрес получает новый объект вместе с чужим значением.
Имя поля приходится дублировать в Proxy("attr"); начиная с Python 3.6 от этого избавляет метод __set_name__, как в Positive.
Встроенные дескрипторы
Три реализации ниже сокращены, но передают устройство, заложенное в интерпретатор.
# @property реализован через дескрипторы
class property:
def __init__(self, fget=None, fset=None, fdel=None):
self.fget, self.fset, self.fdel = fget, fset, fdel
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("свойство без геттера")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("свойство без сеттера")
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("свойство без делитера")
self.fdel(instance)
# @staticmethod и @classmethod тоже используют дескрипторы
class staticmethod:
def __init__(self, method):
self.__method = method
def __get__(self, instance, owner):
return self.__method
class classmethod:
def __init__(self, method):
self.__method = method
def __get__(self, instance, owner):
if owner is None:
owner = type(instance)
return self.__method.__get__(owner, type(owner))
property — дескриптор данных даже без сеттера: запись в свойство только для чтения завершается AttributeError, а не создаёт атрибут экземпляра. Вся разница между @staticmethod и @classmethod заключается в их __get__. Статический метод возвращает исходную функцию без привязки, поэтому self в нём отсутствует. Метод класса привязывает функцию к классу, и первым аргументом приходит сам класс. Аналогично, только к экземпляру, привязываются обычные методы.
Метаклассы
Поскольку класс является объектом, у него также есть тип. Типом обычного класса служит type, а метаклассом называют класс, экземплярами которого оказываются другие классы; обычно это подкласс type. Сам type — метакласс по умолчанию, и type(type) — снова type. Если обычный класс описывает, как устроены его объекты, то метакласс описывает, как устроены сами классы.
Требуются они редко: почти всё, ради чего раньше писались метаклассы, сегодня выполняется декоратором, применённым к классу, или методом __init_subclass__. На метаклассах построены abc.ABCMeta, enum.EnumType (до Python 3.11 — EnumMeta), модели Django и pydantic, где поля, объявленные в классе, описываются декларативно.
Создание класса
Инструкция class выполняется в несколько шагов, и каждый даёт точку вмешательства:
- выбор метакласса — наиболее производного из указанного аргументом
metaclassи метаклассов базовых классов, при их отсутствии —type; - вызов метода метакласса
__prepare__(name, bases), возвращающего словарь для пространства имён тела; - выполнение тела класса в этом словаре;
- вызов метакласса
metaclass(name, bases, namespace): его__new__создаёт объект класса,__init__донастраивает; - внутри
type.__new__— вызов__set_name__у дескрипторов и__init_subclass__у ближайшего базового класса; - применение декораторов класса к готовому классу и связывание имени.
Создание классов через type()
Без инструкции class тот же класс создаётся вызовом type с тремя аргументами: именем, родителями и словарём атрибутов.
# Эквивалентно class Something: attr = 42
name, bases, attrs = "Something", (), {"attr": 42}
Something = type(name, bases, attrs)
some = Something()
print(some.attr) # 42
Так классы строят программно, например по описанию таблицы или конфигурации. Эквивалентность неполная: методы, переданные в type() извне, не могут вызывать super() без аргументов, поскольку ячейку __class__ компилятор создаёт только для функций, объявленных в теле класса.
__init_subclass__
Метод __init_subclass__ (Python 3.6, PEP 487) вызывается у базового класса при создании каждого подкласса и получает именованные аргументы из строки объявления. Так строится реестр подклассов, позволяющий выбрать реализацию по строке из файла конфигурации, проверяются обязательные атрибуты, задаются настройки, — без метакласса.
class Reader:
registry = {}
def __init_subclass__(cls, /, name=None, **kwargs):
super().__init_subclass__(**kwargs)
if name is not None:
Reader.registry[name] = cls
class CsvReader(Reader, name="csv"): ...
class Hdf5Reader(Reader, name="hdf5"): ...
class GzipCsvReader(CsvReader): ... # без имени — в реестр не попадает
sorted(Reader.registry) # ['csv', 'hdf5']
reader = Reader.registry["hdf5"]()
Значение None по умолчанию позволяет объявлять промежуточные подклассы без имени: если бы параметр name был обязательным, объявление GzipCsvReader завершилось бы TypeError.
Базовый метакласс
Метакласс перехватывает создание класса, а его метод __new__ получает всё, из чего класс будет собран, то есть имя, кортеж родителей и словарь, составленный из тела класса, и может изменить или проверить полученное до создания класса.
class Meta(type):
def __new__(mcls, name, bases, ns):
print("создаётся", name, list(ns))
return super().__new__(mcls, name, bases, ns)
class Detector(metaclass=Meta):
gain = 2.5
# создаётся Detector ['__module__', '__qualname__', 'gain']
class Tracker(Detector):
pass
# создаётся Tracker ['__module__', '__qualname__']
Метакласс наследуется: подкласс Tracker создан тем же Meta, хотя не указывает его. Если у базовых классов разные метаклассы, не связанные наследованием, возникает TypeError: metaclass conflict. В Python 3.13 в пространстве имён класса появились также служебные __firstlineno__ и __static_attributes__, и печать в примере длиннее.
Второй метод, __prepare__, выбирает, в чём накапливать тело класса, пока оно выполняется. С версии 3.6 (PEP 520) обычный словарь и без него сохраняет порядок объявления атрибутов, поэтому собственное отображение нужно не ради порядка, а для контроля присваиваний: так enum запрещает повторное имя члена перечисления, и объявление RED дважды завершается TypeError: 'RED' already defined as 1. После создания класса пространство имён копируется в обычный словарь, и возможности собственного отображения доступны лишь внутри __new__.
Модуль abc для абстрактных базовых классов
Абстрактный базовый класс описывает интерфейс: перечисляет методы, обязательные для наследника, и может содержать общий алгоритм, опирающийся на них. Ниже базовый класс реализует общий цикл интегрирования run, а подклассы — только шаг схемы; такой приём называется шаблонным методом.
from abc import ABC, abstractmethod
class Integrator(ABC):
@abstractmethod
def step(self, f, t, y, h): ...
def run(self, f, y0, t0, t1, n):
h, t, y = (t1 - t0) / n, t0, y0
for _ in range(n):
y, t = self.step(f, t, y, h), t + h
return y
class Euler(Integrator):
def step(self, f, t, y, h):
return y + h * f(t, y)
Integrator() # TypeError
Euler().run(lambda t, y: -y, 1.0, 0.0, 1.0, 1000) # 0.36769542477096373
Пока хотя бы один абстрактный метод не реализован, экземпляр не создаётся. Ошибка TypeError возникает при создании экземпляра, а не при первом обращении к недостающему методу, тогда как неполный подкласс объявляется без ошибки: так допускаются промежуточные абстрактные классы, а нереализованные абстрактные методы наследуются абстрактными. Текст ошибки зависит от версии; в Python 3.12 он звучит как Can't instantiate abstract class Integrator without an implementation for abstract method 'step'. Схема Эйлера на отрезке [0, 1] за 1000 шагов даёт 0,36770 при точном значении exp(−1) ≈ 0,36788, как и ожидается для схемы первого порядка; схемы Рунге — Кутты и Верле (для уравнений движения q'' = a(q)) реализуют тот же метод step, и сравнение их точности на одной задаче сводится к замене объекта-интегратора.
Отдельной конструкции interface, как в Java, в Python нет: интерфейсы описываются обычными классами с метаклассом abc.ABCMeta, проще всего — наследованием от готового abc.ABC. Метод register объявляет виртуальный подкласс для isinstance без проверки методов. Структурную проверку без наследования даёт typing.Protocol (Python 3.8, PEP 544): анализатор типов принимает любой объект с нужными методами.
Модуль collections.abc
Абстрактные классы для самих коллекций уже написаны и находятся в collections.abc, и это те же Container, Iterable, Sequence и Mapping, рассмотренные в главе «Коллекции». Наследование даёт две выгоды: интерпретатор сначала проверит, что реализовано всё обязательное, а затем сам добавит остальное. Реализация у MutableMapping пяти методов даёт готовые get, pop, popitem, setdefault, update, clear, items, keys, values, проверку in и сравнение.
from collections import deque
from collections.abc import MutableMapping
class MemorizingDict(MutableMapping):
def __init__(self, *args, **kwargs):
self._data = {}
self._history = deque(maxlen=10)
self.update(*args, **kwargs) # через __setitem__, с историей
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._history.append(key)
self._data[key] = value
def __delitem__(self, key):
del self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
def get_history(self):
return self._history
Написано пять обязательных методов и один собственный, а в результате получается полноценный словарь, запоминающий десять последних записанных ключей, включая ключи из конструктора: update из MutableMapping записывает их через __setitem__. Он пройдёт isinstance(d, MutableMapping) и будет принят функциями, работающими с любым отображением, например str.format_map; json.dumps принимает только dict, и для него выполняется dict(d).
Наследоваться ради той же задачи напрямую от dict ненадёжно: методы dict, реализованные на C, не вызывают переопределённый __setitem__, и конструктор, update и setdefault записывают ключи в обход истории. Для собственных отображений поэтому берут collections.abc.MutableMapping или collections.UserDict.
Заключение
Большая часть этого инструментария в повседневной практике не потребуется: собственный метакласс за студенческую практику, скорее всего, не понадобится ни разу, собственный дескриптор — в лучшем случае однажды. Рассматриваются они потому, что почти всё, кажущееся в Python магией, оказывается одним из этих механизмов.
В повседневной работе достаточно немногого:
- Класс необходим там, где есть состояние, которое требуется поддерживать согласованным. Если состояния нет, достаточно функции.
- Для записи с именованными полями достаточно
@dataclass. - Атрибут остаётся атрибутом; свойство добавляется тогда, когда за ним потребовалась логика.
__repr__определяется сразу, а методы сравнения и арифметики возвращаютNotImplementedдля чужих типов.- В переопределённых методах родитель вызывается через
super(), а не по имени класса. - Прежде чем строить иерархию наследования, целесообразно проверить, не решается ли задача композицией: объект, содержащий другой объект, почти всегда проще объекта, унаследованного от него.
- Реестр подклассов строится через
__init_subclass__; метакласс нужен лишь для изменения самого создания класса.
Язык одинаково принимает и модуль из десяти функций, и иерархию классов; выбор определяется задачей, а не привычкой, сформированной в других языках.