Функциональное программирование, знакомься — ООП

от автора

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

Идея

Я искал вдохновения от Алана Кея — создателя объектно-ориентированного программирования.

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

Оригинал:

OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.

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

Собственно, вот и самая главная проблема всей идеи — состояние.

Состояние

У нас вообще не должно быть состояния в функциональном программировании. Как же тогда изменять значения в ФП? Обычно, с помощью рекурсии (псевдокод):

function list_sum(list, result)   if empty?     result   else     list_sum(tail(list), result + first(list)) list_sum([1, 2, 3, 4], 0)

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

Но объекту нужно состояние и еще прием сообщений. Давайте попробуем сделать вот так:

function some_object(state)   msg = receive_message()   next_state = process_message(msg)   some_object(next_state)

Мне кажется, вполне логично. Но этот код блокирует программу. Как мне создать другие объекты? Как мне отправлять сообщения между ними? Позвольте опять процитировать Алана Кея:

Я видел объекты как биологические клетки и/или отдельные компьютеры в сети, способные лишь общаться с помощью сообщений.

Это подарило мне идею использовать параллелизм. Я обозвал функцию some_object(state) «объектный цикл» и решил запускать ее в отдельном потоке. Единственной тайной пока что остается обмен сообщениями.

Обмен сообщениями

Для сообщений я решил, что могу просто использовать каналы (похоже, они ужасно популярны в языке Go). В таком случае receive_message() будет просто ждать, пока какое-нибудь сообщение не появится на канале (очередь сообщений). Звучит довольно легко.

Язык

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

Следует упомянуть, что он смешивает разные парадигмы, поэтому Clojure поддерживает настоящее состояние:

(def user (atom {:id 1, :name "John"})) @user ; ==> {:id 1, :name "John" } (reset! user {:id 1, :name "John Doe"}) @user ; ==> {:id 1, :name "John Doe"}

Разумеется, мы будем избегать этого.

Объект

Ключевым концептом объектно-ориентированного программирования является объект. Вещи вроде классов необязательны (например, JavaScript является ОО-языком, но у него на самом деле нет классов; он эмулирует их с помощью прототипов). Давайте начнет с реализации объектов.

Что же нужно нашим объектам? Я уже упомянул «объектный цикл» и каналы. Помимо этого, нам нужна функция process_message(message) — обработчик сообщений.

У Clojure есть собственная реализация каналов в библиотеке clojure.core.async, так что мы будем использовать ее. Но сначала нам нужно подумать о структуре данных для наших объектов. Собственно, ничего сложного:

(ns functional-oop.object   (:require [clojure.core.async :as async]))  (defn- datastructure [message-handler channel]   {:message-handler message-handler    :channel channel})

Теперь нам просто нужно добавить объектный цикл:

(defn- object-loop [obj state]   (let [message (async/<!! (:channel obj))         next-state ((:message-handler obj) obj state message)]     (if (nil? next-state)       nil       (recur obj next-state))))

Функция async/<!! попросту ждет сообщения из канала. Функция в :message-handler по идее должна принимать сам объект (self, this), состояние и само сообщение как аргументы.

Все готово, нам нужно только объединить все это — создать объект:

(defn init [state message-handler]   (let [channel (async/chan 10)         obj (datastructure message-handler channel)]     (async/thread (object-loop obj state))     obj))  (defn send-msg [obj msg]   (async/>!! (:channel obj) msg))

В этом коде мы буквально запускаем цикл и возвращаем структуру данных, чтобы можно было отправлять объекту сообщения. Остальной код может отправить сообщения этому объекту с помощью функции send-msg. Функция async/>!!, как вы могли догадаться, пишет что-нибудь в канал.

Используем объекты

Это все, конечно, здорово, но работает ли оно? Давайте попробуем. Я решил протестировать это, реализовав string builder.

String builder — это просто объект, который склеивает несколько строк:

builder = new StringBuilder builder.add "Hello" builder.add " world" builder.build # ===> "Hello world"

Давайте попробуем реализовать его:

(defn message-handler [self state msg]   (case (:method msg)     :add (update state :strings conj (:str msg))     :add-twice (let [add-msg {:method :add, :str (:str msg)}]                  (object/send-msg self add-msg)                  (object/send-msg self add-msg)                  state)     :reset (assoc state :strings [])     :build (do              ((:callback msg) (apply str (:strings state)))              state)     :free nil     ;; ignore incorrect messages     state))  (def string-builder   (object/init {:strings []} message-handler))

(это немного измененная версия теста, который я написал)

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

Давайте попробуем запустить наш пример с «hello world»:

(object/send-msg string-builder {:method :add, :str "Hello"}) (object/send-msg string-builder {:method :add, :str " world"})  (let [result-promise (promise)]   (object/send-msg string-builder                    {:method :build                     :callback (fn [res] (deliver result-promise res))})   @result-promise)  ;; ===> "Hello world"

Первые две строки вполне понятны и без объяснений. Но что происходит дальше?

Наш объект живет в другом потоке и ему как-то нужно вернуть какой-то результат. Как же нам получить этот результат? Используя колбеки и промисы (promises).

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

@result-promise просто вытаскивает значение из промиса. Если оно еще не установлено, то она будет ждать (блокирует текущий поток).

Обратите внимание на метод add-twice, он немного поинтересней, т.к. в нем объект отправляет сообщения сам себе. Одна из проблем моей архитектуры заключается в том, что мы не можем в методе вызывать другие методы, т.к. объектный цикл обрабатывает только одно сообщение сразу. Поэтому для этого нам придется делать это асинхронно. Это попросту косяк (или фича?) этого дизайна и его нужно иметь в виду, иначе объекты могут попросто зависнуть.

Когда я тестировал этот метод, я сделал что-то вроде такого:

1. Вызвать метод :add-twice с аргументом "ha" 2. Вызвать метод :build и проверить, что он равен "haha"

Но тест не прошел. Это происходит из-за того, что сообщение :build было отправлено до того, как метод :add-twice отправил сообщения :add (не забывайте, у нас очередь сообщений).

Я потратил значительное количество времени, пытаясь понять, что было не так. Это произошло из-за того, что я не привык к параллельному программированию (мой бекграунд — Ruby on Rails) и это довольно распространенная проблема.
Собственно, это одна из причин, почему функциональное программирование становится все более популярным в наше время — чистые функции уменьшают шанс подобных ошибок. В моем объекте просто случился race condition (два потока пытались получить доступ к одному куску памяти). Мютабилити — зло!:)

Это было фундаментом для нашей объектной системы. Мы можем построить множество всего на нем. Давайте попробуем классы?

Классы

Для меня класс — это всего лишь калька (шаблон) объекта, хранящий его поведение (методы). И, честно говоря, классы сами по себе могут быть объектами (например, как в Ruby). Так что давайте добавим классы.

Сначала давайте «стандартизируем» как методы вызываются и выполняются. Мне уже лень писать, поэтому я просто вывалю эту кучу кода прям здесь (сорян):

(ns functional-oop.klass.method   (:require [functional-oop.object :as object]))  (defn- call-message [method-name args]   {:method method-name :args args})  (defn call-on-object [obj method-name & args]   (object/send-msg obj (call-message method-name args)))  (defn for-message [method-map msg]   (method-map (:method msg)))  (defn execute [method self state msg]   (apply method self state (:args msg)))

И так. Сообщение для вызова метода — это просто хеш, состоящий из двух вещей: имя метода и аргументы для него.

Еще обратите внимание на функцию for-message. Я захожу немного вперед, но мы будем давать классам методы в виде хеша. Функция execute задает, как объекты должны запускать методы: теперь они принимают не сообщения, а аргументы напрямую, так что когда мы реализуем методы, нам не придется думать о сообщениях совершенно.

Обработка сообщений тоже довольно проста:

(ns functional-oop.klass   (:require [functional-oop.object :as object]             [functional-oop.klass.method :as method]))  (defn- message-handler [method-map]   (fn [self state msg]     ;; Ignore invalid messages (at least for now)     (when-let [method (method/for-message method-map msg)]       (method/execute method self state msg))))

Теперь давайте глянем, как будут выглядеть наши классы:

(defn new-klass [constructor method-map]   (object/init {:method-map method-map                 :constructor constructor                 :instances []}                (message-handler {:new instantiate})))

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

Ну что ж, наши классы — всего лишь объекты, в которых состояние — это методы, конструктор (для инициализации инстансов класса) и массив с экземплярами класса. Массив, на самом деле, нам не нужен, но почему бы и нет.

Так, что же это за такая функция instantiate? А вот она:

(defn- instantiate [klass state promise-obj & args]   (let [{:keys [constructor method-map]} state         instance (object/init (apply constructor args)                               (message-handler method-map))]     (update state :instances conj @(deliver promise-obj instance))))

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

Еще я добавил вспомогательную функцию для синхронизированного создания:

(defn new-instance   "Calls :new method on a klass and blocks until the instance is ready. Returns the instance"   [klass & constructor-args]   (let [instance-promise (promise)]     (apply method/call-on-object klass :new instance-promise constructor-args)     @instance-promise))

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

(defn- constructor [& strings]   {:strings (into [] strings)})  (def string-builder-klass   (klass/new-klass    constructor    {:add (fn [self state string]            (update state :strings conj string))     :build (fn [self state promise-obj]              (deliver promise-obj                       (apply str (:strings state)))              state)     :free (constantly nil)}))  (def string-builder-1 (klass/new-instance string-builder-klass)) (method/call-on-object instance :add "abc") (method/call-on-object instance :add "def") (let [result (promise)]   (method/call-on-object instance :build result)   @result) ;; ==> "abcdef  (def string-builder-2 (klass/new-instance string-builder-klass "Hello" " world")) (method/call-on-object instance :add "!") (let [result (promise)]   (method/call-on-object instance :build result)   @result) ;; ==> "Hello world!"

Четко!

Что дальше?

Это всего-лишь прототип с кучей проблем (нет обработки ошибок, объекты могут зависнуть, память течет). Но мы могли бы реализовать еще множество вещей. Например, наследование. Или мы могли бы пойти по пути прототип-ориентированного программирования. Другой фичей мог бы стать приятный DSL для всего этого, и могло бы получиться круто, т.к. мы используем Clojure.

Еще мы у нас уже есть миксины прям из коробки. Миксины — просто хеши с методами, которые мы можем использовать, когда создаем новый класс.

Можно ли сделать что-то полезное с этим?

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

# add Title: Buy lots of toilet paper  # add Title: Make a TODO list  # list TODO list: - Buy lots of toilet paper - Make a TODO list  # complete Index: 1  # list TODO list: - Buy lots of toilet paper + Make a TODO list  # exit

Заключение

Ух, это было довольно интересно (для меня). Попутно я пытался понять, можно ли было бы сделать то же самое в Haskell. Я не могу сказать наверняка, но я думаю, что это возможно. У Haskell есть каналы, промисы и параллелизм. И даже если бы этого всего не было, то мы могли бы немного расширить идею объекта и создавать их как отдельные процессы и отправлять сообщения с помощью какого-нибудь RabbitMQ.

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

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

Репозиторий с программкой и некоторыми тестами можно найти здесь.

Дополнение к переводу

Господа на реддите сказали, что я заново изобрел модель акторов и посоветовали поглядеть Erlang. У меня пока руки так и не дошли, но возможно вам будет интересно.


ссылка на оригинал статьи https://habr.com/ru/articles/510380/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *