Введение

Линзы – это функциональные композиционабельные проекции типов, сильно облегчающие доступ к многоуровневым, вложенным структурам данных. Под “проекцией типа” я здесь имею в виду некоторый тип T, конструируемый N-арным конструктором значений makeT, вместе с некоторой функцией proj_N, деконструирующей значение T в его N-ый элемент:

data T = makeT A B C D

proj_A :: T -> A
proj_B :: T -> B
proj_C :: T -> C
proj_D :: T -> D

т.е. обыкновенным аксессором (accessor). Аксессоры компилятор умеет генерировать автоматически с помощью синтаксиса записей (records):

data Address = Address
  { street :: String
  , house :: String
  , postCode :: Integer
  , phone :: Integer
  }
> let a = Address "Evergreen" "3-A" 778899 12345678
> house a
"3-A"
> phone a
12345678
> street a ++ " st., " ++ house a
"Evergreen st., 3-A"
> :t street
street :: Address -> String
> -- переезжаем на другую улицу
> let a' = a {street = "Everblue", postCode = 334456}
a' :: Address

Линзы и другие типы из пакета lens (Control.Lens) обобщают концепцию аксессоров: конкретную линзу можно представить себе как пару геттера (getter, напр. функция house выше) и сеттера (setter, напр. конструкция a {stree = "Everblue"} выше, которая преобразует Address в обновлённый Address), совместимых между собой в некотором смысле. Самые фундаментальные операции над линзами таковы:

view :: Lens' a b -> a -> b
set :: Lens' a b -> b -> a -> a

Простые примеры

Грубо говоря, линзы дают нам возможность удобно комбинировать “проекции” с помощью обычной операции композиции . и выстраивать цепочки наподобие john.address.house и john.address.phone=1112220 из мира объектно-ориентированного программирования:

> a ^. phone
12345678
> a & street .~ "Everpink"
Address {_street = "Everpink", _house = "3-A", _postCode = 778899, _phone = 12345678}
> a & street .~ "Everblue" & phone .~ 334456
Address {_street = "Everblue", _house = "3-A", _postCode = 778899, _phone = 334456}
> a & street .~ "Everblue" & phone .~ a ^. phone.to succ
Address {_street = "Everblue", _house = "3-A", _postCode = 778899, _phone = 12345679}

Где phone и street – линзы, а с помощью комбинатора to из phone и обычной функции над числами succ построен более частный тип линз (Getter). Инфиксные функции (^.) и (.~) являются синонимами view и set соответственно; слева от оператора (&) (обратное применение функции, cos x == x & cos) располагается собственно структура данных. Пример с композицией посложнее:

> :m +Data.Map
> let mapFromList = Data.Map.fromList
> let numdict = mapFromList [ (1, [("ru", "odin"), ("en", "one")]), (2, [("ru", "dva"), ("en", "two")]) ]
> numdict ^. at 2 . _Just . to mapFromList . at "ru" . _Just
"dva"

Создание линз

Линзы (соответственно каждой необходимой проекции) можно написать самому, а можно сгенерировать по шаблону с помощью makeLenses. В этом случае по соглашению все элементы структуры данных, для которой они генерятся, должны начинаться с символа подчёркивания. Слегка расширенный пример с адресом:

{-# Language TemplateHaskell #-}
import Control.Lens

data Student = Student
  { _firstName :: String
  , _lastName :: String
  , _address :: Address
  }

data Address = Address
  { _street :: String
  , _house :: String
  , _postCode :: Integer
  , _phone :: Integer
  }

data Class = Class
  { _number :: Int
  , _students :: [Student]
  }

-- использует расширение TemplateHaskell
makeLenses ''Student
makeLenses ''Address
makeLenses ''Class


cl = Class 10
     [ Student "John" "Doe" (Address "Evergreen" "1A" 1122 1234567)
     , Student "Mary" "Smith" (Address "Evergreen" "2A" 2233 8798763)
     , Student "Bob" "Grey" (Address "Everpink" "4C" 7786 112332123)
     ]

Теперь обычные аксессоры будут называться с подчёркивания (_number), а за нормальными именами будут закреплены построенные для данного типа линзы:

> :t number
number :: Functor f => (Int -> f Int) -> Class -> f Class
> :t _number
_number :: Class -> Int

Использование линз

> -- просто имя и телефон каждого студента в классе
> cl ^.. students.traversed.to (\s -> (s ^. firstName, s ^. address.phone))
[("John",1234567),("Mary",8798763),("Bob",112332123)]
> -- поиск студентов с именем Mary или живущих на Evergreen st.
> cl ^.. students.folded.filtered (\s -> s ^. firstName == "Mary" || s ^. address.street == "Evergreen")
[Student {_firstName = "John", _lastName = "Doe", _address = Address {_street = "Evergreen", _house = "1A", _postCode = 1122, _phone = 1234567}},Student {_firstName = "Mary", _lastName = "Smith", _address = Address {_street = "Everblack", _house = "2A", _postCode = 2233, _phone = 8798763}}]

Конструкции вроде s ^. address.street очень удобны при работе со вложенными друг в друга типами. Оператор (^..) (инфиксный вариант toListOf) извлекает список элементов “под линзой” типа Fold (то есть то, на что эта линза “показывает” внутри структуры данных). В частности, в первом случае линза students.traversed указывает на каждого студента из списка (список траверсабелен), т.е. на тип Student. Линза students.traversed.to (\s -> (s ^. firstName, s ^. address.phone)) указывает уже на пары имя-телефон: значения из неё извлекаются оператором (^..).

Замечательная штука!

Линзы, призмы, изоморфизмы

В данном тексте я везде использую слово “линза”, но это не совсем правильно. В пакете lens определена целая красивая иерархия абстрактных объектов: Equality, Iso, Lens, Prism и другие. Например, призмы можно “поворачивать”, получая геттеры “в обратном направлении”. Это действительно очень круто.

Например, _Just из примеров выше – это призма. Мы можем “смотреть” через неё на объект Maybe a и “видеть” значение a (или юнит, если Nothing):

> Just "me" ^. _Just
"me"
> Nothing ^. _Just
()
> Just "me" ^.. _Just.traversed.to toUpper
"ME"

А можем “повернуть” её с помощью re и “видеть” Maybe a, “смотря” на a:

> "me" ^. re _Just
Just "me"
> Just "me" ^.. _Just.traversed.to toUpper . re _Just
[Just 'M',Just 'E']

В некотором смысле это как будто если получить (полное) значение типа Student через Address (а не наоборот, как обычно): некоторая сущность, достраивающая целый объект по его части.

Цель данной короткой заметки – поделиться самыми общими сведениями о технике линз, поэтому за кадром осталось очень много всего интересного. За документацией к Control.Lens можно обратиться на Hackage или Wiki проекта.