Схожеcть принципов в ООП и ФП


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

Однако многие принципы ООП могут быть эффективно применены в функциональном программировании. Попробуем рассмотреть букву I из SOLID - Принцип разделения интерфейса (ISP), и его применимость в ФП.

Принцип разделения интерфейса гласит:

"Клиенты не должны зависеть от интерфейсов, которые они не используют."

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

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

public interface Connectable {
    Connection connect(String connectionString);
}

public interface Queryable {
    void executeQuery(Connection conn, String query);
}

public interface Transactional {
    void beginTransaction(Connection conn);
    void commit(Connection conn);
    void rollback(Connection conn);
}

public interface Closable {
    void close(Connection conn);
}

Саму реализацию бд можно осуществить следующим образом:

public class MyDatabase implements Connectable, Queryable, Closable { 
    ... // реализация 
}

А вот если нам нужна БД с поддержкой транзакций:

public class MyTransactionalDatabase implements Connectable, Queryable, Transactional, Closable {
    // Реализация всех методов
}

Обратим внимание на то, что если нам нужна БД с поддержкой транзакций, то мы просто добавляем еще один интерфейс. При этом более простая реализация БД вообще не нуждается в каком либо коде, связанном с транзацкиями, так как мы так удачно разделили эти интерфейсы.

Так, например, если имеется клиент, которому нужно только выполнение запросов, нам достаточно всего лишь перечислить нужные интерфейсы:

public class ClientQuery {
    private Connectable connectable;
    private Queryable queryable;
    private Closable closable;
    ...

// А для клиента с поддержкой транзакций мы просто добавим еще одно поле

    private Transactional transactional;

Теперь посмотрим, как ситуация с таким подходом обстоит в OCaml (да, я знаю, что OCaml является еще и ООП языком и поддерживает как императивные так и ООП подходы (например, мутабельные значения полей у классов), но для простоты притворимся, что их не существует :))

В OCaml, мы можем применять ISP с помощью модулей и сигнатур (module types). Шаг 1: Определение общих интерфейсов

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

(* Интерфейс для установления соединения *)
module type Connectable = sig
  type connection
  val connect : string -> connection
end

(* Интерфейс для выполнения запросов *)
module type Queryable = sig
  type connection
  val execute_query : connection -> string -> unit
end

(* Интерфейс для управления транзакциями *)
module type Transactional = sig
  type connection
  val begin_transaction : connection -> unit
  val commit : connection -> unit
  val rollback : connection -> unit
end

(* Интерфейс для закрытия соединения *)
module type Closable = sig
  type connection
  val close : connection -> unit
end

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

module MyDatabase : sig
  include Connectable
  include Queryable
  include Closable
end = struct
    (* Реализация тут *)
end

(* Если нам нужна поддержка транзакций, мы можем создать другой модуль: *)

module MyTransactionalDatabase : sig
  include Connectable
  include Queryable
  include Transactional
  include Closable
end = struct
  (* Реализация с поддержкой транзакций *)
end

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

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

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

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


module MakeLoggedDatabase (DB : Queryable) = struct
  type connection = DB.connection

  let execute_query conn query =
    Printf.printf "Logging query: %s\n" query;
    DB.execute_query conn query
end

(* Пример использования *)

module LoggedDatabase = MakeLoggedDatabase(MyDatabase)

let () =
  let conn = MyDatabase.connect "db_connection_string" in
  LoggedDatabase.execute_query conn "SELECT * FROM users";
  MyDatabase.close conn

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

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

Легко заметить, что несмотря на разную реализацию, эти подходы на самом деле очень похожи. На самом деле, если мы продолжим эту тему, и попробуем применить другие принципы SOLID к функциональному коду, они так или иначе находят своё отражения и там.

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