Раздел 21 · Haskell

Optics: Lens, Prism, Traversal

lead~25 мин

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

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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.