📖 Теория: абстрактные классы и пользовательские исключения

⚡ Кратко

Абстрактный класс — это шаблон, который запрещает создание объектов и требует реализации определённых методов в наследниках. Пользовательские исключения наследуются от Exception и помогают точно описывать ошибки предметной области. Docstring и аннотации типов делают классы понятнее.

  • Абстрактный метод помечается @abstractmethod; чаще всего его тело — pass, но оно может содержать общий код, доступный наследнику через super(). Переопределить метод всё равно обязательно.
  • Класс становится абстрактным, если наследуется от ABC или содержит абстрактные методы.
  • Исключения принято называть с суффиксом Error; для своих ошибок заводят общего предка (BankError), чтобы ловить их и точно, и «оптом».
  • Если ошибка связана с конкретным типом данных, наследуйте её от подходящего встроенного исключения.

1. Абстрактные классы

Абстрактный класс — это класс, который не предназначен для создания объектов напрямую. Он задаёт структуру и обязательные методы, которые должны быть реализованы в дочерних классах.

Зачем нужны

  • Определить единый интерфейс для группы классов.
  • Заставить наследников реализовать нужные методы.
  • Описать общую логику, но запретить создание «незаконченных» объектов.

Отличие от обычного класса

АспектОбычный классАбстрактный класс
Создание объектовМожноНельзя
Переопределение методовНеобязательноАбстрактные методы — обязательны

Создание абстрактного класса

from abc import ABC, abstractmethod


class Employee(ABC):
    """Абстрактный базовый класс для сотрудников."""

    @abstractmethod
    def work(self) -> None:
        """Описать рабочий процесс конкретного сотрудника."""
        pass


# e = Employee()  # TypeError: Cannot instantiate abstract class Employee

class Programmer(Employee):
    def work(self) -> None:
        print("Write code")


p = Programmer()
p.work()  # Write code

Ключевые моменты:

  • Класс должен наследоваться от ABC (модуль abc).
  • Абстрактные методы помечаются декоратором @abstractmethod.
  • Абстрактный класс может содержать как абстрактные, так и обычные методы.

Абстрактный метод может содержать код

@abstractmethod не обязывает писать внутри только pass. Метод может содержать общую часть работы, а наследник вызовет её через super() и дополнит своей логикой. Переопределить метод всё равно обязательно.

from abc import ABC, abstractmethod


class Employee(ABC):
    @abstractmethod
    def calculate_salary(self) -> float:
        print("Выполняется расчёт зарплаты")
        return 0


class Developer(Employee):
    def calculate_salary(self) -> float:
        super().calculate_salary()   # общая часть из базового класса
        return 4000


developer = Developer()
print(developer.calculate_salary())
# Выполняется расчёт зарплаты
# 4000

Цепочка абстрактных классов

Абстрактным может быть и наследник: если он не реализовал все унаследованные абстрактные методы (или добавил свои), объект из него по-прежнему создать нельзя.

from abc import ABC, abstractmethod


class Animal(ABC):
    @abstractmethod
    def make_sound(self) -> str:
        raise NotImplementedError


class Mammal(Animal):
    # make_sound() не реализован, добавлен ещё один метод → Mammal тоже абстрактный

    @abstractmethod
    def feed_baby(self) -> str:
        raise NotImplementedError


class Dog(Mammal):
    # конечный класс реализует весь накопленный контракт

    def make_sound(self) -> str:
        return "Гав"

    def feed_baby(self) -> str:
        return "Кормит щенка молоком"


dog = Dog()          # работает
# Mammal()           # TypeError: Can't instantiate abstract class Mammal

В теле абстрактного метода часто пишут raise NotImplementedError вместо pass. Смысл такой: если наследник каким-то образом вызовет базовую версию, ошибка скажет прямо — «метод обязан быть переопределён».

Ошибка проектирования: метод, нужный не всем

В общий контракт нельзя помещать то, что подходит лишь части наследников:

class Animal(ABC):
    @abstractmethod
    def fly(self) -> None: ...   # ❌ теперь и собака обязана «летать»

Класс Dog будет вынужден реализовать бессмысленный метод. Правильно — разделить контракты:

class Animal(ABC):
    @abstractmethod
    def eat(self) -> None: ...


class FlyingAnimal(Animal):
    @abstractmethod
    def fly(self) -> None: ...

Тогда Dog наследует Animal, а BirdFlyingAnimal, и каждый реализует только то, что для него осмысленно.

Абстрактный класс как «договор»

Абстрактный класс удобен, когда несколько разных классов обязаны поддерживать один и тот же набор операций. Тогда остальной код может работать с любым из них, не зная деталей:

class PaymentProvider(ABC):
    @abstractmethod
    def pay(self, amount: float) -> None: ...

    @abstractmethod
    def refund(self, amount: float) -> None: ...


class BankProvider(PaymentProvider):
    def pay(self, amount: float) -> None:
        print(f"Оплата через банк: {amount} €")

    def refund(self, amount: float) -> None:
        print(f"Возврат через банк: {amount} €")


def checkout(provider: PaymentProvider, amount: float) -> None:
    provider.pay(amount)   # неважно, банк это или что-то ещё


checkout(BankProvider(), 100)  # Оплата через банк: 100 €

Если позже появится CardProvider или CryptoProvider, функцию checkout() менять не придётся — она полагается на договор, а не на конкретный класс.

Проверить по документации: подробнее о модуле abc см. abc — Abstract Base Classes.

2. Документация классов

Docstring — многострочная строка сразу после объявления класса или метода. Она отображается в help() и помогает другим разработчикам быстро понять назначение класса.

class Book:
    """
    Represents a book.

    Attributes:
        title (str): The title of the book.
        author (str): The author of the book.

    Methods:
        get_info(): Returns a brief description of the book.
    """

    def __init__(self, title: str, author: str) -> None:
        self.title = title
        self.author = author

    def get_info(self) -> str:
        """Return a brief description of the book."""
        return f"{self.title} by {self.author}"


print(Book.__doc__)

Рекомендации:

  • Кратко опишите, что делает класс.
  • Перечислите основные атрибуты и методы.
  • Используйте единый стиль по всему проекту.

Полный docstring метода: Args, Returns, Raises

Для метода полезно описать не только назначение, но и аргументы, возвращаемое значение и исключения, которые он может выбросить:

class BankAccount:
    """
    Представляет банковский счёт.

    Attributes:
        owner (str): Имя владельца счёта.
        balance (float): Текущий баланс.
    """

    def __init__(self, owner: str, balance: float = 0) -> None:
        self.owner = owner
        self.balance = balance

    def deposit(self, amount: float) -> float:
        """
        Пополняет баланс.

        Args:
            amount (float): Сумма пополнения.

        Returns:
            float: Новый баланс.

        Raises:
            ValueError: Если сумма не является положительной.
        """
        if amount <= 0:
            raise ValueError("Сумма должна быть положительной.")

        self.balance += amount
        return self.balance

Такую документацию Python показывает автоматически:

print(BankAccount.__doc__)        # docstring класса
print(BankAccount.deposit.__doc__)  # docstring метода

help(BankAccount)                 # полная справка по классу
help(BankAccount.deposit)         # справка по одному методу
Docstring — не то же самое, что комментарий. Комментарий # увеличиваем баланс виден только тому, кто читает исходник. Docstring становится частью объекта: он доступен через __doc__, help() и подсказки редактора.
Проверить по документации: соглашения по docstring описаны в PEP 257.

3. Пользовательские исключения

Встроенных исключений иногда недостаточно, чтобы точно объяснить, что пошло не так. Пользовательские исключения позволяют явно разделить ошибки бизнес-логики от стандартных.

Базовый синтаксис

class CustomError(Exception):
    """Raised when a custom business rule is violated."""
    pass


raise CustomError("Error message")

Особенности

  • Наследуйтесь от Exception или его подклассов (ValueError, TypeError и др.).
  • Не наследуйтесь от BaseException — он предназначен для системных исключений.
  • Названия принято заканчивать на Error.

Пример: ограничение доступа по возрасту

class AccessDeniedError(Exception):
    """Raised when a user is too young to access the resource."""
    pass


def check_age(age: int) -> None:
    if age < 18:
        raise AccessDeniedError("Access denied: age must be at least 18")
    print("Access granted")


try:
    check_age(16)
except AccessDeniedError as e:
    print("Ошибка:", e)

Дополнительные поля в исключениях

Можно сохранять в объекте исключения полезный контекст: аргументы, вызвавшие сбой, идентификаторы и т.д.

class AccessDeniedError(Exception):
    """Raised when a user is too young to access the resource."""

    def __init__(self, age: int) -> None:
        self.age = age
        super().__init__(f"Access denied: age {age} is too low")


def check_age(age: int) -> None:
    if age < 18:
        raise AccessDeniedError(age)
    print("Access granted")


try:
    check_age(15)
except AccessDeniedError as e:
    print("Error:", e)
    print("Age:", e.age)

Вызов super().__init__() передаёт сообщение в базовый Exception, поэтому print(e) работает как обычно.

Наследование от стандартных исключений

Если ошибка связана с неправильным значением, лучше наследовать её от ValueError. Это позволяет перехватывать её и как TemperatureTooLowError, и как ValueError.

class TemperatureTooLowError(ValueError):
    """Raised when temperature is below absolute zero."""
    pass


def set_temperature(value: float) -> None:
    if value < -273.15:
        raise TemperatureTooLowError("Temperature cannot be below absolute zero")
    print(f"Temperature set to {value}°C")


set_temperature(15)
set_temperature(-300)  # TemperatureTooLowError

Иерархия исключений проекта

Когда своих исключений становится много, для них заводят общего предка. Тогда вызывающий код сам решает, насколько точно ловить ошибку:

class BankError(Exception):
    """Базовая ошибка банковской системы."""


class InsufficientFundsError(BankError):
    """Недостаточно средств."""


class InvalidAmountError(BankError):
    """Некорректная сумма."""


try:
    account.withdraw(250)
except InsufficientFundsError:
    print("Недостаточно денег")   # точная реакция на конкретную ошибку
except BankError:
    print("Ошибка банковской операции")   # любая ошибка нашей системы

Порядок except важен: сначала более конкретные исключения, затем общий предок — иначе BankError перехватит всё первым.

Соглашение об именах: имя пользовательского исключения заканчивается на ErrorPaymentError, ReceiptNotFoundError, InvalidPriceError, ClosedShiftError.

4. Магические методы

Магические (или dunder-) методы позволяют классам встраиваться в поведение самого Python: строковое представление, сравнение, арифметика, итерация и многое другое.

ВыражениеВызываемый метод
str(obj) или print(obj)__str__
len(obj)__len__
obj1 == obj2__eq__
obj1 + obj2__add__
item in obj__contains__
obj()__call__
bool(obj)__bool__

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

Чтобы объект можно было перебирать через for, нужно реализовать протокол итератора:

  • __iter__() — возвращает сам итератор.
  • __next__() — возвращает следующее значение или выбрасывает StopIteration.
class CumulativeSum:
    """Iterator yielding running totals of a number sequence."""

    def __init__(self, numbers: list[int]) -> None:
        self.numbers = numbers
        self.index = 0
        self.total = 0

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.index >= len(self.numbers):
            raise StopIteration
        self.total += self.numbers[self.index]
        self.index += 1
        return self.total


data = [3, 5, 2, 4]
acc = CumulativeSum(data)
for value in acc:
    print(value)  # 3, 8, 10, 14

Что на самом деле делает for

Цикл for — это сокращённая запись работы с итератором. Вот тот же цикл «вручную»:

iterator = iter(collection)   # вызывает collection.__iter__()

while True:
    try:
        item = next(iterator)  # вызывает iterator.__next__()
        print(item)
    except StopIteration:      # элементы закончились
        break

Поэтому __iter__ возвращает self: объект CumulativeSum уже сам является итератором, так как в нём есть __next__.

Итерация по внутреннему списку

Если класс просто хранит список, писать __next__ не нужно — достаточно вернуть готовый итератор списка:

class ShoppingCart:
    def __init__(self) -> None:
        self.products: list[str] = []

    def add(self, product: str) -> None:
        self.products.append(product)

    def __iter__(self):
        return iter(self.products)   # стандартный итератор списка

    def __reversed__(self):
        return reversed(self.products)


cart = ShoppingCart()
cart.add("Телефон")
cart.add("Ноутбук")
cart.add("Наушники")

for product in cart:
    print(product)            # Телефон, Ноутбук, Наушники

for product in reversed(cart):
    print(product)            # Наушники, Ноутбук, Телефон

__reversed__ добавляет поддержку встроенной функции reversed() — обхода в обратном порядке.

Объект-итератор перебирается один раз. После StopIteration класс вроде CumulativeSum исчерпан, и повторный for ничего не выдаст. Если нужен многократный обход, __iter__ должен возвращать новый итератор при каждом вызове — как в примере с iter(self.products).
Проверить по документации: о протоколе итератора см. Iterator Types.