Главная мысль урока: значение не меняется никогда. Меняется то, на какое значение указывает идентичность. Герой Ирма это идентичность, а «Ирма с 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 | асинхронно, по очереди | одна ссылка | запись в журнал, отправка уведомлений | агенты |
var | def и 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!, валидаторы и наблюдателей.
домашка