Классы в Python
Классы и экземпляры, методы и свойства, наследование, магические методы, дескрипторы и метаклассы
Вячеслав Федоров
Лекция 6
Разработка и применение ПО
в физических исследованиях
Изложение. Шестая лекция посвящена классам — способу объединить данные и операции над ними в один объект, отвечающий за своё состояние. В рамках лекции рассматриваются объявление класса и создание экземпляров, атрибуты класса и экземпляра, методы и свойства, классы данных, наследование и порядок разрешения методов, магические методы, через которые объекты участвуют в операторах и встроенных функциях, а также дескрипторы и метаклассы — механизмы, на которых построено всё остальное. Надпись на титуле — ключевое слово class, с которого начинается объявление класса.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в интерактивной оболочке на виртуальной машине lab. На каждом таком слайде проигрывается запись тех же команд, что позволяет воспроизвести показ и без живого терминала.
Содержание
От объявления класса до метаклассов
Изложение. Лекция состоит из шести частей. Первая — классы и экземпляры: объявление класса, создание объекта, атрибуты класса и экземпляра, словарь атрибутов и __slots__. Вторая — методы и свойства: связанные методы, методы класса и статические методы, свойства и классы данных. Третья — наследование: super, порядок разрешения методов, примеси, композиция и абстрактные классы. Четвёртая — магические методы: модель данных, строковое представление, арифметика, сравнение и хеширование, протоколы коллекций, вызываемые объекты и управление доступом к атрибутам. Пятая — дескрипторы. Шестая — метаклассы и другие способы повлиять на создание класса.
Цель лекции
Класс как модель объекта: состояние, его согласованность, единый интерфейс
Класс связывает данные с операциями над ними: вместо разрозненных переменных и обрабатывающих их функций получается объект, отвечающий за своё состояние. Классы в Python не обязательны — модуль из функций остаётся полноценной программой, а класс нужен там, где есть состояние, которое требуется поддерживать согласованным, или где одну операцию выполняют по-разному для объектов разного вида.
Изложение. Класс решает две задачи. Первая — согласованность состояния: данные объекта предполагается менять через его методы и свойства, которые проверяют допустимые значения и пересчитывают зависимые величины, поэтому объект не оказывается в противоречивом состоянии. Вторая — единый интерфейс: объекты разных классов с одинаковыми методами обрабатываются одним кодом, не проверяющим их тип. В Python классы не обязательны: модуль из десятка функций является полноценной программой, и оформлять его в виде класса без явной причины не требуется. От Java и C++ Python отличается тем, что не скрывает механику: экземпляр передаётся методу явным первым аргументом, а почти всё, что выглядит особым свойством языка, сводится к нескольким протоколам, разобранным в лекции.
Для физиков: объектами являются массив NumPy, фигура и оси Matplotlib, результат scipy.optimize.minimize; понимание классов объясняет, как устроены эти библиотеки, и позволяет описывать собственные модели — частицы, детекторы, результаты измерений — так же удобно.
Классы и экземпляры
Объявление класса, создание объекта, атрибуты, __dict__ и __slots__
01
Классы и экземпляры
Методы и свойства
Наследование
Магические методы
Дескрипторы
Метаклассы
Изложение. Первая часть показывает, из чего состоят класс и экземпляр: как объект создаётся, где хранятся его атрибуты и чем атрибут класса отличается от атрибута экземпляра.
Объявление
класса
class Particle: """Материальная точка.""" def __init__(self, mass, v=0.0): self.mass = mass self.v = v def kinetic_energy(self): return self.mass * self.v ** 2 / 2 p = Particle(2.0, v=3.0) p.kinetic_energy() # 9.0
Элемент Назначение
class Particle: создание объекта класса и связывание имени
__init__ начальное состояние экземпляра
self экземпляр; явный первый параметр
p.mass атрибут экземпляра в p.__dict__
Particle(...) создание экземпляра вызовом класса
Пояснение
Методы — функции в теле класса; self — обычный параметр, имя которого закреплено соглашением.
Изложение. Класс объявляется инструкцией class: её тело выполняется при выполнении инструкции — обычно один раз, при импорте модуля, — и получившиеся имена — функции и значения — становятся атрибутами класса. Вызов класса как функции создаёт экземпляр и передаёт аргументы методу __init__, который записывает начальное состояние в атрибуты экземпляра через self. Методы — обычные функции, объявленные в теле класса; первым параметром они получают экземпляр, на котором вызваны, и по правилу «явное лучше неявного» этот параметр объявляется явно. Имя self является соглашением, а не ключевым словом. По PEP 8 имена классов записываются в стиле CapWords (Particle, MeasurementSeries), а имена методов и атрибутов — строчными буквами с подчёркиваниями.
Для физиков: класс Particle объединяет массу и скорость с величинами, вычисляемыми из них; функция kinetic_energy(m, v) из прошлой лекции становится методом, которому параметры не передаются: они уже хранятся в объекте.
Создание
экземпляра
__new__(Particle, 2.0, v=3.0)
создание пустого объекта
obj.__init__(2.0, v=3.0)
заполнение атрибутов
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
Пояснение
__init__ называют конструктором условно: объект уже создан методом __new__. У неизменяемых типов значение задаётся в __new__.
Изложение. Вызов класса выполняется в два шага. Сначала метод __new__ создаёт объект — пустую заготовку нужного класса; затем, если __new__ вернул экземпляр этого класса, вызывается __init__, который заполняет атрибуты. Поэтому название «конструктор», закрепившееся за __init__, условно: к его вызову объект уже существует. Переопределять __new__ приходится редко — при наследовании от неизменяемых типов (int, float, str, tuple), у которых значение задаётся при создании и в __init__ его уже не изменить, а также для кеширования экземпляров. Рассмотрим класс PositiveFloat на слайде: он наследует float и проверяет значение до создания объекта. __new__ получает те же аргументы, что и вызов класса, поэтому значение доступно ему до создания объекта; результат остаётся числом, и PositiveFloat(2.5) + 1 даёт обычный float 3.5.
Для физиков: подкласс float с проверкой знака подходит для величин, положительных по смыслу, — масс, времён жизни, концентраций: объект проверен при создании и при этом участвует в арифметике как обычное число. Результат арифметики — обычный float, и проверка на него уже не действует: PositiveFloat(2.5) - 3 даёт -0.5.
Соглашения
об именах
Запись Смысл Механизм
mass публичный атрибут —
_cache внутренний атрибут соглашение без проверки языком
__tag защита от совпадения имён в подклассах искажение: _Particle__tag
__init__ специальный метод без искажения: __ с обеих сторон
Пояснение
Модификаторов private и protected нет: доступ ограничивается соглашением, а не языком.
Важно
Искажение имён не скрывает данные: p._Particle__tag по-прежнему доступен.
Изложение. В Python нет модификаторов доступа: все атрибуты доступны извне, а намерение автора выражается именем. Имя без подчёркивания — часть публичного интерфейса. Одно подчёркивание в начале означает внутренний атрибут, к которому код снаружи класса не обращается; язык это не проверяет, но это учитывают линтеры и автодополнение, а у имён модуля — и from module import *. Два подчёркивания в начале включают искажение имени (name mangling): внутри класса Particle имя __tag заменяется на _Particle__tag. Механизм защищает от случайного совпадения имён в базовом классе и подклассе — если оба объявят __cache, атрибуты не столкнутся, — а не скрывает данные: по полному имени атрибут доступен. Имена с двумя подчёркиваниями с обеих сторон, такие как __init__, зарезервированы для специальных методов и не искажаются.
Демонстрация:
класс и экземпляр
01
Класс Particle с методом и атрибутом __tag
02
Создание экземпляра и вызов метода
03
Атрибуты в p.__dict__: имя _Particle__tag
04
Обращение к __tag: AttributeError
05
Тип экземпляра и тип класса
06
Новый атрибут после создания
Что наблюдается
__tag хранится под искажённым именем _Particle__tag; атрибуты экземпляра — записи словаря, и новый добавляется присваиванием.
root@lab — запись со стенда
Демонстрация. В оболочке объявляется класс Particle с методом kinetic_energy и атрибутом __tag. Метод kinetic_energy экземпляра p = Particle(2.0, v=3.0) возвращает 9.0. Словарь p.__dict__ содержит mass, v и _Particle__tag: имя __tag искажено при компиляции тела класса. Обращение p.__tag снаружи класса завершается AttributeError, а p._Particle__tag возвращает 'p'. Отметим, что type(p) — класс Particle, а type(Particle) — type: класс тоже объект, о чём пойдёт речь в шестой части. Присваивание p.charge = -1.0 добавляет атрибут уже созданному объекту, и vars(p) показывает его в словаре.
Атрибуты
класса и экземпляра
class Detector: unit = "keV" # атрибут класса hits = [] # общий список def __init__(self, name): self.name = name # атрибут экземпляра
Важно
Изменяемый атрибут класса общий для всех экземпляров: a.hits.append(...) меняет и b.hits.
Пояснение
Присваивание a.unit = "MeV" создаёт атрибут экземпляра и заслоняет атрибут класса только у a.
a.__dict__
1 — атрибуты экземпляра
Detector.__dict__
2 — атрибуты класса
базовые классы
3 — в порядке MRO
Изложение. Атрибут, объявленный в теле класса, принадлежит классу и один на все экземпляры; атрибут, присвоенный через self, принадлежит экземпляру. При чтении a.unit интерпретатор ищет имя сначала в словаре экземпляра, затем в словаре класса, затем в базовых классах в порядке MRO; порядок MRO рассматривается в третьей части, а исключения из этой схемы — дескрипторы — в пятой. Присваивание a.unit = "MeV" записывает значение в словарь экземпляра и заслоняет атрибут класса только у этого экземпляра; после del a.unit снова виден атрибут класса. Атрибуты класса подходят для констант, настроек по умолчанию и общих реестров. Изменяемый атрибут класса создаётся один раз при выполнении тела класса — та же ловушка, что у изменяемых значений по умолчанию: список hits оказывается общим для всех детекторов, а список, свой для каждого экземпляра, необходимо создавать в __init__.
Для физиков: единицы измерения и калибровочные константы, общие для всех детекторов одного типа, естественно хранить в атрибутах класса, а накопленные отсчёты конкретного детектора — только в атрибутах экземпляра.
Демонстрация:
атрибуты класса
01
Класс с атрибутами класса unit и hits
02
Общий список hits у двух детекторов
03
Заслонение атрибута класса атрибутом экземпляра
04
Атрибут класса после del
05
setattr и getattr с именем-строкой
06
Опечатка в имени: лишний атрибут
Что наблюдается
a.hits и b.hits — один список; присваивание a.unit меняет только a; запись b.treshold без ошибки добавила лишний атрибут.
root@lab — запись со стенда
Демонстрация. Класс Detector содержит атрибуты класса unit = 'keV' и hits = []. После a.hits.append(511) список b.hits тоже содержит 511, и проверка a.hits is b.hits возвращает True: список один. Присваивание a.unit = 'MeV' меняет единицу только у a, у b и у класса остаётся 'keV', а vars(a) показывает новую запись в словаре экземпляра; после del a.unit снова виден атрибут класса. setattr(b, 'gain', 2.5) и getattr(b, 'gain') работают с именем, заданным строкой, а getattr с третьим аргументом возвращает значение по умолчанию 0.0 вместо ошибки. Присваивание b.treshold = 0.2 с опечаткой в имени выполняется без предупреждения и добавляет лишний атрибут.
Словарь
атрибутов
det = Detector("A") cfg = {"gain": 2.5, "threshold": 0.1} for name, value in cfg.items(): setattr(det, name, value) getattr(det, "gain") # 2.5 getattr(det, "offset", 0.0) # по умолчанию hasattr(det, "threshold") # True vars(det) # {'name': 'A', 'gain': 2.5, 'threshold': 0.1}
Функция Действие
getattr(obj, name, default) чтение по имени-строке
setattr(obj, name, value) запись
hasattr(obj, name) проверка наличия
delattr(obj, name) удаление
vars(obj) словарь атрибутов obj.__dict__
Важно
Опечатка в присваивании не вызывает ошибки: det.treshold = 0.2 создаёт новый атрибут.
Изложение. Атрибуты экземпляра хранятся в словаре __dict__; функция vars(obj) возвращает тот же словарь. Отсюда динамика: атрибут добавляется и после создания объекта, а функции getattr, setattr, hasattr и delattr работают с именем, заданным строкой во время выполнения, например при загрузке настроек из файла. getattr с третьим аргументом возвращает значение по умолчанию вместо AttributeError. Оборотная сторона той же свободы — опечатка в имени при присваивании без предупреждения создаёт новый атрибут, а прежний остаётся без изменений. Защиту обеспечивают __slots__ на следующем слайде, классы данных с параметром slots=True или frozen=True из второй части и статические анализаторы.
Для физиков: параметры моделирования, прочитанные из YAML или JSON, переносятся в объект циклом с setattr; при этом имена целесообразно проверять по списку допустимых, иначе опечатка в файле настроек без сообщения об ошибке создаст лишний параметр, а нужный останется со значением по умолчанию.
__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
Важно
Без __dict__ нет новых атрибутов и слабых ссылок, если '__weakref__' не указан в слотах.
Объект с двумя полями в списке, 3.12 Память
обычный класс 88 байт
после обращения к __dict__ 152 байта
__slots__ 56 байт
Для физика
Для миллионов объектов-событий применяется __slots__, для массивов чисел — NumPy: событие из двух float64 занимает 16 байт.
Изложение. Словарь у каждого экземпляра обеспечивает гибкость, но занимает память. Атрибут класса __slots__ перечисляет допустимые атрибуты заранее, и интерпретатор хранит значения в фиксированных ячейках объекта без словаря. Замер в Python 3.12 для объекта с двумя полями, включающий 8 байт ссылки на него в списке, даёт 88 байт для обычного класса, пока к __dict__ не обращались, 152 байта после создания словаря и 56 байт со __slots__. Взамен теряется динамика: атрибут вне списка вызывает AttributeError, слабые ссылки работают, только если '__weakref__' указан в слотах, а подкласс без собственного __slots__ снова получает словарь. Скорость доступа к атрибутам меняется мало. Таким образом, __slots__ добавляют, когда профилирование показало, что память расходуется на множество мелких объектов.
Для физиков: к памяти самих объектов добавляются числа: каждое отдельное значение float — объект в 24 байта. Миллион событий из двух чисел в объектах со __slots__ занимает около 100 МБ, а в структурированном массиве NumPy из двух float64 — 16 МБ вместе со значениями; для массовых данных объекты заменяются массивами.
Демонстрация:
__slots__
01
Два класса: со словарём и со __slots__
02
Замер памяти через tracemalloc
03
Память на объект: 88 и 56 байт
04
Новый атрибут у объекта со слотами
05
Наличие __dict__ у двух объектов
Что наблюдается
На объект в списке приходится 56 байт со __slots__ против 88 у обычного; новых атрибутов он не принимает.
root@lab — запись со стенда
Демонстрация. Объявляются два класса с полями x и y: P хранит их в словаре экземпляра, S объявляет __slots__ = ('x', 'y'). Функция per_object создаёт 100 тысяч объектов, измеряет занятую память модулем tracemalloc и делит её на число объектов. Результат — 88 байт на объект P и 56 байт на объект S, включая 8 байт ссылки на объект в списке. Попытка присвоить S(1, 2).z завершается AttributeError: 'S' object has no attribute 'z'. hasattr(S(1, 2), '__dict__') возвращает False, а у объекта P словарь есть.
Методы и свойства
Связанные методы, classmethod и staticmethod, свойства, классы данных
02
Классы и экземпляры
Методы и свойства
Наследование
Магические методы
Дескрипторы
Метаклассы
Изложение. Вторая часть посвящена методам: как функция в теле класса становится методом, какие ещё виды методов бывают, как свойства добавляют логику атрибуту без изменения интерфейса и как классы данных избавляют от однообразного кода.
Методы
и связывание
p = Particle(2.0, v=3.0) f = Particle.kinetic_energy # функция f(p) # 9.0 m = p.kinetic_energy # связанный метод m.__self__ is p, m.__func__ is f # (True, True) m() # 9.0 energies = map(Particle.kinetic_energy, beam)
Пояснение
И функция, и связанный метод — объекты: их передают в map, в sorted(key=...) и в обработчики событий.
функция в Particle.__dict__
__get__(p, Particle)
связанный метод
__self__ = p __func__ = функция
Изложение. Метод представляет собой функцию, хранящуюся в атрибуте класса. Обращение через класс, Particle.kinetic_energy, даёт эту функцию, и экземпляр передаётся ей явно. Обращение через экземпляр, p.kinetic_energy, даёт связанный метод — объект, в котором запомнены функция (__func__) и экземпляр (__self__); при вызове экземпляр подставляется первым аргументом. Связывание выполняет протокол дескрипторов из пятой части: функция при обращении через экземпляр возвращает связанный метод. В Python 2 обращение через класс давало особый «несвязанный метод», проверявший тип первого аргумента; в Python 3 это обычная функция. Связанный метод передаётся как обычная функция: в map, в sorted как ключ, в обработчики событий или в интегратор.
Для физиков: правой частью для scipy.integrate.solve_ivp может служить связанный метод модели, model.rhs: параметры берутся из атрибутов объекта, и аргумент args не нужен.
classmethod
и staticmethod
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): ... Electron.from_momentum(9.1e-31, 2.7e-24) # Electron
Вид Первый аргумент Применение
обычный метод self — экземпляр работа с состоянием
@classmethod cls — класс альтернативные конструкторы
@staticmethod нет функция, относящаяся к классу
Пояснение
cls — фактический класс вызова: Electron.from_momentum создаёт Electron, а не Particle.
Изложение. Декоратор classmethod делает метод, первым аргументом которого приходит класс, а не экземпляр. Типичное применение — альтернативные конструкторы: dict.fromkeys, datetime.fromtimestamp, Particle.from_momentum. Поскольку cls — фактический класс, на котором вызван метод, подкласс получает объект своего типа; конструктор, записанный внутри метода как Particle(...), этого не обеспечил бы. Декоратор staticmethod убирает привязку совсем: метод получает только переданные аргументы и является функцией, помещённой в пространство имён класса, потому что относится к нему по смыслу. Если функция нужна и вне класса, её оставляют функцией модуля.
Для физиков: альтернативные конструкторы позволяют читать данные разных форматов — Spectrum.from_csv(path), Spectrum.from_hdf5(path): каждый разбирает свой формат и вызывает общий __init__.
Свойства
property
K_B = 1.380649e-23 # Дж/К class Gas: def __init__(self, temperature): self.temperature = temperature @property def temperature(self): return self._t @temperature.setter def temperature(self, value): if value < 0: raise ValueError("T < 0 K") self._t = value @property def kT(self): # только чтение return K_B * self._t
Средство Вычисление Значение
атрибут — obj.__dict__
property при каждом чтении обычно в _поле
cached_property (3.8) при первом чтении obj.__dict__
Пояснение
Атрибут остаётся атрибутом: проверка и вычисление добавляются позже, без изменения кода, который его использует.
Изложение. Публичный атрибут иногда требуется защитить проверкой или вычислять при обращении. В языках со строгой инкапсуляцией для этого заранее пишут методы get и set для всех полей; в Python атрибут остаётся атрибутом, а когда понадобится логика, его заменяют свойством, и код, который им пользуется, не меняется. Метод под @property вызывается при чтении, метод под @имя.setter — при записи, под @имя.deleter — при удалении. Свойство без сеттера доступно только для чтения: присваивание вызывает AttributeError. Значение обычно хранится в атрибуте с подчёркиванием, здесь _t; присваивание в __init__ уже проходит через сеттер, поэтому проверка действует и при создании объекта. Проверку данных, приходящих извне, необходимо выполнять явным raise ValueError, а не assert, который удаляется при запуске с ключом -O. functools.cached_property (3.8) вычисляет значение при первом обращении и сохраняет его в словаре экземпляра; он подходит для трудоёмких вычислений над неизменяемыми данными.
Для физиков: kT, плотность, давление — производные величины, которые не хранятся, а вычисляются из состояния; свойство обеспечивает их согласованность с температурой при любом её изменении.
Демонстрация:
свойства
01
Класс Gas со свойством temperature и проверкой
02
Чтение температуры и вычисляемого kT
03
Отрицательная температура: ValueError
04
Запись в свойство без сеттера: AttributeError
05
Словарь экземпляра: только _t
Что наблюдается
Присваивание проходит через сеттер с проверкой; kT вычисляется и не хранится, поэтому записать его нельзя.
root@lab — запись со стенда
Демонстрация. Класс Gas объявляет свойство temperature с сеттером, отвергающим отрицательные значения, и свойство kT только для чтения. У объекта Gas(300.0) температура 300.0 и kT = 4.141947e-21 Дж; постоянная Больцмана записана в константу K_B. Присваивание g.temperature = -5 проходит через сеттер и завершается ValueError: T < 0 K. Присваивание g.kT = 1.0 завершается AttributeError: property 'kT' of 'Gas' object has no setter. vars(g) показывает единственную запись _t; таким образом, значение kT не хранится, а вычисляется при каждом обращении.
Классы
данных
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)
Параметр Что даёт
по умолчанию __init__, __repr__, __eq__ по полям
frozen=True неизменяемость и __hash__
order=True сравнения <, <=, >, >= по полям
slots=True (3.10) без словаря и новых атрибутов
field(default_factory=list) свой список у каждого объекта
Для физика
Результат измерения, параметры прогона, конфигурация детектора — естественные классы данных.
Изложение. Декоратор dataclass (Python 3.7, PEP 557) строит класс по аннотированным полям: порождает __init__ с параметрами в порядке полей, __repr__ с именами и значениями и __eq__, сравнивающий поля. frozen=True запрещает присваивание полям после создания и делает объект хешируемым; order=True добавляет сравнения по кортежу полей; slots=True (3.10) создаёт __slots__. Изменяемое значение по умолчанию запрещено: для списка или словаря указывается field(default_factory=list), и каждый экземпляр получает свой объект, а объявление events: list = [] вызывает ValueError. Аннотации при этом не проверяются: Measurement("1.2") создаётся без ошибки. dataclass является декоратором класса, а не метаклассом: класс остаётся обычным, методы дописываются после его создания; при slots=True декоратор возвращает новый класс со __slots__. Для простых записей без методов есть также typing.NamedTuple.
Для физиков: Measurement(value, error, unit), Run(number, events), DetectorConfig(gain, threshold) — записи с именованными полями вместо словарей и кортежей: опечатка в имени поля при создании объекта вызывает TypeError, при присваивании — ошибку только у классов со slots=True или frozen=True, а repr сразу пригоден для журнала расчёта.
Демонстрация:
классы данных
01
Класс данных Measurement с frozen=True
02
repr и сравнение по полям
03
Присваивание полю: FrozenInstanceError
04
Множество из равных измерений
05
Список событий через default_factory
06
Список по умолчанию: ValueError
Что наблюдается
repr и сравнение — по полям; frozen запрещает присваивание; список по умолчанию — только через default_factory.
root@lab — запись со стенда
Демонстрация. Класс данных Measurement с полями value и error объявлен с frozen=True. Объект Measurement(9.81, 0.02) печатается как Measurement(value=9.81, error=0.02) и равен другому объекту с теми же полями. Присваивание m.value = 10 завершается dataclasses.FrozenInstanceError: cannot assign to field 'value'. Множество из двух равных измерений содержит один элемент: frozen-объект хешируется по полям. Класс Run со списком events через field(default_factory=list) даёт каждому прогону свой список: событие, добавленное в r1, не появляется в r2. Объявление events: list = [] в классе данных отвергается ValueError с подсказкой use default_factory.
Наследование
super и MRO, примеси, композиция, абстрактные классы и протоколы
03
Классы и экземпляры
Методы и свойства
Наследование
Магические методы
Дескрипторы
Метаклассы
Изложение. Третья часть посвящена наследованию: как подкласс получает поведение базового класса, в каком порядке ищутся методы при множественном наследовании, когда вместо наследования уместна композиция и как абстрактные классы задают общий интерфейс.
Наследование
и super
class Particle: def __init__(self, mass, charge=0.0): self.mass, self.charge = mass, charge def describe(self): return f"m={self.mass:.3e} кг" class Electron(Particle): def __init__(self): super().__init__(9.109e-31, -1.602e-19) def describe(self): return "электрон, " + super().describe()
Пояснение
super() обращается к следующему классу в MRO, а не к явно названному родителю.
Particle
super().describe()
Изложение. Подкласс получает атрибуты и методы базового класса и добавляет или переопределяет свои. Поиск метода идёт по цепочке: сначала подкласс, затем базовые классы, в конце object. Переопределённый метод часто дополняет родительский, а не заменяет: super() обеспечивает доступ к реализации следующего класса в порядке разрешения методов. Вызов Particle.__init__(self, ...) по имени родителя работает при одиночном наследовании, но при множественном пропускает классы, стоящие в MRO между ними, поэтому в переопределённых методах родителя необходимо вызывать через super(). isinstance(e, Particle) проверяет принадлежность с учётом наследования, а сравнение type(e) is Particle — нет, поэтому для проверок типа применяется isinstance.
Для физиков: иерархия Particle с подклассами Electron, Muon, Proton задаёт константы частиц в конструкторах подклассов, а общие методы описания и вычисления энергии остаются в базовом классе.
Множественное
наследование и MRO
D.__mro__: D → B → C → A → object
class A: ... class B(A): ... class C(A): ... class D(B, C): ... D.__mro__ # (D, B, C, A, object) class Bad(A, B): ... # TypeError: Cannot create a consistent # method resolution order (MRO)
Пояснение
Алгоритм C3: подкласс раньше родителей, порядок родителей сохраняется, каждый класс — один раз.
Изложение. Родителей у класса может быть несколько. Порядок, в котором интерпретатор перебирает классы при поиске атрибута, называется MRO (method resolution order) и хранится в атрибуте __mro__; метод mro() возвращает тот же порядок списком. Его вычисляет алгоритм линеаризации C3, предложенный для языка Dylan и используемый в Python с версии 2.3. Он обеспечивает три гарантии: подкласс предшествует своим родителям, порядок родителей в объявлении сохраняется, каждый класс встречается один раз. В ромбе D(B, C) с общим предком A порядок — D, B, C, A, object: A проверяется после обоих своих подклассов. Если требования противоречат друг другу, как в class Bad(A, B), где B — подкласс A, но указан в списке базовых классов после него, класс не создаётся и возникает TypeError.
Для физиков: множественное наследование встречается в библиотеках визуализации и графических интерфейсов, где виджеты собираются из примесей; в расчётном коде иерархии обычно неглубокие.
Демонстрация:
порядок MRO
01
Ромб: A, B(A), C(A), D(B, C)
02
Цепочка super(): D, B, C, A
04
Противоречивый порядок: TypeError
Что наблюдается
super().hello() в B передал управление C, а не A: следующий класс берётся из MRO класса D, и A выполняется один раз.
root@lab — запись со стенда
Демонстрация. Объявляются классы A, B(A), C(A) и D(B, C); метод hello каждого печатает имя класса и вызывает super().hello(). Для вызова D().hello() получим вывод D, B, C, A: super() внутри B обращается не к его родителю A, а к следующему классу в MRO класса D, которым оказывается C. Порядок D.__mro__ — D, B, C, A, object. Объявление class Bad(A, B) завершается TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B — требование поставить A раньше B противоречит тому, что B — подкласс A.
Классы-примеси
class ReprMixin: def __repr__(self): items = vars(self).items() args = ", ".join(f"{k}={v!r}" for k, v in items) name = type(self).__name__ return f"{name}({args})" class AsDictMixin: def as_dict(self): return dict(vars(self)) class Detector(ReprMixin, AsDictMixin): def __init__(self, name, gain): self.name, self.gain = name, gain Detector("A", 2.5) # Detector(name='A', gain=2.5)
01
Одна черта
представление, сериализация, журналирование, блокировка
02
Минимум состояния
__init__ примеси передаёт аргументы дальше через super()
03
Перед базовым классом
class X(Mixin, Base): метод примеси вызывается первым
04
Примеры
socketserver.ThreadingMixIn, примеси представлений Django
Изложение. Примесь (mixin) представляет собой класс, который не описывает самостоятельную сущность, а добавляет к чужому классу одну черту: строковое представление, преобразование в словарь, журналирование, блокировку. Примесь не наследуется от класса, к которому подмешивается, и опирается только на общий протокол — здесь на словарь атрибутов. Если примесь переопределяет метод базового класса, она указывается в списке родителей перед ним и передаёт управление дальше через super(): следующий класс определяется MRO собранного класса, а не самой примесью. В главе книги так устроен ThreadSafeMixin, оборачивающий методы счётчика в блокировку; в стандартной библиотеке — socketserver.ThreadingMixIn, в Django — примеси представлений.
Для физиков: примесь с методами сохранения в HDF5 или JSON подмешивается к разным классам данных установки, не превращая их в наследников одного «хранимого объекта».
Композиция
вместо наследования
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)
Отношение Пример Средство
«является» электрон — частица наследование
«содержит» детектор содержит каналы композиция
«умеет» объект сохраняется в файл примесь или протокол
Важно
Признак неудачного наследования: подкласс отключает унаследованные методы или нарушает гарантии родителя.
Изложение. Наследование выражает отношение «является»: электрон — частица, и любой код, работающий с частицей, обязан работать и с электроном (принцип подстановки Лисков). Если же объект лишь использует другой — детектор состоит из каналов, ускоритель — из магнитов и резонаторов, — это композиция: объект хранит другие объекты в атрибутах и поручает им работу. Композицию проще понимать и тестировать, её части заменяются независимо, а глубокие иерархии наследования связывают классы жёстко. Признак неудачного наследования — подкласс, отключающий унаследованные методы или меняющий их смысл, как квадрат, унаследованный от прямоугольника с независимо изменяемыми сторонами. Таким образом, прежде чем строить иерархию, проверяют, не решается ли задача композицией: объект, содержащий другой объект, почти всегда проще объекта, унаследованного от него.
Для физиков: модель установки собирается из объектов-компонентов: магнитная структура ускорителя — список элементов (дрейф, квадруполь, диполь) с общим методом матрицы перехода, а не иерархия, в которой ускоритель наследуется от квадруполя.
Абстрактные
базовые классы
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)
01
Обязательные методы без реализации
abstractmethod помечает методы наследника
02
Ошибка при создании экземпляра
TypeError при Integrator(), а не при вызове step
03
Общий алгоритм в базе
run один для всех схем интегрирования
04
Регистрация
register(cls): виртуальный подкласс для isinstance, без проверки методов
Для физика
Схемы Эйлера, Рунге — Кутты и Верле (для q'' = a(q)) — подклассы одного интерфейса: сравнение сводится к замене объекта.
Изложение. Абстрактный базовый класс описывает интерфейс: перечисляет методы, которые обязан реализовать наследник, и может содержать общий алгоритм, опирающийся на них. Методы помечаются декоратором abc.abstractmethod, а класс наследуется от abc.ABC, то есть получает метакласс ABCMeta. Для создания экземпляра необходимо реализовать все абстрактные методы: TypeError возникает при создании объекта, а не при первом обращении к недостающему методу. Рассмотрим пример на слайде: базовый класс Integrator реализует общий цикл run, а подклассы — только шаг схемы; такой приём называется шаблонным методом. Отдельной конструкции interface, как в Java, в Python нет; для структурной проверки без наследования служит typing.Protocol (3.8, PEP 544): анализатор типов принимает любой объект с нужными методами.
Для физиков: метод Эйлера, схема Рунге — Кутты четвёртого порядка и симплектическая схема Верле для уравнений движения q'' = a(q), разделяющая координаты и скорости в векторе состояния, реализуют один метод step, и сравнение их точности и устойчивости на одной задаче сводится к замене объекта-интегратора.
collections.abc
и протоколы
from collections.abc import MutableMapping class History(MutableMapping): def __init__(self): self._data, self.log = {}, [] def __getitem__(self, key): return self._data[key] def __setitem__(self, key, value): self.log.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)
Пять методов → get, pop, setdefault, update, keys, items, values, in, ==
Пояснение
isinstance(h, MutableMapping) — True: объект примут библиотеки, ожидающие отображение.
Важно
Наследование от dict ненадёжно: dict.update и конструктор не вызывают переопределённый __setitem__.
Изложение. Модуль collections.abc содержит абстрактные классы протоколов коллекций: Container, Iterable, Sized, Sequence, Mapping, MutableMapping и другие; они рассматривались в главе «Коллекции». Наследование от них даёт два преимущества. Интерпретатор проверяет, что реализованы все обязательные методы, а базовый класс добавляет остальные: реализации пяти методов MutableMapping достаточно, чтобы класс получил get, pop, setdefault, update, keys, items, values, проверку in и сравнение. Кроме того, isinstance(obj, Mapping) признаёт такой объект отображением; встроенные dict и list зарегистрированы в этих классах без наследования. Наследоваться напрямую от dict или list менее надёжно: их методы на C не вызывают переопределённый __setitem__ — dict.update и конструктор dict его обходят, — поэтому для собственных коллекций используют collections.abc или collections.UserDict; у последнего мимо __setitem__ проходит только оператор |=.
Для физиков: класс History, запоминающий порядок записи параметров, ведёт себя как словарь и передаётся в функции, принимающие любое отображение, например в str.format_map или в вызов с распаковкой **h; json.dumps принимает только dict, поэтому перед сохранением выполняется dict(h).
Демонстрация:
абстрактный класс
01
Абстрактный Integrator с общим методом run
02
Создание экземпляра абстрактного класса: TypeError
03
Подкласс Euler с методом step
04
Решение y' = -y на [0, 1]: y(1) и exp(-1)
05
Проверка протоколов collections.abc
Что наблюдается
Экземпляр абстрактного класса не создаётся; Euler, реализовав один step, получил run и дал 0,36770 при точном значении 0,36788.
root@lab — запись со стенда
Демонстрация. Абстрактный класс Integrator объявляет абстрактный метод step и общий метод run, выполняющий n шагов. Вызов Integrator() завершается TypeError: Can't instantiate abstract class Integrator without an implementation for abstract method 'step' — формулировка этого сообщения принята в Python 3.12. Подкласс Euler реализует только шаг явной схемы Эйлера и решает уравнение y' = -y с y(0) = 1 на отрезке [0, 1] за 1000 шагов. Оценим погрешность: результат 0.36769542477096373 отличается от точного exp(-1) = 0.36787944117144233 на 1,8·10⁻⁴, как и ожидается для схемы первого порядка. Последняя строка проверяет протоколы: словарь — Mapping, а кортеж не является MutableSequence.
Магические методы
Модель данных, арифметика, сравнение, протоколы, вызов, атрибуты
04
Классы и экземпляры
Методы и свойства
Наследование
Магические методы
Дескрипторы
Метаклассы
Изложение. Четвёртая часть посвящена специальным, или магическим, методам: через них собственный класс участвует в операторах, встроенных функциях и синтаксических конструкциях так же, как встроенные типы.
Модель
данных
Запись Вызов
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).
Изложение. Модель данных Python представляет собой набор специальных методов, через которые объект участвует в синтаксисе и встроенных функциях. Их имена окружены двойными подчёркиваниями, и вызывает их интерпретатор, а не программист: len(x) вызывает x.__len__(), выражение x + y — x.__add__(y), цикл for — x.__iter__(). При определении нужных методов собственный класс ведёт себя как встроенный тип: складывается, сравнивается, индексируется, перебирается в цикле, печатается. Неявный вызов ищет специальный метод у типа объекта, минуя словарь экземпляра, поэтому метод, присвоенный экземпляру, не действует на len(x) или +; так сделано и ради скорости, и ради корректности для самих классов, которые тоже объекты. Полный перечень методов — раздел Data model справочника языка; протокол менеджеров контекста (__enter__ и __exit__) в рамках лекции не рассматривается, его описывает раздел With Statement Context Managers справочника языка.
Для физиков: NumPy построен на той же модели данных: массив определяет __add__, __mul__, __getitem__ и десятки других методов, поэтому выражение a * b + c записывается для массивов так же, как для чисел.
Строковое
представление
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 __str__(self): return f"{self.value} ± {self.error}" def __format__(self, spec): return f"{self.value:{spec}} ± {self.error:{spec}}"
Вызов Результат
repr(m) Measured(9.81, 0.02)
str(m), print(m) 9.81 ± 0.02
f'{m:.3f}' 9.810 ± 0.020
без __repr__ <__main__.Measured object at 0x…>
Пояснение
__repr__ — для программиста, однозначно и по возможности как выражение; __str__ — для человека и по умолчанию вызывает __repr__.
Изложение. Три метода отвечают за текстовое представление. __repr__ предназначен для программиста: однозначное описание, в идеале — выражение, воссоздающее объект; его показывают интерактивная оболочка, отладчик, контейнеры при печати и f-строки с преобразованием !r. __str__ предназначен для человека и вызывается функциями str и print; если его нет, используется __repr__, поэтому часто достаточно определить один __repr__. __format__ получает спецификатор формата из f-строки или функции format и передаёт его составным частям — здесь точность применяется и к значению, и к погрешности. Без __repr__ объект печатается как <__main__.Measured object at 0x…>, что при отладке малоинформативно. Одна точность для значения и погрешности удобна не всегда: при .1f погрешность 0.02 округлилась бы до нуля, поэтому разрядность выбирают по погрешности.
Для физиков: представление величины с погрешностью в виде 9.81 ± 0.02 и форматирование с заданным числом знаков упрощают журналы расчёта и подписи на графиках.
Арифметика
и погрешности
class Measured: ... def __add__(self, other): if not isinstance(other, Measured): return NotImplemented e = math.hypot(self.error, other.error) return Measured(self.value + other.value, e) 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².
a.__add__(b)
результат, если не NotImplemented
NotImplemented
b.__radd__(a)
отражённый метод правого операнда
NotImplemented
Изложение. Арифметические операторы вызывают методы __add__, __sub__, __mul__, __truediv__, __pow__ и другие. Если левый операнд не поддерживает операцию с правым, метод возвращает специальное значение 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.
Демонстрация:
величина с погрешностью
01
Класс Measured: repr, формат, сложение, умножение
02
Сумма: погрешности в квадратуре
03
Умножение на число слева и справа
04
Формат с заданной точностью
05
print без __str__: вызов __repr__
06
Сложение с числом: TypeError
Что наблюдается
Погрешности 0,3 и 0,4 дали для суммы 0,5; 2 * a работает через __rmul__, а a + 1 после двух NotImplemented завершается TypeError.
root@lab — запись со стенда
Демонстрация. Класс Measured хранит значение и погрешность, определяет __repr__, __format__, __add__ с погрешностью в квадратуре, __mul__ на число и __rmul__ = __mul__. Сумма Measured(1.5, 0.3) и Measured(2.5, 0.4) печатается как Measured(4.0, 0.5). Выражения 2 * a и a * 2 дают одинаковый результат Measured(3.0, 0.6): первое выполняется через отражённый __rmul__, поскольку int не поддерживает умножение на Measured. f-строка со спецификатором .2f передаёт его __format__ и печатает 4.00 ± 0.50. print(a) без __str__ использует __repr__. Сложение a + 1 завершается TypeError: unsupported operand type(s) for +: 'Measured' and 'int' — и Measured.__add__, и int.__radd__ вернули NotImplemented.
Сравнение
и хеширование
import functools @functools.total_ordering class Energy: def __init__(self, e): self.e = e def __eq__(self, other): if not isinstance(other, Energy): return NotImplemented return self.e == other.e def __lt__(self, other): if not isinstance(other, Energy): return NotImplemented return self.e < other.e def __hash__(self): return hash(self.e)
Определено в классе hash(obj)
ничего по идентичности объекта
__eq__ нет: __hash__ = None
__eq__ и __hash__ по полям сравнения
dataclass(frozen=True) по полям
Важно
Равные объекты обязаны иметь равные хеши; объект, изменившийся после попадания в множество, поиск в нём его не находит.
Изложение. Шесть методов сравнения — __eq__, __ne__, __lt__, __le__, __gt__, __ge__ — записывать полностью не требуется: __ne__ по умолчанию обращает результат __eq__, а декоратор functools.total_ordering достраивает остальные по __eq__ и одному из методов упорядочивания. Определение __eq__ без __hash__ делает класс нехешируемым: интерпретатор присваивает __hash__ = None, и объект не попадает в множество и в ключи словаря. Причина — правило «равные объекты имеют равные хеши»: хеш по умолчанию основан на идентичности объекта и с новым равенством не согласован. Если объект хешируемый, хеш вычисляют из тех же полей, что участвуют в сравнении, и только для объектов, которые после создания не меняются: изменение поля после помещения в множество делает объект ненаходимым. Заметим, что сравнение с объектом другого типа завершается возвратом NotImplemented, как в арифметике: тогда Energy(1) == None даёт False, а не AttributeError, а Energy(1) < 1 — понятный TypeError.
Для физиков: энергетические уровни, версии калибровок и прогоны с номерами сортируются и сравниваются по одному полю; total_ordering избавляет от записи остальных методов сравнения.
Демонстрация:
сравнение и хеш
01
Класс Version только с __eq__
02
__hash__ = None и множество: TypeError
03
Energy с total_ordering и NotImplemented
04
Сортировка и достроенное >=
05
Множество из Energy(1) и Energy(1.0)
06
Сравнение с None: False
Что наблюдается
__eq__ без __hash__ отключил хеш; у Energy равные значения дают один элемент множества, а сравнение с None — False.
root@lab — запись со стенда
Демонстрация. Класс Version определяет только __eq__; проверка Version.__hash__ is None даёт True, и множество {Version(1)} не создаётся: TypeError: unhashable type: 'Version'. Класс Energy с декоратором total_ordering определяет __eq__ и __lt__, возвращающие NotImplemented для чужих типов, а также __hash__ и __repr__. sorted упорядочивает список энергий, а сравнение Energy(1) >= Energy(2) работает, хотя __ge__ не написан: его достроил total_ordering. Множество {Energy(1), Energy(1.0)} содержит один элемент: значения равны, и хеши hash(1) и hash(1.0) совпадают. Сравнение Energy(1) == None даёт False: __eq__ вернул NotImplemented, и интерпретатор сравнил объекты по идентичности.
Протоколы
коллекций
class Spectrum: def __init__(self, counts): self._counts = list(counts) def __len__(self): return len(self._counts) def __getitem__(self, i): return self._counts[i] s = Spectrum([3, 5, 8, 2]) len(s), s[2], s[1:3] # (4, 8, [5, 8]) list(s), 8 in s # ([3, 5, 8, 2], True)
Метод Даёт
__len__ len(s), истинность
__getitem__ s[i], срезы, for, in
__len__ и __getitem__ reversed(s)
__iter__ итератор для for
__contains__ быстрая проверка in
__setitem__, __delitem__ s[i] = v, del s[i]
Пояснение
С __len__ и __getitem__ объект уже перебирается в цикле; полный набор методов последовательности даёт collections.abc.Sequence.
Изложение. Коллекции определяются протоколами — наборами методов. __len__ даёт функцию len и, при отсутствии __bool__, истинность: пустая коллекция ложна. __getitem__ даёт индексирование, а получив объект slice — срезы. Двух этих методов достаточно, чтобы объект перебирался в цикле for: интерпретатор вызывает __getitem__ с индексами 0, 1, 2 и так далее до IndexError. Тот же старый протокол последовательности поддерживают оператор in и функция reversed. Современным протоколом перебора является метод __iter__, возвращающий итератор; он рассматривается в лекции об итераторах, генераторах и корутинах. __contains__ ускоряет проверку in, если ответ получается без перебора. Наследование от collections.abc.Sequence добавит index, count и __reversed__ по тем же двум методам.
Для физиков: спектр, временной ряд или набор событий, обёрнутый в класс с __len__ и __getitem__, сохраняет привычную запись spectrum[i] и spectrum[100:200], а класс при этом хранит единицы, калибровку и метаданные. Чтобы срез оставался спектром, а не списком, __getitem__ проверяет isinstance(i, slice) и возвращает type(self)(self._counts[i]).
Вызываемые
объекты
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 quad(g, -10, 10) # (5.0132..., ...)
Модель с параметрами Где параметры
замыкание в ячейках __closure__, не в атрибутах
partial параметры в args и keywords
объект с __call__ параметры в атрибутах, repr, другие методы
Для физика
Объект с __call__ передаётся в интегратор как функция, а его параметры видны в атрибутах.
Изложение. Метод __call__ делает экземпляр вызываемым: g(x) выполняет g.__call__(x). Такой объект подходит везде, где ожидается функция: в интеграторе, оптимизаторе, в sorted как ключ, и функция callable(g) возвращает для него True. По сравнению с замыканием и partial из прошлой лекции объект с __call__ хранит параметры в именованных атрибутах, что позволяет читать и изменять их; у объекта есть repr и могут быть другие методы — нормировка, производная, построение графика. На __call__ построены декораторы, реализованные классами, и объекты, сохраняющие состояние между вызовами.
Для физиков: модели для curve_fit удобнее писать функциями с параметрами-аргументами, а для интегрирования и построения графиков с найденными параметрами — объектами с __call__, такими как Gaussian(mu, sigma).
Доступ
к атрибутам
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) cfg.gain # 2.5 cfg.gain = 3 # AttributeError
Метод Когда вызывается
__getattr__(name) после AttributeError обычного поиска
__getattribute__(name) при любом чтении
__setattr__(name, value) при любой записи
__delattr__(name) при любом удалении
Важно
Для неизвестного имени __getattr__ возбуждает AttributeError: на этом основаны hasattr, getattr с умолчанием, copy и pickle.
Изложение. Четыре метода перехватывают работу с атрибутами. __getattr__ вызывается только тогда, когда обычный поиск не нашёл атрибут, поэтому подходит для подстановки значений, объектов-посредников и ленивой загрузки. __getattribute__ вызывается при любом чтении и переопределяется редко: ошибка в нём нарушает доступ ко всем атрибутам, включая служебные. Тонкость __getattr__: он вызывается при любой AttributeError обычного поиска, в том числе возникшей внутри геттера свойства, и тем самым маскирует ошибку в свойстве. __setattr__ и __delattr__ перехватывают любую запись и любое удаление; внутри них запись необходимо выполнять через object.__setattr__ или super().__setattr__, иначе self.name = value снова вызовет перехватчик и приведёт к бесконечной рекурсии. По той же причине __getattr__ читает служебные поля через self.__dict__: у объекта, созданного копированием без вызова __init__, атрибута _values ещё нет. Для неизвестного имени __getattr__ обязан возбуждать AttributeError, а не KeyError: на этом основаны hasattr, getattr со значением по умолчанию, копирование copy и сериализация pickle.
Для физиков: объект настроек с доступом через точку и запретом записи защищает параметры расчёта от случайного изменения посреди программы.
Демонстрация:
протоколы и __call__
01
Spectrum с __len__ и __getitem__
03
Перебор, in и reversed без __iter__
04
len при методе, присвоенном экземпляру
06
Вызов объекта и сумма Римана
Что наблюдается
Двух методов достаточно для len, срезов, for, in и reversed; len ищет __len__ у класса, а Gaussian вызывается как функция.
root@lab — запись со стенда
Демонстрация. Класс Spectrum определяет только __len__ и __getitem__. Для s = Spectrum([3, 5, 8, 2]) работают len(s), индекс s[2] и срез s[1:3], а также list(s), проверка 8 in s и reversed(s), хотя __iter__ не определён. Присваивание s.__len__ = lambda: 99 не меняет результат len(s) = 4: специальный метод ищется у класса. Объект Gaussian(0.0, 2.0) с методом __call__ вызывается как функция: g(0.0) = 1.0, g(2.0) = 0.6065…, callable(g) — True. Для суммы Римана по отрезку [-10, 10] с шагом 0,1 получим 5.013254…; от σ√(2π) ≈ 5.013257 она отличается на 2,5·10⁻⁶, почти целиком за счёт хвостов гауссианы за пределами ±5σ.
Дескрипторы
Протокол, проверка значений, дескрипторы внутри языка
05
Классы и экземпляры
Методы и свойства
Наследование
Магические методы
Дескрипторы
Метаклассы
Изложение. Пятая часть посвящена дескрипторам — механизму, через который устроены методы, свойства, classmethod, staticmethod и __slots__. Протокол позволяет вынести логику доступа к атрибуту в отдельный класс и применять её к любому числу полей.
Протокол
дескрипторов
Метод Когда вызывается
__get__(self, obj, objtype) чтение obj.attr
__set__(self, obj, value) запись obj.attr = value
__delete__(self, obj) del obj.attr
__set_name__(self, owner, name) при создании класса (3.6)
Пояснение
Дескриптор работает, только будучи атрибутом класса; дескриптор данных определяет __set__ или __delete__.
дескриптор данных в классе
1 — приоритет над словарём
obj.__dict__['x']
2 — атрибут экземпляра
дескриптор не данных, атрибут класса
3 — например, метод
__getattr__(obj, 'x')
4 — иначе AttributeError
Изложение. Дескриптор является объектом, класс которого определяет хотя бы один из методов __get__, __set__, __delete__. Помещённый в атрибут класса, он перехватывает обращение к этому атрибуту у всех экземпляров: чтение obj.x вызывает __get__(obj, type(obj)), запись — __set__, удаление — __delete__. Метод __set_name__ (3.6, PEP 487) вызывается при создании класса и сообщает дескриптору имя, под которым он записан. Дескрипторы данных определяют __set__ или __delete__, остальные — только __get__. Порядок поиска при чтении атрибута: дескриптор данных в классе и его базовых классах имеет приоритет над словарём экземпляра, словарь экземпляра — над дескриптором не данных и обычным атрибутом класса, а __getattr__ вызывается последним. Таким образом, свойство нельзя заслонить атрибутом экземпляра, а метод — можно.
Дескриптор
с проверкой
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()
01
Одна проверка — много полей
масса, время жизни, температура
02
Значение — в экземпляре
obj._mass, а не в дескрипторе, общем для класса
03
Имя из __set_name__
без повторения Positive('mass') в аргументе
04
obj is None
при обращении через класс — сам дескриптор
Изложение. Свойство защищает один атрибут одного класса; если та же проверка нужна десяткам полей в разных классах, логику выносят в класс-дескриптор. Positive проверяет положительность при каждой записи и хранит значение в атрибуте экземпляра с подчёркиванием: сам дескриптор один на класс, и значение, записанное в него, было бы общим для всех экземпляров. Хранение в словаре «экземпляр → значение» внутри дескриптора хуже: оно требует хешируемых экземпляров и удерживает их от сборки мусора, если не использовать weakref.WeakKeyDictionary. Имя поля дескриптор получает из __set_name__, поэтому повторять его в аргументе не требуется. Обращение через класс, Particle.mass, передаёт obj = None, и по соглашению возвращается сам дескриптор — этим пользуются справка help и инструменты интроспекции.
Для физиков: дескрипторы Positive, InRange(lo, hi) или WithUnit("м") позволяют проверять физический смысл параметров модели при каждой записи, а не только в конструкторе; на дескрипторах построены атрибуты моделей в Django и SQLAlchemy.
Дескрипторы
внутри языка
Объект в классе Вид obj.attr даёт
функция не данных связанный метод
property дескриптор данных вызов геттера
classmethod не данных метод, связанный с классом
staticmethod не данных исходная функция
слот из __slots__ дескриптор данных значение из ячейки объекта
Пояснение
Метод становится связанным, потому что функция — дескриптор: её __get__ возвращает связанный метод. __set__ у функции нет, поэтому атрибут экземпляра её заслоняет.
class C: def f(self): ... c = C() C.__dict__["f"].__get__(c, C) # <bound method C.f of <C object ...>> c.f = 42 c.f # 42: атрибут заслонил функцию p = Particle() p.mass = 0.1057 p.__dict__["mass"] = -5 p.mass # 0.1057: приоритет у дескриптора
Изложение. Через протокол дескрипторов устроена почти вся работа с атрибутами. Функция определяет __get__ и при обращении через экземпляр возвращает связанный метод — это и есть механизм связывания из второй части. property является дескриптором данных, методы которого вызывают переданные ему функции. classmethod и staticmethod — дескрипторы, у которых __get__ возвращает функцию, связанную с классом, или исходную функцию без привязки. Атрибуты, объявленные в __slots__, реализованы дескрипторами, читающими фиксированные ячейки объекта. Поскольку функция — дескриптор без __set__, атрибут экземпляра с тем же именем заслоняет метод, а дескриптор данных записью в словарь экземпляра заслонить нельзя. Знание протокола требуется нечасто, но оно позволяет объяснить поведение, которое иначе выглядит набором особых случаев.
Демонстрация:
дескриптор
01
Дескриптор Positive с __set_name__
02
Particle с двумя полями Positive
03
Значения в vars(p): _mass, _lifetime
04
Отрицательное время жизни: ValueError
05
Запись в __dict__ при дескрипторе данных
06
Функция как дескриптор: связанный метод
Что наблюдается
Значения хранятся в экземпляре под именами из __set_name__; дескриптор данных имеет приоритет над словарём, а __get__ функции дал связанный метод.
root@lab — запись со стенда
Демонстрация. Дескриптор Positive получает имя поля из __set_name__ и хранит значение в атрибуте экземпляра с подчёркиванием. Класс Particle объявляет два поля, mass и lifetime, одной строкой каждое. Для мюона с массой 0.1057 ГэВ и временем жизни 2.2e-6 с vars(p) показывает записи _mass и _lifetime. Присваивание p.lifetime = -1 завершается ValueError: lifetime: ожидается > 0. Запись p.__dict__['mass'] = -5 не меняет результат p.mass: дескриптор данных имеет приоритет над словарём экземпляра. Наконец, вызов __get__ у функции __init__, взятой из словаря класса, возвращает связанный метод — так связывание выглядит изнутри.
Итоги
лекции
01
Класс связывает состояние с операциями; атрибуты экземпляра — в __dict__, атрибуты класса общие для всех.
02
Метод связывается с экземпляром при обращении; classmethod и staticmethod меняют привязку, property добавляет логику.
03
Атрибуты ищутся по MRO, super() обращается к следующему классу; композиция часто проще иерархии.
04
Магические методы включают объект в операторы; возврат NotImplemented передаёт операцию другому операнду.
05
На дескрипторах построены методы, свойства и слоты; дескриптор данных имеет приоритет над словарём.
06
Класс — экземпляр метакласса type; __init_subclass__ и декораторы классов заменяют большинство метаклассов.
Изложение. Класс связывает состояние с операциями над ним; атрибуты экземпляра хранятся в его словаре, атрибуты класса общие для всех экземпляров, а __slots__ заменяет словарь фиксированными ячейками. Функция в теле класса становится связанным методом при обращении через экземпляр; classmethod и staticmethod меняют привязку, свойства добавляют логику атрибуту без изменения интерфейса, а классы данных избавляют от однообразного кода. Атрибуты ищутся по порядку разрешения методов, super() вызывает следующий класс в нём, а композиция часто проще наследования. Магические методы включают объект в операторы и встроенные функции, и возврат NotImplemented передаёт операцию другому операнду. Дескрипторы объясняют методы, свойства и слоты. Класс является объектом метакласса type, и большинство задач, ради которых писали метаклассы, решают __init_subclass__ и декораторы классов.
Следующая лекция посвящена итераторам, генераторам и корутинам.
Упражнения для самопроверки
01
Класс Vector3 с __add__, __sub__, __mul__ и __rmul__ для умножения на число, __abs__, __eq__ и __repr__; проверить 2 * v и v * 2
02
Класс данных Measurement(frozen=True) со сложением, погрешностью в квадратуре и форматом через __format__
03
Дескриптор InRange(lo, hi) для температуры и давления класса Gas с понятным сообщением об ошибке
04
Абстрактный Integrator и подклассы Euler и RK4; сравнить погрешность на y' = -y при n = 10, 100, 1000
05
Реестр интеграторов через __init_subclass__ и выбор метода по строке из файла конфигурации
Глава книги: «Классы» · упражнения для самопроверки, без сдачи
Изложение. Отдельного задания к лекции нет: упражнения на слайде служат самопроверке и опираются на главу книги «Классы». Первое проверяет арифметические методы и отражённое умножение. Второе связывает классы данных с форматированием и распространением погрешностей. Третье — дескриптор с параметрами, общий для нескольких полей. Четвёртое — абстрактный класс с общим алгоритмом: для схемы Эйлера погрешность убывает пропорционально шагу (при n = 10, 100, 1000 — около 1,9·10⁻², 1,8·10⁻³ и 1,8·10⁻⁴), для RK4 — пропорционально четвёртой степени шага (3,3·10⁻⁷ и 3,1·10⁻¹¹ при n = 10 и 100; при n = 1000 сказываются ошибки округления). Пятое — реестр подклассов, позволяющий выбирать реализацию по имени без цепочки условий.
Источники
7
PEP 557, 487, 3119, 3129, 544 — классы данных, хуки подклассов, ABC, декораторы классов, протоколы
peps.python.org
8
Ramalho L. Fluent Python. – 2nd ed. – O'Reilly, 2022
Изложение. Основой лекции служит глава книги курса «Классы»; порядок изложения и часть примеров следуют лекциям курса «Python» Computer Science Center. Для самостоятельного изучения рекомендуются раздел учебника Python о классах, раздел Data model справочника языка, руководство по дескрипторам Descriptor Guide, перечисленные PEP и книга Л. Рамальо «Fluent Python», где модели данных, дескрипторам и метапрограммированию посвящены отдельные главы.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Надпись на последнем слайде — self, первый параметр обычного метода. Лекция завершается ответами на вопросы.