ООП — определённо не самая моя любимая парадигма, но я считаю, что в мейнстримном ООП со статической типизацией кое-что сделано правильно, и это очень важно для программирования.

В этом посте я хочу рассказать, что же самое важное реализовано в мейнстримных ООП-языках со статической типизацией.

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

▍ Что вообще такое ООП?


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

  1. Классы, сочетающие в себе состояние и методы для изменения состояния.
  2. Наследование, которое позволяет классам использовать состояние и методы других классов.
  3. Создание подтипов, при котором, если тип B реализует публичный интерфейс типа A, то значения типа B можно передавать как A.
  4. Виртуальные вызовы, при которых принимающий класс вызова метода опре��еляется не статическим типом получателя, а его типом времени выполнения.

Примеры ОО-языков, соответствующих этому определению: C++, Java, C#, Dart.

▍ Пример того, какие возможности это открывает


Этот набор возможностей предоставляет простой и удобный способ разработки компонуемых библиотек и расширения библиотек новой функциональностью с сохранением обратной совместимости.

Вероятно, лучше всего это объяснить на примере. Допустим, у нас есть простая библиотека логгера:

class Logger {
  // Приватный конструктор: инициализирует состояние, возвращает экземпляр `Logger`.
  Logger._();

  // Публичная фабрика: может возвращать `Logger` или любой из подтипов.
  factory Logger() => Logger._();

  void log(String message, Severity severity) { /* ... */ }
}

enum Severity {
  Info,
  Error,
  Fatal,
}

и ещё одна библиотека, выполняющая действия с базами данных:

class DatabaseHandle {
  /* ... */
}

а также приложение, использующее обе библиотеки:

class MyApp {
  final Logger _logger;
  final DatabaseHandle _dbHandle;

  MyApp()
      : _logger = Logger(),
        _dbHandle = DatabaseHandle(...);
}

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

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

// Класс стал абстрактным.
abstract class Logger {
  // Публичная фабрика теперь возвращает экземпляр конкретного подтипа.
  factory Logger() => _SimpleLogger();

  Logger._();

  // `log` теперь абстрактный.
  void log(String message, Severity severity);
}

class _SimpleLogger extends Logger {
  factory _SimpleLogger() => _SimpleLogger._();

  _SimpleLogger._() : super._() {/* ... */}

  @override
  void log(String message, Severity severity) {/* ... */}
}

Это изменение обратно совместимо, то есть не требует изменений в пользовательском коде.

Теперь нам может потребоваться добавить ещё реализаций, например, для игнорирования сообщений логов:

abstract class Logger {
  factory Logger() => _SimpleLogger();

  // Новое.
  factory Logger.ignoring() => _IgnoringLogger();

  Logger._();

  void log(String message, Severity severity);
}

class _IgnoringLogger extends Logger {
  factory _IgnoringLogger() => _IgnoringLogger._();

  _IgnoringLogger._() : super._() {}

  @override
  void log(String message, Severity severity) {}
}

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

Мы можем сделать то же самое для класса database handle, но для имитации или заглушек в тестах.

Чтобы получить возможность использовать новые подтипы в нашем приложении, мы реализуем фабрику или добавим конструктор, чтобы можно было передавать логгер и database handle:

class MyApp {
  final Logger _logger;
  final DatabaseHandle _dbHandle;

  MyApp()
      : _logger = Logger(),
        _dbHandle = DatabaseHandle();

  MyApp.withLoggerAndDb(this._logger, this._dbHandle);
}

Обратите внимание, что мы не меняли никакие типы и не добавляли параметры типов. Все методы MyApp, использующие поля _logger и _dbHandle, не обязаны знать об изменениях.

А теперь предположим, что одн�� из реализаций DatabaseHandle тоже начнёт использовать библиотеку логгера:

abstract class DatabaseHandle {
  factory DatabaseHandle.withLogger(Logger logger) =>
      _LoggingDatabaseHandle._(logger);

  factory DatabaseHandle() => _LoggingDatabaseHandle._(Logger.ignoring());

  DatabaseHandle._();

  /* ... */
}

class _LoggingDatabaseHandle extends DatabaseHandle {
  final Logger _logger;

  _LoggingDatabaseHandle._(this._logger) : super._();

  /* ... */
}

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

class MyApp {
  // Новое
  MyApp.testingSetup()
      : _logger = Logger(),
        _dbHandle = DatabaseHandle.withLogger(Logger.ignoring());

  // Дополнено, чтобы начать использовать функцию логгинга библиотеки баз данных.
  MyApp()
      : _logger = Logger(),
        _dbHandle = DatabaseHandle.withLogger(Logger.toFile(...));

  /* ... */
}

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

class _LogAboveSeverity extends _SimpleLogger {
  // Выполняем логгинг сообщений только этого и более высокого уровня опасности.
  final Severity _severity;

  _LogAboveSeverity(this._severity) : super._();

  @override
  void log(String message, Severity severity) { /* ... */ }
}

Мы можем добавить ещё одну фабрику к абстрактному классу Logger, которая возвращает этот тип, или даже реализовать это как ещё одну библиотеку:

// Реализовано в другой библиотеке, не в библиотеке `Logger`.
class LogAboveSeverity implements Logger {
  // Выполняем логгинг сообщений только этого и более высокого уровня опасности.
  final Severity _severity;

  final Logger _logger;

  LogAboveSeverity(this._severity) : _logger = Logger();

  LogAboveSeverity.withLogger(this._severity, this._logger);

  @override
  void log(String message, Severity severity) { /* ... */ }
}

В качестве примера добавления новых операций (а не состояния) можно создать логгер, выполняющий логгинг в файл при помощи операции flush:

class FileLogger implements Logger {
  final File _file;

  FileLogger(this._file);

  @override
  void log(String message, Severity severity) {/* ... */}

  void flush() {/* ... */}
}

Подведём итог:

  • Мы начали с простых библиотек логгинга и баз данных и написали приложение.
  • Добавили в библиотеки логгинга и баз данных больше возможностей для тестирования и использования в продакшене. В частности, мы добавили:
    • Новую функциональность в библиотеку логгинга, позволяющую отключать логгинг или сохранять лог в файл.
    • Новую зависимость в библиотеку баз данных для логгинга операций с базами данных. Также мы ��озволили пользователям переопределять логгер, применяемый по умолчанию.

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

Библиотеки логгера и баз данных эволюционировали с полным сохранением обратной совместимости.

Так как ни один из применяемых в нашем приложении типов не поменялся, методы MyApp тоже менять не нужно.

Когда мы решили воспользоваться новой функциональностью, то обновили только способ конструирования экземпляров логгера и database handle в нашем приложении. Остальная часть приложения не изменилась.

А теперь давайте посмотрим, как нечто подобное можно реализовать на Haskell.

▍ Попытка сделать это на Haskell


В самом начале у нас есть несколько вариантов реализации этого.

Вариант 1: алгебраический тип данных (ADT) с полями обратного вызова, чтобы иметь возможность позже добавлять различные типы логгеров:

data Logger = MkLogger
    { _log :: Message -> Severity -> IO ()
    }

simpleLogger :: IO Logger

data Severity = Info | Error | Fatal
    deriving (Eq, Ord)

log :: Logger -> String -> Severity -> IO ()

В такой форме дополнительное состояние, например, минимальный уровень опасности в _LogAboveSeverity, не добавляется к типу, а перехватывается замыканиями:

logAboveSeverity :: Severity -> IO Logger
logAboveSeverity minSeverity = MkLogger
    { _log = \message severity -> if severity >= minSeverity then ... else pure ()
    }

Если нам нужно обновить какое-то общее для замыканий состояние, то состояние нужно хр��нить в каком-нибудь ссылочном типе наподобие IORef.

Примерно так же, как и в ООП-коде, FileLogger должен быть отдельным типом:

data FileLogger = MkFileLogger
  { _logger :: Logger   -- обратные вызовы перехватывают дескриптор файла/буфер и выполняют запись в него
  , _flush  :: IO ()    -- аналогично перехватывает дескриптор файла/буфер и выполняет сброс
  }

logFileLogger :: FileLogger -> String -> Severity -> IO ()
logFileLogger = log . _logger

Однако в отличие от примера с ООП, уже существующий код, использующий тип Logger и функцию log, не может работать с этим новым типом. Нужно выполнить рефакторинг, и способ рефакторинга пользовательского кода зависит от того, как мы хотим сделать доступным этот новый тип пользователям.

Вариант 2: типовой класс, который мы можем реализовать для наших конкретных типов логгера:

class Logger a where
    log :: a -> String -> Severity -> IO ()

data SimpleLogger = MkSimpleLogger { ... }

simpleLogger :: IO SimpleLogger
simpleLogger = ...

instance Logger SimpleLogger where
  log = ...

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

module Logger
    ( Logger
    , simpleLogger -- я могу экспортировать это без экспорта возвращаемого типа
    ) where

...

С этим модулем нам нужно или добавить параметр типа функциям и другим типам, использующим Logger, или использовать экзистенциальные типы.

Добавление параметра типа не является обратно совместимым изменением, и в общем случае это может вызвать эффект лавины, распространение типа параметра непосредственным пользователям, а затем их пользователям и так далее, создавая большие изменения и сложные в использовании типы.

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

data MyApp = forall a . Logger a => MkMyApp
  { _logger :: a
  }

Но у нас не может быть локальной переменной с этим экзистенциальным типом:

createMyApp :: IO MyApp
createMyApp = do
  -- Нельзя добавить сигнатуру типа к myLogger без конкретного типа
  myLogger <- simpleLogger      -- simpleLogger :: IO SimpleLogger
  return MkMyApp { _logger = myLogger }

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

-- Сигнатура типа принимается компилятором, но значение не может быть использовано.
doStuffWithLogging :: (forall a . Logger a => a) -> IO ()
doStuffWithLogging logger = log logger "test" Info -- какая-то непонятная ошибка типа

Вместо этого нам нужно «упаковать» значение логгера с его словарём типового класса типа в новый тип:

data LoggerBox = forall a . Logger a => LoggerBox a

doStuffWithLogging :: LoggerBox -> IO ()
doStuffWithLogging (LoggerBox logger) = log logger "test" Info

Другие проблемы и ограничения такого решения:

  • Синтаксис просто ужасен: forall a . Logger a => ... a ... вместо простого Logger.
  • Оно всегда реализует FileLogger, но
    • Все подтипы должны быть новым типовым классом + реализацией (в ООП лишь один класс).
    • Его нельзя использовать для безопасного приведения вниз значения Logger к FileLogger без знания конкретного типа FileLogger.

▍ Монады с побочными эффектами


Это решение является разновидностью варианта (2), но без экзистенциальных типов. Вместо

class Logger a where
    log :: a -> String -> Severity -> IO ()

Мы добавляем возможность логгинга в монадический параметр типа:

class MonadLogger m where
    log :: String -> Severity -> m ()

Затем мы пишем «монадный преобразователь» для каждой из реализаций логгера:

newtype SimpleLoggerT m a = SimpleLoggerT { runSimpleLoggerT :: m a }

instance MonadIO m => MonadLogger (SimpleLoggerT m) where
  log msg sev = SimpleLoggerT { runSimpleLoggerT = liftIO (logStdout msg sev) }

newtype FileLoggerT m a = FileLoggerT { runFileLoggerT :: Handle -> m a }

instance MonadIO m => MonadLogger (FileLoggerT m) where
  log msg sev = FileLoggerT { runFileLoggerT = \handle -> liftIO (logFile handle msg sev) }

Библиотека баз данных делает то же самое, а приложение комбинирует их вместе:

newtype MyAppMonad a = ...

instance MonadLogger MyAppMonad where ...

instance MonadDb MyAppMonad where ...

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

Библиотека баз данных также может добавить зависимость логгера, не ломая при этом пользовательский код.

Думаю, это лучшее, чего мы можем добиться в Haskell, и это решение достаточно сильно похоже на ООП-решение с точки зрения изменений, которые необходимо вносить в пользовательский код.

Однако чтобы это работало, подобным образом должна быть устроена вся экосистема библиотек. Если разработчик библиотеки баз данных решит использовать решение с ADT, то нам понадобится «адаптер», например, монадический типовой класс для операций с базами данных и с конкретным типом монадного преобразователя для вызова функций библиотеки баз данных.

Кроме того, в этом состоит основная проблема с библиотеками компонуемых эффектов.

(Также существуют проблемы с исполнением подобного вида кода в среде выполнения, но это уже тема для другого поста.)

▍ Компонуемые эффекты


Разработчики на Haskell придумали различные способы моделирования побочных эффектов (например, операций с базами данных, логгинга) в виде «эффектов» и разные способы их компоновки.

Самый простой и популярный способ реализации этого заключается в применении монад эффектов, которые мы видели в предыдущем разделе.

Однако по сравнению с ООП-решением такие системы имеют недостатки:

  • Библиотеки с разными эффектами обычно не работают вместе. Например, функции mtl и eff не работают вместе без какого-нибудь адаптера, превращающего одну в другую.
  • Даже если вся экосистема Haskell решит использовать одну систему эффектов, такие вещи, как применение двух обработчиков для разных частей программы, например, как в примере с использованием разных логгеров в библиотеке баз данных и в основном приложении, потребует жонглирования типами. А в некоторых библиотеках эффектов это вообще невозможно.
  • Наконец, отметим, что показанный в этом посте ООП-код — это очень простой и понятный код, который может написать даже новичок в ООП. Любой новый человек в проекте или любой единократный контрибьютор, который просто хочет устранить баг и двигаться дальше, сможет поработать над любой из библиотек или над кодом приложения. Такое сложно сказать в случае библиотек компонуемых эффектов в Haskell.

▍ Выводы


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

Как и в ООП, в Haskell есть шаблоны проектирования, например, показанный выше шаблон монад эффектов. Некоторые из этих ш��блонов проектирования удобно решают задачи, но чтобы они были полезными, таких шаблонов должна придерживаться вся экосистема.

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

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻