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

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

Затем я сравню ООП-код с 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 ?

Комментарии (36)


  1. rukhi7
    23.10.2024 14:15

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

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


    1. souls_arch
      23.10.2024 14:15

      У всего есть своя ниша. Разные подходы по разному полезны в разных ситуациях. Иногда можно добиться цели примерно с одинаковыми трудозатратами, но разными подходами. Тут уже, как художнику и его команде удобнее и комфортнее. Все направления и подходы программирования нужны. А холивары нет ;)


      1. GospodinKolhoznik
        23.10.2024 14:15

        Разные подходы по разному полезны в разных ситуациях.

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


  1. RodionGork
    23.10.2024 14:15

    Неясно почему вам пришло в голову противопоставлять ООП и ФП - вещи абсолютно ортогональные. Во многих мейнстримовых языках и то и другое уживается вместе и разработчик в принципе волен выбирать стиль по ситуации / задаче.

    Затащили для примера полумёртвый Хаскель и что-то решили с его помощью доказать. Да, известно этот язык - вынос мозга для тех кто не умеет на нем писать - ну и для тех кто умеет неслабый напряг. Но не столько из-за ФП сколько из-за увлеченностью теорией типов.


    1. Dgolubetd
      23.10.2024 14:15

      +1. Я могу прекрасно читать код многих языков, которые не знаю.. но от синтаксиса Haskell мозг вскипает.

      Лучше бы Scala использовали в примере. В ней как раз отлично уживается ООП и ФП.


    1. illinav
      23.10.2024 14:15

      полумёртвый Хаскель

      А хаскелисты-то и не знали!

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

      С претензиями уровня

      • Синтаксис просто ужасен: forall a . Logger a => ... a ... вместо простого Logger.

      хотя в ООПном коде чуть выше есть

      factory _IgnoringLogger() => _IgnoringLogger._();

      что для неподготовленного глаза ничуть не лучше.

      Но не столько из-за ФП сколько из-за увлеченностью теорией типов.

      Её там очень мало, и нужна она настолько же, насколько императивному ООПщику нужно понимать доказательство теоремы останова или смысл Геделевой нумерации.


    1. GospodinKolhoznik
      23.10.2024 14:15

      полумёртвый Хаскель

      В продакшине он действительно почти не используется, но не потому, что умер, а скорее потому, что так и не родился.

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


  1. chaetal
    23.10.2024 14:15

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

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


    1. illinav
      23.10.2024 14:15

      и поймут, что строгая типизация — далеко не панацея.

      Конечно, ведь они топят за статическую.


      1. Nikita22007
        23.10.2024 14:15

        Статическая и строгая типизации - ортогональные понятия.
        Статья на эту тему: Ликбез по типизации в языках программирования / Хабр


        1. illinav
          23.10.2024 14:15

          Я ровно на это и пытался намекнуть своей, видимо, не совсем удачной иронией.


          1. chaetal
            23.10.2024 14:15

            И ирония понятна, и статья известна. Но это ни замечание, ни статью не делают истинными.

            Впрочем, я действительно, возможно, зря написал "строгая". В ООП любая типизация является инородным вкраплением. Грубо говоря, когда возникли рассуждения типа "ах, у вас в Smalltalk-е нету типов? да это же как BASIC!" возник тряпочный ответ "типизация есть, только она динамическая".

            Нет там типизации. Там есть сообщения и объекты. И любому объекту можно послать любое сообщение. И он его должен каким-то образом обработать: либо "штатно" — выдать объект в ответ, либо выкинуть исключение. Всё.


            1. illinav
              23.10.2024 14:15

              Это всё звучит как аргумент против ООП в вашей формулировке, потому что я не хочу тратить время ни на обработку внутри объекта «преобразователь строки в число» сообщения «преобразуй жсон в число», ни на попытки отправить такое сообщение снаружи этого объекта.


    1. GospodinKolhoznik
      23.10.2024 14:15

      Жёсткий тайпчекинг, применяемый в ФП это ни что иное, как Test Driven Development на максималках. Когда вы при написании любого фрагмента кода автоматически создаёте механизм его тестирования. Отсюда следует 1) более медленная скорость написания такого кода 2) его повышенная надёжность 3) простота рефакторинга 4) большая скорость компиляции (т.к. компилятор одновременно и компилирует, и "тестирует" во время тайпчекинга).

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

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


      1. chaetal
        23.10.2024 14:15

        Жёсткий тайпчекинг, применяемый в ФП это ни что иное, как Test Driven Development на максималках.

        "Рак — небольшая красная рыба, которая плавает хвостом вперёд". Не рыба, не красного цвета, не плавает, и не хвостом вперёд. А так — да, всё правильно.

        Типы — это обобщение. Если вы сначала придумываете типы, то вы идёте от общего к частному. Вопрос только в том, откуда взять эти обобщения?

        Тесты — это частное. С TDD вы идёте от частного к общему. А ответить на вопрос, откуда эти частности взялись — элементарно просто: по дедукции из требований.

        …А так да, "всё правильно".


        1. GospodinKolhoznik
          23.10.2024 14:15

          Ну слушайте, разумеестя тип и тест это разные сущности. Но если они позволяют достигнуть одного результата, их можно рассматривать как эквивалентные друг другу. Код можно проверить на 100% покрыв его юнит тестами, а можно точно так же проверить с помощью типизации и тайпчекинга. Ну по крайней мере сегодня считается, что для любой бизнес-логики можно описать такую типизацию, которая будет полностью проверять её корректность. Т.е. тестировать.


          1. rukhi7
            23.10.2024 14:15

            вот вам оптимизированный под SSE код (код с векторизацией):

            смотри параграф: "3. SIMD (SSE + NEON)"

            интересно как вы его проверите с помощью типизации и тайпчекинга ?

            Можно проверить что он действительно оптимизированный? Неплохо бы проверить что он выдает тот же результат что и исходный, как вам тут поможет функциональное программирование, интересно?


            1. GospodinKolhoznik
              23.10.2024 14:15

              Можно проверить что он действительно оптимизированный?

              Да, это очень просто. Достаточно обернуть код в контейнер, который на уровне типов говорит о том - оптимизированный код или нет. При этом конструктор этого типа данных должен быть доступен только в модуле оптимизаторе. Т.е. в оптимизаторе должна быть функция с такой сигнатурой:

              optimizer :: Unoptimized Code -> Optimized Code

              И при этом создать тип данных Optimized Code кроме как с помощью этой функции никак невозможно, т.к. конструктор данных Optimized недоступен. Тогда получив на вход Optimized Code вы получаете уверенность, что код прошел процедуру оптимизации.

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

              perfMeasure :: Unoptimized Code -> Optimized Code -> OptimizeMetrics -> IO (Maybe (SuccessfullyOptimized Code), PerfMeasureResults)

              Которая собственно и проверяет оптимизированный код на предмет соответствия требуемым метрикам OptimizeMetrics по сравнению с неоптимизированным кодом, и в случае, если заданные критерии оптимизации выполнены, то возвращает SuccessfullyOptimized Code и результат измерения производительности, а если не удовлетворены, то возвращает только результаты измерения. А это значит, что пока код не будет соовтетсвовать требуемым критериям, он не будет обёрнут в тип данных SuccessfullyOptimized Code, и это будет выполняться всегда - т.е. это гарантия, что если есть SuccessfullyOptimized Code, значит что этот код был оптимизирован и опимизация прошла успешно .

              Неплохо бы проверить что он выдает тот же результат что и исходный, как вам тут поможет функциональное программирование, интересно?

              В теории с помощью типизации можно так проверить логику работы любого алгоритма. На практике, зачастую это довольно сложно по понятным причинам. Как тут поможет ФП? Там где тяжело проверять логику тайпчекингом, есть прекрасный механизм property based test. Те есть мы описываем не то, какой ожидаем результат на выходе, а то, какими свойствами должен обладать этот результат. А т.к. любая функция в ФП представляет из себя всего лишь композицию других, более простых функций, а те в свою очередь тоже композиция ещё более простых, то для того, чтобы убедиться в коректности работы функции достаточно описать свойства ожидаемого результата всех функций из которых она состоит. А так как те функции - простые, то и свойства их ожидаемого результата достаточно простые и могут быть легко описаны.


              1. rukhi7
                23.10.2024 14:15

                Да, это очень просто. Достаточно обернуть код в контейнер, который на уровне типов говорит о том - оптимизированный код или нет.

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

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


                1. illinav
                  23.10.2024 14:15

                  только после того как вы этот код обернете в контейнер код перестанет быть оптимизированным

                  Почему? newtype в хаскеле не имеет никакого оверхеда.

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

                  Покажете тест, который делает этот взгляд на код?


                  1. rukhi7
                    23.10.2024 14:15

                    Почему? newtype в хаскеле не имеет никакого оверхеда.

                    не имеет оверхеда по сравнению с чем? С отсутствием newtype ? То есть без newtype тоже все работает? Зачем тогда он нужен, не совсем понятно. Я вообще не совсем понимаю как может существовать тип если там данных нет, а только одни функции. Подход основанный на противоречии как будто специально создан чтобы разрушить любое начинание которое на него полагается.

                    Покажете тест, который делает этот взгляд на код?

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


                    1. illinav
                      23.10.2024 14:15

                      не имеет оверхеда по сравнению с чем? С отсутствием newtype ?

                      Да.

                      То есть без newtype тоже все работает?

                      Работает, но типы тогда выражают более слабое утверждение.

                      Зачем тогда он нужен, не совсем понятно.

                      Чтобы выразить больше гарантий на уровне типов. Начиная от банальных, почти школьных обёрток

                      newtype MoneyAmount = MoneyAmount Int

                      чтобы не перепутать деньги с количеством товара, через более интересные

                      data Currency = USD | EUR | RUB
                      
                      newtype MoneyAmount (c :: Currency) = MoneyAmount Int

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

                      Я вообще не совсем понимаю как может существовать тип если там данных нет, а только одни функции.

                      А чем вам Int -> Double плохой тип? Его населяют только функции.

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

                      Исходный оратор писал, очевидно, про юнит-тесты:

                      Жёсткий тайпчекинг, применяемый в ФП это ни что иное, как Test Driven Development на максималках. Когда вы при написании любого фрагмента кода автоматически создаёте механизм его тестирования.

                      Код можно проверить на 100% покрыв его юнит тестами, а можно точно так же проверить с помощью типизации и тайпчекинга.

                      Потом вы требуете показать, как в типах выразить то, что, как вы сами пишете, в [юнит-]тестах не выражается, а требует тестов производительности. Я правильно понял, что вас здесь ничего не смущает?


                      1. rukhi7
                        23.10.2024 14:15

                        А чем вам Int -> Double плохой тип?

                        По моему это преобразование типов, а не тип. Типами являются Int и Double , здесь по моему. Интересно как вы объявите тип структуры с 5-ю полями? Это кстати интересный вопрос: можно объявить какой то не примитивный тип средствами функционального программирования?

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


                      1. illinav
                        23.10.2024 14:15

                        По моему это преобразование типов, а не тип.

                        Это тип функции, которая принимает Int и возвращает Double. Преобразовывать типы в смысле условного C она не обязана, она может делать и что-то поинтереснее.

                        Интересно как вы объявите тип структуры с 5-ю полями?

                        data Person = Person
                          { name :: String
                          , age :: Int
                          , address :: Address
                          , children :: [Person]
                          }

                        Кстати, какие типы и куда преобразовывает вот эта функция?

                        grandchildren :: Person -> [Person]
                        grandchildren = children >=> children

                        но функциональное программирование намеренно игнорирует существование понятия "данные"

                        Нет, конечно.

                        "данные" (переменные)

                        Данные не обязательно императивные, мутабельные переменные. В «структура с пятью полями» нет ничего про мутабельность.

                        вот и вы попались на подмену понятий, называете типом преобразование типов (или даже просто разрешение на преобразование типов)

                        Ох уж эти ловильщики на подменах.


                      1. rukhi7
                        23.10.2024 14:15

                        Это тип функции, которая принимает Int и возвращает Double. Преобразовывать типы в смысле условного C она не обязана, она может делать и что-то поинтереснее.

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

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

                        Кстати, какие типы и куда преобразовывает вот эта функция?

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


                      1. illinav
                        23.10.2024 14:15

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

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

                        Это всё тоже у вас преобразования типов?

                        я попробую догадаться, это функция, которая делает из переменной (или как это надо называть по ФП правилам? я теряюсь) список с одним элементом из переменной

                        Нет, это функция, которая для Person возвращает всех его внуков.


                      1. rukhi7
                        23.10.2024 14:15

                        Интереснее может быть,

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

                        Нет, это функция, которая для Person возвращает всех его внуков.

                        как и ожидалось я не догадался, но если я вам составлю выражение на С++ с интерфейсами и указателями на функции, будет вам интересно его разбирать, если вы знаете что оно вам в ближайшие годы не пригодится? (заметьте я не спрашиваю поймете ли вы его, если серьезно к нему отнесетесь - я исхожу из того что разберетесь!)

                        Кстати, мне такие конструкции (операторы, как это назвать?)

                        >=>

                        очень напоминают стиль Perl-а, вы наверно в курсе где теперь Perl, а он мне очень нравился в свое время для задач по анализу текста.


                      1. illinav
                        23.10.2024 14:15

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

                        Преобразование типов в смысле C — это каст, сохраняющий некий, простите, более-менее «платонический идеал» числа. Условно, из 42 сделать 42.0.

                        как и ожидалось я не догадался

                        Догадливость тут обсуждать не особо конструктивно, интереснее другое: вы по-прежнему считаете, что это просто преобразование типов?

                        но если я вам составлю выражение на С++ с интерфейсами и указателями на функции

                        Достаточно было прочитать название функции.

                        очень напоминают стиль Perl-а, вы наверно в курсе где теперь Perl, а он мне очень нравился в свое время для задач по анализу текста.

                        Ну да, куда лучше написать что-то в духе

                        std::vector<Person> grandchildren(const Person& person)
                        {
                          std::vector<Person> result;
                          for (const auto& child : person.children)
                            for (const auto& grandchild : child.children)
                              result.push_back(grandchild);
                          return result;
                        }

                        А если написать

                        double (*)(double(*)(int));

                        вместо

                        (Int -> Double) -> Double

                        то вообще заживём.


                      1. rukhi7
                        23.10.2024 14:15

                        А если написать double (*)(double(*)(int));

                        вместо (Int -> Double) -> Double то вообще заживём.

                        так не надо так писать то! Вы думаете если вы придумали синтаксис как записать страшное выражение чтобы оно не казалось вам таким страшным оно вас не укусит?

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


                      1. illinav
                        23.10.2024 14:15

                        Вы думаете если вы придумали синтаксис как записать страшное выражение чтобы оно не казалось вам таким страшным оно вас не укусит?

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

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


                      1. rukhi7
                        23.10.2024 14:15

                        Значит вы идейный сторонник ФП и вас на мякине не проведешь! Идейные всегда готовы к любым испытаниям.

                        Успехов вам, было интересно почитать вашу аргументацию.


                    1. GospodinKolhoznik
                      23.10.2024 14:15

                      Newtype имеет значение лишь на этапе компиляции (для работы тайпчекинга). А дальше, при условии удачного тайпчекинга, во время компиляции данные извлекаются из него автоматически, т.е. будет сформирована одна и та же последовательность бит независимо от того, была ли обёртка в newtype или нет. Ну а вообще это всё такие делали, которые ни о чём. Даже если бы у newtype не было такого свойства, из него же можно извлечь содержимое самостоятельно, если понадобится.

                      И что вы пытаетесь донести? То, что тайпчекинг ни на что не годится потому, что с его помощью нельзя провести замеры скорости работы программы и потребления памяти? Нельзя. Но что вам мешает замерять производительность стандартными методами? А тайпчекинг пусть делает то, для чего он преднозначен - проверяет целостность цепочки композиции функций, из которых состоит программа. Он с этим делом прекрасно справляется и действительно помогает проверять весь код на корректность при любом внесении изменения в этот самый код.

                      Написали вы функцию f. Допустим она сложная, состоит из композиции пары десятков других функций. Потом вы её решили оптимизировать, проводите рефакторинг. Скорее всего заодно вы рефачите и некоторые функции её составляющие. А тайпчекинг помогает вам убедиться в том, что все ваши функции остаются согласованными. И это с довольно высокой вероятностью говорит о том, что ошибок нет. Идеальная система типов даст 100% гарантию, что ошибок нет, точно также, как и идеальное покрытие юнит тестами. Но понятно, что разработать идеальную систему типов очень и очень сложно, так же как и идеальные юнит тесты. Обычно каждый из этих методов говорят лишь о том, что скорее всего ошибок нет, но не даёт абсолютной гарантии. А вот если вы какой то критически важный участок кода покроете и типизацией и юнит тестами, то это будет уже супер надёжно.


            1. illinav
              23.10.2024 14:15

              Для этого вам нужна формализация семантики SSE и NEON (у, кажется, Galois Inc что-то про это было). Вы пишете канонический алгоритм и оптимизированный, и потом доказываете, что эти два алгоритма дают один и тот же результат.


  1. theonevolodya
    23.10.2024 14:15

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


  1. Gorbatech
    23.10.2024 14:15

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

    Можно сделать это без рефакторинга, используя композицию:

    fileLogger2AutoFlushLogger :: FileLogger -> Logger
    fileLogger2AutoFlushLogger fileLogger = MkLogger
        { _log = \message severity -> do
            logFileLogger fileLogger message severity
            _flush fileLogger
        }
    

    Советую посмотреть обсуждение этого здесь.


    1. illinav
      23.10.2024 14:15

      Посмотрел автора исходной статьи — а он в well-typed работал. Неожиданно низкий уровень, если честно.