Мне нравится экспериментировать с разными парадигмами и играться с разными интересными (для меня) идеями (некоторые из них превращаются в посты: раз, два). Недавно я решил проверить, смогу ли я писать объектно-ориентированный код на функциональном языке.
Идея
Я искал вдохновения от Алана Кея — создателя объектно-ориентированного программирования.
ООП для меня означает всего-лишь обмен сообщениями; локальное храние, защиту и сокрытие состояний+процессов; а также экстремально позднее связывание.
Оригинал:
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. У меня пока руки так и не дошли, но возможно вам будет интересно.
Virviil
Автору, как спецу в RoR, советую сразу смотреть на Elixir.
Круто, когда переизобретают Erlang, жалко только, что он не на настолько на слуху, чтобы люди знали о нем ДО того как его переизобретают.
Nondv Автор
Ну, если подумать, то оно и так всюду и всеми уже используется в определенном смысле.
Например, множество веб-приложений реализованы в виде нескольких сервисов. По сути, каждый сервис — это отдельный объект ("компьютер в сети" :D).
Как я и сказал, идеи все такие разные, но такие одинаковые.
Тут отличается скорее то, как ты это интерпретируешь. Я в данном конкретном случае это интерпретировал как задачу реализации объектно-ориентированного программирования на функциональном языке. Модель акторов, подозреваю, решает более важную и общую техническую задачу (параллельные вычисления, отказоустойчивость, бла-бла, судя из того, что я слышал кусками)
VolCh
Я несколько раз переизобретал erlang, часть из них уже зная о нём.