Линзы: Hello World!

Приветствую!

В общем, надоело мне, друзья! Сто раз я уже слышал про эти линзы, а что они такое, понятия не имел. Давайте же наконец разберёмся.

В основу данной заметки лёг мой вольный перевод прекрасной статьи, с некоторыми изменениями.

Атлет

Есть у нас атлет, и есть у него имя. Определим следующий тип:

-- Main.hs
module Main where

data Athlete = Athlete String

И захотелось нам изменять/получать имя атлета. Определяем две функции:

getName :: Athlete -> String
getName (Athlete name) = name

setName :: String -> Athlete -> Athlete
setName newName (Athlete _) = Athlete newName

Теперь главный код:

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = setName "Taylor Fausak" athleteWithoutName
        nameOfRealAthlete  = getName realAthlete
    in putStrLn nameOfRealAthlete

Перед нами - классические геттер (getName) и сеттер (setName). Всё предельно просто. Однако в реальном проекте так делать не рекомендуется. Рано или поздно у атлета вслед за именем появится дата рождения, рост, знак Зодиака, любимое блюдо, девичья фамилия мамы и т.п. И что же, на каждое такое поле определять свою пару геттер/сеттер?? Нет, нас категорически не устраивает такое решение.

Упрощаем

Автоматизация - наше всё. Пишем:

-- Main.hs
module Main where

data Athlete = Athlete { name :: String }

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = athleteWithoutName { name = "Taylor Fausak" }
        nameOfRealAthlete  = name realAthlete
    in putStrLn nameOfRealAthlete

Вооот, так уже значительно лучше. Компилятор сделал скучную работу за нас. Теперь у нас есть поле name, которое уже является и сеттером, и геттером. Ура, победа! Но, как вы видите, заметка на этом не заканчивается, ибо имеется ещё одна проблема.

Одноимённые поля

Атлеты атлетами, но вскоре у нас появился клуб (возможно, атлетов):

-- Main.hs
module Main where

data Athlete = Athlete { name :: String }

data Club = Club { name :: String }

...

Увы и ах, компилятор грубо оборвёт наше счастье следующей ошибкой:

Multiple declarations of ‘name’

К сожалению, нельзя использовать одно и то же поле name в подобной манере, ибо компилятор путается, что где. Ну что ж, поможем компилятору и разнесём типы Athlete и Club по разным модулям:

-- Club.hs
module Club where

data Club = Club { name :: String }

-- Athlete.hs
module Athlete where

data Athlete = Athlete { name :: String }

Теперь пишем:

-- Main.hs
module Main where

import Athlete
import Club

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = athleteWithoutName { name = "Taylor Fausak" }
        nameOfRealAthlete  = name realAthlete
    in putStrLn nameOfRealAthlete

Но капризный компилятор и этому варианту не будет рад, ибо хотя прежняя ошибка ушла, пришла новая:

src/Main.hs:9:51:
    Ambiguous occurrence ‘name’
    It could refer to either ‘Athlete.name’,
                             imported from ‘Athlete’ at src/Main.hs:3:1-14
                             (and originally defined at src/Athlete.hs:3:26-29)
                          or ‘Club.name’,
                             imported from ‘Club’ at src/Main.hs:4:1-11
                             (and originally defined at src/Club.hs:3:20-23)

Когда мы с вами глядим на эту строку:

athleteWithoutName { name = "Taylor Fausak" }

нам предельно ясно, какое name имеется в виду, но компилятор не столь понятлив. Ладно, удовлетворим его каприз и уточним:

-- Main.hs
module Main where

import Athlete as A
import Club    as C

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = athleteWithoutName { A.name = "Taylor Fausak" }
        nameOfRealAthlete  = A.name realAthlete
        clubWithoutName    = Club ""
        realClub           = clubWithoutName { C.name = "Fixed Touring" }
        nameOfRealClub     = C.name realClub
    in putStrLn $ nameOfRealAthlete ++ ", " ++ nameOfRealClub

Теперь всё работает. Но такое решение тоже не торт. Рано или поздно количество модулей увеличится, и между alias-именами модулей могут возникнуть коллизии. Конечно, мы можем не использовать alias-имена и указывать имя модуля целиком:

Athlete.name realAthlete

но каждый раз уточнять “модульную принадлежность” наших полей - это скучновато.

Отказ от одноимённых полей

В конце концов, раз от них проблемы, так давайте же избавимся от них:

-- Club.hs
module Club where

data Club = Club { clubName :: String }

-- Athlete.hs
module Athlete where

data Athlete = Athlete { athleteName :: String }

И далее пишем так:

-- Main.hs
module Main where

import Athlete
import Club

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = athleteWithoutName { athleteName = "Taylor Fausak" }
        nameOfRealAthlete  = athleteName realAthlete
        clubWithoutName    = Club ""
        realClub           = clubWithoutName { clubName = "Fixed Touring" }
        nameOfRealClub     = clubName realClub
    in putStrLn $ nameOfRealAthlete ++ ", " ++ nameOfRealClub

Никаких коллизий, всё прекрасно работает, но это некрасивое решение. Ведь мы постоянно сами себя повторяем:

athleteWithoutName { athleteName = "Taylor Fausak" }

Читается как “масло масляное”. Нет, мы любим Haskell за его красоту, поэтому желаем чего-то лучшего.

Именной класс типов

Определим класс для работы с типами, имеющими имя:

class HasName a where
    getName :: a -> String
    setName :: String -> a -> a

Теперь создадим экземпляры этого класса для наших атлетов и клубов:

instance HasName Athlete where
    getName athlete = athleteName athlete
    setName newName athlete = athlete { athleteName = newName }

instance HasName Club where
    getName club = clubName club
    setName newName club = club { clubName = newName }

И теперь пишем:

main :: IO ()
main =
    let athleteWithoutName = Athlete ""
        realAthlete        = setName "Taylor Fausak" athleteWithoutName
        nameOfRealAthlete  = getName realAthlete
        clubWithoutName    = Club ""
        realClub           = setName "Fixed Touring" clubWithoutName
        nameOfRealClub     = getName realClub
    in putStrLn $ nameOfRealAthlete ++ ", " ++ nameOfRealClub

Ну что ж, такое решение значительно красивее, ведь никакого “масла масляного” уже нет. Казалось бы, мы нашли идеальное решение, но…

Разные типы

Что произойдёт, если тип поля name у атлета станет другим, нежели у клуба? Допустим, мы решили использовать более продвинутый тип Text:

-- Athlete.hs
module Athlete where

import Data.Text

data Athlete = Athlete { athleteName :: Text }

К сожалению, всё тут же сломается:

src/Main.hs:11:25:
    Couldn't match type ‘Data.Text.Internal.Text’ with ‘[Char]’
    Expected type: String
      Actual type: Data.Text.Internal.Text
    In the expression: athleteName athlete
    In an equation for ‘getName’:
        getName athlete = athleteName athlete

Вспомним класс типов HasName:

class HasName a where
    getName :: a -> String
    setName :: String -> a -> a

Методы этого класса дружат с обыкновенной строкой, а им тут подсовывают какой-то Text… Но врагу не сдаётся наш гордый Варяг! Сделаем класс более обобщённым:

class HasName a b where
    getName :: a -> b
    setName :: b -> a -> a

Кстати, чтобы это работало, добавим необходимые Haskell-расширения в начале модуля Main.hs:

{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FlexibleInstances #-}

Теперь изменим экземпляры класса для наших типов:

instance HasName Athlete Text where
    getName athlete = athleteName athlete
    setName newName athlete = athlete { athleteName = newName }

instance HasName Club String where
    getName club = clubName club
    setName newName club = club { clubName = newName }

Здесь мы учли разность типов имён атлетов и клубов. И теперь пишем:

main :: IO ()
main =
    let athleteWithoutName = Athlete empty
        realAthlete        = setName (pack "Taylor Fausak") athleteWithoutName
        nameOfRealAthlete  = getName realAthlete
        clubWithoutName    = Club ""
        realClub           = setName "Fixed Touring" clubWithoutName
        nameOfRealClub     = getName realClub
    in putStrLn $ unpack nameOfRealAthlete ++ ", " ++ nameOfRealClub

Работает:

Taylor Fausak, Fixed Touring

Это весьма хорошее решение, и мы могли бы на этом остановиться, но существует иной путь.

Линзы

Определим новый тип:

data Lens a b = Lens { get :: a -> b
                     , set :: b -> a -> a
                     }

Перед нами - главная идея линз. Мы ведь хотим сделать работу с геттерами и сеттерами максимально изящной, не правда ли? Так вот идея в том, чтобы создать линзу (для русскоязычного читателя привычнее будет термин “лупа”), позволяющую нам сфокусироваться на каком-то значении. А фокусироваться нам нужно как раз для того, чтобы изменять (set) или получать (get).

Но чтобы всё это работало, мы должны определить линзы для каждого из наших типов:

athleteNameLens :: Lens Athlete Text
athleteNameLens = Lens { get = \athlete -> athleteName athlete
                       , set = \newName athlete -> athlete { athleteName = newName }
                       }

clubNameLens :: Lens Club String
clubNameLens = Lens { get = \club -> clubName club
                    , set = \newName club -> club { clubName = newName }
                    }

Мы определили линзу athleteNameLens для фокусировки на имени атлета, а также линзу clubNameLens для фокусировки на имени клуба. Фактически, get и set - это лямбда-обёртки для работы с уже известным нам полем athleteName.

Возможно, вы спросите, зачем нужны такие сложности? Но взгляните на новое определение экземпляров класса HasName:

class HasName a b where
    name :: Lens a b

instance HasName Athlete Text where
    name = athleteNameLens

instance HasName Club String where
    name = clubNameLens

Всё сильно упростилось. Класс HasName теперь содержит одно-единственное “линзовое” значение name. Соответственно, в каждом экземпляре этого класса мы просто заявляем: “Значение name теперь связано с конкретной линзой, определённой для данного типа.”

И вот теперь наш главный код приобретает следующий вид:

main :: IO ()
main =
    let athleteWithoutName = Athlete empty
        realAthlete        = set name (pack "Taylor Fausak") athleteWithoutName
        nameOfRealAthlete  = get name realAthlete
        clubWithoutName    = Club ""
        realClub           = set name "Fixed Touring" clubWithoutName
        nameOfRealClub     = get name realClub
    in putStrLn $ unpack nameOfRealAthlete ++ ", " ++ nameOfRealClub

Элегантно, не правда ли? Читается теперь как поэма:

set name "Fixed Touring" clubWithoutName

Здесь set фокусируется на имени клуба через линзу name. Мы как будто говорим: “Измени то, на что смотрит эта линза”, или “Получи то, на что смотрит эта линза”. Грубо и схематично это можно представить себе так:

действие -> линза -> нечто, к чему применяется действие

Правда

А правда, друзья мои, состоит в том, что всё вышеизложенное есть не более чем мааааааленькая демонстрация. Существует мощный пакет lens, в котором всё уже реализовано очень умными людьми. К рассмотрению возможностей этого пакета мы приступим в следующих статьях.