Линзы: Real World

Приветствую, друзья!

Как и было обещано в предыдущей заметке, мы продолжаем беседу о линзах. Впрочем, я чуть-чуть нарушил своё обещание: вместо пакета lens мы рассмотрим пакет fclabels. Дело в том, что я поверил на слово вот этой рекомендации. Пакет fclabels значительно легковеснее, нежели lens, к тому же, что приятно обрадовало лично меня, у него куда как более понятная документация. Разумеется, между этими двумя пакетами есть общие черты. За сим и начнём.

Люди и места

Допустим, есть у нас место:

data Place = Place { _city
                   , _country
                   , _continent :: String
                   } deriving Show

и есть у нас человек, живущий в этом месте:

data Person = Person { _name  :: String
                     , _age   :: Int
                     , _place :: Place
                     } deriving Show

Всё предельно просто, перед нами два многопольных типа. Обратите внимание, что имена полей начинаются с символа _. Это не просто так. Ведь мы собираемся использовать пакет fclabels (который вы уже установили, не так ли?), а он требует, чтобы имена полей (к которым мы будем получать доступ через линзы) начинались с подчёркивания. Чуть позже станет понятно, почему.

Теперь весь наш модуль:

{-# LANGUAGE TemplateHaskell #-}
module Main where

import Control.Category ((.), id)
import Data.Label
import Prelude hiding ((.), id)

data Place = Place { _city
                   , _country
                   , _continent :: String
                   } deriving Show

data Person = Person { _name   :: String
                     , _age    :: Int
                     , _place  :: Place
                     } deriving Show

mkLabels [''Person, ''Place]

main :: IO ()
main =
    let jan      = Person "Jan" 71 (Place "Utrecht" "The Netherlands" "Europe")
        janName  = get name jan
        janAge   = get age jan
        janCity  = get (city . place) jan
    in putStrLn $ janName ++ ", " ++ (show janAge) ++ ", " ++ janCity

Ожидаемый вывод:

Jan, 71, Utrecht

Теперь давайте разбираться. Самая необычная строка, которую мы здесь видим - вот эта:

mkLabels [''Person, ''Place]

Эта строка есть представитель так называемого Template Haskell. Для тех кто не знает - Template Haskell (сокращённо TH) это специальное расширение языка Haskell, предназначенное для метапрограммирования. Суть его очень проста: специальная конструкция, не являющаяся Haskell-кодом, превращается в некий Haskell-код на этапе компиляции. Часто это избавляет от рутины.

Таким образом, конструкция со словом mkLabels вежливо сгенерирует код наших линз за нас. И вот для того, чтобы это прошло гладко, мы и указали символ _ в именах полей наших типов (таково требование TH). Однако при использовании созданных линз мы уже не видим никаких подчёркиваний, и это очень удобно:

janName  = get name jan

Вот наш старый знакомый, линзовый геттер. Красиво и понятно.

Как вы уже догадались, mkLabels создаёт линзы для нескольких типов одновременно. Если же мы работаем с единственным типом, то можно написать проще:

mkLabel ''Person

Комбинирование

Обратите внимание вот на эту строчку:

janCity  = get (city . place) jan

Геттер используется для доступа к имени города, которое, в свою очередь, спрятано в поле place. Комбинирование полей при линзовом доступе - мощный и удобный механизм. Вспомним комбинационное прочтение: вместо точки добавляем фразу “будет вызвана после”:

get (city "будет вызвана после" place) jan

Таким образом, place даёт нам доступ к полю типа Place, а city в свою очередь даёт доступ к имени города.

Давайте чуток усложним:

data Address = Address { _street :: String
                       , _house  :: Int
                       } deriving Show

data Place = Place { _address   :: Address
                   , _city
                   , _country
                   , _continent :: String
                   } deriving Show

Теперь место жительства содержит поле типа Address. Далее пишем:

mkLabels [''Person, ''Place, ''Address]

main :: IO ()
main =
    let anAddress = Address "Bilthoven" 8
        jan       = Person "Jan" 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
        janName   = get name jan
        janStreet = get (street . address . place) jan
    in putStrLn $ janName ++ ", " ++ janStreet

Вывод:

Jan, Bilthoven

Обратите внимание: мы сгенерировали линзы и для типа Address. А теперь комбинируем поля для доступа к названию улицы:

janStreet = get (street . address . place) jan

Красиво, не правда ли? Таким образом, линзовый доступ позволяет нам заглянуть сколь угодно глубоко.

Изменяем

Геттеры геттерами, но пришла пора что-нибудь поменять. Изменим возраст:

main :: IO ()
main =
    let anAddress  = Address "Bilthoven" 8
        jan        = Person "Jan" 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
        youngerJan = set age 65 jan
    in putStrLn . show $ get age youngerJan

Итак, Jan помолодел на 6 лет, сеттер set работает в точности как мы и ожидаем.

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

main :: IO ()
main =
    let anAddress   = Address "Bilthoven" 8
        jan         = Person "Jan" 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
        movedJan    = set (house . address . place) 9 jan
        newJanHouse = get (house . address . place) movedJan
    in putStrLn $ show newJanHouse

Теперь Jan живёт в доме номер 9. Элегантно и просто.

Модифицируем

Пакет fclabels предоставляет нам ещё один способ линзового изменения, а именно через функцию modify. Если уже известная нам функция set изменяет поле прямым значением, то modify делает это через функцию.

Помните, как мы изменили возраст? Сделаем же это иначе: теперь наш Jan повзрослеет на один год. Пишем:

main :: IO ()
main =
    let anAddress   = Address "Bilthoven" 8
        jan         = Person "Jan" 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
        olderJan    = modify age (+1) jan
    in putStrLn . show $ get age olderJan

И как вы уже догадались, мы и тут можем заглянуть вглубь. Пусть наш Jan опять переедет, но уже в другой конец улицы:

main :: IO ()
main =
    let anAddress   = Address "Bilthoven" 8
        jan         = Person "Jan" 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
        movedJan    = modify (house . address . place) (+10) jan
        newJanHouse = get (house . address . place) movedJan
    in putStrLn $ show newJanHouse

Теперь Jan живёт в 18 доме.

Персона в облаке

В модуле Data.Label.Monadic определены вкусности для работы с трансформерами Reader и State. Далее я предполагаю, что вы знакомы с этими трансформерами. Используется пакет mtl.

Поместим нашего Jan в облако, дабы с ним было удобно работать из нескольких функций. Вот наше облако:

...
import qualified Control.Monad.Reader as R
import Data.Label.Monadic

...

type PersonInCloud = R.ReaderT Person IO ()

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

main :: IO ()
main = do
    let anAddress = Address "Bilthoven" 8
        jan       = Person ("Jan", "Bakker") 71 (Place anAddress "Utrecht" "The Netherlands" "Europe")
    R.runReaderT (printName
                  >> printAge
                  >> printStreet) jan
    putStrLn "\nIt's done."

Мы можем работать с облачным значением двумя способами. Рассмотрим на примере функции printAge. Вот первый, канонический способ:

printAge :: PersonInCloud
printAge = do
    jan <- R.ask
    let janAge = get age jan
    R.liftIO . putStrLn $ "Age: " ++ show janAge

Тут всё как обычно: сначала, используя Reader-функцию ask, мы вытаскиваем нашу персону, а затем, через уже известную нам линзу, получаем возраст. Но есть путь короче:

printAge :: PersonInCloud
printAge = do
    anAge <- asks age
    R.liftIO . putStrLn $ "Age: " ++ show anAge

Круто, правда? Мы использовали функцию asks из модуля Data.Label.Monadic. Она упрощает нам жизнь, сразу же вытаскивая из облачной персоны её возраст.

Таким же образом можно работать и с трансформером State: в модуле Data.Label.Monadic вы найдёте функции gets, puts и modify.

Выводы

Ну что ж, на мой взгляд, пакет fclabels более чем заслуживает нашего внимания. Просто, удобно и элегантно. Кстати, у этого пакета значиииительно меньше зависимостей, нежели у lens.

В будущих заметках мы продолжим рассмотрение линз (из этого пакета или из других), в контексте различных интересных задач.