Лекция 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 perimeter3.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)) # Trueprint(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.
Вопросы для самопроверки
- Чем абстракция отличается от инкапсуляции? Приведите пример, где они работают вместе.
- Что произойдёт при попытке создать объект класса, унаследованного от
ABC, но реализовавшего не все абстрактные методы? Почему? - Какой атрибут хранит имена нереализованных абстрактных методов и какой метакласс за это отвечает?
- В каком порядке нужно располагать декораторы
@propertyи@abstractmethod? Почему именно так? - Что такое виртуальная регистрация через
registerи в чём её опасность? - Опишите паттерн «Шаблонный метод». Какой метод делают конкретным, а какие — абстрактными?
- В чём разница между номинальной и структурной типизацией? Когда стоит выбрать
ABC, а когда —
Protocol?