Pull to refresh

Haskell Quest Tutorial — Поляна

Reading time 11 min
Views 2.6K
Clearing
You are in a small clearing in a well marked forest path that extends to the east and west.


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

Часть 3,
в которой мы станем учиться волшебству с АТД и познаем магические преобразователи Show и Read.

В прошлой части мы изобретали различные варианты describeLocation, а в конце создали три алгебраических типа — Location, Direction, Action. Я обмолвился про волшебство и удивительные возможности АТД, но сказал, что мы рассмотрим их позже. Мы только унаследовали наши типы от класса типов Eq, в котором лежат операции "==" и "/=", а теперь…

Хотите чудес? Ну что ж… Посмотрим еще раз на тип Location:

data Location =
          Home
        | Friend'sYard
        | Garden
        | OtherRoom    -- Добавлен новый конструктор.
    deriving (Eq)
 
*Main> Home /= Friend'sYard
True
*Main> Home == OtherRoom
False


Очень хорошо! В первой части мы узнали, что есть функция show, которая переводит что-то в строку. Попробуем:

*Main> show Home

<interactive>:1:1:
    No instance for (Show Location)
      arising from a use of 'show'
    Possible fix: add an instance declaration for (Show Location)
    In the expression: show Home
    In an equation for 'it': it = show Home


Не получилось… Мы с вами уже сталкивались с подобной ошибкой в конце второй части. Там мы пытались сравнить два конструктора, но ничего не вышло, потому что ghci не знал, как их сравнивать. Мы решили проблему, добавив в конце типа Location заклинание «deriving (Eq)», — и получили «фабричную» функцию сравнения "==". Можем ли мы сделать что-либо подобное, чтобы получить функцию show? Можем! Достаточно наследовать класс типов Show:

data Location =
          Home
        | Friend'sYard
        | Garden
        | OtherRoom
    deriving (Eq, Show)
 
*Main> :r
[1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
Ok, modules loaded: Main.
 
*Main> show Home
«Home»
*Main> «Current location name: » ++ show Home ++ "."
«Current location name: Home.»
*Main> show Friend'sYard
«Friend'sYard»


Как это можно использовать? О, самыми разными способами. Давайте сейчас улучшим функцию describeLocation. Перепишем последнюю альтернативу ("otherwise"):

describeLocation :: Location -> String
describeLocation loc = case loc of
            Home         -> «You are standing in the middle room at the wooden table.»
            Friend'sYard -> «You are standing in the front of the night garden behind the small wooden fence.»
            Garden       -> «You are in the garden. Garden looks very well: clean, tonsured, cool and wet.»
            otherwise    -> «No description available for location with name » ++ show loc ++ "."


А теперь, не прибегая к помощи ghci, скажите мне: что будет, если вызвать «describeLocation OtherRoom»? Проследите, куда попадет конструктор OtherRoom, как сработает case, какой из вариантов выберется, и что за строку этот вариант вернёт. Готово? Проверьте себя:

*Main> describeLocation OtherRoom
«No description available for location with name OtherRoom.»


У меня, к сожалению, нет для вас пирожков; но если вы правильно догадались, можете гордиться собой. Только что вы взяли функцию show из класса типов Show и преобразовали конструктор в строку. Красиво? По-моему, да. Попробуйте, например, в С++ столь же легко преобразовать в строку элемент какого-нибудь перечисления…

Функция show очень полезна. Унаследуйте от класса типов Show типы Action и Direction. Обещаю, не прогадаете!

Конструкторы типов, такие как Home, Friend'sYard или Garden, на самом деле, являются особыми функциями, которым позволено начинаться с заглавной буквы. А раз это функции, то у них есть тип. Что выдаст команда ":type Home"? Это же элементарно, Ватсон.

*Main> :type Home
Home :: Location


Знаете, меня здесь что-то не устраивает. Посмотрите на цитаты из Zork в начале каждой из частей: там сначала выводится название локации, а затем — с новой строчки — описание. Давайте перепишем функцию describeLocation… Да-да, опять её, не стоните так!.. Я хочу, чтобы название локации выводилось перед ее описанием. Решение «в лоб»: я просто внедрил название локации в текстовую строку.

describeLocation loc = case loc of
            Home         -> «Home\nYou are standing in the middle room at the wooden table.»
            Friend'sYard -> «Friend'sYard\nYou are standing in the front of the night garden behind the small wooden fence.»
            Garden       -> «Garden\nYou are in the garden. Garden looks very well: clean, tonsured, cool and wet.»
            otherwise    -> «No description available for location with name » ++ show loc ++ "."


Работать, конечно, будет. Если вам хочется загрязнять описания, то пожалуйста. Мне не хочется. Вариант номер два:

describeLocation loc = case loc of
            Home         -> show loc ++ "\n" ++ «You are standing in the middle room at the wooden table.»
            Friend'sYard -> show loc ++ "\n" ++ «You are standing in the front of the night garden behind the small wooden fence.»
            Garden       -> show loc ++ "\n" ++ «You are in the garden. Garden looks very well: clean, tonsured, cool and wet.»
            otherwise    -> «No description available for location with name » ++ show loc ++ "."


Уже лучше, хотя прибавляется много работы со всеми этими плюсиками… И повторяться — дурной тон… Есть более простой и элегантный способ! Следите за руками:

describeLocation loc = show loc ++ "\n" ++
        case loc of
            Home         -> «You are standing in the middle room at the wooden table.»
            Friend'sYard -> «You are standing in the front of the night garden behind the small wooden fence.»
            Garden       -> «You are in the garden. Garden looks very well: clean, tonsured, cool and wet.»
            otherwise    -> «No description available for location with name » ++ show loc ++ "."


Фокус в том, что case-конструкция — это одно большое выражение, от слова «case» и до конца последней альтернативы. Мы можем внедрять case внутрь других выражений. В нашем случае case всегда возвращает строку, значит, мы можем добавить ее к другой строке. Код становится читабельнее, стройнее и красивее. Если вы протестируете все три варианта, то увидите, что они выдают то, что надо.

*Main> describeLocation Home
«Home\nYou are standing in the middle room at the wooden table.»
*Main> putStrLn (describeLocation Home)
Home
You are standing in the middle room at the wooden table.


case-конструкция, безусловно, хороша. Есть, однако, случаи, когда она неудобна. Если вы решали задачу №2 из первой части, вы уже догадываетесь, о чём я. Напомню, что там нужно было реализовать следующую функцию для некоторых x и a:

    | ln (abs(sin(x))), если x > 5
y = | x^2 + a^2,        если x <= 5 и a <= 3
    | x / a + 7.8*a,    если x <= 5 и a > 3


Функция как функция, в математике таких тьма. Но попробуйте-ка реализовать ее на Haskell с помощью if-then-else или case:

y x a = if x > 5
        then log (abs (sin(x) ) )
        else
            if x <= 5 && a <= 3
            then x^2 + a^2
            else x / a + 7.8*a
 
y' x a = case x > 5 of
            True  -> log (abs (sin(x) ) )
            False -> case x <= 5 && a <= 3 of
                        True  -> x^2 + a^2
                        False -> x / a + 7.8*a


Функцию трудно читать из-за нескольких уровней вложенности. Неужели по-другому нельзя?.. Ну как же! Охранные выражения! И Haskell-функция становится похожей на функцию в математике. Смотрите:

y'' x a | x > 5             = log (abs (sin(x) ) )
y'' x a | x <= 5 && a <= 3  = x^2 + a^2
y'' x a | otherwise         = x / a + 7.8*a
 
-- Или то же самое:
 
y'' x a | x > 5             = log (abs (sin(x) ) )
        | x <= 5 && a <= 3  = x^2 + a^2
        | otherwise         = x / a + 7.8*a


Легко понять, что функция принимает вид «log (abs (sin(x) ) )» если x будет больше пяти. Охранное выражение (guard) — это выражение между знаками "|" и "=". Для охранных выражений действуют те же законы, что и для альтернатив case-конструкции: набор выражений должен быть полным, а otherwise всегда срабатывает.


Но давайте вернемся к проектированию игры. В любой игре есть код, где снова и снова вызываются обработчики событий, рассчитываются графика, физика, ИИ. У нас игра проще. Пользователь вводит команду с клавиатуры, — и что-то происходит, потом он снова вводит команду, и снова что-то происходит, и так далее. Будет примерно такой алгоритм:

0. Объясняем игровую обстановку:
— выводим описание текущей локации;
— выводим описание объектов в локации.
1. Ждем команду от игрока в виде строки.
2. Пытаемся распознать команду.
3а. Если команда распознана:
— выполняем её;
— возвращаемся к пункту 0.
3б. Если команда не распознана:
— выдаем сообщение об этом;
— возвращаемся к пункту 1.

Половина пункта 0 уже готова: это функция «describeLocation». Объектов пока у нас нет, мы их добавим позже. Значит, переходим к пункту 1. Как получить ввод с клавиатуры? В первой части я рассказал про функцию putStrLn, которая печатает строку в реальной консоли; пора познакомиться с противоположной функцией — getLine. Рассмотрим следующее заклинание:

run =
    do
        x <- getLine
        putStrLn x


Самое время прокачать навыки «Грамотность» и «Орлиный глаз»! Что происходит в функции run? Несколько простых действий. Мы ждем строку с клавиатуры (getLine); эту строку связываем с x; печатаем x в реальной консоли. И чтобы связать действия в цепочку, используется ключевое слово «do» — такая вот особенность языка Haskell. А теперь испытаем:

*Main> run
Hello!        -- То, что ввел я
Hello!        -- То, что напечатала функция putStrLn
*Main> run
kjljfs
kjljfs


Еще раз: функция getLine просит у на строку. Строка связывается с переменной x, а на следующем шаге функция putStrLn печатает x. Давайте внесем ясность, добавив перед вводом строки приглашение «Enter command: ». Пусть пользователь видит, что от него хотят.

run = do
        putStr «Enter command: »
        x <- getLine
        putStrLn x
 
*Main> run
Enter command: Look
Look


Я использовал функцию putStr: она что-то печатает, но курсор на новую строку не переводит. Вообще, тут полная аналогия с Pascal: writeLn <=> putStrLn, write <=> putStr.

Вы, конечно, заметили, что я написал «связываем с x», а не «присваиваем x». В Haskell присвоения нет, потому-то и стоит там стрелка ("<-"), а не знак присвоения ("=", ":="). Стрелка показывает, откуда мы берем результат и с чем его связываем. Между присвоением и связыванием есть существенная разница с далеко идущими следствиями. Но покуда мы не используем эти следствия, то и переживать не стоит.


Теперь нам нужно выполнить команду, введенную пользователем. Для этого придумаем простую функцию «evalAction» и вызовем её из run:

evalAction :: String -> String
evalAction strAct = «Action: » ++ strAct ++ "!"
 
run = do
        putStr «Enter command: »
        x <- getLine
        putStrLn (evalAction x)
 
-- Тестируем:
 
*Main> run
Enter command: Look
«Action: Look!»
*Main> run
Enter command: Quit
«Action: Quit!»


Хо-хо! Наша заготовка, без сомнений, работает! Только вот evalAction принимает строку, а не специальный тип Action. Из-за этого мы можем передать в функцию любую абракадабру.

*Main> run
Enter command: lubaya_abrakadabra
«Action: lubaya_abrakadabra!»


Нас вводят в заблуждение. Такого Action, как lubaya_abrakadabra, нет… Мы уже как-то провернули трюк с заменой строки на Location в функции describeLocation. Что если повторим его здесь? Заменим строку на Action:

evalAction :: Action -> String
evalAction act = «Action: » ++ show act ++ "!"
 
run = do
        putStr «Enter command: »
        x <- getLine
        putStrLn (evalAction x)


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

*Main> :r
[1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
 
H:\Haskell\QuestTutorial\Quest\QuestMain.hs:46:38:
    Couldn't match expected type 'Action' with actual type '[Char]'
    Expected type: Action
      Actual typeString
    In the first argument of 'evalAction', namely 'x'
    In the first argument of 'putStrLn', namely '(evalAction x)'
Failed, modules loaded: none.


GHCi нам говорит, что не совпадают типы. Функция evalAction хочет тип Action, а вовсе не String. Мы ошиблись тут: «putStrLn (evalAction x)». Хех… А ведь такая идея была хорошая!..

Программируя на Haskell, вы часто будете видеть ошибки типизации. Ничего плохого в этом нет; в них написано, в каком месте нестыковка, что ожидалось получить (Expected type), и что получили на самом деле (Actual type). Скомпилировать неправильный код нельзя. При большом рефакторинге может возникнуть до нескольких десятков ошибок, а то и больше, — и придется их все исправить, одну, другую, третью… Когда наконец ошибки исчезнут, код с высокой вероятностью заработает именно так, как вы ожидаете. И это, скажу я вам, очень-очень здорово!


Чтобы из строки «x» получить конструктор типа Action, есть несколько решений. Для начала попробуем придумать функцию convertStringToAction. Вопрос на «тройку»: какой будет тип у функции, которая преобразует String в Action? Это же очевидно!

convertStringToAction :: String -> Action


Самый простой способ — использовать case. В последней альтернативе мы перестрахуемся и вернем Quit, если вдруг чего.

convertStringToAction :: String -> Action
convertStringToAction str = case str of
        «Look»    -> Look
        «New»     -> New
        otherwise -> Quit


Лучше всего её вставить при вызове evalAction в функции run. Вот так:

-- Обрабатываем действие.
evalAction :: Action -> String
evalAction act = «Action: » ++ show act ++ "!"
 
-- Преобразовываем строку в Action
convertStringToAction :: String -> Action
convertStringToAction str = case str of
        «Look»    -> Look
        «New»     -> New
        otherwise -> Quit
 
-- Получаем ввод с клавиатуры, конвертируем его в действие, вызываем обработчик, выводим результат.
run = do
        putStr «Enter command: »
        x <- getLine
        putStrLn ( evalAction (convertStringToAction x) )


А теперь проверим:

*Main> :r
[1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
Ok, modules loaded: Main.
 
*Main> run
Enter command: Look
Action: Look!
*Main> run
Enter command: dfdf
Action: Quit!


Что ж, это победа! Теперь функция evalAction работает не со строкой, а с Action. Всё бы хорошо, но… Вы видите, сколько работы предстоит, когда мы захотим добавить еще какую-нибудь команду кроме Look? У нас их в типе целых десять: Look, Go, Inventory, Take, Drop, Investigate, Quit, Save, Load, New, — да и другие могут появиться. И что же, снова и снова расширять case-конструкцию у функции convertStringToAction? Не очень-то хочется…

Кстати, пища для размышлений: еще два способа записать функцию convertStringToAction. Тезисами, без объяснений.

-- Охранные выражения (guards)
convertStringToAction' :: String -> Action
convertStringToAction' str | str == «Look» = Look
                           | str == «New»  = New
                           | otherwise     = Quit
 
-- Сопоставление с образцом (pattern matching)
convertStringToAction'' :: String -> Action
convertStringToAction'' «Look» = Look
convertStringToAction'' «New»  = New
convertStringToAction'' _      = Quit
 
-- Проверка в ghci
*Main> convertStringToAction' «New»
New
*Main> convertStringToAction'' «New»
New


«И что же, снова и снова расширять case-конструкцию у функции convertStringToAction? Не очень-то хочется...» — это что за нотки отчаяния?!.. Haskell — ленивый язык, и настоящий программист тоже должен быть ленивым, чтобы не писать лишний код там, где это не нужно. Не хочется расширять case? И не надо! Что мы сделаем? Приготовьтесь! Мы учим новое заклинание! Запишите: "класс типов Read, в котором лежат функции read и reads". Унаследуем от Read все наши АТД-типы и посмотрим, к чему это приведет.

data Location =
          Home
          ...
    deriving (Eq, Show, Read)
 
data Direction =
              North
              ...
    deriving (Eq, Show, Read)
 
data Action =
          Look
          ...
    deriving (Eq, Show, Read)


Чтобы почувствовать функцию read, немного поиграемся в ghci. Два примера для сравнения:

*Main> describeLocation Home
«Home\nYou are standing in the middle room at the wooden table.»
 
*Main> describeLocation (read «Home»)
«Home\nYou are standing in the middle room at the wooden table.»


Какой вывод можно сделать? Функция describeLocation не изменилась, ей по-прежнему нужен тип Location. В первом примере мы передаём конструктор, а во втором — получаем его из строки «Home».

*Main> describeLocation ( read (show Home) )
«Home\nYou are standing in the middle room at the wooden table.»


Функция read берет строку и пытается распарсить её к типу, который нужен в этом месте. Откуда read знает про типы? Он берет их из окружающих выражений. В данном случае он видит, что (read «Home») — это параметр функции describeLocation, а у параметра тип задан строго. Бывают случаи, когда тип брать неоткуда, но очень редко. Простой пример: если вызвать 'read «Home»' в ghci, компилятор нас не поймёт:

*Main> read «Home»
 
<interactive>:1:1:
    Ambiguous type variable 'a0' in the constraint:
      (Read a0) arising from a use of 'read'
    Possible fix: add a type signature that fixes these type variable(s)
    In the expression: read «Home»
    In an equation for 'it': it = read «Home»


Но мы можем ему помочь, указав тип явно с помощью специальной записи:

*Main> read «Home» :: Location
Home
*Main> read «5.5» :: Float
5.5
*Main> read «True» :: Bool


Волшебство, не так ли? Внедряя read в функцию convertStringToAction, мы получим более краткий и при этом более функциональный код.

-- Обрабатываем действие.
evalAction :: Action -> String
evalAction act = «Action: » ++ show act ++ "!"
 
-- Преобразовываем строку в Action
convertStringToAction :: String -> Action
convertStringToAction str = read str
 
-- Получаем ввод с клавиатуры, конвертируем его в действие, вызываем обработчик, выводим результат.
run = do
        putStr «Enter command: »
        x <- getLine
        putStrLn ( evalAction (convertStringToAction x) )
 
-- Проверяем в ghci:
*Main> run
Enter command: Look
Action: Look!
*Main> run
Enter command: Quit
Action: Quit!


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

*Main> run
Enter command: abrakadabra
Action: *** Exception: Prelude.read: no parse


Не расстраивайтесь! У нас есть более безопасная функция reads. Я, конечно, покажу, как ее использовать, но объяснения оставлю на будущее. А сегодня — «Action: Quit!». Отдыхать и учить заклинания.

convertStringToAction :: String -> Action
convertStringToAction str = case reads str of
            [(x, _)] -> x
            _ -> Quit
 
*Main> run
Enter command: abrakadabra
Action: Quit!


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

1. Объекты квеста и действие Investigate.
— Сделать АТД «Объект», добавить туда любые объекты.
— Составить функцию describeObject, которая выдаёт описание объекта.
— Составить функцию investigate, которая запрашивает у пользователя название объекта и выводит описание этого объекта.

2. Программа «Тупой калькулятор».
Имеются целочисленные операции «Сложить», «Вычесть» и «Умножить».
Написать программу, которая требует у пользователя целое число1, затем требует целочисленную операцию, затем требует целое число2. Когда это всё получено, программа должна выполнить над числами соответствующую операцию и вывести результат.


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

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

Articles