Перейти к содержимому

Лекция 3. Абстракция и абстрактные классы (ABC)

1. Введение: зачем нужна абстракция

Раздел 1 нашего курса посвящён фундаментальным принципам ООП. На прошлых занятиях мы говорили о классах, объектах и инкапсуляции. Сегодня мы подробно разбираем абстракцию — принцип, который отвечает на вопрос «что должен уметь объект», не уточняя, «как именно он это делает».

Любая нетривиальная программа — это борьба со сложностью. Когда система состоит из десятков взаимодействующих компонентов, человеку физически тяжело удерживать в голове все детали сразу. Абстракция — это инструмент, который позволяет рассматривать систему на нужном уровне детализации, временно скрывая всё остальное.

В Python абстракция поддерживается на уровне языка модулем abc (Abstract Base Classes — абстрактные базовые классы). Этот модуль даёт нам возможность описать «контракт» — набор операций, которые обязан реализовать каждый потомок, — и при этом запретить создание объектов незавершённого, абстрактного типа.


2. Абстракция как принцип ООП

2.1. Определение

Абстракция — это выделение существенных характеристик объекта или системы и игнорирование несущественных в данном контексте.

Ключевые слова здесь — «в данном контексте». Не существует «правильной» абстракции вообще: то, что существенно для одной задачи, несущественно для другой. Например, для программы учёта автопарка важны марка, госномер и пробег автомобиля, а цвет сидений несуществен. Для программы подбора аксессуаров салона всё может быть наоборот.

Абстракция работает на двух взаимосвязанных уровнях:

  • Выделение существенного. Мы решаем, какие свойства и операции описывают сущность для решаемой задачи. Это проектная, мыслительная работа.
  • Сокрытие деталей реализации. Мы прячем внутреннее устройство за понятным внешним интерфейсом. Пользователь класса работает с «что», не вникая в «как».

2.2. Абстракция и инкапсуляция — в чём разница

Эти два понятия часто путают, потому что оба связаны со «скрытием». Различие такое:

  • Абстракция — это вопрос проектирования: какие операции вообще должны быть у сущности, какой у неё внешний интерфейс. Это взгляд «снаружи».
  • Инкапсуляция — это вопрос реализации: как защитить внутреннее состояние и не дать внешнему коду нарушить инварианты. Это механизм «изнутри».

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

2.3. Бытовая аналогия

Рассмотрим педаль газа в автомобиле. Водитель знает: нажми — машина поедет быстрее. Это абстракция: интерфейс «ускорить» без знания о впрыске топлива, работе цилиндров и электронике. Бензиновый двигатель, дизель и электромотор реализуют этот интерфейс совершенно по-разному, но для водителя он одинаков. Именно такую ситуацию — «единый интерфейс, разные реализации» — мы и хотим выразить средствами языка.


3. Модуль abc: ABC и @abstractmethod

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

Чтобы объявить абстрактный класс, наследуемся от ABC и помечаем обязательные методы декоратором @abstractmethod.

from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Площадь фигуры. Конкретная реализация — за потомками."""
...
@abstractmethod
def perimeter(self) -> float:
"""Периметр фигуры."""
...

Класс Shape описывает контракт: любая фигура обязана уметь считать площадь и периметр. Сам по себе Shape не знает, как это делать, — у него нет тела методов (используется ... или pass).

3.2. Почему нельзя инстанцировать абстрактный класс

Попытка создать объект абстрактного класса завершается ошибкой:

s = Shape()
# TypeError: Can't instantiate abstract class Shape
# with abstract methods area, perimeter

Это разумно: что должен вернуть Shape().area(), если реализации нет? Запрет защищает от создания «недоделанных» объектов. Потомок становится конкретным (instantiable) только тогда, когда переопределит все абстрактные методы.

from math import pi
class Circle(Shape):
def __init__(self, r: float):
self.r = r
def area(self) -> float:
return pi * self.r ** 2
def perimeter(self) -> float:
return 2 * pi * self.r
class Rectangle(Shape):
def __init__(self, w: float, h: float):
self.w = w
self.h = h
def area(self) -> float:
return self.w * self.h
def perimeter(self) -> float:
return 2 * (self.w + self.h)
print(Circle(2).area()) # 12.566...
print(Rectangle(3, 4).area()) # 12

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

class Triangle(Shape):
def area(self) -> float:
return 0.0
# perimeter не реализован!
Triangle()
# TypeError: Can't instantiate abstract class Triangle
# with abstract method perimeter

3.3. Как это работает «под капотом»

Механизм опирается на метакласс ABCMeta. Класс ABC — это просто удобная обёртка: class ABC(metaclass=ABCMeta). Метакласс собирает имена всех методов, помеченных @abstractmethod, в специальный атрибут __abstractmethods__.

print(Shape.__abstractmethods__)
# frozenset({'area', 'perimeter'})
print(Circle.__abstractmethods__)
# frozenset() — пусто, значит класс конкретный

При вызове конструктора object.__new__ проверяет: если __abstractmethods__ непусто — выбрасывает TypeError. Именно поэтому запрет действует во время создания объекта, а не во время описания класса. Само объявление абстрактного класса проходит без ошибок — проблема возникает только при попытке его инстанцировать.


4. Виды абстрактных членов класса

@abstractmethod можно комбинировать с другими декораторами. Важное правило: @abstractmethod должен быть самым внутренним (ближайшим к def) декоратором.

4.1. Абстрактные свойства (@property)

Иногда контракт требует не метод, а атрибут-свойство. Тогда сочетаем @property и @abstractmethod:

from abc import ABC, abstractmethod
class Employee(ABC):
@property
@abstractmethod
def salary(self) -> float:
"""Каждый сотрудник обязан иметь вычисляемую зарплату."""
...
class Manager(Employee):
def __init__(self, base: float, bonus: float):
self._base = base
self._bonus = bonus
@property
def salary(self) -> float:
return self._base + self._bonus
m = Manager(1000, 500)
print(m.salary) # 1500 — обращаемся как к атрибуту, без скобок

Потомок обязан предоставить свойство salary. Снаружи оно выглядит как обычный атрибут, хотя за ним стоит вычисление, — это и есть сокрытие деталей.

4.2. Абстрактные методы класса (@classmethod)

Можно требовать наличие фабричного метода класса:

from abc import ABC, abstractmethod
class Loader(ABC):
@classmethod
@abstractmethod
def from_file(cls, path: str) -> "Loader":
"""Создать объект из файла. Каждый формат — по-своему."""
...
class JsonLoader(Loader):
def __init__(self, data: dict):
self.data = data
@classmethod
def from_file(cls, path: str) -> "JsonLoader":
import json
with open(path, encoding="utf-8") as f:
return cls(json.load(f))

Аналогично работает сочетание @staticmethod + @abstractmethod. Так контракт охватывает не только методы экземпляра, но и поведение на уровне самого класса.

Историческая ремарка: раньше существовали отдельные декораторы @abstractproperty, @abstractclassmethod, @abstractstaticmethod. Начиная с Python 3.3 они считаются устаревшими — используйте комбинацию обычного декоратора с @abstractmethod.


5. Интерфейсы через абстрактные базовые классы

В таких языках, как Java или C#, есть отдельная конструкция interface — чисто контракт без реализации. В Python отдельного ключевого слова нет, и его роль играют абстрактные классы, у которых все методы абстрактны.

from abc import ABC, abstractmethod
class Serializer(ABC):
"""Интерфейс сериализатора: только контракт, без реализации."""
@abstractmethod
def serialize(self, obj: object) -> bytes:
...
@abstractmethod
def deserialize(self, data: bytes) -> object:
...
class PickleSerializer(Serializer):
def serialize(self, obj: object) -> bytes:
import pickle
return pickle.dumps(obj)
def deserialize(self, data: bytes) -> object:
import pickle
return pickle.loads(data)

Преимущество «интерфейса как ABC» в том, что мы можем заранее, до написания конкретного кода, зафиксировать набор операций и опираться на него в остальной программе. Функция, принимающая Serializer, гарантированно получит объект с методами serialize и deserialize.

5.1. Виртуальная регистрация подклассов

ABC умеют признавать «своими» классы, которые формально от них не наследуются, — через метод register. Это полезно для адаптации чужого кода:

class MyList:
def serialize(self, obj):
return b"..."
def deserialize(self, data):
return None
Serializer.register(MyList)
print(issubclass(MyList, Serializer)) # True
print(isinstance(MyList(), Serializer)) # True

Обратите внимание: при виртуальной регистрации Python не проверяет наличие методов — ответственность за соблюдение контракта лежит на программисте.


6. Шаблонный метод (Template Method)

Абстрактные классы особенно хороши, когда у потомков общий алгоритм, но отдельные шаги различаются. Это поведенческий паттерн «Шаблонный метод»: базовый класс задаёт скелет алгоритма в конкретном методе, а изменяемые шаги объявляет абстрактными.

from abc import ABC, abstractmethod
class DataPipeline(ABC):
"""Скелет обработки данных. Шаги переопределяют потомки."""
def run(self) -> None: # шаблонный метод — общий каркас
raw = self.extract()
clean = self.transform(raw)
self.load(clean)
print("Готово")
@abstractmethod
def extract(self) -> list:
...
@abstractmethod
def transform(self, data: list) -> list:
...
@abstractmethod
def load(self, data: list) -> None:
...
class CsvPipeline(DataPipeline):
def extract(self) -> list:
return ["1,2", "3,4"]
def transform(self, data: list) -> list:
return [row.split(",") for row in data]
def load(self, data: list) -> None:
print("Записано строк:", len(data))
CsvPipeline().run()
# Записано строк: 2
# Готово

Метод run зафиксирован в базовом классе: порядок шагов не меняется, и потомок не может его «случайно сломать». А extract, transform, load каждый потомок реализует по-своему. Это прямое применение принципа абстракции: общая логика вынесена и защищена, переменная — делегирована вниз по иерархии.

6.1. Hook-методы

Часть шагов можно сделать необязательными — с пустой реализацией по умолчанию (их называют «hook», крючок). Потомок переопределяет их по желанию:

class DataPipeline(ABC):
def run(self) -> None:
self.before() # необязательный крючок
data = self.extract()
self.load(data)
def before(self) -> None: # реализация по умолчанию — ничего не делает
pass
@abstractmethod
def extract(self) -> list:
...
@abstractmethod
def load(self, data: list) -> None:
...

Так базовый класс гибко разделяет обязательные (абстрактные) и опциональные (hook) шаги.


7. Абстрактный класс vs протокол (мост к лекции 6)

Начиная с Python 3.8 в модуле typing появилась альтернатива — Protocol. Это формализация знаменитой «утиной типизации»: «если объект ходит как утка и крякает как утка — считаем его уткой». Полноценно протоколы мы разберём в лекции 6, а сейчас зафиксируем ключевое отличие.

from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
class Button: # НЕ наследуется от Drawable
def draw(self) -> None:
print("Рисую кнопку")
def render(item: Drawable) -> None:
item.draw()
render(Button()) # OK — структурного совпадения достаточно

Принципиальная разница в природе типизации:

КритерийАбстрактный класс (ABC)Протокол (Protocol)
Тип совместимостиНоминальная: нужно явное наследованиеСтруктурная: достаточно нужных методов
Связь с потомкомЖёсткая, через class X(Base)Никакой явной связи не требуется
Проверка контрактаВо время выполнения (TypeError)В основном статически (mypy)
Можно ли частично реализоватьБазовый класс может нести общий кодТолько сигнатуры (как правило)
Когда удобенОбщая логика + строгая иерархияСовместимость «постфактум», чужой код

Кратко: ABC говорит «ты обязан быть моим потомком и реализовать это»; Protocol говорит «мне всё равно, кто ты, лишь бы у тебя были нужные методы». ABC хорош, когда вы строите собственную иерархию и хотите делиться кодом и гарантиями во время выполнения. Protocol хорош, когда нужно описать ожидания к объекту, не диктуя ему происхождение, — особенно для классов, которые вы не контролируете.


8. Когда применять абстрактные классы

Абстракция через ABC уместна, когда:

  • есть несколько реализаций одной идеи, и важно гарантировать единый набор операций (фигуры, сериализаторы, платёжные системы);
  • хочется зафиксировать общий алгоритм и вынести в потомки только переменные шаги (паттерн «Шаблонный метод»);
  • нужна проверка контракта во время выполнения — чтобы «недоделанный» класс нельзя было инстанцировать.

Не стоит плодить абстрактные классы там, где реализация одна-единственная и не предвидится других, — это лишняя сложность. Абстракция оправдана как ответ на реальную вариативность, а не как украшение.


Краткие итоги

  • Абстракция — выделение существенного и сокрытие деталей реализации; это инструмент управления сложностью и взгляд на систему «снаружи».
  • Абстракция отвечает за «что» (внешний интерфейс), инкапсуляция — за «как» (защита внутреннего состояния).
  • Модуль abc даёт ABC и декоратор @abstractmethod для описания контрактов.
  • Класс с непустым __abstractmethods__ нельзя инстанцировать — проверка выполняется метаклассом ABCMeta в момент создания объекта.
  • @abstractmethod комбинируется с @property, @classmethod, @staticmethod (декоратор @abstractmethod ставится самым внутренним).
  • ABC со всеми абстрактными методами играют роль интерфейсов; register позволяет виртуально признавать чужие классы потомками.
  • Паттерн «Шаблонный метод»: каркас алгоритма — в конкретном методе базового класса, изменяемые шаги — абстрактны; опциональные шаги оформляют как hook.
  • ABC — номинальная типизация (явное наследование), Protocol — структурная (по наличию методов); подробнее — в лекции 6.

Вопросы для самопроверки

  1. Чем абстракция отличается от инкапсуляции? Приведите пример, где они работают вместе.
  2. Что произойдёт при попытке создать объект класса, унаследованного от ABC, но реализовавшего не все абстрактные методы? Почему?
  3. Какой атрибут хранит имена нереализованных абстрактных методов и какой метакласс за это отвечает?
  4. В каком порядке нужно располагать декораторы @property и @abstractmethod? Почему именно так?
  5. Что такое виртуальная регистрация через register и в чём её опасность?
  6. Опишите паттерн «Шаблонный метод». Какой метод делают конкретным, а какие — абстрактными?
  7. В чём разница между номинальной и структурной типизацией? Когда стоит выбрать ABC, а когда — Protocol?