Раздел 2 · Программирование / CS

ООП без карго-культа: столпы, SOLID, GRASP

junior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

ООП без карго-культа: столпы, SOLID, GRASP

ООП обычно рассказывают как список заклинаний: три столпа, пять букв SOLID, и вперёд на собеседование. Этот урок устроен иначе. Сначала мы выясним, что такое ООП на самом деле, и соберём полноценный объект вообще без класса, как это делает третья глава SICP. Потом разберём, какую проблему решает каждая знаменитая идея, где она работает, а где превращается в карго-культ. Паттерны GoF вынесены в следующий урок, здесь фундамент: определение, столпы, виды полиморфизма, SOLID с критикой и GRASP. А ФП-серия потом покажет, что половина этих проблем решается и без объектов.

Что такое ООП: четыре слоя

Спроси «что такое ООП», и в ответах перемешаются четыре разные вещи:

  1. Определение Алана Кэя. Программа состоит из объектов, которые инкапсулируют данные и поведение и общаются сообщениями.
  2. Столпы. Инкапсуляция, наследование, полиморфизм, абстракция.
  3. Паттерны и принципы. GoF, SOLID, GRASP.
  4. Синтаксис. class, extends, private, interface.

Слои перечислены от фундамента к надстройке, и спор про ООП обычно буксует потому, что один собеседник говорит про слой 1, а второй про слой 4. Кэй, придумавший сам термин, позже подчёркивал, что главная идея это сообщения, а не классы, и что C++ он в виду точно не имел. Его метафора биологическая: объекты как клетки, которые не лезут друг другу во внутренности, а обмениваются сигналами.

Полезное упражнение: спросить про каждый слой, обязателен ли он. Обязан ли язык иметь class, чтобы на нём писали ООП? Обязательны ли все четыре столпа? Этим и займёмся.

Объект без класса

Третья глава SICP строит объект из подручных материалов: замыкания и локального состояния. Повторим на TypeScript банковский счёт, классику книги:

const makeAccount = (initialBalance: number) => {
  let balance = initialBalance;

  return {
    deposit: (amount: number) => {
      balance += amount;
      return balance;
    },
    withdraw: (amount: number) => {
      if (amount > balance) throw new Error('Недостаточно средств');
      balance -= amount;
      return balance;
    },
    getBalance: () => balance,
  };
};

const account = makeAccount(100);
account.withdraw(30);   // 70
account.getBalance();   // 70, а самого поля balance снаружи не существует

Ни одного класса, а перед нами полноценный объект: состояние плюс поведение, и состояние снаружи недостижимо. Заметь, что инкапсуляция здесь даже строже классовой: к #private полю класса можно подобраться через devtools и хитрые трюки, а переменную из замыкания не достать никак, её просто нет в видимой области.

SICP делает следующий шаг и показывает message passing в буквальном виде: объект это функция, принимающая сообщение.

type AccountMessage = 'deposit' | 'withdraw' | 'balance';

const makeAccountMsg = (initialBalance: number) => {
  let balance = initialBalance;

  return (message: AccountMessage, amount = 0) =>
    match(message) // match из библиотеки ts-pattern
      .with('deposit', () => (balance += amount))
      .with('withdraw', () => {
        if (amount > balance) throw new Error('Недостаточно средств');
        return (balance -= amount);
      })
      .with('balance', () => balance)
      .exhaustive();
};

const account = makeAccountMsg(100);
account('withdraw', 30); // 70

Вот определение Кэя в чистом виде: данные спрятаны, наружу торчит только канал для сообщений. Вспомни cons из замыкания в прошлом уроке: это был тот же приём, данные как поведение.

Теперь главный вывод этого раздела: языки строят объектные системы по-разному, и класс лишь один из вариантов.

  • Smalltalk: всё объект, всё общение через сообщения, даже if.
  • JS: прототипы. class это синтаксический сахар поверх них, а до ES6 объекты годами собирали фабриками и замыканиями, как выше.
  • Go: классов и наследования нет вовсе. Структуры, методы на них и интерфейсы, которые реализуются неявно. ООП-задачи решаются, слово «класс» не понадобилось.
  • Rust: данные в struct, поведение в trait, наследования реализации нет.
  • Erlang: процессы с приватным состоянием, общающиеся исключительно сообщениями. Иронично, что функциональный язык ближе всех подошёл к ООП по Кэю.

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

ООП больше, чем код

Перечитай определение Кэя ещё раз: сущности инкапсулируют данные и поведение и общаются сообщениями. Подставь вместо «объект» слово «сервис». Твоё приложение, Stripe и почтовый шлюз: у каждого своё приватное состояние (своя БД, в которую никто чужой не ходит), а общение идёт сообщениями через HTTP. Это определение Кэя дословно. Подставь «процесс операционной системы»: приватная память, обмен через IPC. Подставь «актор»: приватное состояние, очередь сообщений.

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

  • ООП как архитектура. Изолированные узлы с приватным состоянием, общающиеся сообщениями: микросервисы, акторы, процессы Erlang. Часто отличное решение, и оно не требует ни одного класса в коде.
  • ООП как синтаксис. class, private, extends как механизмы языка. Вопрос вкуса и инструмента: мы только что видели, что та же семантика собирается из замыканий.
  • ООП как методология. Дизайн кода, где вся программа внутри одного процесса разбита на объекты, каждый прячет своё состояние, и поведение распределяется по владельцам данных. Вот к этому слою относятся SOLID, GRASP и паттерны, и вот здесь живут главные споры.

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

Столпы: три, четыре или пять

Классическая тройка: инкапсуляция, наследование, полиморфизм. Честный список длиннее: абстракция почти всегда четвёртая, а обмен сообщениями (идея Кэя) пятый.

Инкапсуляция

Не «прятать поля», а защищать инварианты: состояние меняется только через операции, которые сохраняют его корректным. Инвариант это то, ради чего весь столп существует.

class Account {
  #balance: number;

  constructor(initial: number) {
    if (initial < 0) throw new Error('Баланс не бывает отрицательным');
    this.#balance = initial;
  }

  withdraw(amount: number) {
    if (amount > this.#balance) throw new Error('Недостаточно средств');
    this.#balance -= amount;
  }
}

Сравни с публичным полем balance: тогда любой код в программе может сделать баланс отрицательным, и инвариант приходится проверять везде, где баланс читают. Инкапсуляция собирает все проверки в одну точку. Если в типах ты делаешь недопустимые состояния непредставимыми (прошлый урок), то инкапсуляция делает непредставимыми недопустимые переходы.

Наследование

Самый переоценённый столп. Разделяй две вещи, которые он смешивает: наследование интерфейса (обещание «я умею то же самое», полезно для полиморфизма) и наследование реализации (переиспользование кода родителя, источник хрупких иерархий). В JS под капотом прототипы: объект делегирует чтение свойств другому объекту, и class ... extends лишь оформляет эту делегацию привычным синтаксисом. Почему реализацию лучше переиспользовать композицией, разберём ниже отдельным разделом.

Полиморфизм

Один интерфейс, много реализаций. Настолько важная тема, что у неё свой раздел ниже: видов полиморфизма три, и на собеседовании обычно ждут все три.

Абстракция

Тип как контракт: пользователь видит операции, не представление. Это ровно барьеры абстракции из урока про типы, четвёртый столп ООП не добавляет к ним ничего нового, только слово.

Одно уточнение, которое избавит от половины споров на ревью: интерфейс это не абстракция. Из прошлого урока помним, что абстракция это отображение в чистый домен, где операции определены точно. А interface это синтаксис, который группирует несколько реализаций одной сигнатуры. Ничто в interface Animal { speak(): void } не заставляет реализации иметь хоть что-то общее: Cat.speak мяукает, Dog.speak гавкает, а третий класс по ошибке кусает, и компилятор доволен. В Julia у функции getindex под двести реализаций: большинство ищет элемент в коллекции, а несколько, наоборот, создают коллекцию. Одна сигнатура, разный смысл.

Хороший интерфейс отличается от плохого ровно тем, что за ним стоит хорошая абстракция: есть абстрактный домен, в котором каждая операция интерфейса почти детерминирована (описывается одной фразой без слова «зависит»), и каждая реализация обоснованно в этот домен отображается. Интерфейс Repository описывается как «множество сущностей с добавлением и поиском по идентификатору», и ни in-memory, ни Postgres-реализация не вправе это нарушить. Без такого домена интерфейс это просто перенаправление: ещё один файл, который надо открыть.

Какие из столпов обязательны? Проверь по разделу про объект без класса: Go обходится без наследования, полиморфизм там интерфейсный, а не объектный, абстракция живёт в любом языке с модулями. Не сдаётся только инкапсуляция: убери её, и от ООП не останется ничего. Это корень, остальное надстройки.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.