Раздел 46 · Effect: Тихая Академия

Неизменяемость

middle~30 мин

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

Доску башен ведёт Ринцвальд. На доске у каждой башни Магистрали две отметки: сколько раз её слышали и когда в последний раз. Когда башня подаёт голос, Ринцвальд правит её отметки. Вечером Рыдкулл положил доску в отчёт, утром Ринцвальд отметил башню T-2, и вчерашний отчёт изменился вместе с сегодняшней доской.

В прошлом уроке у функции нашёлся скрытый выход: sort переставил журнал вызывающего. Здесь та же болезнь в полный рост. Этот урок о том, как устроить данные, чтобы значение, которое однажды записали, больше не менялось, и почему это дешевле, чем кажется.

Один объект на двоих

type Tower = { heard: number; lastAt: number };
type Board = Record<string, Tower>;

const hear = (board: Board, code: string, at: number): void => {
  const tower = board[code];
  if (tower) {
    tower.heard += 1;
    tower.lastAt = at;
  } else {
    board[code] = { heard: 1, lastAt: at };
  }
};

const board: Board = {
  'T-1': { heard: 3, lastAt: 1000 },
  'T-2': { heard: 1, lastAt: 2000 },
};

const yesterday = { day: 30, board };

hear(board, 'T-2', 9000);
console.log(yesterday.board['T-2']);
{ heard: 2, lastAt: 9000 }

Рыдкулл положил в отчёт не копию доски, а саму доску: yesterday.board и board это два имени одного объекта. Объект в JavaScript передаётся ссылкой, и кто держит ссылку, тот видит все правки. hear поправила башню на месте, и правку увидели все, у кого была ссылка: сегодняшняя доска, вчерашний отчёт и всё, что ещё о ней знает.

Это и есть скрытый выход, только растянутый во времени. hear возвращает void, по сигнатуре она ничего не отдаёт, а на деле переписывает чужие данные. Чтобы понять, что напечатает последняя строка, нужно знать всю историю объекта: кто и когда его правил. На трёх строках это видно. На доске, которую правят из десяти мест, нет.

Новое значение вместо правки

Данные, которые не меняют после создания, называют неизменяемыми. Чтобы «поменять» неизменяемое значение, делают новое, в котором отличается нужное, а старое оставляют как было. Для массива и объекта в JavaScript для этого есть разворот ...:

import { type Board, type Entry, record } from './journal';

const journal: readonly Entry[] = [{ kind: 'sent', frame: 1, at: 100 }];
const next = record(journal, { kind: 'acked', frame: 1, at: 250 });
console.log(journal.length, next.length);

const board: Board = {
  'T-1': { heard: 3, lastAt: 1000 },
  'T-2': { heard: 1, lastAt: 2000 },
};
const tower = board['T-2'];
const today: Board = tower ? { ...board, 'T-2': { heard: tower.heard + 1, lastAt: 9000 } } : board;

console.log(board['T-2'], today['T-2']);
console.log(today['T-1'] === board['T-1']);
1 2
{ heard: 1, lastAt: 2000 } { heard: 2, lastAt: 9000 }
true

record из эталона котла возвращает [...journal, entry]: новый массив из старых записей и одной новой. Старый журнал остался с одной записью. С доской так же: { ...board, 'T-2': ... } это новый объект, в котором все башни старой доски, а T-2 заменена новой отметкой. Вчерашняя T-2 по-прежнему «слышали один раз», сегодняшняя «два раза». Отчёт Рыдкулла мог бы держать старую доску сколько угодно: её больше никто не правит.

Сколько стоят горшки

Ринцвальд морщится: копировать доску на каждый голос башни? Башен на Магистрали сотни.

Посмотри на последнюю строку вывода: today['T-1'] === board['T-1']. Это не копия башни T-1, а тот же самый объект. Разворот ...board копирует только верхний уровень, ссылки на башни. Сами башни, которые не менялись, переходят в новую доску как есть. Новыми стали два объекта: сама доска и отметка T-2. Всё остальное старая и новая доска делят на двоих.

Такой приём называют структурным разделением. Делить безопасно ровно потому, что никто ничего не правит на месте: раз башню T-1 нельзя поменять, всё равно, сколько досок на неё ссылаются. Мутация и разделение вместе не живут. Пока данные меняют на месте, делить их опасно, и приходится копировать всё целиком «на всякий случай»; как только перестали, копировать стало почти нечего.

У разделения есть второй подарок. Изменилась ли башня, проверяется одним сравнением ссылок: today['T-1'] === board['T-1'] значит «не менялась», и заглядывать внутрь не нужно. На этом держатся интерфейсы, которые перерисовывают только изменившиеся части экрана.

Чтобы правку не пропустил компилятор

Договориться «не править на месте» мало: завтра Ринцвальд забудет. Пусть следит компилятор. В TypeScript для этого есть модификатор readonly:

  • readonly у поля объекта: { readonly heard: number };
  • readonly T[] для массива: у такого массива нет push, sort, splice и других методов, которые меняют его на месте;
  • Readonly<T> делает readonly все поля типа сразу, а Readonly<Record<string, Tower>> запрещает менять и добавлять ключи.

В котле доска описана так:

export type Tower = { readonly heard: number; readonly lastAt: number };
export type Board = Readonly<Record<string, Tower>>;

Перевод с Ук

Ринцвальд принёс из прошлого урока свою overdue с сортировкой, но журнал теперь readonly:

import type { Entry } from './journal';

const overdue = (journal: readonly Entry[], now: number, timeoutMs: number): number[] =>
  journal
    .sort((a, b) => a.at - b.at)
    .filter((entry) => entry.kind === 'sent' && now - entry.at >= timeoutMs)
    .map((entry) => entry.frame);
sort19.ts(5,6): error TS2339: Property 'sort' does not exist on type 'readonly Entry[]'.
sort19.ts(5,12): error TS7006: Parameter 'a' implicitly has an 'any' type.
sort19.ts(5,15): error TS7006: Parameter 'b' implicitly has an 'any' type.
sort19.ts(6,14): error TS7006: Parameter 'entry' implicitly has an 'any' type.
sort19.ts(7,11): error TS7006: Parameter 'entry' implicitly has an 'any' type.

Пять ошибок, и читать нужно одну, первую. Строка 5, колонка 6, TS2339: у типа readonly Entry[] нет свойства sort. Это и есть защита: в типе массива только для чтения нет методов, которые меняют его на месте. Остальные четыре ошибки это эхо первой. Раз компилятор не нашёл sort, он не знает, что принимает её колбэк, и параметры a и b остались без типа. Дальше цепочка тоже потеряла тип, и entry в filter и map стал неизвестным. Почини первую ошибку, и четыре остальные исчезнут сами. Правило на будущее: когда ошибок много и они идут подряд по одной цепочке, начинай с самой первой.

Чинится заменой sort на toSorted: метод из ES2023 возвращает новый отсортированный массив и есть у readonly-массивов. Компилятор не запретил сортировать, он запретил переписывать чужое.

С доской так же. Строка board['T-3'] = { heard: 1, lastAt: 1 } для Board из котла не собирается:

board19.ts(5,1): error TS2542: Index signature in type 'Readonly<Record<string, Tower>>' only permits reading.

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

as const

Есть ещё один способ сказать «не меняется», для значений, которые ты пишешь руками:

const loose = ['sent', 'acked'];
const KINDS = ['sent', 'acked'] as const;
type Kind = (typeof KINDS)[number];

const LIMITS = { timeoutMs: 5000, quietMs: 60_000 } as const;

Без as const компилятор видит в loose массив строк string[]: в него можно дописать что угодно, и о словах 'sent' и 'acked' тип ничего не помнит. С as const тип KINDS это кортеж только для чтения readonly ['sent', 'acked'], а Kind из него получается индексным доступом, как в уроке 10: 'sent' | 'acked'. LIMITS с as const получает поля readonly timeoutMs: 5000 и readonly quietMs: 60000: не какие-то числа, а именно эти, и менять их нельзя.

constuk19.ts(2,8): error TS2540: Cannot assign to 'timeoutMs' because it is a read-only property.

Это ошибка на строке LIMITS.timeoutMs = 10_000. as const годится для таблиц и настроек, которые записаны в коде и не должны расползтись.

Под капотом

Readonly<T> это отображённый тип из урока 10: { readonly [P in keyof T]: T[P] }. Он ставит readonly на поля верхнего уровня, а вглубь не идёт. readonly T[] это другой тип, ReadonlyArray<T>, в котором методов изменения нет вовсе. Массив number[] можно отдать туда, где ждут readonly number[]: кто обещает только читать, тому можно дать и изменяемый. Обратно нельзя:

assign19.ts(3,7): error TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.

Это вариантность из урока 13 в быту: тот, кому дали «только читать», не может выдать себя за того, кому можно писать.

Защита в рантайме есть, Object.freeze, но она тоже мелкая:

const board = Object.freeze({
  'T-1': { heard: 3, lastAt: 1000 },
});
board['T-1'].heard += 1;
console.log(board['T-1'].heard);
4

Заморожена доска, а не башни на ней. Поэтому котёл описывает readonly на каждом уровне: у доски и у полей башни. Тесты этапа на всякий случай морозят входные данные, и если твоя функция их правит, она упадёт с TypeError.

Где тут дыра

const yesterday = { day: 30, board: { ...board } };

hear(board, 'T-3', 8000);
console.log(yesterday.board['T-3']);
hear(board, 'T-2', 9000);
console.log(yesterday.board['T-2']);
undefined
{ heard: 2, lastAt: 9000 }

Здесь та же изменяемая hear, что в начале урока. Новая башня T-3 во вчерашний отчёт не попала: она добавлена ключом на верхний уровень, а верхний уровень Ринцвальд скопировал. Старая башня T-2 поменялась и во вчерашнем отчёте, потому что { ...board } скопировал ссылки на башни, а не сами башни. Изменяемая hear правит объект башни на месте, и этот объект лежит в обеих досках.

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

Котёл, этап 3

Котёл учится записывать новое, не трогая старого. Ты напишешь record, которая возвращает журнал с новой записью, и hear, которая отмечает голос башни на новой доске. Тесты морозят входные данные и проверяют разделение: башни, которых hear не касалась, должны перейти в новую доску теми же объектами.

Практика

Глубже в архиве

Структурное разделение доведено до предела в персистентных структурах данных: деревья, в которых каждая версия живёт вечно, а новая стоит логарифм от размера. Как они устроены, разбирает урок «Персистентные структуры: иммутабельность без копирования».

Дальше

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

Как собрать условие из маленьких функций и что такое замыкание, разберём в следующем уроке.