Значения и ссылки: почему (= @[1] @[1]) это false
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Значения и ссылки: почему (= @[1] @[1]) это false
Самая важная тема первой половины раздела. Вокруг развилки изменяемое плюс неизменяемое построен весь язык: от того, что можно класть в ключ словаря, до того, как писать тесты. Разберём её всерьёз.
Начнём с ошибки, которую делает каждый новичок. Ты пишешь первый тест: функция вернула @[1 2 3], ты сравниваешь (= результат @[1 2 3]) и получаешь false. Значения на экране одинаковые. Ты перечитываешь код, печатаешь обе стороны, они совпадают до символа, а тест красный. Этот урок объясняет, что здесь произошло, и почему это не баг, а фундамент модели данных Janet.
Две половины мира
| Назначение | Неизменяемый | Изменяемый |
|---|---|---|
| Последовательность | кортеж [1 2 3] | массив @[1 2 3] |
| Словарь | структура {:a 1} | таблица @{:a 1} |
| Текст | строка "abc" | буфер @"abc" |
Правило записи одно: знак @ означает изменяемый.
Изменяемую версию можно менять на месте, неизменяемую нет:
(def arr @[1 2 3])
(array/push arr 4) # @[1 2 3 4], работает
(array/push [1 2 3] 4) # ошибка: bad slot #0, expected array, got <tuple ...>
(put {:a 1} :b 2) # ошибка: expected array, table or buffer, got <struct ...>
Неудачная попытка изменить неизменяемое это ошибка, а не тихое создание копии.
Почему две версии, а не одна. Clojure выбрал только неизменяемые структуры с персистентными деревьями под капотом, Python только изменяемые. Janet даёт обе ветки и оставляет выбор тебе. Цена: надо понимать, что выбираешь. Выигрыш: прямые массивы без накладных расходов там, где нужна скорость, и настоящие неизменяемые значения там, где нужна надёжность.
Присваивание не копирует
Главное, что нужно усвоить: def связывает имя со значением, а не копирует его. Для изменяемых структур это значит, что два имени указывают на один объект:
Это называется алиасингом и ведёт себя так же, как объекты в Python, Java или JavaScript. Ничего экзотического, но если держать в голове модель “переменная хранит значение”, будут сюрпризы.
Прогони три сценария по шагам, картинка кучи слева от кода:
С неизменяемыми структурами такой проблемы нет по определению: изменить кортеж через второе имя невозможно, потому что его вообще нельзя изменить.
Отсюда практическое правило: неизменяемое значение можно свободно раздавать наружу, изменяемое нет. Если функция возвращает свой внутренний массив, вызывающий код получает возможность его испортить.
Три вида равенства
Раз изменяемые структуры это ссылки, встаёт вопрос, что значит “равны”. В 31-janet/03 · Литералы и коллекции: всё парами ты уже видел это поведение мельком, теперь разберём, откуда оно берётся.
= сравнивает неизменяемые значения по содержимому, а изменяемые по идентичности: тот же это объект в памяти или нет. Операция быстрая.
deep= рекурсивно сравнивает содержимое независимо от изменяемости:
(deep= {:a @[1]} {:a @[1]}) # true
compare= и семейство compare дают упорядочивание, нужны для сортировки и возвращают -1, 0 или 1:
(compare 1 2) # -1
(compare= [1] [1]) # true
Где это стреляет на практике: sort внутри пользуется compare, поэтому кортежи сортируются поэлементно, как слова в словаре:
(sort @[[2 :b] [1 :a] [2 :a]]) # @[(1 :a) (2 :a) (2 :b)]
= на массивах почти никогда не то, что тебе нужно. Тот красный тест из начала урока объясняется одной строкой: функция вернула массив, литерал в тесте другой массив, а = сравнил их по идентичности. В тестах и при сравнении данных бери deep=.
Ключи словарей
Теперь понятно, зачем разделение нужно на практике. Ключ словаря должен быть стабильным: если ключ можно изменить после того, как его положили в таблицу, таблица сломается.
Неизменяемые значения работают как ключи ожидаемо:
А изменяемые по идентичности:
Массив как ключ это почти всегда ошибка. (get t @[1 2]) возвращает nil молча: ни ошибки, ни предупреждения, просто ничего не нашлось. Отладка такого занимает неприятно много времени.
Правило: ключами делай только неизменяемые значения, то есть ключевые слова, строки, числа, кортежи, структуры. Если ключ приходит откуда-то массивом, пропускай его через freeze.
freeze и thaw
Две функции переводят значения между половинами мира, обе работают рекурсивно.
freeze делает значение полностью неизменяемым:
thaw обратная операция, всегда возвращает изменяемую копию:
Типичное применение freeze это граница функции: внутри строим результат в изменяемом массиве, потому что так удобно и быстро, а наружу отдаём замороженный, чтобы вызывающий код не мог его испортить.
(defn collect-evens [xs]
(def acc @[])
(each x xs
(when (even? x) (array/push acc x)))
(freeze acc)) # отдаём наружу неизменяемое
А thaw полезен, когда пришли неизменяемые данные, а поменять их надо:
(defn with-tag [config tag]
(def copy (thaw config))
(put copy :tag tag)
copy)
Копирование
freeze и thaw всегда строят новую структуру целиком. Иногда нужна копия того же типа:
Эти копии поверхностные. array/slice и table/clone копируют только верхний уровень, вложенные изменяемые структуры остаются общими:
Для настоящей глубокой копии есть два пути. Первый, заморозить и разморозить: (thaw (freeze x)). Второй, через сериализацию:
(unmarshal (marshal x))
marshal превращает значение в байты, unmarshal обратно. Побочный эффект это полностью независимая глубокая копия. Пути не взаимозаменяемы: freeze оставляет функции внутри структуры как есть и зацикливается на структуре, ссылающейся на саму себя, а marshal копирует и функции, и циклические ссылки. К marshal мы вернёмся в уроке про окружения и образы, там на нём построена целая механика быстрого старта.
Как выбирать
Эвристика, которая почти всегда работает.
Бери неизменяемое по умолчанию. Кортежи и структуры безопасно передавать, они годятся в ключи, их можно сравнивать через =.
Переходи на изменяемое, когда:
- строишь результат в цикле, накапливать в массиве проще и быстрее, чем пересобирать кортеж на каждой итерации;
- структура большая и меняется часто, каждое изменение неизменяемой структуры это создание новой;
- нужно состояние, которое переживает вызов функции.
Замораживай на выходе. Это правило границы функции из раздела про freeze: внутри работаешь изменяемыми структурами, как collect-evens выше, наружу отдаёшь замороженное.
И помни про стандартную библиотеку: map, filter, keys, values, pairs возвращают массивы, даже если на вход дали кортеж. Это сделано ради скорости. Нужен неизменяемый результат, заворачивай в freeze явно.
Что запомнить
defсвязывает имя, а не копирует значение. Для изменяемых структур это алиасинг.=сравнивает неизменяемое по содержимому, изменяемое по идентичности. В тестах бериdeep=.- Ключами делай только неизменяемые значения, иначе
getвернётnilмолча. freezeиthawрекурсивны и переводят значение между половинами мира.array/sliceиtable/cloneкопируют только верхний уровень.- Глубокая копия это
(thaw (freeze x))или(unmarshal (marshal x)). Второй путь единственный, который переживает функции и циклы внутри. compareупорядочивает значения, на нём работаетsort.- Стандартная библиотека почти всегда отдаёт изменяемый результат.
Упражнения
Дальше
Мы разобрались, какими бывают значения. Дальше посмотрим, что происходит с кодом до того, как он начнёт выполняться: как текст превращается в структуры данных, структуры в байткод, и почему в лиспе код и данные это одно и то же.
домашка