Управление состоянием

Традиционно функциональными считают те языки, в которых реализованы функции первого класса. Но под это определение попадают так называемые мультипарадигменные языки вроде javascript и ruby. В нашем случае подойдет более строгое определение. Я буду называть функциональными те языки, в которых отсутствует присваивание. Однако, тот же Haskell допускает присваивание, но под особым контролем. Аналогично и Clojure имеет особый механизм для этого.

Например, в javascript есть присваивание и переменные:

let x = 1
let f = () => x
x = 2
f() // => 2

В clojure это не так, т.к. используется связывание вместо присваивания:

(let [x 1
      f (fn [] x)
      x 2]
  (f)) ;; => 1

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

Atom

(let [x (atom 1)
      f (fn [] @x)
      _ (reset! x 2)]
  (f)) ;; => 2

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

Атом имеет следующий интерфейс:

  • (atom 1) - новый атом с начальным значением 1
  • @x - доступ к значению атома x. В один момент времени все потоки прочитают одно и то же значение.
  • (reset! x 2) - установка значения атома x в 2
  • (swap! x inc) - замена значения с помощью функции inc. Т.е. inc принимает текущее состояние атома и возвращает будущее.

swap! может принимать неограниченное кол-во аргументов, это используется, чтобы уменьшить вложенность:

(let [x (atom 0)]
  (swap! x (fn [old-state] (+ old-state 10))))

(let [x (atom 0)]
  (swap! x + 10))

Т.е. в функцию + первым аргументом подставится старое состояние, а после все дополнительные аргументы swap!.

swap! использует атомарную операцию Сравнение с обменом (Compare And Swap). Это работает следующим образом:

  • Запоминаем текущее состояние атома.
  • Применяем переданную функцию к запомненному значению, получаем новое значение.
  • Атомарно сравниваем запомненное значение с текущим и если оно не изменилось, устанавливаем новое значение. Если текущее значение изменилось в другом потоке, начинаем сначала.

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

Если запустить 5 параллельных потоков(thread), то значение счетчика будет равно 5. Если бы атом не поддерживал Compare And Swap (CAS), то значение счетчика было бы меньше 5, т.к. параллельные потоки затирали бы результаты друг друга.

(let [counter (atom 0)]
  (->> (repeatedly #(future (swap! counter inc)))
       (take 5)
       (doall)
       (map deref)
       (doall))
  @counter) ;; => 5

Функция repeatedly создает ленивую последовательность, где каждый элемент вычисляется с помощью вызова анонимной функции. #(future ...) - анонимная функция, создающая future. Макрос future выполняет свое содержимое в другом потоке и возвращает future. Функция deref блокирует текущий поток, дожидается исполнения future и возвращает результат. map и repeatedly возвращают ленивую коллекцию, поэтому мы используем doall, чтобы вычислить все ее элементы.

Состояние и идентичность

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

//javascript
class Person {
  constructor(name, age, friends, savings) {
    this.name = name
    this.age = age
    this.friends = friends
    this.savings = savings
  }
}

let alice = new Person("Alice", 22, 5, 100)

С течением времени Алиса менялась. Каждый день она находится в каком-то определенном состоянии.

Например, сегодня, в ее день рождения, она получила в подарок 300 монет.

alice.age++
alice.savings += 300

Или, в другой день, она не отдала долг другу и потеряла его:

alice.savings += 10
alice.friends -= 1

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

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

alice.name = "Bob"

При таком подходе происходит объединение понятий Идентичность и Состояние. Идентичность это нечто, что однозначно определяет моделируемую сущность. А состояние - это некие данные, описывающие сущность в заданный момент времени.

В нашем случае, идентичность - это сам объект, т.е. та область памяти, которую он занимает. Если бы объекты в javascript имели object_id, то он и представлял бы идентичность. Этим же объектом моделируется и состояние сущности.

В Clojure эти понятия разделяются. Состояние моделируется с помощью неизменяемых структур данных, а идентичность - с помощью ссылочных типов, вроде атомов.

(def alice (atom {:name "Alice"
                  :age 22
                  :friends 5
                  :savings 100}))

;; устанавливаем валидатор, запрещающий изменять имя
;; перед заменой значения будет запускаться валидатор,
;; если он вернет false, то состояние не будет заменено и будет брошено исключение
(set-validator! alice (fn [new-state]
                         (= (:name new-state)
                            (:name @alice))))

;; атомарно увеличиваем накопления и теряем друга
;; никто не сможет увидеть Алису в несогласованном состоянии
(swap! alice (fn [state]
                 (-> state
                     (update :savings + 100)
                     (update :friends - 1))))

(swap! alice assoc :name "Bob")
;; Boom!
;; IllegalStateException Invalid reference state  clojure.lang.ARef.validate (ARef.java:33)

Другим примером разделения идентичности и состояния является философское высказывание: «Нельзя войти в реку дважды». Вода течет, но мы продолжаем ассоциировать воду в разные моменты времени с рекой.