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»! Але ви можете додати