Haskell Quest Tutorial — Вид каньона

  • Tutorial
Canyon View
You are at the top of Great Canyon on its west wall. From here there is a marvelous view of the canyon and parts of the Frigid River upstream. Across the canyon, the walls of the White Cliffs join the mighty ramparts of the Flathead Mountains to the east. Following the Canyon upstream to the north, Aragain Falls may be seen, complete with rainbow. The mighty Frigid River flows out from a great dark cavern. To the west and south can be seen an immense forest, stretching for miles around. A path leads northwest. It is possible to climb down into the canyon here.


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

Часть 4,
в которой мы займёмся рефакторингом, реализуем пару действий, узнаем о pattern matching и рекурсии, а так же сделаем из квеста настоящую программу.


А давайте введём текущую локацию? И то сказать — давно пора. У нас уже есть функция run, где происходит всё самое важное, — значит, в ней и должна быть текущая локация. Предположим, в функции run сначала выводится описание локации, а затем уже всё остальное. Значит, функция run знает о текущей локации. Передадим ей эту локацию как параметр:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        putStrLn ( evalAction (convertStringToAction x) )


Хорошо. Пробуем:

*Main> run Home
Home
You are standing in the middle room at the wooden table.
Enter command: Look
Action: Look!
*Main>


Какая короткая, однако, программа! Она работает, как ожидалось, но тут же и завершается. Ну, это не игра. В играх обычно обработчик событий крутится в цикле, пока его явно не прервут кнопкой «Выход». Хотелось бы что-то подобное: игра должна работать, пока мы ей не скажем «Quit». Как это сделать? Для начала нам нужно организовать непрерывную обработку команд от пользователя. Самый простой способ — это вызвать run из самой себя. У неё есть параметр, — текущая локация, — пока передадим старую текущую локацию, а позже что-нибудь придумаем.

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        putStrLn ( evalAction (convertStringToAction x) )
        putStrLn «End of turn.\n»  -- Новый ход - с новой строки.
        run curLoc
 
-- Тестируем:
 
*Main> run Home
Home
You are standing in the middle room at the wooden table.
Enter command: Look
Action: Look!
End of turn.
 
Home
You are standing in the middle room at the wooden table.
Enter command: <Ctrl+C> Interrupted.


Программа послушно завела шарманку с начала. У нас нет пока адекватной обработки событий, поэтому единственный способ прервать программу — это нажать <Ctrl + C>. А чтобы заставить её реагировать на команду «Quit», нужен небольшой рефакторинг. Перепишем функцию run следующим образом:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit      -> putStrLn «Be seen you...»
            otherwise -> do
                            putStrLn (evalAction (convertStringToAction x))
                            putStrLn «End of turn.\n»
                            run curLoc


Ага, что-то тут нечисто!.. Давайте разберёмся. Начало функции run вам знакомо. Ключевое слово «do», стоящее в самом начале, связывает действия в цепочку. Какие действия входят в эту цепочку? Всего четыре: (напечатать описание локации) — (вывести строчку «Enter command: „) — (получить от пользователя строку и связать её с переменной x) — (выполнить выражение внутри case-конструкции). Когда выполнение доходит до case, оно перескакивает в нужную альтернативу и продолжается уже там. Допустим, пользователь ввел “Quit», и тогда функция (convertStringToAction x) вернёт конструктор Quit, — значит, должна сработать первая альтернатива. В этой альтернативе всего одно действие — печать строки «Be seen you...». Больше действий нет нигде — ни внутри альтернативы Quit, ни после case-конструкции, так что функции run больше нечего делать, и она завершится. Теперь предположим, что пользователь ввёл не «Quit», а «Look», — что же произойдет? Понятно, что альтернатива Quit не сработает, но otherwise всегда на страже, — там-то и продолжится выполнение. И что там? Ещё одно ключевое слово «do»! Значит, здесь началась новая цепочка действий, и она выполняется точно так же, по шагам. Какие действия входят в эту цепочку? Всего три: (напечатать обработанную команду) — (вывести строчку «End of turn.\n») — (запустить функцию run).

На первый взгляд функция непонятная: все эти case, do, левые и правые стрелочки! Но всё становится на места, если аккуратно проследить выполнение. Нам осталось только проверить, на самом ли деле программа работает, как мы думаем.

*Main> run Home
Home
You are standing in the middle room at the wooden table.
Enter command: Look
Action: Look!
End of turn.
 
Home
You are standing in the middle room at the wooden table.
Enter command: Quit
Be seen you...
*Main>


Ура! Мы молодцы! Но наша функция run ещё очень далека от идеала. Обратите внимание: в ней два раза встречается вызов (convertStringToAction x), и это плохо. Благо, что функция convertStringToAction простая, ресурсов не требует, а то был бы перерасход. В языке Haskell, да и в любом другом языке, нужно избегать повторений. Поскольку этот вызов у нас находится в case-конструкции, его результат можно положить в переменную. Чуть-чуть изменим функцию run:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            convertResult -> do
                                putStrLn (evalAction convertResult)
                                putStrLn «End of turn.\n»  -- Новый ход - с новой строки.
                                run curLoc


Да, вместо otherwise теперь переменная convertResult. Если прошлые альтернативы на каком-то значении не сработали, то это значение, вычисленное один раз, помещается в переменную и далее используется. И это хорошая практика, которая встречается даже чаще, чем otherwise, по понятным причинам.

Что ж, это было очень сложно, так что на сегодня всё.

Не забывайте об отступах, они здесь важны; для первого и второго «do» все действия располагаются строго там, где началось первое из них. Можно было бы и по-другому выровнять, например, так:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr "Enter command: "
        x <- getLine
        case (convertStringToAction x) of
            Quit      -> putStrLn "Be seen you..."
            otherwise -> do
                putStrLn (evalAction (convertStringToAction x))
                putStrLn "End of turn.\n"  -- Новый ход - с новой строки.
                run curLoc
 


У каждого блока свои отступы, и смешивать их не нужно.


Кто сказал, что можно расходиться? Мы продолжаем! Вы, конечно, уже строите планы, как улучшить программу. И правильно! Реальную (а не фиктивную) обработку команды Look добавьте сами когда-нибудь, а сейчас подумаем над командой Go. Как она реализована в Zork?

Clearing


> Go West
Forest
...


Если мы напишем «Go West», парсер команд будет недоволен, потому что ему непонятно, как парсить эту строку:

*Main> run Home
Home
You are standing in the middle room at the wooden table.
Enter command: Go West
*** Exception: Prelude.read: no parse


Может быть, разобьём строку «Go West» на две и отдельно их распарсим, ведь у нас есть конструкторы Go и West? Мысль, безусловно, правильная, и она, конечно, сработает, но… Haskell не был бы волшебным языком, если бы не позволял распарсить «Go West» ещё проще, только для этого нужно кое-что подготовить. Зайдём издалека. Как вы помните, в третьей части была врезка о том, что конструкторы АТД-типов — это особые функции. Например, Home — функция типа Location, Go — функция типа Action, а West — функция типа Direction.

*Main> :type Home
Home :: Location
 
*Main> :type West
West :: Direction
 
*Main> :type Go
Go :: Action


А что мы знаем про функции? Правильно. Что у них могут быть аргументы. И какой вопрос мы должны задать? Правильно. «Если конструкторы — это функции, то значит ли это, что у них тоже могут быть аргументы?» Как вы уже догадались, да! При определении АТД-типа можно указать, аргументы каких типов передаются в тот или иной конструктор. Добавим конструктору Go аргумент типа Direction.

data Action =
          Look
        | Go Direction
        | Inventory
        | Take
        | Drop
        | Investigate
        | Quit 
        | Save 
        | Load 
        | New
    deriving (Eq, Show, Read)


При этом тип у конструктора слегка изменится, ведь мы сделали функцию, которая принимает Direction, а возвращает Action. Проверим, как сработает перевод конструктора в строку и обратно:

*Main> show (Go West)
«Go West»
 
*Main> read «Go North» :: Action
Go North
 
*Main> :t Go
Go :: Direction -> Action


Вот. И, что самое приятное, усилий с нашей стороны — почти никаких. Составной конструктор ничуть не сложнее, чем другие, он даже лучше, потому что интуитивно задаёт данные. И работать с ним очень просто! Обновим case-конструкцию у run:

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            Go dir        -> putStrLn («You going to » ++ show dir ++ "!")
            convertResult -> do
                                putStrLn (evalAction convertResult)
                                putStrLn «End of turn.\n»  -- Новый ход - с новой строки.
                                run curLoc
 
-- Проверим:
 
*Main> run Home
Home
You are standing in the middle room at the wooden table.
Enter command: Go West
You going to West!


Если парсер распознает в строке «Go Что-то», сработает альтернатива «Go dir», и в переменную dir упадёт то самое «Что-то». Ну а дальше мы работаем уже с переменной dir. Волшебство!

Алгебраические типы данных в Haskell еще лучше. Возможно, вы уже придумали, как переписать задачку «Тупой калькулятор» из третьей части, используя конструкторы с аргументами. У вас мог бы получиться примерно такой код:

data IntegerArithmOperation = 
      Plus   Integer Integer
    | Minus  Integer Integer
    | Prod   Integer Integer
    | Negate Integer
 
evalOp :: IntegerArithmOperation -> Integer
evalOp op = case op of
                Plus   x y -> x + y   -- Параметр «op» сопоставляется с каждым вариантом по очереди.
                Minus  x y -> x - y   -- Какой образец подошел, такая альтернатива и выбирается.
                Prod   x y -> x * y   -- Вместо x и y подставляются числа, которые мы передали вместе с конструктором.
                Negate x   -> -x      -- Возвращается результат вычисления. Например, 2 * 3 = 6.
 
*Main> evalOp (Plus 2 3)    -- (Plus 2 3) - это конструктор с двумя аргументами.
5
*Main> evalOp (Minus 2 3)
-1
*Main> evalOp (Prod 2 3)
6
*Main> evalOp (Negate 5)
-5


Что мы за приключенцы, если топчемся в одной и той же локации? Стоит собрать силы в кулак и сделать уже переход между локациями! Пусть у нас есть функция walk (задание из второй части), которая принимает текущую локацию и направление движения, и возвращает новую локацию. Вот такая:

walk :: Location -> Direction -> Location
walk curLoc toDir = case curLoc of
                        Home -> case toDir of
                                North -> Garden
                                South -> Friend'sYard
                                otherwise -> Home
                        Garden -> case toDir of
                                North -> Friend'sYard
                                South -> Home
                                otherwise -> Garden
                        -- ... Добавить сюда оставшиеся варианты.


На самом деле, ужасный подход. Столько лишней работы! Столько вложенных case! Мы обязательно придумаем что-нибудь другое, когда у нас будет достаточно знаний, а сейчас мы можем лишь улучшить функцию walk, чтобы не было никаких case:

walk :: Location -> Direction -> Location
 
walk Home North         = Garden
walk Home South         = Friend'sYard
walk Garden North       = Friend'sYard
walk Garden South       = Home
walk Friend'sYard North = Home
walk Friend'sYard South = Garden
walk curLoc _           = curLoc


Ух, как много функций walk! И всё равно — строчек меньше, чем в предыдущем примере, а функционала больше. Как это работает? Очень просто. Когда мы вызываем функцию walk с аргументами, выбирается подходящий вариант. Например, «walk Garden South» — и будет выбран четвёртый, который вернёт Home.

*Main> walk Garden South
Home


Интерес представляет последняя walk. Она просто оставляет нас в текущей локации. Можно догадаться, что когда не сработают все остальные, сработает она. В ней первый параметр поместится в переменную curLoc, а второй параметр — никуда не поместится. Нам, в общем-то, и не важно, что там во втором параметре, мы же не будем его использовать, поэтому и поставили знак «подчеркивание». Можно, конечно, переменную какую-нибудь подсунуть, но подчеркивание более наглядно. Не указывать аргумент нельзя; если вы так сделаете, ghci заругается, мол, чего это вы такие непостоянные?..

......
walk Friend'sYard North = Home
walk Friend'sYard South = Garden
walk curLoc             = curLoc
 
 
*Main> :r
[1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:50:1:
    Equations for 'walk' have different numbers of arguments
      H:\Haskell\QuestTutorial\Quest\QuestMain.hs:50:1-24
      H:\Haskell\QuestTutorial\Quest\QuestMain.hs:56:1-22
Failed, modules loaded: none.


Сопоставление с образцом (pattern matching), которое используется здесь, — полезный инструмент, с ним код становится понятнее, безопаснее, короче. Есть и другие приятные особенности сопоставления с образцом. Можно «подсовывать» не только константы, но и переменные, и даже разбирать терм на составляющие части. С помощью специальной записи легко разделить первый элемент списка и остальную часть. Вот как это выглядит для строк:

headLetter :: String -> Char
headLetter (ch:chs) = ch
 
tailLetters :: String -> String
tailLetters (ch:chs) = chs
 
*Main> headLetter «Abduction»
'A'
*Main> tailLetters «Abduction»
«bduction»


Последний шаг — модифицировать функцию run. Всё тривиально: добавляем цепочку действий в альтернативу Go dir, выводим строчку "\nYou walking to " ++ show dir ++ ".\n", и снова запускаем функцию run, — но в этот раз мы получим новую текущую локацию с помощью walk.

run curLoc = do
        putStrLn (describeLocation curLoc)
        putStr «Enter command: »
        x <- getLine
        case (convertStringToAction x) of
            Quit          -> putStrLn «Be seen you...»
            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
 
-- Проверка
 
*Main> run Home
Home
You are standing ...
Enter command: Go North
 
You walking to North.
 
Garden
You are in the garden. ...


Вот, собственно, и всё. Действие Go работает.

Рекурсия - это рекурсия. это вызов функции из самой себя. Мы активно пользуемся рекурсией в Haskell, — и в этом нет ничего плохого. В отличие от императивных языков, таких как С++, функциональные языки поощряют нас использовать рекурсию там, где сложно обойтись иными методами. В данном случае рекурсия приемлема; мы не плодим кучу данных, мы не крутим рекурсию даже тысячи раз, — значит, не стоит бояться утечки ресурсов, какая могла бы быть в том же С++. В Haskell существуют механизмы, вместе с которыми рекурсия становится безопасным средством. Так, вычисляются только те выражения, которые непосредственно нужны здесь и сейчас, значит, ресурсы не тратятся на огромный пласт прочего кода. Компилятор умеет повторно использовать то, что когда-то было вычислено. Стека как такового в Haskell нет, — память не переполнится. Наконец, оптимизируя рекурсию в хвостовой вызов, компилятор может добиться, чтобы рекурсии в обычном смысле не было вообще.

Можно скомпилировать простую программку и посмотреть на динамику использования ресурсов. Программка вычисляет числа Фибоначчи на каждом шагу рекурсии и выводит последнее из них.

-- test.hs:
 
module Main where
 
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
 
run cnt = do
        putStrLn ("\nTurns count: " ++ show cnt)
        putStrLn $ show $ last $ take cnt fibs
        run (cnt + 1)
 
main = do
    run 1


Компиляция с оптимизацией:

ghc -O test.hs


Любите ли вы порядок в коде? Код нашего квеста выглядит, в целом, неплохо, только ему не хватает одной важной вещи: точки входа в программу. Его даже нельзя скомпилировать в исполняемый файл. Пока мы пользовались интерпретатором GHCi, об этом не задумывались, но что если друг захочет поиграть в нашу игру? Не код же на Haskell ему давать, — друг вообще, может, не программист и ничего не поймёт. А вот исполняемый файл — это просто. Чтобы скомпилировать программу по-настоящему, в командной строке следует выполнить команду ghc, указав путь к QuestMain.hs:

H:\Haskell\QuestTutorial\Quest> ghc QuestMain.hs
[1 of 1] Compiling Main           ( QuestMain.hs, QuestMain.)
 
QuestMain.hs:1:1
    The function 'main' is not defined in module 'Main'


Компилятор GHC говорит, что не найдена функция main в модуле Main. Именно так оформляется точка входа в программу на языке Haskell. Похоже на прочие языки, где есть функция main в том или ином виде. Добавим её внизу файла QuestMain.hs:

main = do
    putStrLn «Quest adventure on Haskell.\n»
    run Home


А в самом начале файла определим модуль Main, в котором будут лежать все наши функции:

module Main where


Теперь компилятор благополучно скушает исходник, и вы увидите исполняемый файл. У меня это QuestMain.exe. Кроме прочего появятся файлы с расширениями .o и .hi — это временные файлы (объектный и файл с интерфейсами). Если они вам мешают, можете удалить их. Пока проект маленький, особой роли они не играют. Потом их, как и в других языках, можно использовать для частичной компиляции, что существенно быстрее, нежели компиляция с нуля. Например, если какой-то модуль был однажды скомилирован и больше не изменялся, равно как не изменялись модули, от которых он зависит, то его не нужно перекомпилировать, достаточно взять старые .o- и .hi-файлы. Поэтому хорошей практикой будет разделять код по модулям; ещё лучшей — по модулям и папкам; и ещё лучшей — по модулям, папкам и библиотекам.

Давайте разделим наш квест на два модуля: модуль Types и модуль Main. Для этого создадим файл Types.hs, в самом верху определим его как модуль с помощью строчки «module Types where» и перенесём туда все АТД-типы из файла QuestMain.hs.

-- Types.hs:
 
module Types where
 
data Location =
          Home
        | ...
    deriving (Eq, Show, Read)
 
data Direction =
              North
            | ...
    deriving (Eq, Show, Read)
 
data Action =
          Look
        | Go Direction
        | ...
    deriving (Eq, Show, Read)


Если в ghci сейчас выполнить команду :r, интерпретатор запаникует: он не знает эти типы!

*Main> :r
[1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:4:21:
    Not in scope: type constructor or class 'Location'
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:7:13:
    Not in scope: data constructor 'Home'
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:8:13:
    Not in scope: data constructor 'Friend'sYard'
 
... и таких строчек ещё штук двадцать.
 
Prelude>


Не беда! Мы должны подключить наш модуль с типами, — и они станут видны в Main. Где-нибудь вверху модуля Main, под «module Main where», добавим простую строчку:

-- QuestMain.hs:
module Main where
 
import Types
-- ... остальной код...


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

Prelude> :r
[1 of 2] Compiling Types    ( H:\Haskell\QuestTutorial\Quest\Types.hs, interpreted )
[2 of 2] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
Ok, modules loaded: Main, Types.
*Main>


Мы разделили код на модули, — и это хорошо. Мы добавили точку входа, — и код стал настоящей программой. Это тоже хорошо. Мы, в конце концов, познакомились с рекурсией и с pattern matching, а так же придумали один составной конструктор типа Action. Славно поработали! Теперь следует хорошо отдохнуть и закрепить знания.

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

1. Создать модуль GameAction в файле GameAction.hs и вынести в него все функции из Main, кроме main и run.
2. Добавить (если ещё нет) обработку команды Look рядом с обработкой Quit, Go dir. Подумать, как можно улучшить дублирующийся код "(describeLocation curLoc)", чтобы не вызывать его два раза.
3. Добавить обработку команды New с проверкой, уверен ли пользователь, что хочет начать новую игру.


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

Оглавление, список литературы и дополнительную информацию вы найдете в «Приветствии».
  • +18
  • 2,6k
  • 7
Поделиться публикацией

Похожие публикации

Комментарии 7

    +2
    Оглавление, имхо стоит дублировать в каждой части. Ctrl+C, Ctrl+V; делов-то.

    Вообще вся линейка туториалов несколько недооцененная. Возможно дело в том, что отсутствует ЦА (цикл из серии «просто о сложном»). Возможно, дело в том, что он несколько раздут. Возможно, вы слишком сильно ориентируетесь на не-программистов (объясняете понятия типа рекурсии). Но я не считаю ничего из вышеперечисленного серьезным недостатком.

    Вообще стоит на хабре сделать в каждом блоге раздел «для начинающих». Было бы очень полезно.
      +1
      Спасибо за отзыв. Действительно, статьи как-то обделены вниманием, но ведь кто-то же ставит плюсы и добавляет в избранное, — значит, есть люди, кому туториал нужен. Это радует.

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

      Оглавления вставлю. Раздел для начинающих тоже поддерживаю, пусть даже это только идея.
        +1
        Сколько частей мне еще удастся написать, не знаю, отпуск уже кончается.

        Хорошо бы побольше:)
          0
          Да, хорошо бы. Я тоже так думаю, но потом осознаю, что только думать, увы, не достаточно. :) Приложу все усилия.
      +1
      Для начала, спасибо за статью/статьи. Хотел уточнить. Где у нас происходит дублирование вызова «describeLocation curLoc», с которым предполагается бороться? Или это появится после добавления обработки команды Look? =)
        0
        О! Вы внимательны! Точно: дублирование появится после добавления команды Look. Но так же это есть в исходниках к текущей части. :)
      • НЛО прилетело и опубликовало эту надпись здесь

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

        Самое читаемое