
ООП — определённо не самая моя любимая парадигма, но я считаю, что в мейнстримном ООП со статической типизацией кое-что сделано правильно, и это очень важно для программирования.
В этом посте я хочу рассказать, что же самое важное реализовано в мейнстримных ООП-языках со статической типизацией.
Затем я сравню ООП-код с Haskell, чтобы показать, что ООП не так плох во всём, как, похоже, считают поклонники функционального программирования.
▍ Что вообще такое ООП?
В этом посте я буду использовать аббревиатуру ООП для обозначения программирования на языках со статической типизацией, имеющих:
- Классы, сочетающие в себе состояние и методы для изменения состояния.
- Наследование, которое позволяет классам использовать состояние и методы других классов.
- Создание подтипов, при котором, если тип
Bреализует публичный интерфейс типаA, то значения типаBможно передавать какA. - Виртуальные вызовы, при которых принимающий класс вызова метода опре��еляется не статическим типом получателя, а его типом времени выполнения.
Примеры ОО-языков, соответствующих этому определению: 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 💻

