Pull to refresh

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

Reading time 9 min
Views 11K
Original author: Dmitry Non

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


Идея


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


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

Оригинал:


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. У меня пока руки так и не дошли, но возможно вам будет интересно.

Tags:
Hubs:
+5
Comments 6
Comments Comments 6

Articles