Пользователь
0,0
рейтинг
20 августа 2013 в 10:19

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

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

Линзы



Ваш билет к элегантному коду — это библиотека линз (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, которая по праву считается одной из королевских драгоценностей экосистемы Хаскеля. Вы можете также использовать линзы для чистого программирования, для того, чтобы мощные и сложные конструкции сжать в очень читаемый и элегантный код. Впрочем, можно ещё много писать про эту замечательную библиотеку.
Перевод: Gabriel Gonzalez
@Vitter
карма
36,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (18)

  • –19
    Исправьте название поста «пРограммируем»… В глаза бросается.
    • –16
      Ну и чего минусуем?
      • +14
        Разве сложно о таком писать в личку?
        Название поправят а Ваш комментарий станет неуместным и будет засорять тред.
        Вот поэтому и минусуют.
  • 0
    Блин, сам хотел перевести эту статью пару месяцев назад, но думал что никому не будет интересно :)
    • +1
      Я подумал, что после этой статьи — habrahabr.ru/post/189712/, ещё одна статья про линзы не помешает
    • +2
      Как такое…

      units.traversed.(around target 1.0).health -= 3

      … может не заинтересовать?
      ***даже без упоминания, что это — чистый, в смысле изменения состояния, код.
      • 0
        просто бросают изучать функциональные языки намного раньше, чем смогут обнаружить подобные удобные вещи. Имхо
        • 0
          А зачем изучать декларированный язык, что бы писать на нём императивный код?
          • +3
            Вообще задачи разные бывают. И необходимы разные подходы к разным задачам.
          • 0
            Во-первых, не декларированный, а декларативный. Во-вторых, Haskell — не декларативный, а функциональный язык.

            В чисто функциональном языке для обычной разработки особого смысла нет, поэтому в Haskell есть средства работы с состояниями. Какой смысл изучать? Хотя бы для того, чтобы писать в несколько раз меньше кода.
            • +2
              За поправку спасибо, но функциональный язык это частный случай декларативного.
  • +2
    Стоит ли идти к функциональному языку, а потом придумывать/искать костыли решения, чтоб писать на нем в императивном стиле?
    • +3
      Стоит
    • +1
      Некоторые вещи удобно выразить с помощью одного способа, а некоторые — с помощью другого. Самый банальный пример: рекурсивные алгоритмы можно выразить без рекурсии (иногда просто через циклы, иногда надо писать свой стек), но при этом можно потерять красивость и понятность кода. Решение любых динамических задач можно выразить через ручной императивный алгоритм, через рекурсию с кешем или через определение переходов между состояниями (не видел такого. но как идея интересна; редактирование: хотя, возможно стоит глянуть на пролог). Точно так же с функциональщиной и императивщиной. Смешивая парадигмы мы можем получить оптимальный код с точки зрения удобности, понятности и скорости.
      • +1
        Думаю, люди, которые высказываются подобно тредстартеру, имеют в виду не мультипарадигменность, а именно костыли (попытки писать в парадигме, явно языком не поддерживающейся). Другое дело, что линзы в Haskell, как они здесь описаны, совсем на костыли не похожи.
        • 0
          Ну, тут надо очень сильно задуматься о разнице между костылями и написанием своего кода.
    • +1
      Линзы — это не костыли, а другой подход. Более удобный и выразительный.
  • 0
    Думаю, что несмотря на название «программируем императивно», линзы ближе к «программируем объектно-ориентированно».
    Но, линзы — это всё же не объекты, поэтому говорить о костылях в данном случае неуместно.
    В основном человек принимает новое только через что-то знакомое. Поэтому дана попытка показать совершенно новый инструментарий (ранее неизвестный) при помощи аналогий.

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