Объектно-ориентированное программирование: инкапсуляция, полиморфизм, наследование
В предыдущей статье мы разбирали процедурный код: данные отдельно, процедуры отдельно, программа — это последовательность шагов над общей кучей структур. Такая модель прекрасно работает, пока структур мало. Когда их становится двести, а процедур две тысячи, возникает вопрос, на который процедурная парадигма не отвечает: кто имеет право менять поле balance и по каким правилам? Ответ «любая функция, которая до него дотянется» — это и есть определение системы, которую невозможно сопровождать. ООП — это ответ на вопрос о границах, а не «про классы» и не «про наследование», хотя учебники начинают именно с них.
Зачем это вообще: две независимые идеи, которые слиплись
Исторически в ООП сошлись две линии мысли, и путаница между ними — источник половины плохого объектного кода. Линия первая — абстракция данных. Дэвид Парнас в 1972 году в работе «On the Criteria To Be Used in Decomposing Systems into Modules» сформулировал принцип сокрытия информации: модуль должен скрывать решение, которое может измениться. Разбивать систему по шагам обработки — плохо, потому что шаги меняются вместе. Разбивать по «секретам» — хорошо, потому что секрет меняется внутри одного модуля. Отсюда растёт инкапсуляция и абстрактные типы данных (Барбара Лисков, CLU).
Линия вторая — обмен сообщениями. Алан Кэй, придумавший термин «object-oriented», в «The Early History of Smalltalk» писал, что имел в виду не классы, а биологическую метафору: множество мелких автономных клеток, которые общаются сообщениями и не лезут друг другу внутрь. Позже он уточнял: «I’m sorry that I long ago coined the term “objects” for this topic because it gets many people to focus on the lesser idea. The big idea is messaging».
Практическое следствие: объект — это не «структура с методами», это поставщик поведения за контрактом. Если ваш класс — это struct с геттерами и сеттерами на каждое поле, вы получили процедурный код с лишним синтаксисом. Мартин Фаулер называет это анемичной моделью предметной области и считает антипаттерном именно потому, что она платит цену ООП, не получая выгоды.
Инкапсуляция — это про инварианты, а не про private
Инкапсуляция — это объединение состояния с операциями, которые сохраняют его корректность, и запрет любых других способов это состояние менять. Модификатор доступа — механизм, а не цель. Инвариант — утверждение, истинное между вызовами методов. У банковского счёта: balance == sum(transactions) и balance >= -overdraft_limit. Если поле публичное, инвариант защищать невозможно: его нарушит любая строчка кода в любом файле.
from dataclasses import dataclass
from decimal import Decimal
from typing import Iterator
class InsufficientFunds(Exception):
"""Доменная ошибка — часть контракта, а не техническая авария."""
@dataclass(frozen=True)
class Entry:
"""Неизменяемая запись в журнале. Историю нельзя переписать — только дополнить."""
amount: Decimal
reason: str
class Account:
"""Инвариант: _balance == sum(e.amount) и _balance >= -_overdraft."""
def __init__(self, overdraft: Decimal = Decimal("0")):
self._overdraft = overdraft
self._balance = Decimal("0")
self._entries: list[Entry] = []
@property
def balance(self) -> Decimal:
# Чтение — да. Запись — нет. Асимметрия здесь не случайна.
return self._balance
def entries(self) -> Iterator[Entry]:
# Возвращаем итератор, а не сам список: иначе клиент дописал бы запись мимо нас.
return iter(self._entries)
def post(self, amount: Decimal, reason: str) -> None:
if amount == 0:
raise ValueError("Нулевая проводка бессмысленна")
new_balance = self._balance + amount
if new_balance < -self._overdraft:
raise InsufficientFunds(f"Не хватает {(-new_balance) - self._overdraft}")
# Мутация состояния — одной транзакцией, после всех проверок.
self._entries.append(Entry(amount, reason))
self._balance = new_balance
Четыре приёма отличают настоящую инкапсуляцию от «полей с подчёркиванием»:
- Нет сеттера на
balance. Есть операция предметной областиpost, которая знает правила. - Не отдаём наружу изменяемую коллекцию. Возврат
self._entriesнапрямую — это утечка представления: клиент получил право менять внутренности. Отдаём итератор, кортеж или копию. - Проверка идёт до мутации. Если исключение вылетит после
append, но до присваивания_balance, объект останется в невалидном состоянии. Это называется гарантией строгой безопасности исключений и нарушается в реальном коде постоянно. - Утечка через вложенный объект (
order.customer.address.city = "X") не менее опасна: формально всё приватно, фактически инвариант заказа не защищён. Лекарство — value-объекты, неизменяемые типы без идентичности (Money,Address,DateRange): в C# этоrecord struct, в Java —record, в Python —@dataclass(frozen=True).
Полиморфизм — это единственная вещь, ради которой стоит терпеть ООП
Полиморфизм — способность одного участка кода работать с разными типами. Кардель и Вегнер в классической работе 1985 года выделили виды, которые люди до сих пор путают:
| Вид | Где встречается | Разрешается |
|---|---|---|
| Ad-hoc (перегрузка) | print(int), print(str) |
компиляцией, по статическим типам |
| Приведение (coercion) | 1 + 2.0 |
компилятором неявно |
| Параметрический | List<T>, дженерики, шаблоны |
компиляцией, один код на все типы |
| Подтипов (subtype) | Animal a = new Dog() |
во время выполнения, по фактическому типу |
Именно последний — «тот самый» полиморфизм ООП (место всех четырёх на общей карте — в обзоре трека). Его настоящая ценность — инверсия зависимости: высокоуровневый модуль зависит от интерфейса, а реализация подставляется снаружи. Из этого растут все паттерны GoF, вся тестируемость и вся модульность.
Ключевой момент — LoyaltyPolicy одновременно реализует интерфейс и содержит его. Это паттерн Декоратор, показывающий, почему интерфейс сильнее иерархии: комбинаций политик экспоненциально много, а классов остаётся линейно мало.
from typing import Protocol
from decimal import Decimal
class PricingPolicy(Protocol):
"""Структурный интерфейс: реализации НЕ обязаны наследоваться от него."""
def price(self, order: "Order") -> Decimal: ...
class RetailPolicy:
def price(self, order: "Order") -> Decimal:
return order.subtotal
class WholesalePolicy:
def __init__(self, threshold: int, rate: Decimal):
self._threshold, self._rate = threshold, rate
def price(self, order: "Order") -> Decimal:
return order.subtotal * (self._rate if order.items >= self._threshold else Decimal(1))
class LoyaltyPolicy:
"""Декоратор: работает поверх ЛЮБОЙ другой политики, включая себя."""
def __init__(self, inner: PricingPolicy, discount: Decimal):
self._inner, self._discount = inner, discount
def price(self, order: "Order") -> Decimal:
return self._inner.price(order) * (Decimal(1) - self._discount)
class Checkout:
def __init__(self, policy: PricingPolicy): # зависимость инъектируется снаружи
self._policy = policy
def total(self, order: "Order") -> Decimal:
return self._policy.price(order) # ветвлений по типу здесь нет и не будет
# Сборка поведения на лету — без единого нового класса:
policy = LoyaltyPolicy(WholesalePolicy(threshold=10, rate=Decimal("0.9")), Decimal("0.05"))
Здесь Protocol из PEP 544 даёт структурную типизацию: RetailPolicy ничего не наследует, но подходит по форме. Тот же принцип — интерфейсы Go, трейты Rust, интерфейсы TypeScript. Это ООП без иерархий, и в новых языках это доминирующий стиль (см. Go FAQ: «Is Go an object-oriented language?»).
Наследование — самый переоценённый из трёх
Наследование делает сразу три вещи, и в этом проблема:
- Подтипизацию —
Dogможно использовать там, где ждутAnimal(полезно); - Переиспользование кода — наследник получает реализацию базы (опасно);
- Жёсткую связность — наследник зависит от внутренностей базы (вредно).
Джошуа Блох в «Effective Java» посвятил этому item 18: «Favor composition over inheritance». Его аргумент — наследование ломает инкапсуляцию: наследник зависит не от контракта базы, а от того, как база реализована. Канонический пример — наследование HashSet, чтобы считать добавленные элементы:
// СЛОМАННЫЙ КОД — классический пример из Effective Java
public class CountingSet<E> extends HashSet<E> {
private int addCount = 0;
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // а внутри addAll вызывается add() -> двойной счёт!
}
public int getAddCount() { return addCount; }
}
addAll в HashSet реализован через add. Счётчик удваивается. Ошибка не в вашем коде и не в коде HashSet — она в невидимом контракте самовызова, о котором нигде не написано. Через версию библиотеки реализация поменяется, и код сломается или починится случайно. Правильное решение — композиция плюс делегирование:
public class CountingSet<E> implements Set<E> {
private final Set<E> delegate; // храним, а не наследуем
private int addCount = 0;
public CountingSet(Set<E> delegate) { this.delegate = delegate; }
@Override public boolean add(E e) { addCount++; return delegate.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c); // самовызовы внутри delegate нас не касаются
}
// ...остальные методы Set просто проксируются в delegate
public int getAddCount() { return addCount; }
}
Плата — бойлерплейт делегирования (в Kotlin его убирает by, в Go — встраивание структур, в C# — генераторы исходников), и она того стоит. Практическое правило: Наследуйтесь только если: (а) отношение — настоящее «является» по поведению, а не по здравому смыслу; (б) базовый класс спроектирован под наследование и это задокументировано; (в) иерархия глубиной 1–2. Во всех остальных случаях — интерфейс + композиция. В C# и Java классы стоит объявлять sealed/final по умолчанию.
Принцип подстановки Лисков: строгая формулировка
Барбара Лисков и Дженнет Уинг в «A Behavioral Notion of Subtyping» (1994) дали строгое условие:
Пусть
φ(x)— свойство, доказуемое для объектовxтипаT. Тогдаφ(y)должно быть истинно для объектовyтипаS, гдеS— подтипT.
По-человечески: клиент, написанный против базового типа, не должен уметь отличить подтип по поведению. Из этого механически выводятся правила вариантности.
- Предусловия нельзя усиливать. Если база принимает любое положительное число, наследник не имеет права требовать «только чётные».
- Постусловия нельзя ослаблять. Если база обещает отсортированный результат, наследник обязан вернуть отсортированный.
- Инварианты базы сохраняются, а исключения наследника — подтипы объявленных базой: новый непроверяемый тип ошибки ломает клиента.
Классический контрпример — Square extends Rectangle. Геометрически квадрат является прямоугольником, поведенчески — нет: клиент вправе написать r.setWidth(5); r.setHeight(4); assert r.area() == 20, а квадрат даст 16. Мораль: иерархию строят по поведению, а не по таксономии предметной области. Ещё один частый нарушитель — UnsupportedOperationException в реализации интерфейса: Collections.unmodifiableList возвращает List, у которого add бросает исключение. Для стандартной библиотеки это осознанный компромисс, но в вашем коде такой метод — сигнал, что интерфейс слишком широк и его надо разделить (принцип разделения интерфейсов — тема трека принципов проектирования).
Как это работает под капотом: механика диспетчеризации
Полиморфизм подтипов реализуется таблицей виртуальных методов. Каждый объект хранит указатель на таблицу своего класса; вызов метода — это индексирование по фиксированному слоту.
Сложность. Вызов виртуального метода — O(1): две загрузки из памяти плюс косвенный переход. Память — одно машинное слово на объект (в JVM заголовок объекта — 12–16 байт с mark word и class pointer). Интерфейсные вызовы дороже: у класса может быть несколько интерфейсов, поэтому используются либо itable с поиском, либо inline caches. Реальная цена, однако, — не такты, а предсказуемость перехода:
- мономорфный сайт вызова (всегда один тип) — JIT подставляет прямой вызов и часто инлайнит его целиком;
- биморфный (два типа) — проверка типа плюс два прямых вызова, ещё дёшево; мегаморфный (три и более) — полноценный косвенный переход, промах предсказателя ветвлений стоит 15–20 тактов, инлайнинг невозможен, оптимизации через границу вызова отваливаются.
HotSpot собирает статистику типов в профиле и выполняет девиртуализацию: если сайт мономорфен, вставляется guard-проверка и прямой инлайн, а при появлении нового типа код деоптимизируется. Подробности механики — в документации HotSpot и в оптимизационных руководствах Agner Fog. В C++ тот же эффект даёт final на классе или методе — компилятор доказывает, что переопределений нет, и вызывает напрямую. Практический вывод: полиморфизм бесплатен в холодном коде и заметен только в горячем цикле. Если профилировщик показывает мегаморфный сайт внутри цикла на миллионы итераций — там место типо-специализированной ветке или data-oriented раскладке (массив структур → структура массивов). В остальных 99% кода спор о «стоимости виртуальных вызовов» — преждевременная оптимизация.
Множественное наследование и порядок разрешения
Когда классов-родителей несколько, возникает «ромб»: от какого предка брать метод? Python решает это C3-линеаризацией — детерминированным алгоритмом, сохраняющим порядок объявления и монотонность. Разбор — в официальном описании MRO.
экземпляра?"} B -- да --> C["взять атрибут экземпляра"] --> J["выполнить тело"] B -- нет --> D["обход MRO класса
(C3-линеаризация)"] D --> E{"найден в текущем
классе MRO?"} E -- нет --> G{"MRO закончился?"} -- нет --> E G -- да --> H["__getattr__ / AttributeError"] E -- да --> F{"дескриптор?
(метод, property)"} F -- нет --> C F -- да --> I["__get__ → связанный метод"] --> J style D fill:#6f8fbf,fill-opacity:0.2 style I fill:#5aa17f,fill-opacity:0.2
class Storage:
def save(self, data): return f"storage({data})"
class Encrypted(Storage):
def save(self, data): return super().save(f"enc[{data}]")
class Compressed(Storage):
def save(self, data): return super().save(f"zip[{data}]")
class SecureArchive(Encrypted, Compressed):
pass
print([c.__name__ for c in SecureArchive.__mro__])
# ['SecureArchive', 'Encrypted', 'Compressed', 'Storage', 'object']
print(SecureArchive().save("doc"))
# storage(zip[enc[doc]])
Здесь super() — не «родитель», а «следующий в MRO». Именно поэтому Encrypted.save вызвал Compressed.save, хотя они не связаны наследованием. Это делает миксины работоспособными и одновременно опасными: порядок базовых классов меняет поведение. Сложность построения MRO — O(n·m) от числа классов и длин линеаризаций, считается один раз при создании класса. C++ решает ромб иначе — виртуальным наследованием (одна общая база ценой более сложной раскладки объекта), а Java и C# отказались от множественного наследования состояния вовсе, оставив множественные интерфейсы и default-методы: осознанный размен сложности на предсказуемость.
Жизненный цикл объекта как конечный автомат
Сильная сторона ООП, которую редко проговаривают: объект естественно моделирует состояние с ограниченными переходами, а инкапсуляция позволяет сделать невалидные переходы невыразимыми.
Типичная ошибка — вынести status в публичное поле и раскидать if (order.status == PAID) по всему коду: правила переходов оказываются размазаны и никем не гарантированы. Правильно — метод ship(), который сам проверяет, что заказ оплачен, и бросает доменную ошибку иначе. Это прямой мост к DDD-агрегатам: агрегат есть граница транзакционной консистентности, реализованная средствами инкапсуляции.
Обмен сообщениями и двойная диспетчеризация
Виртуальный вызов диспетчеризуется по одному типу — получателю. Когда поведение зависит от двух типов (столкновение астероида с кораблём, сериализация узла AST в разные форматы), одного уровня не хватает. Классическое решение — паттерн Посетитель, реализующий двойную диспетчеризацию через два последовательных виртуальных вызова.
выбран NumberNode.accept N->>V: visitNumber(self) Note over V: 2-я диспетчеризация:
выбран JsonVisitor.visitNumber V-->>N: сериализованное число N-->>C: результат Note over C,V: Новый Visitor добавить легко.
Новый тип Node — придётся править ВСЕ Visitor.
Финальная ремарка на диаграмме — это проблема выражения (expression problem, термин Филипа Вадлера): ООП дёшево добавляет новые типы к фиксированному набору операций, а функциональный стиль с сопоставлением с образцом — новые операции над фиксированным набором типов. Это фундаментальный размен, а не недостаток парадигмы. Подробнее о второй половине размена — в статье о функциональном программировании.
from abc import ABC, abstractmethod
class Node(ABC):
@abstractmethod
def accept(self, visitor: "Visitor"): ...
class Number(Node):
def __init__(self, value: float): self.value = value
def accept(self, visitor): return visitor.visit_number(self) # 1-я диспетчеризация
class Add(Node):
def __init__(self, left: Node, right: Node): self.left, self.right = left, right
def accept(self, visitor): return visitor.visit_add(self)
class Visitor(ABC):
@abstractmethod
def visit_number(self, node: Number): ...
@abstractmethod
def visit_add(self, node: Add): ...
class Evaluator(Visitor): # новая операция — новый класс, старые не трогаем
def visit_number(self, node): return node.value
def visit_add(self, node): return node.left.accept(self) + node.right.accept(self)
class Printer(Visitor):
def visit_number(self, node): return str(node.value)
def visit_add(self, node): return f"({node.left.accept(self)} + {node.right.accept(self)})"
tree = Add(Number(2), Add(Number(3), Number(4)))
print(tree.accept(Printer()), "=", tree.accept(Evaluator())) # (2 + (3 + 4)) = 9
Сложность обхода — O(n) по числу узлов, память — O(h) на стек рекурсии, где h — высота дерева; для глубоких деревьев (сгенерированный код, цепочки a+b+c+...) это реальный риск переполнения стека, поэтому в продакшн-парсерах обход делают явным стеком.
Типичные ошибки, которые видно в каждом втором проекте
- Анемичная модель. Классы — мешки геттеров/сеттеров, вся логика в
*Service. Симптом: сервис читает три поля объекта, что-то считает и записывает четвёртое. Лечение — перенести вычисление внутрь объекта (Tell, Don’t Ask). Исключение: DTO и ORM-проекции анемичны намеренно, и это нормально. - God object. Класс на 3000 строк с именем
Manager,Helper,Utils. Симптом: любое изменение требует его правки. Лечение — резать по причинам изменения, а не по техническим слоям. - Глубокие иерархии.
AbstractBaseHttpAuthenticatedJsonController. Каждый уровень — новый неявный контракт. Три уровня — практический потолок. - Виртуальный вызов из конструктора. База вызывает переопределённый метод, наследник ещё не инициализировал свои поля — получаете
nullили нули. В Java/C# компилятор промолчит, в C++ вызовется версия базы (что удивляет иначе):
class Base {
public Base() { Init(); } // ОПАСНО
protected virtual void Init() { }
}
class Derived : Base {
private readonly List<int> _items = new();
protected override void Init() => _items.Add(1); // NullReferenceException: _items ещё null
}
- Нарушенный контракт
equals/hashCode(Equals/GetHashCode,__eq__/__hash__). Переопределили сравнение и забыли хеш — объект «теряется» в множествах и словарях. Хуже: изменяемое поле участвует в хеше, и объект, положенный вHashSet, становится ненаходимым после мутации. Правило — в хеш только неизменяемые поля. - Публичные изменяемые коллекции.
getItems().clear()из чужого модуля. Возвращайте неизменяемые представления. - Наследование ради переиспользования. «У
OrderиInvoiceесть общие поля — сделаем базовыйAbstractDocument». Общие данные не означают общее поведение; через полгода базовый класс становится свалкой. - Ветвление по
instanceof/isinstance. Цепочкаif (x is A) ... else if (x is B)— это виртуальный метод, который забыли написать. Исключение: паттерн-матчинг на закрытой иерархии (sealed-классы Java 17+, C# 9+) — осознанный ADT-стиль, где компилятор проверяет полноту.
Как ООП выглядит в реальном продакшене
ООП живёт на границах, а не в ядре вычислений. Устойчивая современная схема: интерфейсы на входе/выходе системы (репозитории, шлюзы к API, шины сообщений), а внутри — данные и чистые функции. Это «функциональное ядро, императивная оболочка» (Гэри Бернхардт) и оно же — гексагональная архитектура.
инварианты + переходы"] end HTTP --> CTRL --> UC UC --> IREPO & INOTIF IREPO -. реализует .- REPO --> DB INOTIF -. реализует .- MAIL --> SMTP TEST["Тесты:
InMemoryRepository, FakeNotifier"] -. реализует .- IREPO TEST -. реализует .- INOTIF style CORE fill:#5aa17f,fill-opacity:0.15 style PORTS fill:#6f8fbf,fill-opacity:0.15
Почему это работает: интерфейс объявлен потребителем (ядром), а реализация живёт снаружи. Стрелка зависимости направлена внутрь, и это позволяет подменить Postgres на in-memory в тестах, не трогая доменный код. Мартин Фаулер разбирает механику подстановки в «Inversion of Control Containers and the Dependency Injection pattern». Что ещё делают на практике:
- Интерфейсы держат узкими — 1–3 метода. Широкий интерфейс невозможно реализовать честно и приходится городить заглушки.
- DI-контейнер (Spring, ASP.NET Core,
wireв Go) — деталь инфраструктуры. Домен о нём не знает; иначе получаете фреймворк вместо модели. - Сущности vs value-объекты. У сущности есть идентичность и жизненный цикл (
Order), у value-объекта — только значение (Money). Value-объекты делают неизменяемыми всегда — они бесплатно потокобезопасны, что критично для конкурентных парадигм. - ORM — самая протекающая абстракция в ООП. Object-relational impedance mismatch: наследование, ленивая загрузка, идентичность объектов плохо ложатся на реляционную модель. Практика — держать доменную модель отдельно от persistence-модели, если домен сложный, и не бороться с ORM, если домен — CRUD.
- Тестируемость — главный практический дивиденд. Возможность подставить фейк без mock-фреймворка — признак того, что границы проведены верно.
- Обратное движение тоже есть. В геймдеве и высокопроизводительных системах иерархии вытеснены ECS (entity-component-system): компоненты — чистые данные, системы — процедуры над массивами. Причина не идеологическая, а кешевая:
Array of Structsс виртуальными вызовами проигрываетStruct of Arraysв разы на горячих циклах. ООП там остаётся на уровне менеджеров подсистем, а не каждой сущности.
ООП в разных языках
| Язык | Модель | Наследование | Особенность |
|---|---|---|---|
| Java | классы + интерфейсы | одиночное + default-методы | sealed-иерархии с 17, records |
| C# | классы + интерфейсы | одиночное | свойства, record, паттерн-матчинг |
| Python | всё объект, утиная типизация | множественное, C3 MRO | Protocol, дескрипторы, метаклассы |
| C++ | классы, статическая диспетчеризация по умолчанию | множественное, виртуальное | virtual явно, RAII, шаблоны |
| Go | структуры + интерфейсы | нет, только встраивание | структурная типизация, неявная реализация |
| Rust | структуры + трейты | нет | трейты со статической и динамической диспетчеризацией |
| TypeScript | классы + структурные типы | одиночное | типы структурные, interface стирается |
| Elixir | объектов нет | — | протоколы дают полиморфизм без состояния |
Показательно: во всех языках, спроектированных после 2005 года, наследование реализации либо убрано, либо ограничено, а полиморфизм остался — лучший индикатор того, какой из «трёх китов» реально несёт ценность. Подробности по языкам — в треках Go, C#, TypeScript и Elixir.
// Go: интерфейс объявляет ПОТРЕБИТЕЛЬ, реализация — неявная.
// PgOrderRepository нигде не пишет "implements" — достаточно совпадения сигнатур.
type OrderRepository interface {
ByID(ctx context.Context, id string) (*Order, error)
}
type PlaceOrder struct{ repo OrderRepository } // зависимость от абстракции
func (uc PlaceOrder) Handle(ctx context.Context, id string) error {
order, err := uc.repo.ByID(ctx, id)
if err != nil {
return fmt.Errorf("загрузка заказа: %w", err)
}
return order.Place() // инвариант проверяется внутри агрегата
}
Когда ООП — не тот инструмент
- Преобразования данных без состояния (ETL, аналитика) — функциональный или процедурный стиль короче; см. декларативные подходы.
- Числодробилки и горячие циклы — важна раскладка памяти, а не иерархия типов.
- Скрипты до 200 строк — оверхед проектирования не окупается.
- Конкурентность с общим состоянием — изменяемые объекты плюс потоки дают гонки; надёжнее акторы или неизменяемые данные.
- Алгоритмические ядра — нужны структуры данных и инварианты, а не полиморфизм.
Зато ООП сильнее всего там, где есть много вариаций одного поведения (плагины, платёжные провайдеры, стратегии тарификации), долгоживущее состояние с правилами (домен) и необходимость подменять реализации (тесты, разные окружения).
Мини-итог
- Объект — это контракт с защищённым состоянием, а не структура с методами. Инкапсуляция защищает инварианты;
private— механизм, не цель. Не отдавайте наружу изменяемые внутренности. - Полиморфизм подтипов — главная ценность ООП: он даёт инверсию зависимостей, расширяемость и тестируемость. Стоимость
O(1), но мегаморфные сайты в горячем коде мешают JIT. - Наследование смешивает подтипизацию, переиспользование и связность: берите подтипизацию через интерфейсы, переиспользование — через композицию. LSP формализует «подтип не должен удивлять клиента»: вход контравариантен, выход ковариантен, инварианты сохраняются.
- В проде ООП живёт на границах: порты-интерфейсы снаружи, данные и правила внутри. Иерархии — плоские, интерфейсы — узкие, value-объекты — неизменяемые.
Источники
- Alan Kay. The Early History of Smalltalk (ACM HOPL-II, 1993).
- David Parnas. On the Criteria To Be Used in Decomposing Systems into Modules (CACM, 1972).
- Barbara Liskov, Jeannette Wing. A Behavioral Notion of Subtyping (TOPLAS, 1994).
- Luca Cardelli, Peter Wegner. On Understanding Types, Data Abstraction, and Polymorphism (1985).
- Bertrand Meyer. «Object-Oriented Software Construction», 2-е изд. — проектирование по контракту; Joshua Bloch, «Effective Java», 3-е изд., items 15–20; Sandi Metz, «Practical Object-Oriented Design in Ruby» — лучшая книга про то, когда не наследовать.
- Gamma, Helm, Johnson, Vlissides. «Design Patterns» (GoF, 1994); современный разбор — refactoring.guru.
- Martin Fowler. AnemicDomainModel, Dependency Injection.
- Robert Nystrom. Crafting Interpreters, глава «Classes» — как объекты реализуются изнутри.
- Python MRO / C3-линеаризация, PEP 544 — Protocols, Go FAQ: Is Go an object-oriented language?
- Agner Fog. Software optimization resources — цена косвенных переходов.
Что дальше
ООП делает состояние управляемым, окружая его границами. Следующая парадигма решает ту же задачу радикальнее — убирая изменяемое состояние вовсе: Функциональное программирование: чистота, неизменяемость, композиция. Там же вы увидите вторую половину проблемы выражения и поймёте, почему современные кодовые базы почти всегда гибридные — тема финальной статьи трека, как выбирать и смешивать парадигмы.