Pull to refresh

Haskell Quest Tutorial — Зал

Reading time 13 min
Views 3.3K
Скорее всего, это последняя часть, опубликованная точно в срок. Мой отпуск почти закончился, и теперь писать по статье в неделю будет очень сложно. Спасибо всем, кому было интересно руководство «Haskell Quest Tutorial»!

Living Room
You are in the living room. There is a doorway to the east, a wooden door with strange gothic lettering to the west, which appears to be nailed shut, a trophy case, and a large oriental rug in the center of the room.
Above the trophy case hangs an elvish sword of great antiquity.
A battery-powered brass lantern is on the trophy case.


Содержание:
Приветствие
Часть 1 — Преддверие
Часть 2 — Лес
Часть 3 — Поляна
Часть 4 — Вид каньона
Часть 5 — Зал

Часть 5,
в которой мы из маленькой ошибки выведем значительные следствия, а затем добавим в игру объекты.


Для начала давайте освежим память. Пробежимся по функции run, которая у нас получилась в четвёртой части. Как видите, я поработал за вас, добавил действие Look. Функция run теперь выглядит внушительно:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Look          -> do
                                putStrLn (describeLocation curLoc)
                                run curLoc
            Go dir        -> do
                                putStrLn ("\nYou walking to " ++ show dir ++ ".\n")
                                run (walk curLoc dir)
            convertResult -> do
                                putStrLn (evalAction convertResult)
                                putStrLn «End of turn.\n»
                                run curLoc


В задании №2 к прошлой части было предложение подумать, как избежать двух вызовов "(describeLocation curLoc)". Мы, конечно, не гравитационную задачу для N тел решаем, чтобы бороться за ресурсы на примере одной маленькой функции, но из этой незначительности вытекает несколько важных следствий. Если бы у нас был императивный язык, мы бы просто присвоили результат переменной. В Haskell данные считаются неизменяемыми, поэтому присвоения как такового нет. Зато есть иные механизмы, которые, как оказывается, не только обобщают присвоение, но и, с учётом неизменяемого состояния, дают интересные эффекты. Например, детерминированность выполнения. В самом деле, если какое-то глобальное состояние не влияет на функцию, а внутри неё данные заведомо неизменяемы, значит, на одни и те же аргументы она вернёт одни и те же значения. Вызвав функцию один раз, мы можем запомнить её параметры и полученный результат, — и если понадобится второй раз вызывать её с теми же параметрами, мы просто возвращаем вычисленный ранее результат, потому что мы в нём уверены.

Но перейдём к делу. Есть несколько решений нашей проблемы. Сначала попробуем, например, связать результат с какой-нибудь переменной, — точно так же, как мы связывали строку от getLine и переменную x. Это похоже на присвоение, и идея здесь простая: переменную можно использовать столько, сколько нужно. С первого захода получится приблизительно следующий код:

run curLoc = do
        locDescr <- describeLocation curLoc
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            ... -- оставшийся код


Но этот код с подвохом, потому что он больше не компилируется.

*Main> :r
[2 of 2] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:58:17:
    Couldn't match expected type '[a0]' with actual type 'IO ()'
    In the return type of a call of 'putStrLn'
    In a stmt of a 'do' expression: putStrLn locDescr
    In the expression:
    ...
 
Failed, modules loaded: Types.


В чём разница между «x < — getLine» и «locDescr < — describeLocation curLoc»? Вроде бы, одно и то же: функция getLine возвращает строку, которая связывается с переменной x; и функция (describeLocation curLoc) тоже возвращает строку, которая связывается с другой переменной locDescr. Но интерпретатор говорит, что какие-то типы не совпадают. Давайте разберёмся в ситуации, — а для этого нужно понять, что на самом деле происходит в функции run.

Вернёмся к рабочему коду, закомментировав ошибочные строки.

run curLoc = do
        -- locDescr <- describeLocation curLoc
        -- putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            ... -- оставшийся код
 
*Types> :r
[2 of 2] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
Ok, modules loaded: Main, Types.
*Main>


Мы намеренно не писали определение функции run, чтобы не вдаваться в тонкости её типа. Но тип у неё есть, и компилятор покорно выводит его сам. Проверим:

*Main> :t run
run :: Location -> IO ()


И здесь нас поджидает сюрприз. Понятно, что Location — это тип единственного параметра, и для странного типа IO () ничего не остаётся, кроме как быть типом возвращаемого значения. Очень интересно! Ведь мы нигде никакой IO () не используем, откуда он взялся? Если вы уже вознамерились пенять на Haskell за его тёмные делишки и самовольство, то не спешите: IO мы очень даже используем. Дело в том, что по мнению Haskell, любое действие ввода-вывода (Input-Output, I/O) — это потенциальные ошибки и сбои, ещё известные как «побочные эффекты». Следовательно, функции ввода-вывода (putStrLn, putStr, putChar, getLine, getChar, readFile, writeFile) — опасные, могут вернуть разный результат на одни и те же аргументы, а в некоторых случаях могут даже выбросить ошибку. Поэтому они имеют тип IO, что, по мнению языка, должно нас настораживать. Кроме того, все прочие функции, у которых внутри возникает тип IO, тоже становятся недетерминированными и обязаны его перенять. Наша функция run выбрала небезопасные действия, из-за чего заразилась типом IO (). Отсюда следует, между прочим, что и точка входа в программу — функция main — не избежала этой участи, поскольку в ней вызывается run.

*Main> :t main
main :: IO ()


Справедливости ради стоит сказать, что в объяснениях сделано допущение, что функции с IO-действиями — недетерминированные. На самом деле, это не совсем так; и такие функции в некотором смысле могут быть детерминированными и даже — чистыми. Этот вопрос, вообще говоря, является камнем преткновения в спорах о чистоте Haskell, и его обсуждения можно найти в Интернете.


Как сказанное связано с нашей проблемой? Поглядим ещё раз (без компиляции) на неправильный код, приписав к нему, для порядка, определение функции run.

run :: Location -> IO ()
run curLoc = do
        locDescr <- describeLocation curLoc
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            ... -- оставшийся код


Ключевое слово do, как мы знаем, связывает действия в цепочку, но не какие попало. do-нотация требует, чтобы все действия в цепочке возвращали один и тот же тип. Поскольку мы работаем с вводом-выводом, то тип у функций должен содержать IO.

*Main> :t putStrLn
putStrLn :: String -> IO ()
 
*Main> :t putStr
putStr :: String -> IO ()


Кроме аргумента типа String, у этих двух функций один и тот же тип возвращаемого значения — IO (). Пустые скобки здесь показывают, что больше ничего полезного функции не возвращают. По сути, нам и не важно, что там вернёт putStrLn, — лишь бы она печатала в консоли свой аргумент. Напротив, от функции getLine мы ждём что-то «полезное», и это по-своему отражено в её типе:

*Main> :t getLine
getLine :: IO String


getLine получает от пользователя строку и запаковывает её, как подарок, в тип IO. И делайте с подарком, что хотите. Мы, например, передаём его в функцию convertStringToAction:

...
<- getLine
case (convertStringToAction x) of
...


Но ведь у функции convertStringToAction другой тип! — воскликните вы и будете правы. Она же на входе ждёт String, а не IO String! В чём фокус? Ну, где-то в этих двух строчках подарок разворачивается, коробка IO выбрасывается, а чистая строка уже идет дальше. И этим занимается стрелка. Она берёт от правой части запакованный результат и распаковывает его в переменную слева. Потому-то, кстати, данная операция называется связыванием, а не присвоением. И если поразмыслить, наше выражение «locDescr < — describeLocation curLoc» ошибочно, потому что в правой части ничто никуда не запаковывается, — читай, результат не кладётся в коробку IO.

*Main> :t (describeLocation Home)
(describeLocation Home) :: String


Ну вот, приехали… Хотели как проще, а получили вон какое следствие аж на несколько страниц. Но у меня для вас хорошие новости: решение есть! Мы сами запакуем то, что справа, в тип IO, и пусть стрелка подавится. Для этого существует функция return. Следующий код будет работать так, как и задумано:

run :: Location -> IO ()
run curLoc = do
        locDescr <- return (describeLocation curLoc)
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Look          -> do
                                putStrLn locDescr
                                run curLoc
            ... -- оставшийся код


Да, функция return запакует строку в тип блока do (IO), а стрелка его распакует и свяжет с переменной locDescr. И больше не нужно вычислять описание локации несколько раз. Логика, конечно, непривычная, однако согласитесь: есть в ней какая-то внутренняя красота. За это стоит выпить!

За do-нотацией и типом IO кроется ещё больше следствий и подводных камней. Если капнуть глубже, то мы увидим универсальный способ укрощения не только побочных эффектов, но и многих других вычислений, которые могут быть связаны между собой по иным законам, нежели действия IO. Оформляя вычисления с помощью так называемых монад, мы получаем в итоге в удивительно простую и удобную логику, а код становится кратким, понятным и выразительным. Немаловажно, что такой код строго математичен и весьма удобен. Он очень подходит для описания специфических задач, таких как парсинг, DSL, изменяемые состояния, и многое другое. Возможно, когда-нибудь мы вернёмся к этой теме, — но не раньше, чем это действительно будет нужно.


Что ж, мы решили задачку, которая оказалась неожиданно ёмкой. Так зачем же нам напрягаться и придумывать какие-то другие решения? Да в общем-то, для того же: чтобы узнать ещё что-нибудь о Haskell.

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

Никаких особых объяснений where-конструкция не требует, просто посмотрите на пример:

run :: Location -> IO ()
run curLoc = do
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Look          -> do
                                putStrLn locDescr
                                run curLoc
            ... -- оставшийся код
        where
            locDescr = describeLocation curLoc


Мы сделали перед where-конструкцией такой же отступ, что и у блока do. По сути, мы определили where для этого блока. Может показаться, что мы присваиваем результат переменной locDescr, но это не так. Никаких переменных и присвоения тут нет; locDescr — это функция, которая возвращает описание локации. Мы вызываем эту функцию из родительского кода, причём два раза; однако вычисляется она, скорее всего, только один раз. Компиляторы Haskell умеют сохранять прошлые результаты для повторного использования. Когда данные больше не нужны, они удаляются встроенным сборщиком мусора. Вспомните врезку из прошлой части, где вычисляются числа Фибоначчи. Как бы работала программа, если бы на каждом шагу рекурсии значения вычислялись с самого начала?

Наконец, последнее решение — так называемые let-выражения — самое простое и, пожалуй, самое здесь подходящее. И хотя let-выражения весьма схожи с where, в них определяются псевдонимы выражений, а не функции с такими именами. Внутри блока do let-выражение выглядит следующим образом:

run :: Location -> IO ()
run curLoc = do
        let locDescr = describeLocation curLoc
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Look          -> do
                                putStrLn locDescr
                                run curLoc
            ... -- оставшийся код


Если же использовать let-выражение вне блока do, — например, внутри других выражений, — то запись чуть-чуть изменится. Добавится ключевое слово in:

run :: Location -> IO ()
run curLoc =
    let locDescr = describeLocation curLoc in
    do
        putStrLn locDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Look          -> do
                                putStrLn locDescr
                                run curLoc
            ... -- оставшийся код


По сути, между let и in можно определить сколько угодно таких выражений, — вы увидите, как это просто делается, когда мы перейдём к добавлению объектов. А пока мы оставим предыдущий вариант, где let-выражение находится внутри do-блока.

let-выражения хороши не только тем, что записываются короче, но и тем, что их можно использовать в некоторых особых случаях. Так, например, let можно вставлять в генераторы списков (list comprehensions) с той же целью: чтобы определить выражение с ограниченной областью видимости. Однако в сравнении с where есть и недостатки: нельзя использовать охранные выражения и сопоставление с образцом. Само собой, раз мы задаём псевдонимы, то внутри своей области видимости они не должны повторяться.


Вы спрашиваете, когда мы перейдём к объектам? Я уже перешёл. А вы ещё нет, что ли? Тогда догоняйте. Создайте АТД-тип для объектов, вот такой:

data Object = Table
            | Umbrella
            | Drawer
            | Phone
            | MailBox
            | Friend'sKey
    deriving (Eq, Show, Read)


По образу и подобию локаций, добавьте функцию с описанием объектов:

describeObject :: Object -> String
describeObject Umbrella = «Nice red mechanic Umbrella.»
describeObject Table    = «Good wooden table with drawer.»
describeObject Phone    = «The Phone has some voice messages for you.»
describeObject MailBox  = «The MailBox is closed.»
describeObject obj      = «There is nothing special about » ++ show obj


Никаких подвохов нет, всё это мы уже знаем и умеем. Новое начинается, когда нам нужно определить объекты для локаций. Нам бы хотелось, конечно, чтобы всё уже было готово, действия Take и Drop работали, был инвентарь, составные объекты… Но так не бывает. Сейчас мы сделаем черновой вариант объектов в локациях, где их нельзя ни взять, ни бросить, и на его основе станем придумывать другие, более совершенные и подходящие механизмы.

Предположим, в локации Home, где начинается игра, есть несколько объектов, и среди них — стол, выдвижной ящик стола, телефон и зонт. Зададим функцию locationObjects, которая бы возвращала список объектов для данной локации:

locationObjects :: Location -> [Object]
locationObjects Home = [Umbrella, Drawer, Phone, Table]
locationObjects _    = []


В общем-то, списки выглядят просто. Список из объектов — это тип [Object]. Для локации Home мы возвращаем список из четырёх объектов. Пустой список обозначается пустыми квадратными скобками, — пусть пока все остальные локации будут без объектов. Стоит протестировать код, прежде чем двигаться дальше.

*Main> locationObjects Home
[Umbrella,Drawer,Phome,Table]
 
*Main> locationObjects Garden
[]
 
*Main> describeObject Table
«Good wooden table with drawer.»


Поскольку тип Object наследуется от класса типов Show, мы можем использовать функцию show не только для конструкторов типа, но и для списка этого типа:

*Main> show Umbrella
«Umbrella»
 
*Main> show [Umbrella, Table]
"[Umbrella,Table]"
 
*Main> show (locationObjects Home)
"[Umbrella, Drawer, Phone, Table]"
 
*Main> putStrLn (show (locationObjects Home))
[Umbrella, Drawer, Phone, Table]


Обратное тоже верно. Если элементы списка могут быть распарсены функцией read, то и весь список тоже можно распарсить:

*Main> read "[Table, MailBox]" :: [Object]
[Table,MailBox]


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

run curLoc = do
        let locDescr = describeLocation curLoc
        let objectsDescr = "\nThere are some objects here: " ++ show (locationObjects curLoc)
        let fullDescr = locDescr ++ objectsDescr
        putStrLn fullDescr
        ... -- остальной код, не забываем обновить действие Look.


Вывод программы теперь выглядит следующим образом:

*Main> main
Quest adventure on Haskell.
 
Home
You are standing in the middle room at the wooden table.
There are some objects here: [Umbrella,Drawer,Phone,Table]
Enter command:


У кода есть небольшая неприятность. Даже если в локации нет объектов, надпись «There are some objects here: » всё равно будет мозолить нам глаза.

Enter command: Go North
 
You walking to North.
 
Garden
You are in the garden. .....
There are some objects here: []
Enter command:


Чтобы для пустого списка объектов её не выводить, вынесем весь код описания объектов в отдельную функцию, назовём её enumerateObjects. На вход она принимает список, а на выход передаёт строку с перечисленными объектами.

enumerateObjects :: [Object] -> String
enumerateObjects [] = ""
enumerateObjects objects = "\n  There are some objects here: " ++ show objects


Первый вариант функции enumerateObjects возвращает пустую строку, — но только тогда, когда список переданных объектов будет пуст. Если же он не пуст, первый вариант будет отвергнут, а сработает второй. Функция run:

run curLoc = do
        let locDescr     = describeLocation curLoc
        let objectsDescr = enumerateObjects (locationObjects curLoc)
        let fullDescr    = locDescr ++ objectsDescr
        putStrLn fullDescr
        ... -- ...


Или, если хотите, можете вынести let-выражения из блока do:

run curLoc =
        let
                locDescr     = describeLocation curLoc
                objectsDescr = enumerateObjects (locationObjects curLoc)
                fullDescr    = locDescr ++ objectsDescr
        in do
        putStrLn fullDescr
        ... -- ...


Благодаря такому нехитрому рефакторингу мы можем теперь изменять функцию enumerateObjects, не затрагивая при этом run. Вот захотели мы, скажем, не только объекты перечислять, но и описания их выводить, — так залезем своими шаловливыми ручками в enumerateObjects и поправим чего умного там. Но как-нибудь потом. А сейчас добавим пользовательское действие Investigate. По этой команде программа должна выдавать детальное описание объекта. Понятно, что пользователь должен ввести что-то подобное:

Enter command: Investigate Umbrella


Строку «Investigate Umbrella» придётся распарсить. Знакомо, верно? Мы уже это проходили с командой Go. Здесь то же самое: просто поместим в конструктор Investigate параметр типа Object.

data Action =
        ...
        | Investigate Object
        ...


Осталась самая малость — поправить функцию run:

run curLoc = do
        ...
        ...
        case (convertStringToAction x) of
            Investigate obj -> do
                                putStrLn (describeObject obj)
                                run curLoc
        ...


И вуа-ля! Вы реализовали очередное действие пользователя! Поздравляю! Можете проверить, что если ввести команду «Investigate Umbrella», программа выдаст строку «Nice red mechanic Umbrella.», я не обманываю.

Вот только… Знаете, что нехорошо у нас? Если мы, находясь в локации Home, введём команду «Investigate MailBox», нам дадут описание почтового ящика, который отсюда и не видно совсем! Что ж, придумаем тогда функцию isVisible, которая вернёт True, если мы видим объект, — и только в этом случае будем выдавать описание объекта. Какие аргументы у функции isVisible? Нужен исследуемый объект, а так же список всех объектов локации. Как-то так:

isVisible :: Object -> [Object] -> Bool


Теперь нужно как-то узнать, есть ли объект в этом списке. Мы не будем придумывать «тупые» варианты с перебором всех объектов в списке, хотя это очень даже возможно, а просто воспользуемся штатной функцией elem. Она принимает два параметра: элемент и список. Если элемент найден в списке, возвращает True. То что нужно!

isVisible :: Object -> [Object] -> Bool
isVisible obj objects = elem obj objects


В Haskell есть специальная форма записи, при которой функция помещается между своими аргументами, — для пущей наглядности и прозрачности смысла. Для этого нужно функцию заключить в обратные апострофы, — те, что находятся на кнопке «Ё».

isVisible' :: Object -> [Object] -> Bool
isVisible' obj objects = obj `elem` objects


А вот «тупые» варианты той же функции. В них мы вручную ищем объект внутри списка.

isVisible'' :: Object -> [Object] -> Bool
isVisible'' obj [] = False
isVisible'' obj (o:os) = (obj == o) || (isVisible'' obj os)
 
isVisible''' :: Object -> [Object] -> Bool
isVisible''' obj [] = False
isVisible''' obj objects = (obj == head objects) || (isVisible''' obj (tail objects))


Работают они одинаково, разница лишь в используемых средствах. И там, и там список расщепляется на части: головной элемент и хвост из оставшихся элементов. В первом примере список расщепляется с помощью записи (o:os). Понятно, что переменная «o» содержит голову, а переменная «os» — всё остальное. Во втором примере мы делаем то же самое, только с помощью встроенных функций над списками head и tail. Далее мы просто проверяем, совпадает ли объект с головным элементом, и если нет, вызываем isVisible рекурсивно для оставшихся элементов. Для того, чтобы рекурсия не была бесконечной, мы добавили вариант функции «isVisible obj [] = False», — он сработает, если вдруг мы откусим от списка все элементы и ничего не останется.


Ну что ж, осталось только воспользоваться этой функцией. Как водится, изменим run, причём чтобы несколько раз не запрашивать объекты текущей локации, вынесем их в let-выражение:

run curLoc = do
        let locObjects = locationObjects curLoc
        let locDescr = describeLocation curLoc
        let objectsDescr = enumerateObjects locObjects
        let fullDescr = locDescr ++ objectsDescr
        putStrLn fullDescr
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Investigate obj -> do
                                if (isVisible obj locObjects)
                                    then putStrLn (describeObject obj)
                                    else putStrLn («You don't see any » ++ show obj ++ " here.")
                                run curLoc
            Quit          -> putStrLn «Be seen you...»
            ... -- остальной код


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

Задания для закрепления.

1. Вынести все функции, связанные с объектами, в модуль Objects, а все функции, связанные с локациями, — в модуль Locations.
2. Сделать экспериментальный вывод объектов локации в следующем виде:

Home
You are standing in the middle room at the wooden table.
  There are some objects here:
     Umbrella: Nice red mechanic Umbrella.
     Table: Good wooden table with drawer.
     Phone: The Phone has some voice messages for you.
     Drawer: There is nothing special about Drawer.


3. Провести рефакторинг обработки действия Investigate, создав для этого отдельную функцию. Код функции run после рефакторинга должен выглядеть примерно так:

    ...
    Investigate obj -> do
                        putStrLn (investigate obj locObjects)
                        run curLoc
    ...


Исходники к этой части.

Оглавление, список литературы и дополнительную информацию вы найдете в «Приветствии».
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+4
Comments 0
Comments Leave a comment

Articles