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








