Haskell Quest Tutorial — Лес

    Forest
    This is a forest, with trees in all directions. To the east, there appears to be sunlight.
    You hear in the distance the chirping of song bird.


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

    Часть 2,
    в которой мы будем мучить функцию describeLocation, и даже узнаем, что такое АТД.

    Настало время получше подумать над игрой. Что это будет? Классическая приключенческая игра, где можно куда-то идти, находить и использовать предметы, взаимодействовать с неигровыми персонажами? Или это будет rogue-like текстовая игра с магией, злыми существами, с кучей оружия, брони, свитков, мечей и луков? Или, быть может, мы хотим создать квесты а-ля «Космические рейнджеры-2»? Ну, по части игровой механики мы пойдем по стопам Zork, а историю выберем другую — замечательный НФ-квест Lighthouse. Просто потому, что он мне нравится.

    Сотрите все, что у вас написано в файле QuestMain.hs. Если жалко стирать, то оставьте. Или воспользуйтесь системой контроля версий (git, svn): уверяю вас, страх, что вы случайно сломаете код, исчезнет навсегда! Любую версию кода можно увидеть и восстановить, когда вам захочется. Рефакторить программы на Haskell само по себе удовольствие, а с системой контроля версий и вовсе необременительно. Да-да, рефакторинг тоже может быть приятным! Вы правите, правите Haskell-код, чтобы он наконец-то скомпилировался, и когда он скомпилируется, он начинает работать! Та часть ошибок, которую вы бы допустили в императивном языке, здесь просто невозможна. Еще остаются, конечно, ошибки логики, но найти и исправить нетрудно, а с системой контроля версий — еще легче. Кроме того, логи с сотней-другой правок — это наглядный пример того, как вы хорошо поработали, и видимые результаты от пройденного пути мотивируют работать дальше.


    В прошлый раз мы придумали функцию, которая выдает описание локации по ее номеру:

    describeLocation locNumber = case locNumber of
                1 -> «You are standing in the middle room at the wooden table.»
                2 -> «You are standing in the front of the night garden behind the small wooden fence.»
                otherwise -> «Unknown location.»


    Какой может быть тип у этой функции? Давайте порассуждаем. Она принимает целое число (Integer) и возвращает строку (String), значит, тип должен быть такой:

    describeLocation :: Integer -> String


    Ну, это практически так, — за исключением того, что пока мы явно не указали Integer, компилятор будет думать, что locNumber — это параметр более общего числового типа, Num, в который входят и числа с плавающей точкой. Мы можем передать такое число, — ошибки не будет.

    *Main> describeLocation 2.0
    «You are standing in the front of the night garden behind the small wooden fence.»
    *Main> describeLocation 2.6
    «Unknown location.»


    Пока мы не указали тип явно, посмотрим, что о нем думает компилятор:

    *Main> :type describeLocation
    describeLocation :: Num a => a -> [Char]


    Хмм, запись «Num a => a -> [Char]» — таинственная и пугающая. Пусть её! Нам эти сложности пока ни к чему. Добавим определение функции перед самой функцией и зададим явно Integer, String:

    describeLocation :: Integer -> String
    describeLocation locNumber = case locNumber of
                1 -> «You are standing in the middle room at the wooden table.»
                2 -> «You are standing in the front of the night garden behind the small wooden fence.»
                otherwise -> «Unknown location.»


    Проверим:

    *Main> :t describeLocation
    describeLocation :: Integer -> String


    О! Так-то лучше. Но, к сожалению, мы теперь не можем передавать в качестве аргумента число с плавающей точкой:

    *Main> describeLocation 2
    «You are standing in the front of the night garden behind the small wooden fence.»
    *Main> describeLocation 2.0
     
    <interactive>:1:18:
        No instance for (Fractional Integer)
          arising from the literal '2.0'
        Possible fix: add an instance declaration for (Fractional Integer)
        ...


    Мы ограничили тип первого параметра с более общего (Num) до более частного (Integer), внесли ясность. Определения функций — вещь необязательная, но с ними код понятнее. В Haskell одно из правил хорошего тона — составлять определение каждой функции; иногда его бывает достаточно, чтобы понять, как функция должна работать.

    Что получается: мы берём первый аргумент (locNumber) и сопоставляем ему тип на первой позиции (Integer). Второго параметра у нас нет, значит тип на второй позиции — это тип возвращаемого значения (String). Помните функцию «prod x y»? Какой был бы у нее тип? Он мог быть, например, таким:

    prod :: Float -> Float -> Float
    prod x y = x * y


    Уловили суть?.. Первый Float — это тип для x, второй Float — это тип для y, а последний Float — это тип результата. Вот, собственно, и все шаманства.

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


    Здесь нам стоит задуматься, как мы будем различать локации. Номер — не слишком понятное обозначение, лучше бы это было что-нибудь мнемоническое. Может быть, строка в качестве названия? Попробуем:

    describeLocation :: String -> String
    describeLocation locName = case locName of
                «Home»          -> «You are standing in the middle room at the wooden table.»
                «Friend's yard» -> «You are standing in the front of the night garden behind the small wooden fence.»
                otherwise       -> «Unknown location.»
     
    *Main> describeLocation «Home»
    «You are standing in the middle room at the wooden table.»


    … Вы любите Caps Lock? А если он включается ВНЕЗАПНО, и вы замечаете это, уже набрав пару слов? Вот представьте, у вас — истеричный Caps Lock. Вы хотели набрать «Home», а получили «hOmE». Тогда функция describeLocation вас не поймет, хотя и сработает. Это очень неприятная и трудноуловимая ошибка, если кода много.

    *Main> describeLocation «hOmE»
    «Unknown location.»


    Чтобы застраховаться от истеричного Caps Lock, можно придумать функцию, которая переводит слово в верхний регистр. Альтернативы в case-конструкции тоже должны быть написаны большими буквами.

    upperCaseString :: String -> String
    upperCaseString str = ............ -- Как-нибудь делаем все буквы БОЛЬШИМИ.
    describeLocation :: String -> String
    describeLocation locName = case (upperCaseString locName) of
                «HOME»          -> «You are standing in the middle room at the wooden table.»
                «FRIEND'S YARD» -> «You are standing in the front of the night garden behind the small wooden fence.»
                otherwise       -> «Unknown location.»


    Теперь истеричный Caps Lock не страшен:

    *Main> describeLocation «FRieNd'S yard»
    «You are standing in the front of the night garden behind the small wooden fence.»
    *Main> describeLocation «hOMe»
    «You are standing in the middle room at the wooden table.»


    Ага, вижу ваши любопытные глаза. Хотите функцию upperCaseString? А не рано ли? Ну ладно. Мне нечего скрывать. Нам понадобится кое-какая функция, «toUpper», в стандартном модуле Prelude ее нет. Она из модуля «Char», поэтому его нужно подключить:

    import Char       -- В начале QuestMain.hs подключаем модуль Char
     
    upperCaseString :: String -> String
    upperCaseString str = map toUpper str


    Ничего я тут объяснять не буду! Сами захотели вперед залезть — сами и разбирайтесь!

    … Ну ладно, ладно, уговорили. В общих чертах. Функция map принимает два аргумента: функцию toUpper и нашу строку str. Задача у map простая: применить функцию toUpper к каждому элементу строки str. А из каких элементов состоит строка? Правильно, из символов. Вот ко всем этим символам и применяется функция toUpper, которая их возводит в верхний регистр (ну, если это буквы, разумеется).


    Еще одно решение — добавить функции-константы. Уж их-то неправильно вы не напишете, потому что программа просто не скомпилируется!

    home :: String
    home = «HOME»
     
    friend'sYard :: String            -- Знак апострофа (') можно использовать внутри и вконце как букву.
    friend'sYard = «FRIEND'S YARD»
     
    garden :: String
    garden = «GARDEN»
     
    *Main> describeLocation home
    «You are standing in the middle room at the wooden table.»
    *Main> describeLocation friend'sYard
    «You are standing in the front of the night garden behind the small wooden fence.»
    *Main> describeLocation garden
    «Unknown location.»


    Но подумайте: сколько будет еще функций, в которых потребуется различать локации? Функция путешествия из одной локации в другую, команда Look («Осмотреться»), какие-нибудь действия с объектами в данной локации… Каждый раз возводить буквы в верхний регистр — неудобно, ненаглядно, затратно. Должен быть какой-то другой способ задания локаций, их идентификации.

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

    home :: String
    home = «You are standing in the middle room at the wooden table.»
     
    friend'sYard :: String
    friend'sYard = «You are standing in the front of the night garden behind the small wooden fence.»
     
    garden :: String
    garden = «You are in the garden. Garden looks very well: clean, tonsured, cool and wet.»
     
    describeLocation :: String -> String
    describeLocation location = location
     
    *Main> describeLocation garden
    «You are in the garden. Garden looks very well: clean, tonsured, cool and wet.»


    То есть, мы передаем в describeLocation функцию-константу с описанием локации. Функция describeLocation его возвращает. В таком случае case-конструкция уже не нужна, а функций-констант мы можем наплодить хоть тысячу. Кстати, заметьте, что наши функции-константы ничем не отличаются от просто строк. Поскольку в Haskell каждое выражение является функцией, а строка — это выражение, то строка — это и функция тоже. Мы просто присвоили строке имя и получили функцию-константу. (Можно предположить, что функция «pi» — это тоже функция-константа, что-то вроде этого: pi = 3.1415....)

    *Main> friend'sYard == «You are standing in the front of the night garden behind the small wooden fence.»
    True
    *Main> :t friend'sYard
    friend'sYard :: [Char]
    *Main> :t «You are standing in the front of the night garden behind the small wooden fence.»
    «You are standing in the front of the night garden behind the small wooden fence.» :: [Char]


    Здесь [Char] — то же самое, что и String. Буквально, [Char] — это список символов, синоним типа String. Мы могли бы заменить String на [Char] или даже смешать оба типа в одной программе, ошибки бы не было. Но удобнее писать «Строка», чем «Список символов». Мы и сами будем задавать синонимы для многих своих типов. Так, у меня в adv2game определен ObjectName, тоже строка (тоже список символов). Глядя на ObjectName, я понимаю, что это не просто строка, а в ней, по идее, должно быть название объекта. Синонимы задаются ключевым словом type, которое работает схожим образом, что и typedef в С++:

    type ObjectName = String


    А так задан тип String в модуле Prelude:

    type String = [Char]


    Квадратные скобки говорят, что это — список из Char. Скажем, «STRING» — то же самое, что и список символов: ['S', 'T', 'R', 'I', 'N', 'G']. Просто никто в здравом уме не будет писать строку в таком виде, потому что в Haskell для списка символов есть упрощение — «строки в кавычках».

    *Main> ['S', 'T', 'R', 'I', 'N', 'G']
    «STRING»
    *Main> «I am a » ++ ['S', 'T', 'R', 'I', 'N', 'G'] ++ "."
    «I am a STRING.»
    *Main> putStrLn ['S', 'T', 'R', '\n', 'I', 'N', 'G']
    STR
    ING
    *Main> ['S', 'T', 'R', 'I', 'N', 'G'] == «STRING»
    True


    Можно задать списки чего угодно: список целых чисел [Integer], список строк [String] (который раскрывается в список списков Char), и так далее. Списки — основная структура в ФЯ, и мы еще многое о них узнаем.


    Мы хотим как-то различать локации или нет? Только не по строковому имени, ведь так легко допустить ошибку. Хотелось бы, чтобы вместо строки было что-нибудь постоянное. Вот:

    describeLocation :: ????? -> 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    -> «Unknown location.»


    Очевидно, вместо вопросительных знаков должен быть PROFIT какой-то тип, в котором есть Home, Friend'sYard, Garden. Для таких случаев в Haskell реализованы так называемые алгебраические типы данных (АТД). С их помощью можно интуитивно описать данные совершенно разной структуры. Алгебраические типы данных заменяют собой перечисления, объединения, объекты в ООП-языках любой сложности. Через АТД выражаются АТД (абстрактные типы данных, которые тоже «АТД»), и еще через АТД выражаются сами АТД (рекурсивно). Кроме того, на них можно сделать списки, деревья, множества, и многое другое. Причем АТД — это не какое-либо специфическое средство языка, а элемент математической теории типов, благодаря которой компилятор сам выводит типы, а так же проверяет код на корректность во время компиляции.

    В начале файла QuestMain.hs зададим тип для локаций:

    data Location = Home | Friend'sYard | Garden


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

    data Location =
                  Home
                | Friend'sYard
                | Garden


    Знак "|" можно читать как «или». То есть, переменные типа Location могут принимать одно значение: или Home, или Friend'sYard, или Garden. Вызывая конструкторы типа Location, мы создаем переменную этого типа. Важно понимать, что вызывая какой либо из конструкторов, мы все равно имеем дело с типом Location, а таких типов как «Home» или «Garden» не существует.

    Подставляем новый тип вместо вопросительных знаков:

    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    -> «Unknown location.»
     
    *Main> describeLocation Home
    «You are standing in the middle room at the wooden table.»
    *Main> describeLocation Friend'sYard
    «You are standing in the front of the night garden behind the small wooden fence.»


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

    describeHomeLocation = describeLocation Home
    describeGardenLocation = describeLocation garDEN
    describeGardenLocation' = describeLocation GarDEN
     
    *Main> :r
    [1 of 1] Compiling Main    ( H:\Haskell\QuestTutorial\Quest\QuestMain.hs, interpreted )
     
    H:\Haskell\QuestTutorial\Quest\QuestMain.hs:15:43:
        Not in scope: 'garDEN'
     
    H:\Haskell\QuestTutorial\Quest\QuestMain.hs:16:44:
        Not in scope: data constructor 'GarDEN'
    Failed, modules loaded: none.


    А теперь сотрите этот ошибочный код!.. У нас нет никаких «garDEN» или «GarDEN»! Но вы можете добавить конструктор GarDEN, если хотите. Что он будет значить, — дело ваше. И это будет корректно: GarDEN и Garden — разные конструкторы, ведь регистр имеет значение. Кстати, никто не запрещает вам сделать конструктор с таким же названием, что и тип; это бывает полезно, правда, не в нашем случае:

    data Location =
                  Location    -- Это правильно, хотя и непонятно, зачем.
                | Home
                | Friend'sYard
                | Garden


    Ошибки не будет, потому что умный компилятор языка Haskell знает, где Location нужно понимать как тип, а где — как конструктор. И эти места не пересекаются. («Типы и их конструкторы лежат в разных пространствах имен».)

    Давайте пофантазируем на будущее, какие еще у нас будут типы.

    -- Куда идти по команде Walk или Go.
    data Direction = North | South | West | East
     
    -- Действия игрока.
    data Action = Look | Go | Inventory | Take | Drop | Investigate | Quit | Save | Load | New


    Что мы можем делать с этими типами? Ну, пока не так много. Мы даже сравнивать конструкторы одного типа не можем:

    *Main> North == North
     
    <interactive>:1:7:
        No instance for (Eq Direction)
          arising from a use of '=='
        Possible fix: add an instance declaration for (Eq Direction)
        In the expression: North == North
        In an equation for 'it': it = North == North


    Интерпретатор жалуется, что у нас нигде не написано, как сравнивать конструкторы типа Direction. Мол, хотите операцию "=="? Тогда добавьте ваш тип в семейство сравниваемых типов!

    Если точнее, он просит, чтобы вы добавили тип Direction в класс типов Eq. Eq — это класс типов, для которых определены операции "==" и "/=".

    А теперь забудьте, что написано в этой врезке. Нам пока рано говорить о классах типов.


    Есть несколько способов сделать наши конструкторы сравниваемыми. Самый простой из них — это добавить пару волшебных слов в определение типа:

    data Direction =
                  North
                | South
                | West
                | East
        deriving (Eq)    -- Здесь от левого края должен быть отступ.


    Волшебные слова вы видите. Буквально это значит, что мы заимствуем (наследуем) операцию сравнения, которая есть по умолчанию. Она лежит в классе типов Eq. И эта операция будет работать для наших конструкторов вполне ожидаемым образом:

    *Main> North == North
    True
    *Main> North /= North
    False
    *Main> North == South
    False


    Вот и хорошо! У нас теперь есть знаки «равно» и «не равно». Сделайте то же самое с другими нашими типами — Action и Direction. Пригодится!

    deriving (Eq) — это один из волшебных вариантов, с помощью которого мы можем сравнивать конструкторы. А вообще-то, волшебных вариантов для data много, и они делают из вашего типа что-то большее. Но оставим чудеса на следующую часть. На сегодня достаточно. Мы и так умучались с этой функцией describeLocation. В следующей части мы узнаем про АТД что-нибудь ещё.

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

    Придумайте функцию walk — функцию путешествия между локациями. Она принимает два параметра: текущую локацию (Location) и направление (Direction), а возвращает новую локацию, расположенную в данном направлении. Схема маршрутов для каждой из локаций следующая:

    Home:
    на север — Garden
    на юг — Friend'sYard
    на восток — Home
    на запад — Home

    Garden:
    на север — Friend'sYard
    на юг — Home
    на восток — Garden
    на запад — Garden

    Friend'sYard:
    на север — Home
    на юг — Garden
    на восток — Friend'sYard
    на запад — Friend'sYard


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

    Оглавление, список литературы и дополнительную информацию вы найдете в «Приветствии»
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 2
    • +1
      Граф бы путешествий наглядный =)
      • 0
        Вот только его нет… Но — хорошо, я набросаю граф, какой получится — для нескольких локаций в самом начале.

        А граф путешествий в задании, в общем-то, с реальным графом не имеет ничего общего. Но сама функция walk в том или ином виде нам пригодится.

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