Optics: Lens, Prism, Traversal
Начинается deep-dive для тех, кто дошёл до конца. Обновить поле во вложенной записи в Haskell это боль: record update копирует уровень за уровнем вручную. Optics это композируемый доступ к частям структуры, который убирает эту боль. Lens фокусируется на поле, Prism на ветке sum-типа, Traversal на множестве элементов, и все они компонуются одной точкой. Разберём, как это устроено внутри, чтобы линза перестала быть магией.
Боль, которую лечат optics
Возьмём Env из проекта 3: внутри Config, внутри него порт. Поменять только порт, не трогая остального:
bumpPort :: Env -> Env
bumpPort env =
env { envConfig = (envConfig env) { configPort = configPort (envConfig env) + 1 } }
Два уровня вложенности уже читаются тяжело, три превратятся в кашу. Проблема в том, что record update не композируется: каждый уровень надо распаковать и собрать руками. Optics решают ровно это.
Lens: фокус на поле
Lens это значение, которое знает, как добраться до части структуры и обратно. Три операции:
view portL env -- прочитать: Env -> Int
set portL 9090 env -- записать: Int -> Env -> Env
over portL (+1) env -- изменить: (Int -> Int) -> Env -> Env
Сила в композиции. Линза до Config и линза от Config до порта склеиваются в линзу от Env до порта:
-- в пакете optics композиция это %
portFromEnv :: Lens' Env Int
portFromEnv = configL % portL
bumpPort = over portFromEnv (+1)
Тот же bumpPort, но вложенность исчезла: цепочка оптик читается слева направо, как путь к нужному месту.
Как линза устроена внутри
Чтобы линза не была магией, посмотрим на van Laarhoven-кодировку, на которой стоит пакет lens. Линза это всего одна функция:
type Lens s a = forall f. Functor f => (a -> f a) -> (s -> f s)
Идея простая: линза берёт функцию над частью (a -> f a) и поднимает её до функции над целым (s -> f s), для любого функтора f. А разные операции получаются подстановкой разных функторов:
- подставь
Const a(функтор, который игнорирует функцию и держит значение), и линза отдаст часть наружу: этоview; - подставь
Identity(функтор-обёртка без эффекта), и линза применит функцию и пересоберёт целое: этоoverиset.
Один тип, две операции, добытые выбором функтора. Композиция линз это обычная композиция функций, поэтому линзы и склеиваются так гладко. Пакет optics прячет эту кодировку за абстрактным типом (отсюда % вместо .), зато ошибки типов становятся читаемыми, а в lens они печально знамениты своей длиной.
Законы линзы
Линза не любая такая функция, а та, что подчиняется трём законам. Они говорят, что линза честно фокусируется на части, ничего не теряя:
- get-put: записать то, что прочитал, значит ничего не изменить.
set l (view l s) s == s. - put-get: прочитать только что записанное значит получить его же.
view l (set l a s) == a. - put-put: вторая запись затирает первую.
set l b (set l a s) == set l b s.
Если эти законы держатся, линзой можно пользоваться, не думая о её устройстве. Сломаешь закон (например, при записи ещё и счётчик увеличиваешь), и композиция начнёт врать.
Prism: фокус на ветке sum-типа
Lens хорош для product-типов (записей), где часть есть всегда. Для sum-типов нужен Prism: ветки может не быть, поэтому чтение даёт Maybe.
preview _Rented status -- Maybe (MemberId, DueDate): а вдруг не Rented
review _Rented (m, d) -- собрать Rented из части
Возьмём CopyStatus из урока 42. Призма _Rented фокусируется на ветке Rented: preview _Rented вернёт Just (member, due), если экземпляр арендован, и Nothing иначе. Призма это двойник линзы: линза для “часть есть всегда”, призма для “часть может быть”.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.