Магические методы (dunder methods) -- это специальные методы, определяющие поведение объектов с операторами, встроенными функциями и конструкциями языка. Они превращают ваши классы в полноценных участников Python-экосистемы.
Обзор магических методов
# Dunder methods control how objects interact with Python syntax:
# obj[key] -> __getitem__
# len(obj) -> __len__
# str(obj) -> __str__
# repr(obj) -> __repr__
# obj == other -> __eq__
# hash(obj) -> __hash__
# obj() -> __call__
# bool(obj) -> __bool__
# for x in obj -> __iter__
# with obj -> __enter__ / __exit__
Контейнерные методы
class Matrix:
"""2D matrix with element access via [] operator."""
def __init__(self, data: list[list[float]]) -> None:
self._data = data
self._rows = len(data)
self._cols = len(data[0]) if data else 0
def __getitem__(self, key: tuple[int, int]) -> float:
"""Access element: matrix[row, col]."""
row, col = key
return self._data[row][col]
def __setitem__(self, key: tuple[int, int], value: float) -> None:
"""Set element: matrix[row, col] = value."""
row, col = key
self._data[row][col] = value
def __len__(self) -> int:
"""Total number of elements."""
return self._rows * self._cols
def __contains__(self, value: float) -> bool:
"""Check if value exists: value in matrix."""
return any(value in row for row in self._data)
def __repr__(self) -> str:
rows = "\n ".join(str(row) for row in self._data)
return f"Matrix(\n {rows}\n)"
m = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(m[0, 0]) # 1
print(m[1, 2]) # 6
m[0, 0] = 99
print(len(m)) # 9
print(5 in m) # True
print(10 in m) # False
Создание последовательности
class Fibonacci:
"""Lazy Fibonacci sequence with indexing and iteration."""
def __init__(self, limit: int) -> None:
self._limit = limit
self._cache: dict[int, int] = {0: 0, 1: 1}
def __getitem__(self, index: int) -> int:
"""Get nth Fibonacci number: fib[n]."""
if index < 0 or index >= self._limit:
raise IndexError(f"Index {index} out of range [0, {self._limit})")
if index not in self._cache:
self._cache[index] = self[index - 1] + self[index - 2]
return self._cache[index]
def __len__(self) -> int:
return self._limit
def __iter__(self):
"""Iterate over all Fibonacci numbers."""
for i in range(self._limit):
yield self[i]
def __reversed__(self):
"""Iterate in reverse."""
for i in range(self._limit - 1, -1, -1):
yield self[i]
fib = Fibonacci(10)
print(fib[7]) # 13
print(list(fib)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(list(reversed(fib))) # [34, 21, 13, 8, 5, 3, 2, 1, 1, 0]
Операторы сравнения
from functools import total_ordering
@total_ordering # generates __le__, __gt__, __ge__ from __eq__ and __lt__
class Money:
"""Money with currency, supporting comparison and arithmetic."""
def __init__(self, amount: float, currency: str = "RUB") -> None:
self.amount = round(amount, 2)
self.currency = currency
def _check_currency(self, other: "Money") -> None:
if self.currency != other.currency:
raise ValueError(f"Cannot compare {self.currency} and {other.currency}")
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return self.amount == other.amount
def __lt__(self, other: "Money") -> bool:
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return self.amount < other.amount
def __hash__(self) -> int:
return hash((self.amount, self.currency))
def __add__(self, other: "Money") -> "Money":
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other: "Money") -> "Money":
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return Money(self.amount - other.amount, self.currency)
def __mul__(self, factor: float) -> "Money":
if not isinstance(factor, (int, float)):
return NotImplemented
return Money(self.amount * factor, self.currency)
def __rmul__(self, factor: float) -> "Money":
return self.__mul__(factor)
def __repr__(self) -> str:
return f"Money({self.amount}, {self.currency!r})"
def __str__(self) -> str:
return f"{self.amount:,.2f} {self.currency}"
# Usage
price = Money(1500.0)
tax = Money(150.0)
total = price + tax
print(total) # 1,650.00 RUB
print(price > tax) # True (generated by @total_ordering)
print(price <= total) # True
print(2 * price) # 3,000.00 RUB (__rmul__)
eq и hash
# RULE: if you define __eq__, you MUST define __hash__
# (or set __hash__ = None to make unhashable)
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int:
return hash((self.x, self.y))
def __repr__(self) -> str:
return f"Point({self.x}, {self.y})"
# Now Points can be used in sets and as dict keys
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
print(p1 == p2) # True
print({p1, p2, p3}) # {Point(1, 2), Point(3, 4)} (p1 and p2 deduplicated)
print({p1: "origin"}) # works as dict key
# Without __hash__, defining __eq__ makes class unhashable:
class Unhashable:
def __eq__(self, other):
return True
# __hash__ implicitly set to None
# {Unhashable()} # TypeError: unhashable type
call -- вызываемые объекты
class Validator:
"""Reusable validator as a callable object."""
def __init__(self, min_val: float, max_val: float) -> None:
self.min_val = min_val
self.max_val = max_val
def __call__(self, value: float) -> bool:
"""Validate value is within range."""
return self.min_val <= value <= self.max_val
def __repr__(self) -> str:
return f"Validator({self.min_val}, {self.max_val})"
# Use as a callable
is_valid_age = Validator(0, 150)
is_valid_score = Validator(0, 100)
print(is_valid_age(25)) # True
print(is_valid_age(200)) # False
print(is_valid_score(85)) # True
# Check if callable
print(callable(is_valid_age)) # True
# Practical: configurable pipeline step
class Normalizer:
"""Normalize values to [0, 1] range."""
def __init__(self, min_val: float, max_val: float) -> None:
self.min_val = min_val
self.max_val = max_val
def __call__(self, value: float) -> float:
return (value - self.min_val) / (self.max_val - self.min_val)
normalize_temp = Normalizer(-30, 50)
print(normalize_temp(20)) # 0.625
print(normalize_temp(-30)) # 0.0
print(normalize_temp(50)) # 1.0
@property -- свойства
class Circle:
"""Circle with computed properties."""
def __init__(self, radius: float) -> None:
self._radius = radius
@property
def radius(self) -> float:
"""Get the radius."""
return self._radius
@radius.setter
def radius(self, value: float) -> None:
"""Set the radius with validation."""
if value < 0:
raise ValueError("Радиус не может быть отрицательным")
self._radius = value
@radius.deleter
def radius(self) -> None:
"""Reset radius to 0."""
print("Радиус сброшен")
self._radius = 0
@property
def diameter(self) -> float:
"""Computed property: diameter = 2 * radius."""
return self._radius * 2
@property
def area(self) -> float:
"""Computed property: area = pi * r^2."""
import math
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
"""Computed property: circumference = 2 * pi * r."""
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5 (getter)
print(c.diameter) # 10 (computed)
print(f"{c.area:.2f}") # 78.54 (computed)
c.radius = 10 # setter with validation
print(c.diameter) # 20
# c.radius = -1 # ValueError: Радиус не может быть отрицательным
del c.radius # deleter: "Радиус сброшен"
Дескрипторы
Дескрипторы -- низкоуровневый механизм, лежащий в основе @property, @classmethod, @staticmethod:
class TypedField:
"""Descriptor that enforces type checking on attribute assignment."""
def __init__(self, name: str, expected_type: type) -> None:
self.name = name
self.expected_type = expected_type
self.storage_name = f"_typed_{name}"
def __set_name__(self, owner: type, name: str) -> None:
"""Called when descriptor is assigned to a class attribute."""
self.name = name
self.storage_name = f"_typed_{name}"
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed."""
if obj is None:
return self # access from class, return descriptor itself
return getattr(obj, self.storage_name, None)
def __set__(self, obj, value) -> None:
"""Called when attribute is assigned."""
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.storage_name, value)
class User:
name = TypedField("name", str)
age = TypedField("age", int)
email = TypedField("email", str)
def __init__(self, name: str, age: int, email: str) -> None:
self.name = name # calls TypedField.__set__
self.age = age
self.email = email
user = User("Иван", 25, "[email protected]")
print(user.name) # 'Иван' (calls TypedField.__get__)
print(user.age) # 25
# user.age = "двадцать пять" # TypeError: age must be int, got str
Validated descriptor
class Validated:
"""Base descriptor with customizable validation."""
def __set_name__(self, owner: type, name: str) -> None:
self.storage_name = f"_validated_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, None)
def __set__(self, obj, value) -> None:
self.validate(value)
setattr(obj, self.storage_name, value)
def validate(self, value) -> None:
"""Override in subclasses."""
pass
class PositiveNumber(Validated):
def validate(self, value) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"Expected number, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"Expected positive number, got {value}")
class NonEmptyString(Validated):
def validate(self, value) -> None:
if not isinstance(value, str):
raise TypeError(f"Expected str, got {type(value).__name__}")
if not value.strip():
raise ValueError("String cannot be empty")
class Product:
name = NonEmptyString()
price = PositiveNumber()
quantity = PositiveNumber()
def __init__(self, name: str, price: float, quantity: int) -> None:
self.name = name
self.price = price
self.quantity = quantity
def __repr__(self) -> str:
return f"Product({self.name!r}, {self.price}, qty={self.quantity})"
p = Product("Python Book", 1500.0, 10)
print(p) # Product('Python Book', 1500.0, qty=10)
# p.price = -100 # ValueError: Expected positive number, got -100
# p.name = "" # ValueError: String cannot be empty
Контекстные менеджеры (enter / exit)
class Timer:
"""Context manager for timing code blocks."""
def __enter__(self):
import time
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"Время выполнения: {self.elapsed:.4f}с")
return False # don't suppress exceptions
with Timer() as t:
total = sum(range(1_000_000))
print(f"Результат: {total}, за {t.elapsed:.4f}с")
class DatabaseConnection:
"""Context manager for database connections."""
def __init__(self, connection_string: str) -> None:
self.connection_string = connection_string
self.connected = False
def __enter__(self):
print(f"Подключение к {self.connection_string}")
self.connected = True
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print("Закрытие соединения")
self.connected = False
if exc_type is not None:
print(f"Ошибка: {exc_val}")
return False # propagate exceptions
with DatabaseConnection("postgresql://localhost/mydb") as db:
print(f"Подключено: {db.connected}")
# Подключение к postgresql://localhost/mydb
# Подключено: True
# Закрытие соединения
Итоги
- Магические методы позволяют объектам работать с операторами и встроенными функциями
__getitem__/__setitem__-- доступ по индексу,__len__-- длина,__contains__-- операторin__eq__+__hash__-- равенство и хешируемость (оба или ни одного)NotImplemented-- сигнал Python попробовать метод другого операнда@total_ordering-- генерирует операторы сравнения из__eq__и__lt____call__-- делает объект вызываемым как функция@property-- контролируемый доступ к атрибутам (getter/setter/deleter)- Дескрипторы (
__get__/__set__/__set_name__) -- основа property, classmethod, staticmethod __enter__/__exit__-- контекстные менеджеры дляwith