Как стать автором
Обновить

Haskell Quest Tutorial — Лес

Время на прочтение 12 мин
Количество просмотров 5.2K
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


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

Оглавление, список литературы и дополнительную информацию вы найдете в «Приветствии»
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+4
Комментарии 2
Комментарии Комментарии 2

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн