HardТеория9 min

Магические методы и дескрипторы

__getitem__, __len__, __eq__/__hash__, __call__, @property и дескрипторы

Магические методы (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

Проверь себя

Какой магический метод вызывается при использовании оператора in?

Что возвращает NotImplemented в магических методах?

Зачем использовать @property вместо обычного атрибута?

Что такое дескриптор в Python?