Программируем императивно в Хаскеле, используя линзы

в 6:19, , рубрики: haskell, Программирование, функциональное программирование

Хаскель получает много нелестных отзывов, потому что в нём нет встроенного инструментария для работы с изменениями и состояниями. Поэтому, если мы хотим испечь полный состояний яблочный пирог, нам необходимо для начала создать целую вселенную операторов для работы с состояниями. Однако за это уже заплачено с лихвой и это уже пройденный этап, и сейчас программисты на Хаскеле наслаждаются более элегантным, лаконичным и мощным императивным кодом, чем даже то, что вы можете найти в само-описывающих императивных языках. Впрочем, вы и сами сможете в этом убедится.

Линзы

Программируем императивно в Хаскеле, используя линзы
Ваш билет к элегантному коду — это библиотека линз (lens).
Вы определяете ваши данные как обычно, только добавляете к началу имён ваших полей знак подчёркивания. Например, мы можем определить игру(Game) как:

data Game = Game
    { _score :: Int
    , _units :: [Unit]
    , _boss  :: Unit
    } deriving (Show)

полную существ(Unit):

data Unit = Unit
    { _health   :: Int
    , _position :: Point
    } deriving (Show)

чьи местоположения определяются через точки (Point):

data Point = Point
    { _x :: Double
    , _y :: Double
    } deriving (Show)

Мы добавляем к полям знак подчёркивания, потому что мы не будем их использовать напрямую. Вместо этого, мы будем их использовать для того, чтобы построить линзы, с которыми значительно приятнее работать.

Мы можем построить линзы двумя путями. Первый вариант — вручную создать линзы используя удобную функцию lens из Control.Lens. Например, мы можем определить линзу score для поля _score следующим образом:

import Control.Lens

score :: Lens' Game Int
score = lens _score (game v -> game { _score = v })

Тип Lens как карта для навигации по сложным типам данных. Мы используем линзу score для того, что бы от типа Game прийти к _score.
Тип отражает, откуда мы должны начать и чем закончить: Lens' Game Int означает, что мы должны начать с Game и закончить Int (для поля _score в нашем случае). Аналогично, наши другие линзы ясно отражают начальные и конечные точки их типов:

units :: Lens' Game [Unit]
units = lens _units (game v -> game { _units = v })

boss :: Lens' Game Unit
boss = lens _boss (game v -> game { _boss = v })

health :: Lens' Unit Int
health = lens _health (unit v -> unit { _health = v })

position :: Lens' Unit Point
position = lens _position (unit v -> unit { _position = v })

x :: Lens' Point Double
x = lens _x (point v -> point { _x = v })

y :: Lens' Point Double
y = lens _y (point v -> point { _y = v })

Однако, зачастую мы ленивы и не хотим писать рутинный код, В этом случае можно выбрать другой путь, используя шаблонный Хаскель (Template Haskell) чтобы он создал линзы за нас:

{-# LANGUAGE TemplateHaskell #-}

import Control.Lens

data Game = Game
    { _score :: Int
    , _units :: [Unit]
    , _boss  :: Unit
    } deriving (Show)

data Unit = Unit
    { _health   :: Int
    , _position :: Point
    } deriving (Show)

data Point = Point
    { _x :: Double
    , _y :: Double
    } deriving (Show)

makeLenses ''Game
makeLenses ''Unit
makeLenses ''Point

Только помните, шаблонный Хасель трубует, что бы декларация makeLenses шла после декларации типов данных.

Начальное состояние

Следующее, что нам надо сделать, это инициализировать начальное состояние игры.

initialState :: Game

initialState :: Game
initialState = Game
    { _score = 0
    , _units =
        [ Unit
            { _health = 10
            , _position = Point { _x = 3.5, _y = 7.0 }
            }
        , Unit
            { _health = 15
            , _position = Point { _x = 1.0, _y = 1.0 }
            }
        , Unit
            { _health = 8
            , _position = Point { _x = 0.0, _y = 2.1 }
            }
        ]
    , _boss = Unit
        { _health = 100
        , _position = Point { _x = 0.0, _y = 0.0 }
        }
    }

Мы создали трёх героев, которые будут сражаться против босса подземелья. Да начнётся битва!

Первые шаги

Теперь мы можем использовать наши линзы! Давайте создадим функцию, что бы наши воины нападали на босса.

import Control.Monad.Trans.Class
import Control.Monad.Trans.State

strike :: StateT Game IO ()
strike = do
    lift $ putStrLn "*shink*"
    boss.health -= 10

Функция нападение(strike) печатает нам похожий звук в консоле, далее уменьшает здоровье боса 10 единиц здоровья.
Тип функции нападения показывает нам, что мы оперируем с StateT Game IO монадой. Вы можете думать, что это такой встроенный язык, где мы создаём слой чистых состояний игры (то есть StateT Game) поверх побочных эффектов (то есть IO) так, что мы можем одновременно и изменять состояния, и печатать наши милейшие эффекты от битвы на консоль. Всё, что нужно сейчас помнить, это то, что если мы хотим использовать побочные эффекты нам нужно использовать функцию lift.
Давайте попробуем использовать нашу функцию в интерпретаторе (ghci). Для этого нам понадобится начальное состояние:

execStateT strike initialState

>>> execStateT strike initialState 
*shink*
Game {_score = 0, _units = [Unit {_health = 10, _position = Poin
t {_x = 3.5, _y = 7.0}},Unit {_health = 15, _position = Point {_
x = 1.0, _y = 1.0}},Unit {_health = 8, _position = Point {_x = 0
.0, _y = 2.1}}], _boss = Unit {_health = 90, _position = Point {
_x = 0.0, _y = 0.0}}}

Функция execStateT берёт наш код с состояниями и наше начальное состояние, запускает его, и производит новое состояние. Интерпретатор автоматически выводит нам на экран, и мы сразу можем анализировать результат. На выходе получилась каша, однако, если натренировать свой глаз, вы сможете увидеть, что у босса сейчас только 90 единиц здоровья.
Мы сможем увидеть это более легко, если мы сначала создадим новую переменную для полученного состояния

>>> newState <- execStateT strike initialState 
*shink*

а потом извлечём из него необходимую информацию:

>>> newState^.boss.health
90

Композиция

Программируем императивно в Хаскеле, используя линзы
Следующий код очень сильно напоминает императивный и объекто-ориентированный код:

boss.health -= 10

Что тут происходит?? Хаскель определённо не мульти-парадигменный язык, но мы имеем то, что появляется в мульти-парадигменном коде.
Невероятно, но ничто в этом коде не является фишкой встроенной в язык!

  • boss и health — всего лишь линзы, которые мы определили выше
  • (-=) — инфиксная функция
  • (.) — функциональная композиция из хаскельного Prelude!

Подождите, (.) — это функциональная композиция?! Действительно?!
Вот где происходит вся магия линз. Линзы — это самые обычные функции, и весь наш «мульти-парадигменный» код на самом деле является ничем иным, как смесью функций!

Фактически, тип Lens' a b представляет собой синоним типа функций высшего порядка:

type Lens' a b =
    forall f . (Functor f) => (b -> f b) -> (a -> f a)

Вам нет необходимости всё понимать сейчас. Просто помните, что Lens' a b — функция высшего порядка, которая берёт тип (b -> f b) в качестве входного аргумента, и возвращает новую функцию типа (a -> f a). Functor — часть теории, которую сейчас можно рассматривать как «магию».

Убедимся, что boss . health :: Lens' Game Int

Вооружённые этим знанием, давайте посмотрим, как можно разложить типы функций boss и health:

boss :: Lens' Game Unit
-- раскрывается в :
boss :: (Functor f) => (Unit -> f Unit) -> (Game -> f Game)

health :: Lens' Unit Int
-- раскрывается в :
health :: (Functor f) => (Int -> f Int) -> (Unit -> f Unit)

Теперь посмотрим определение функциональной композиции:

(.) :: (b -> c) -> (a -> b) -> (a -> c)
(f . g) x = f (g x)

Заметьте, если мы заменим наши переменные типов на:

a ~ (Int  -> f Int)
b ~ (Unit -> f Unit)
c ~ (Game -> f Game)

тогда мы получим совершенно однозначное соответствие для композиции двух линз:

(.) :: ((Unit -> f Unit) -> (Game -> f Game))
    -> ((Int  -> f Int ) -> (Unit -> f Unit))
    -> ((Int  -> f Int ) -> (Game -> f Game))

Если мы проведём обратную замену синонима на Lens', мы получим:

(.) :: Lens' Game Unit -> Lens' Unit Int -> Lens' Game Int

boss . health :: Lens' Game Int

Отсюда следует, что композиция линз — тоже линза! Фактически, линзы формируют категорию, где (.) — категориальный оператор композиции, а функция идентичности id — тоже линза:

(.) :: Lens' x y -> Lens' y z -> Lens' x z

id  :: Lens' x x

В итоге, используя то, что можно убрать пробелы возле оператора, мы получаем код, который выглядит так, как и нотация объектно-ориентированного кода!

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

bossHP :: Lens' Game Int
bossHP = boss.health

и теперь можем использовать это везде, где ранее было необходимо использовать boss.health.

strike :: StateT Game IO ()
strike = do
    lift $ putStrLn "*shink*"
    bossHP -= 10

и так же находить новое значение здоровья:

>>> newState^.bossHP
90
Перечеслимые

Линзы основываются на одной действительно очень элегантной теории, и в результате мы получаем то, что в большинстве императивных языков нельзя сделать просто!
Программируем императивно в Хаскеле, используя линзы
Например, давайте скажем, что наш босс — это дракон который дышит огнём, повреждающим героев. Используя линзы, можем добиться этого эффекта одной строчкой:

fireBreath :: StateT Game IO ()
fireBreath = do
    lift $ putStrLn "*rawr*"
    units.traversed.health -= 3

Это создаёт возможность работать с линзами по новому!

traversed :: Traversal' [a] a

traversed помогает нам «докопаться» до значений в списке так, что мы можем работать с ним как с единым целым, вместо того, чтобы вручную обходить весь список. Однако, в этот раз мы используем тип Traversal' вместо Lens'.

Traversal' — это та же самый Lens', только слабее:

type Traversal' a b =
    forall f . (Applicative f) => (b -> f b) -> (a -> f a)

Если мы создаём композицию Traversal' и Lens', мы получаем более слабый тип, а именно Traversal'. Это работает вне зависимости от того, в каком порядке мы объединяем:

(.) :: Lens' a b -> Traversal' b c -> Traversal' a c
(.) :: Traversal' a b -> Lens' b c -> Traversal' a c

units                  :: Lens'      Game [Unit]
units.traversed        :: Traversal' Game  Unit
units.traversed.health :: Traversal' Game  Int

Фактически, нам даже не надо это знать. Компилятор правильно сам найдёт тип:

>>> :t units.traversed.health
units.traversed.health
  :: Applicative f =>
     (Int -> f Int) -> Game -> f Game

Это в точности совпадает с определением Traversal' Game Int!

Собственно, почему бы нам не объединить эти две линзы в одну?

partyHP :: Traversal' Game Int
partyHP = units.traversed.health

fireBreath :: StateT Game IO ()
fireBreath = do
    lift $ putStrLn "*rawr*"
    partyHP -= 3 

Давайте так же используем функцию partyHP, чтобы узнать новое значение здоровья:

>>> newState <- execStateT fireBreath initialState 
*rawr*
>>> newState^.partyHP

<interactive>:3:11:
    No instance for (Data.Monoid.Monoid Int)
      arising from a use of `partyHP'
    .........

Упс! Это ошибка типа, потому что мы не можем получить единственное значение здоровья! Именно поэтому Traversal' слабее, чем Lens': обходиимые могут указывать на множество значений, поэтому они не поддерживают хорошо определённый путь показать единственное значение. Система помогла нам избавиться от возможного бага.

Вместо этого, мы должны определить, что мы хотим получить список с помощью функции toListOf:

toListOf :: Traversal' a b -> a -> [b]

Это даёт нам удовлетворительный результат:

>>> toListOf partyHP newState 
[7,12,5]

или инфиксный эквивалент функции toListOf: (^..):

>>> initialState^..partyHP
[10,15,8]
>>> newState^..partyHP
[7,12,5]

Это даёт нам ясный вид того, что получили мы то, что и хотели при помощи fireBreath.

А давайте получим что-то на самом деле причудливое. Мы можем определить перечисление по географической области. Мы сможем это сделать?

around :: Point -> Double -> Traversal' Unit Unit
around center radius = filtered (unit ->
    (unit^.position.x - center^.x)^2
  + (unit^.position.y - center^.y)^2
  < radius^2 )

Конечно, мы можем! Мы смогли ограничить огненное дыхание окружностью!
filtered на самом деле не является теоретически перечислимым, поскольку он не сохраняет количество элементов

fireBreath :: Point -> StateT Game IO ()
fireBreath target = do
    lift $ putStrLn "*rawr*"
    units.traversed.(around target 1.0).health -= 3

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

В любом случае, давайте вернёмся к огнедыщащему. Для начала посмотрим, кто рядом с ним:

> initialState^..units.traversed.position
[Point {_x = 3.5, _y = 7.0},Point {_x = 1.0, _y = 1.0},Point {_x
 = 0.0, _y = 2.1}]

Хм, два воина находятся близко друг другу. Давайте-ка я метну туда файербол.

>>> newState <- execStateT (fireBreath (Point 0.5 1.5)) initialState 
*rawr*
>>> (initialState^..partyHP, newState^..partyHP)
([10,15,8],[10,12,5])

Попал!

Масштабирование

Программируем императивно в Хаскеле, используя линзы
Мы можем делать более уникальные вещи с линзами. Например, масштабировать подмножество нашего глобального состояния.

retreat :: StateT Game IO ()
retreat = do
    lift $ putStrLn "Retreat!"
    zoom (units.traversed.position) $ do
        x += 10
        y += 10

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

partyLoc :: Traversal' Game Point
partyLoc = units.traversed.position

retreat :: StateT Game IO ()
retreat = do
    lift $ putStrLn "Retreat!"
    zoom partyLoc $ do
        x += 10
        y += 10

Что ж, давайте попробуем!

>>> initialState^..partyLoc
[Point {_x = 3.5, _y = 7.0},Point {_x = 1.0, _y = 1.0},Point {_x
 = 0.0, _y = 2.1}]
>>> newState <- execStateT retreat initialState 
Retreat!
>>> newState^..partyLoc
[Point {_x = 13.5, _y = 17.0},Point {_x = 11.0, _y = 11.0},Point
 {_x = 10.0, _y = 12.1}]

Давайте посмотрим внимательно на тип масштабирования в нашем контексте:

zoom :: Traversal a b -> StateT b IO r -> StateT a IO r

Функция zoom имеет несколько теоретических замечательных возможностей. Например, мы ожидаем, что композиция масштабированых 2х линз должно давать тот же результат, что и масштабирование их композиций.

zoom lens1 . zoom lens2 = zoom (lens1 . lens2)

и что масштабирование пустой линзы даст себя саму:

zoom id = id

Другими словами, функция zoom — это функтор, а значит он подчиняется законам функтора!

Объединяем команды

До этого мы рассматривали одну команду за раз, но сейчас давайте объединим концепции и императивно зададим битву между действующими лицами:

battle :: StateT Game IO ()
battle = do
    -- Зарядить!
    forM_ ["Take that!", "and that!", "and that!"] $ taunt -> do
        lift $ putStrLn taunt
        strike

    -- Дракон просыпается!
    fireBreath (Point 0.5 1.5)
    
    replicateM_ 3 $ do
        -- настоящее мужество!
        retreat

        -- Дракон преследует их
        zoom (boss.position) $ do
            x += 10
            y += 10

Что же, поехали!

>>> execStateT battle initialState 
Take that!
*shink*
and that!
*shink*
and that!
*shink*
*rawr*
Retreat!
Retreat!
Retreat!
Game {_score = 0, _units = [Unit {_health = 10, _position = Poin
t {_x = 33.5, _y = 37.0}},Unit {_health = 12, _position = Point 
{_x = 31.0, _y = 31.0}},Unit {_health = 5, _position = Point {_x
 = 30.0, _y = 32.1}}], _boss = Unit {_health = 70, _position = P
oint {_x = 30.0, _y = 30.0}}}

Я думаю, что люди действительно не шутят, когда говорят, что Хаскель — лучший императивный язык!

Заключение

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

Автор: Vitter

Источник

Поделиться