Раздел 28 · Clojure

Значения и идентичность: модель времени Хики

middle~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: значение не меняется никогда. Меняется то, на какое значение указывает идентичность. Герой Ирма это идентичность, а «Ирма с 18 единицами здоровья на седьмом ходу» это значение, и оно останется таким навсегда, даже когда Ирма выпьет зелье.

Мир «Хроник Пограничья» должен меняться: герои получают урон, монстры умирают, сундуки пустеют. А все уроки до этого твердили, что мапы в Clojure неизменяемы. Как написать игру, в которой ничего нельзя изменить?

Начнём с того, как это делает JavaScript. Песочница работает в браузере, поэтому можно создать настоящий JS-объект и посмотреть, что с ним происходит:

Мы «сохранили» Ирму в saved, потом вылечили её, и сохранённая копия тоже вылечилась. Потому что копии не было: saved и js-irma смотрят на одно место в памяти, и set! переписал это место. Хики называет такой стиль программированием мест: переменная это ячейка, присваивание затирает старое содержимое, прошлое исчезает.

Теперь то же на мапах Clojure:

saved осталась с 18. Мапа {:hp 18} это значение, как число 18: ты не можешь «изменить» число 18 так, чтобы оно стало 25. Можно только взять другое число.

Четыре понятия вместо одного

В языках с объектами слово «объект» склеивает четыре разные вещи. Хики их расклеивает:

ПонятиеЧто этоПример из игры
Значениенеизменяемый факт{:hp 18 :x 3}
Идентичностьсущность, которая проходит через разные значенияИрма
Состояниезначение идентичности в данный моментИрма на седьмом ходу: {:hp 18 :x 3}
Времяпорядок, в котором идентичность сменяет значенияход 7, ход 8, ход 9

Идентичность это то, что остаётся «той же Ирмой», хотя здоровье, позиция и сумка у неё каждый ход разные. Хики любит цитировать Гераклита: в одну реку нельзя войти дважды. Река это идентичность, а вода в ней в каждый момент разная. Никто не перекрашивает вчерашнюю воду, сейчас течёт другая.

В объекте TypeScript все четыре понятия живут в одной ячейке: irma это и идентичность, и текущее значение, и способ его поменять, а время вообще не видно. Отсюда классические беды: кто-то прочитал объект наполовину изменённым, кто-то сохранил ссылку и неожиданно увидел чужое изменение, как saved в первом примере.

Время как последовательность значений

Если значения не меняются, то история мира это последовательность значений. Её можно построить из лога событий функцией reductions из урока про библиотеку последовательностей:

timeline это все состояния мира, от начального до текущего. Каждое лежит целиком, и ни одно не испорчено последующими. Это дёшево: соседние версии делят почти всю структуру, как мы видели в уроке про персистентность. Общая идея разобрана в уроке про персистентные структуры.

Из такой модели бесплатно получаются вещи, которые в мире мест требуют отдельной работы: отмена хода (возьми предыдущее значение), повтор боя по логу, тест, где состояния «до» и «после» лежат рядом и сравниваются через =.

Ссылка: идентичность в коде

Но игре нужно и «сейчас»: экран рисует текущий мир, а не всю историю. Для этого в Clojure есть ссылочные типы. Ссылка это коробка, в которой лежит одно значение. Значение внутри не меняется, меняется то, какое значение лежит в коробке.

world это идентичность мира. @world (или (deref world)) достаёт текущее значение, и это обычная неизменяемая мапа. snapshot взял значение на седьмом ходу и сохранил его навсегда: следующий swap! положил в атом новое значение, а старое не тронул. Сравни с saved из примера на JS: там сохранённое «значение» менялось у нас за спиной.

Отсюда главное следствие для конкурентных программ. Поток, который рисует экран, делает @world и получает целый мир на один момент. Пока он рисует, другой поток может обновить атом сколько угодно раз, но у рисующего в руках неизменяемая мапа, её никто не испортит. Читать можно без блокировок: значение нельзя увидеть «наполовину изменённым». Хики называет это восприятием: наблюдатель получает снимок, а не доступ к живой ячейке.

Четыре ссылочных типа

Ссылочные типы Clojure отличаются тем, как ссылка переходит от одного значения к следующему. Два вопроса: ждёт ли вызывающий, пока переход случится, и меняется ли эта ссылка заодно с другими.

ТипПереходСогласованностьДля чегоУрок
atomсинхронноодна ссылкаобщее состояние приложения, кэшатомы
refсинхронно, в транзакциинесколько ссылок сразуперевод предмета между двумя инвентарямиref и STM
agentасинхронно, по очередиодна ссылказапись в журнал, отправка уведомленийагенты
vardef и alter-var-rootглобальное имя, своё значение в потоке через bindingфункции и настройки неймспейсаэтот урок

Все четыре читаются одинаково, через @ или deref, а меняются каждый своей функцией. Вот они рядом на настоящем Clojure, потому что ref и agent в браузере нет:

(def hp-atom (atom 18))
(def hp-ref (ref 18))
(def hp-agent (agent 18))
(def hp-var 18)

(swap! hp-atom + 7)                 ; сразу, без согласования
(dosync (alter hp-ref + 7))         ; в транзакции, вместе с другими ref
(send hp-agent + 7)                 ; когда-нибудь, в очереди агента
(await hp-agent)                    ; ждём, пока очередь дойдёт
(alter-var-root #'hp-var + 7)       ; корневое значение var

(println @hp-atom @hp-ref @hp-agent hp-var)
(assert (= [25 25 25 25] [@hp-atom @hp-ref @hp-agent hp-var]))

Функция изменения везде одна и та же, + 7. Она чистая: получает старое значение и возвращает новое. Ссылочный тип решает только, когда и как применить её к ссылке. Это и есть разделение, ради которого Clojure задуман: логика игры пишется чистыми функциями над значениями, а вопрос «как это переживёт конкуренцию» решается выбором ссылки.

Var ты уже используешь в каждом уроке: def создаёт var, и имя функции в неймспейсе это var, который указывает на функцию. Менять var во время работы программы не принято, это инструмент REPL и настройки, а не игрового состояния.

Похожее разделение есть и в других языках. В Janet разница между значением и ссылкой видна прямо в литералах, её разбирали в уроке про значения и ссылки в Janet. В Go общий доступ к памяти защищают мьютексами и атомарными операциями, об этом урок про конкурентность в Go. Clojure идёт с другой стороны: делает значения неизменяемыми, и защищать остаётся только сам момент перехода.

Проверь себя

Задание

Дальше

Самый частый ссылочный тип в коде на Clojure это атом. В следующем уроке положим весь мир игры в один атом и разберём swap!, reset!, валидаторы и наблюдателей.

домашка

Домашка