Как стать автором
Обновить
3241.45
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

На самом деле, ООП — это не так уж плохо

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров12K
Автор оригинала: Ömer Sinan Ağacan

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

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

Затем я сравню ООП-код с 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 💻
Теги:
Хабы:
Всего голосов 33: ↑26 и ↓7+34
Комментарии156

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds