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.»
Очевидно, вместо вопросительных знаков должен быть
В начале файла 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
Исходники к этой части.
Оглавление, список литературы и дополнительную информацию вы найдете в «Приветствии»