Lenses и optics: композабельные апдейты вложенных структур
В уроке 07 мы научились читать Functor, Applicative, Monad. В
уроке 08 эта алгебра выехала на эффекты. Сегодня та же алгебра выйдет
на структуру: на вложенные иммутабельные данные, у которых нужно
обновить одно поле где-то глубоко внутри, не сломав остальное.
Если ты писал хоть один Redux-редьюсер, ты уже сталкивался с этой
болью: spread-цепочки растут с глубиной. На третьем уровне читаемость
кончается, на четвёртом начинается копи-пейст. Сегодня мы разберёмся,
почему это происходит, и заменим spread на компонуемые “оптики”:
Lens, Prism, Optional, Traversal. Всё это, на удивление, частный
случай тех же Functor и Monad из урока 07.
После урока ты прочитаешь без шифровки документацию monocle-ts,
@fp-ts/optic и lens из Haskell.
Боль spread-цепочек
Возьмём вложенный конфиг для нашего сквозного weather-сервиса:
type WeatherConfig = {
readonly api: {
readonly endpoint: string;
readonly retry: {
readonly attempts: number;
readonly backoffMs: number;
};
};
readonly cities: readonly { readonly name: string; readonly tempC: number }[];
};
Хочется обновить attempts с 3 до 5. Императивно:
config.api.retry.attempts = 5; // мутация
Иммутабельно через spread:
const next: WeatherConfig = {
...config,
api: {
...config.api,
retry: {
...config.api.retry,
attempts: 5,
},
},
};
Семь строк, четыре spread-а, имя config.api.retry повторяется три
раза. Если нужно обновить два поля внутри retry, появится ещё одна
лесенка. Если глубина вырастет до пятого уровня, читаемость
кончается.
Корень проблемы: для апдейта нужно дойти до листа, но возвращать на каждом уровне нужно весь объект. Spread это сканирует и копирует руками. Хочется одну операцию вида “вот путь, вот функция апдейта, сделай мне новый верхний объект”.
Lens: get + set, упакованные вместе
Lens это пара функций:
type Lens<S, A> = {
readonly get: (s: S) => A;
readonly set: (a: A) => (s: S) => S;
};
S это “целое” (source), A это “часть” (focus). get достаёт часть
из целого. set берёт новую часть и старое целое, возвращает новое
целое.
Для нашего конфига линзы выглядят так:
const apiL: Lens<WeatherConfig, WeatherConfig['api']> = {
get: (c) => c.api,
set: (api) => (c) => ({ ...c, api }),
};
const retryL: Lens<WeatherConfig['api'], WeatherConfig['api']['retry']> = {
get: (a) => a.retry,
set: (retry) => (a) => ({ ...a, retry }),
};
const attemptsL: Lens<WeatherConfig['api']['retry'], number> = {
get: (r) => r.attempts,
set: (attempts) => (r) => ({ ...r, attempts }),
};
Каждая линза локальна: смотрит на один уровень. Сила появляется, когда мы их склеиваем.
Композиция линз
Если у нас есть Lens<S, A> и Lens<A, B>, мы можем получить
Lens<S, B>:
const compose = <S, A, B>(outer: Lens<S, A>, inner: Lens<A, B>): Lens<S, B> => ({
get: (s) => inner.get(outer.get(s)),
set: (b) => (s) => outer.set(inner.set(b)(outer.get(s)))(s),
});
get тривиален: достать A из S, потом B из A. set чуть
тоньше: достать A, обновить в нём B (получить новый A),
вставить новый A обратно в S.
С композицией наш апдейт сжимается:
const attemptsDeepL: Lens<WeatherConfig, number> =
compose(compose(apiL, retryL), attemptsL);
const next = attemptsDeepL.set(5)(config);
// тот же результат, что у семистрочного spread выше
Появляется удобный комбинатор over: применить функцию вместо чистого
set:
const over =
<S, A>(lens: Lens<S, A>, f: (a: A) => A) =>
(s: S): S =>
lens.set(f(lens.get(s)))(s);
const incAttempts = over(attemptsDeepL, (n) => n + 1);
// удвоить retry: тоже одна функция
const doubleAttempts = over(attemptsDeepL, (n) => n * 2);
Теперь любое глубокое обновление это композиция линз и применение
over. Spread остался только внутри элементарных линз, где он
неизбежен.
Чтобы почувствовать разницу руками, поиграй с деревом конфига ниже. Кликай на лист, виджет покажет линзовую цепочку и эквивалентный spread, плюс счётчик строк. На листах третьего уровня линза это одна строка, spread разворачивается в семь.
config: WeatherConfig
путь и код
apiL.compose(retryL).compose(attemptsL)// выбери лист, чтобы увидеть spreadрезультат: next config
Каждый лист дерева, фокус линзы. Композиция склеивает локальные линзы
в одну глубокую: apiL.compose(retryL).compose(attemptsL).
Spread-альтернатива растёт с глубиной, линза остаётся одной строкой.
Lens laws
У линзы три закона. Если они нарушены, линза начинает врать, и рефакторинг ломается без явных причин.
// 1. get-set: что получили, то и положили (no-op)
lens.set(lens.get(s))(s) === s;
// 2. set-get: что положили, то и получили
lens.get(lens.set(a)(s)) === a;
// 3. set-set: последний set перебивает предыдущий
lens.set(a2)(lens.set(a1)(s)) === lens.set(a2)(s);
Get-set говорит: установка прочитанного значения возвращает оригинал. Линза не имеет права что-то трогать сама по себе.
Set-get
говорит: после установки a чтение даст ровно a. Если линза
“нормализует” значение по дороге, закон нарушается.
Set-set говорит: первый set теряется, последний выигрывает. Линза не должна помнить старые установки.
Все три закона стоят на самой простой линзе на поле объекта. Если
линза собрана через lens.fromProp, законы выполняются автоматически.
Если ты пишешь линзу руками с какой-то логикой внутри, проверяй.
Проверять законы глазами скучно и ненадёжно, поэтому напиши машину, которая ловит враньё в чужой линзе и называет сломанный закон по имени.
Prism: оптика для sum-типов
Линза работает на product-типах: у объекта всегда есть все поля,
поэтому get всегда возвращает значение. У sum-типов всё хитрее:
если у нас Either<L, R>, мы не можем “достать” R всегда, иногда
там L.
Prism для этого случая:
type Prism<S, A> = {
readonly preview: (s: S) => Option<A>;
readonly review: (a: A) => S;
};
preview это “попробовать достать”: вернёт some(a), если S
действительно содержит A, иначе none. review это “построить
обратно”: из A собрать конкретную ветку S.
Для Either<L, R> есть два призмы:
const prismRight = <L, R>(): Prism<Either<L, R>, R> => ({
preview: (e) => (e.kind === 'right' ? some(e.value) : none),
review: (r) => right(r),
});
const prismLeft = <L, R>(): Prism<Either<L, R>, L> => ({
preview: (e) => (e.kind === 'left' ? some(e.value) : none),
review: (l) => left(l),
});
Призмы хорошо ложатся на ADT-теги. Например, для дискриминированного union из урока 06:
type Event =
| { kind: 'login'; userId: string }
| { kind: 'logout' }
| { kind: 'navigate'; path: string };
const prismLogin: Prism<Event, { userId: string }> = {
preview: (e) => (e.kind === 'login' ? some({ userId: e.userId }) : none),
review: ({ userId }) => ({ kind: 'login', userId }),
};
Закон у призмы один и зеркально похож на Lens-законы:
preview(review(a)) === some(a) (review всегда даёт ветку, из которой
можно достать обратно). И обратный: если preview(s) === some(a),
то review(a) === s для той ветки, в которой мы были.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.