Джоэла Каасинена (Nitor) и Джона Лонга (Университет Хельсинки)
Это онлайн-курс по функциональному программированию, в котором используется язык программирования Haskell. Вы можете учиться в своем собственном темпе. Весь материал и упражнения находятся в открытом доступе.
Этот курс предназначен для новичков, желающих изучить функциональное программирование, а также для людей, имеющих опыт функционального программирования и желающих изучить, в частности, Haskell. Курс не предполагает никаких предварительных знаний, но предварительное знание хотя бы одного языка программирования облегчит курс.
Работа над упражнениями предполагает знание того, как использовать командную строку и основы использования системы контроля версий Git.
Это первая часть курса, состоящего из двух частей. Часть 1 охватывает основы синтаксиса и возможностей Haskell. Вы узнаете о рекурсии, функциях высшего порядка, алгебраических типах данных и некоторых расширенных функциях Haskell. Однако часть 1 будет придерживаться чистого функционального программирования без побочных эффектов. Ввод-вывод и монады будут представлены во второй части.
Курс разделен на 8 лекций. Они примерно одинакового размера, но в некоторых лекциях больше материала, чем в других. Каждый блок лекций завершается 10-30 небольшими упражнениями по программированию по темам лекции.
В дополнение к материалам курса следующие источники могут быть полезны, если вы чувствуете, что вам не хватает примеров или объяснений.
#haskell на libera.chat — хорошее место для новичков.Haskell — это
Функциональный (Functional) — основными строительными блоками программ являются функции. Функции могут возвращать функции и принимать функции в качестве аргументов. Кроме того, единственная конструкция цикла в Haskell — это рекурсия.
Чистый (Pure) — Функции Haskell являются чистыми, то есть не имеют побочных эффектов. Побочные эффекты означают такие вещи, как чтение файла, распечатка текста или изменение глобальных переменных. Все входные данные функции должны находиться в ее аргументах, а все выходные данные функции — в ее возвращаемом значении. Это звучит ограничивающе, но упрощает рассуждения о программах и позволяет компилятору провести дополнительную оптимизацию.
Ленивый (Lazy) — значения оцениваются только тогда, когда они необходимы. Это дает возможность работать с бесконечными структурами данных, а также делает чистые программы более эффективными.
Строго типизированный (strongly typed) — каждое значение и выражение Haskell имеет тип. Компилятор проверяет типы во время компиляции и гарантирует отсутствие ошибок типов во время выполнения. Это означает отсутствие ошибок AttributeErrors (а-ля Python), ClassCastExceptions (а-ля Java) или ошибок сегментации (а-ля C). Система типов Haskell очень мощная и может помочь вам разрабатывать более качественные программы.
С выводом типов (type inferred) — помимо проверки типов компилятор может выводить типы для большинства программ. Это упрощает работу со строго типизированным языком. Действительно, большинство функций Haskell можно написать вообще без типов. Однако программисты по-прежнему могут давать аннотации типов функций и значений, чтобы упростить поиск ошибок типа. Аннотации типов также облегчают чтение программ.
Со сборкой мусора (garbage-collected) — как и большинство современных языков высокого уровня, Haskell имеет автоматическое управление памятью посредством сборки мусора. Это означает, что программисту не нужно беспокоиться о выделении или освобождении памяти, среда выполнения языка обрабатывает все это автоматически.
Компилируемый (compiled) — хотя в этом курсе мы в основном используем Haskell через интерактивную среду GHCi, Haskell является компилируемым языком. Программы на Haskell могут быть скомпилированы в очень эффективные двоичные файлы, а компилятор GHC очень хорош в оптимизации функционального кода в производительный машинный код.
В ходе этого курса вы узнаете, что означают эти термины на практике. Не волнуйтесь, если некоторые из них сейчас звучат абстрактно.
См. также: Страница Haskell Wiki по функциональному программированию.
Вот демонстрация некоторых интересных возможностей Haskell:
Функции высшего порядка – функции могут принимать функции в качестве аргументов:
map length ["abc","abcdef"]В результате получается [3,6].
Анонимные функции, также известные как лямбды — вы можете определять одноразовые вспомогательные функции, не присваивая им имени.
filter (\x -> length x > 1) ["abc","d","ef"]В результате получается ["abc","ef"].
Частичное применение — вы можете определять новые функции, передавая другой функции только некоторые из необходимых ей аргументов. Например, это умножает все элементы в списке на 3:
map (*3) [1,2,3]Алгебраические типы данных (algebraic data types) – синтаксис для определения типов данных, который может содержать несколько различных случаев:
data Shape = Point | Rectangle Double Double | Circle DoubleТеперь тип Shape может иметь значения типа Point, Rectangle 3 6 и Circle 5
Сопоставление с образцом – определение функций на основе случаев, которые соответствуют вашим определениям данных:
area Point = 0
area (Rectangle width height) = width * height
area (Circle radius) = pi * radius * radiusLists – В отличие от многих языков, Haskell имеет краткий встроенный синтаксис для списков. Списки можно создавать из других списков, используя list Comprehensions. Вот фрагмент, который генерирует имена четной длины из набора параметров для имени и фамилии:
[whole | first <- ["Eva", "Mike"],
last <- ["Smith", "Wood", "Odd"],
let whole = first ++ last,
even (length whole)]В результате получается ["EvaSmith","EvaOdd","MikeWood"]. Благодаря ленивости Haskell мы можем даже создавать так называемые бесконечные списки:
primes = [ n | n <- [2..] , all (\k -> n `mod` k /= 0) [2..n `div` 2] ]Первые десять простых чисел можно получить, вычислив
take 10 primesРезультатом будет [2,3,5,7,11,13,17,19,23,29].
Параметризованные типы – вы можете определить типы, которые параметризуются другими типами. Например, [Int] — это список Int, а [Bool] — список логических значений. Вы можете определить типизированные функции, которые работают со всеми типами списков, например reverse имеет тип [a] -> [a], что означает, что он принимает список, содержащий любой тип a, и возвращает список того же типа.
Классы типов – еще одна форма полиморфизма, при которой вы можете дать функции разную реализацию в зависимости от типов аргументов. Например, класс типов Show определяет функцию show, которая может преобразовывать значения различных типов в строки. Класс типов Num определяет арифметические операторы, такие как +, которые работают со всеми типами чисел (Int, Double, Complex, …).
Краткая хронология Haskell:
Слово «haskel» на иврите означает мудрость, но название языка программирования Haskell происходит от логика Хаскелла Карри. Название Haskell происходит от древнескандинавских слов áss (бог) и ketill (шлем).
Вот несколько примеров программных проектов, написанных на Haskell.
Дополнительную информацию см. в The Haskell Wiki и этой записи блога!
Самый простой способ получить Haskell — установить инструмент stack, см. https://haskellstack.org. Упражнения этого курса предназначены для работы со Stack, поэтому вам следует пока использовать его.
Кстати, если вас интересует, что такое Stack и как он связан с другими инструментами Haskell, такими как Cabal и GHC, читайте больше здесь или здесь. Мы вернемся к пакетам Haskell и подробному их использованию во второй части курса.
А сейчас, после установки Stack, просто запустите stack ghci, чтобы получить интерактивную среду Haskell.
Примечание! GHC 8.10.7 содержит ошибку GHCi, которая делает невозможным редактирование строк в системах на базе ARM. В качестве обходного пути используйте TERM=dumb stack ghci. Дополнительную информацию здесь.
GHCi — интерактивный интерпретатор Haskell. Вот пример сеанса:
$ stack ghci
GHCi, version 9.2.8: https://www.haskell.org/ghc/ :? for help
Prelude> 1+1
2
Prelude> "asdf"
"asdf"
Prelude> reverse "asdf"
"fdsa"
Prelude> :type "asdf"
"asdf" :: [Char]
Prelude> tail "asdf"
"sdf"
Prelude> :type tail "asdf"
tail "asdf" :: [Char]
Prelude> :type tail
tail :: [a] -> [a]
Prelude> :quit
Leaving GHCi.
Кстати, при первом запуске stack ghci он загрузит GHC и некоторые библиотеки, поэтому не волнуйтесь, если вы увидите какой-то вывод и вам придется подождать некоторое время, прежде чем получить приглашение Prelude>.
Давайте разберемся в этом. Не волнуйтесь, если вы еще чего-то не понимаете, это всего лишь первое знакомство с выражениями и типами.
Prelude> 1+1
2
Prelude> — это приглашение GHCi. Это указывает на то, что мы можем использовать функции из базовой библиотеки Haskell под названием Prelude. Мы вычисляем 1 плюс 1, и результат равен 2.
Prelude> "asdf"
"asdf"
Здесь мы вычисляем строковый литерал, и результатом является та же строка.
Prelude> reverse "asdf"
"fdsa"
Здесь мы вычисляем обратную строку, применяя функцию reverse к значению "asdf".
Prelude> :type "asdf"
"asdf" :: [Char]
Помимо оценки выражений мы также можем запросить их тип с помощью команды :type (сокращенно :t) GHCi. Тип "asdf" представляет собой список символов. Команды, начинающиеся с :, являются частью пользовательского интерфейса GHCi, а не частью языка Haskell.
Prelude> tail "asdf"
"sdf"
Prelude> :t tail "asdf"
tail "asdf" :: [Char]
Функция tail работает со списками и возвращает все элементы списка, кроме первого. Здесь мы видим применение tail к "asdf". Также мы проверяем тип выражения, а оно, как и ожидалось, представляет собой список символов.
Prelude> :t tail
tail :: [a] -> [a]
Наконец, вот тип функции tail. Он принимает список любого типа в качестве аргумента и возвращает список того же типа.
Prelude> :quit
Leaving GHCi.
Вот как вы выходите из GHCi.
Как мы видели в примере GHCi выше, expressions и types являются хлебом с маслом Хаскелл. Фактически, почти все в программе на Haskell является выражением. В частности, здесь нет statements, как в Python, Java или C.
Выражение имеет value и type. Пишем выражение и его тип так: expression :: type. Вот несколько примеров:
| Выражение | Тип | Значение |
|---|---|---|
True |
Bool |
True |
not True |
Bool |
False |
"as" ++ "df" |
[Char] |
"asdf" |
Выражения состоят из функций applied к аргументам. Функции applied (т. е. вызываются) путем размещения аргументов после имени функции – специального синтаксиса для вызова функции не существует.
| Haskell | Python, Java или C |
|---|---|
f 1 |
f(1) |
f 1 2 |
f(1,2) |
Круглые скобки можно использовать для group выражений (так же, как в математике и других языках).
| Haskell | Python, Java или C |
|---|---|
g h f 1 |
g(h,f,1) |
g h (f 1) |
g(h,f(1)) |
g (h f 1) |
g(h(f,1)) |
g (h (f 1)) |
g(h(f(1))) |
Имена некоторых функций состоят из специальных символов и используются как операторы: между аргументами, а не перед ними. Вызовы функций связаны более жестко , чем операторы, точно так же, как умножение связывается более жестко, чем сложение.
| Haskell | Python, Java или C |
|---|---|
a + b |
a + b |
f a + g b |
f(a) + g(b) |
f (a + g b) |
f(a+g(b)) |
PS. в Haskell приложение-функция связывает left, то есть f g x y фактически совпадает с (((f g) x) y). Мы вернемся к этой теме позже. А пока вы можете просто думать, что f g x y — это f, примененный к аргументам g, x и y.
Вот несколько основных типов Haskell, с которых можно начать.
| Тип | Литералы | Используйте | Операции |
|---|---|---|---|
Int |
1, 2, -3 |
Тип числа (со знаком, 64 бита) | +, -, *, div, mod |
Integer |
1, -2, 900000000000000000 |
Неограниченный тип числа | +, -, *, div, mod |
Double |
0.1, 1.2e5 |
Числа с плавающей запятой | +, -, *, /, sqrt |
Bool |
True, False |
Истинные значения | &&, ||, not |
String он же [Char] |
"abcd", "" |
Строки символов | reverse, ++ |
Как видите, имена типов в Haskell начинаются с заглавной буквы. Некоторые значения, например True, также начинаются с заглавной буквы, но переменные и функции начинаются со строчной буквы (reverse, not, x). Мы вернемся к значению заглавных букв в Лекции 2.
Типы функций записываются с использованием синтаксиса ->:
argumentType -> returnTypeargument1Type -> argument2Type -> returnTypeargument1Type -> argument2Type -> argument3Type -> returnTypeВыглядит немного странно, правда? Мы еще вернемся к этому.
Иногда типы, которые вы видите в GHCi, немного отличаются от тех, которые вы предполагаете. Вот два типичных случая.
Prelude> :t 1+1
1+1 :: Num a => aНа данный момент тип Num a => a следует читать как «любой числовой тип». В Haskell числовые литералы перегружены (overloaded): их можно интерпретировать как значения разных числовых типов, например Int или Double. Позже, в разделе о классах типов, мы вернёмся к точному значению Num a.
Prelude> :t "asdf"
"asdf" :: [Char]Тип String — это просто псевдоним типа [Char], который означает «список символов». К спискам вернемся на следующей лекции! В любом случае вы можете использовать String и [Char] взаимозаменяемо, но GHCi в основном будет использовать [Char] при описании типов.
Вот простая программа на Haskell, которая выполняет некоторые арифметические действия и печатает некоторые значения.
module Gold where
-- The golden ratio
phi :: Double
phi = (sqrt 5 + 1) / 2
polynomial :: Double -> Double
polynomial x = x^2 - x - 1
f x = polynomial (polynomial x)
main = do
print (polynomial phi)
print (f phi)Если вы поместите это в файл с именем Gold.hs и запустите его (например) с stack runhaskell Gold.hs, вы должны увидеть этот вывод
0.0
-1.0
Давайте пройдемся по файлу.
module Gold whereВ каждом исходном файле имеется один Haskell module. Модуль состоит из definitions.
-- The golden ratioЭто комментарий. Комментарии не являются частью самой программы, а являются текстом для читателей программы.
phi :: Double
phi = (sqrt 5 + 1) / 2Это определение константы phi с сопровождающей аннотацией типа (она же сигнатура типа) phi :: Double. Аннотация указывает, что phi имеет тип Double. Строка со знаком равенства (=) называется уравнением (equation). Слева от = находится определяемое выражение, справа от = — его определение.
Обычно определение (функции или константы) состоит из необязательной аннотации type и одного или нескольких equations
polynomial :: Double -> Double
polynomial x = x^2 - x - 1Это определение функции polynomial. Он имеет аннотацию типа и уравнение. Обратите внимание, чем уравнение функции отличается от уравнения константы наличием параметра x слева от знака =. Также обратите внимание, что ^ — это оператор степени в Haskell, а не побитовый оператор xor, как во многих других языках.
f x = polynomial (polynomial x)Это определение функции f. Обратите внимание на отсутствие аннотации типа. Что такое тип f?
main = do
print (polynomial phi)
print (f phi)Это описание того, что происходит при запуске программы. Он использует do-синтаксис и IO-монаду. Мы вернемся к ним во второй части курса.
Когда вы видите пример определения, подобный этому
polynomial :: Double -> Double
polynomial x = x^2 - x - 1С таким определением обычно стоит поэкспериментировать: сначала запустите его. Сделать это можно несколькими способами.
Если определение помещается в одну строку, вы можете просто определить его в GHCi:
Prelude> polynomial x = x^2 - x - 1
Prelude> polynomial 3.0
5.0
Для многострочного определения вы можете либо использовать ; для разделения строк, либо использовать специальный синтаксис :{ :} для вставки блока кода в GHCi:
Prelude> :{
Prelude| polynomial :: Double -> Double
Prelude| polynomial x = x^2 - x - 1
Prelude| :}
Prelude> polynomial 3.0
5.0Наконец, вы можете вставить код в новый или существующий файл .hs, а затем :load в GHCi. Если файл уже загружен, вы также можете использовать :reload.
-- first copy and paste the definition into Example.hs, then run GHCi
Prelude> :load Example.hs
[1 of 1] Compiling Main ( Example.hs, interpreted )
Ok, one module loaded.
*Main> polynomial 3.0
5.0
-- now you can edit the definition
*Main> :reload
[1 of 1] Compiling Main ( Example.hs, interpreted )
Ok, one module loaded.
*Main> polynomial 3
3.0После запуска примера попробуйте изменить его или создать другую похожую, но отличающуюся функцию. Вы учитесь программированию, программируя, а не читая!
Поскольку Haskell является типизированным языком, вы довольно быстро столкнетесь с ошибками типа. Вот пример ошибки во время сеанса GHCi:
Prelude> "string" ++ True
<interactive>:1:13: error:
• Couldn't match expected type ‘[Char]’ with actual type ‘Bool’
• In the second argument of ‘(++)’, namely ‘True’
In the expression: "string" ++ True
In an equation for ‘it’: it = "string" ++ TrueЭто наиболее распространенная ошибка типа «Не удалось соответствовать ожидаемому типу». Несмотря на то, что ошибка выглядит длинной и пугающей, она довольно проста, если вы ее просто прочитаете.
Первая строка сообщения об ошибке <interactive>:1:13: error: сообщает нам, что ошибка произошла в GHCi. Если бы мы загрузили файл, вместо этого мы могли бы получить что-то вроде Sandbox.hs:3:17: error:, где Sandbox.hs — имя файла, 3 — номер строки, а 17 — номер символа в строке.
Строка • Couldn't match expected type ‘[Char]’ with actual type ‘Bool’ сообщает нам, что непосредственной причиной ошибки является наличие выражения типа Bool, когда GHCi ожидал найти выражение типа [Char] ". Местоположение этой ошибки было указано в первой строке сообщения об ошибке. Обратите внимание, что ожидаемый тип не всегда правильный. Предоставление аннотаций типа вручную может помочь в отладке ошибок ввода.
Строка • In the second argument of ‘(++)’, namely ‘True’ сообщает, что выражение неправильного типа было вторым аргументом оператора (++). Почему оно заключено в круглые скобки, мы узнаем позже.
Полное выражение с ошибкой: "string" ++ True. Как упоминалось выше, String — это псевдоним типа [Char], типа списков символов. Первым аргументом ++ был список символов, а поскольку ++ может объединять только два списка одного и того же типа, второй аргумент тоже должен был иметь тип [Char].
Строка In an equation for ‘it’: it = "string" ++ True говорит, что выражение встречается в определении переменная it, которая является именем переменной по умолчанию, которую GHCi использует для автономных выражений. Если бы у нас была строка x = "string" ++ True в файле или объявление let x = "string" ++ True в GHCi, GHCi вместо этого напечатал бы In an equation for ‘x’: x = "string" ++ True.
Существуют и другие типы ошибок.
Prelude> True + 1
<interactive>:6:1: error:
• No instance for (Num Bool) arising from a use of ‘+’
• In the expression: True + 1
In an equation for ‘it’: it = True + 1Это тип ошибки, которую вы получаете, когда пытаетесь использовать числовую функцию, например +, для чего-то, что не является числом.
Обычно сложнее всего отследить следующую ошибку:
Prelude> True +
<interactive>:10:7: error:
parse error (possibly incorrect indentation or mismatched brackets)Есть много способов вызвать ее. Возможно, вы где-то упускаете какие-то символы. Мы вернемся к отступам позже в этой лекции.
В арифметике Haskell есть одна вещь, которая часто сбивает с толку новичков, — это деление.
В Haskell есть две функции деления: оператор / и функция div. Функция div выполняет целочисленное деление:
Prelude> 7 `div` 2
3Оператор / выполняет обычное деление:
Prelude> 7.0 / 2.0
3.5Однако вы можете использовать div только для типов целых чисел, таких как Int и Integer, и вы можете использовать только / для десятичных типов, таких как Double. Вот пример того, что произойдет, если вы попытаетесь их смешать:
halve :: Int -> Int
halve x = x / 2error:
• No instance for (Fractional Int) arising from a use of ‘/’
• In the expression: x / 2
In an equation for ‘halve’: halve x = x / 2
Просто постарайтесь пока иметь это в виду. Мы вернемся к разнице между / и div и к тому, что означают Num и Fractional, когда речь идет о классах типов.
До сих пор вы видели некоторые арифметические действия, переворачивание строки и т. д. Как писать настоящие программы на Haskell? Многие из обычных программных конструкций, таких как циклы, операторы и присваивание, отсутствуют в Haskell. Далее мы рассмотрим основные строительные блоки программ на Haskell:
На других языках if — это оператор. У него нет значения, он просто условно выполняет другие операторы.
В Haskell if — это expression. Это имеет ценность. Он выбирает между двумя другими выражениями. Он соответствует оператору ?: в C или Java.
// Java
int price = product.equals("milk") ? 1 : 2;Условные выражения Python очень близки к условным выражениям Haskell if:
# Python
price = 1 if product == "milk" else 2Вот как тот же пример выглядит в Haskell:
price = if product == "milk" then 1 else 2Поскольку if в Haskell возвращает значение, вам всегда нужен else!
BoolЧтобы писать выражения if, вам необходимо знать, как получать значения типа Bool. Самый распространенный способ – сравнения. В Haskell работают обычные операторы ==, <, <=, > и >=. Вы можете выполнять упорядоченные сравнения (<, >) для всех видов чисел, а сравнения на равенство (==) практически для любых чисел:
Prelude> "foo" == "bar"
False
Prelude> 5.0 <= 7.2
True
Prelude> 1 == 1
TrueОдной из странностей Haskell является то, что оператор «не равно» пишется /= вместо обычного !=:
Prelude> 2 /= 3
True
Prelude> "bike" /= "bike"
FalseПомните, что помимо этих сравнений вы можете получить значения Bool из других значений Bool, используя операторы && («и») и || («или»), а также функцию not.
checkPassword password = if password == "swordfish"
then "You're in."
else "ACCESS DENIED!"absoluteValue n = if n < 0 then -n else nlogin user password = if user == "unicorn73"
then if password == "f4bulous!"
then "unicorn73 logged in"
else "wrong password"
else "unknown user"Haskell имеет два разных способа создания локальных определений: let...in и where.
where добавляет к определению локальные определения:
circleArea :: Double -> Double
circleArea r = pi * rsquare
where pi = 3.1415926
rsquare = r * rlet...in — это выражение:
circleArea r = let pi = 3.1415926
rsquare = r * r
in pi * rsquareЛокальные определения также могут быть функциями:
circleArea r = pi * square r
where pi = 3.1415926
square x = x * xcircleArea r = let pi = 3.1415926
square x = x * x
in pi * square rМы вернемся к различиям между let и where, но в большинстве случаев вы можете использовать то, что захотите.
Хотя такие вещи, как pi выше, часто называют variables, я решил называть их определения здесь. Это связано с тем, что в отличие от переменных в Python или Java значения этих определений нельзя изменить. Переменные Haskell — это не ящики, в которые можно помещать новые значения, переменные Haskell присваивают имя значению (точнее, выражению) и все.
Мы еще поговорим о неизменяемости позже в этом курсе, но сейчас достаточно знать, что подобные вещи не работают.
increment x = let x = x+1
in xЭто просто бесконечный цикл, поскольку он пытается определить новую переменную x со свойством x = x+1. Таким образом, при вычислении x Haskell просто продолжает вычислять 1+1+1+1+... неопределенное время.
compute x = let a = x+1
a = a*2
in aerror:
Conflicting definitions for ‘a’
Bound at: <interactive>:14:17
<interactive>:15:17
Здесь мы получаем прямую ошибку, когда пытаемся «обновить» значение a.
В качестве примечания, локальные определения могут затенять имена переменных, определенных где-то еще. Затенение не является побочным эффектом. Вместо этого затенение создает новую переменную в более ограниченной области, которая использует то же имя, что и некоторая переменная во внешней области. Например, все функции f, g и h ниже допустимы:
x :: Int
x = 5
f :: Int -> Int
f x = 2 * x
g :: Int -> Int
g y = x where x = 6
h :: Int -> Int
h x = x where x = 3Если мы применим их к глобальной константе x, мы увидим эффект затенения:
f 1 ==> 2
g 1 ==> 6
h 1 ==> 3
f x ==> 10
g x ==> 6
h x ==> 3Лучше всегда выбирать новые имена для локальных переменных, чтобы никогда не происходило затенение. Таким образом, читатель кода поймет, откуда берутся переменные, используемые в выражении. Обратите внимание, что в следующем примере f и g не дублируют аргументы друг друга:
f :: Int -> Int
f x = 2 * x + 1
g :: Int -> Int
g x = x - 2Определение (функции) может состоять из нескольких уравнения. Уравнения сопоставляются по порядку с аргументами, пока не будет найдено подходящее. Это называется соответствие шаблону.
Сопоставление шаблонов в Haskell является очень мощным инструментом, и в этом курсе мы будем продолжать узнавать о нем что-то новое, но вот несколько первых примеров:
greet :: String -> String -> String
greet "Finland" name = "Hei, " ++ name
greet "Italy" name = "Ciao, " ++ name
greet "England" name = "How do you do, " ++ name
greet _ name = "Hello, " ++ nameФункция greet генерирует приветствие по стране и имени (оба String). У него есть особые случаи для трех стран и случай по умолчанию. Вот как это работает:
Prelude> greet "Finland" "Pekka"
"Hei, Pekka"
Prelude> greet "England" "Bob"
"How do you do, Bob"
Prelude> greet "Italy" "Maria"
"Ciao, Maria"
Prelude> greet "Greenland" "Jan"
"Hello, Jan"Специальный шаблон _ соответствует чему угодно. Обычно он используется для случаев по умолчанию. Поскольку шаблоны сопоставляются по порядку, важно (usually) ставить регистр _ последним. Рассмотрим:
brokenGreet _ name = "Hello, " ++ name
brokenGreet "Finland" name = "Hei, " ++ nameТеперь для всех входов выбирается первый вариант.
Prelude> brokenGreet "Finland" "Varpu"
"Hello, Varpu"
Prelude> brokenGreet "Sweden" "Ole"
"Hello, Ole"GHC даже предупреждает вас об этом коде:
<interactive>:1:1: warning: [-Woverlapping-patterns]
Pattern match is redundant
In an equation for ‘brokenGreet’: brokenGreet "Finland" name = ...
Ниже приведены еще несколько примеров. Но сначала давайте представим стандартную библиотечную функцию show, которая может превратить (почти!) что угодно в строку:
Prelude> show True
"True"
Prelude> show 3
"3"Итак, вот пример функции с сопоставлением шаблонов и регистром по умолчанию, которая действительно использует значение (вместо того, чтобы просто игнорировать его с помощью _):
describe :: Integer -> String
describe 0 = "zero"
describe 1 = "one"
describe 2 = "an even prime"
describe n = "the number " ++ show nВот как это работает:
Prelude> describe 0
"zero"
Prelude> describe 2
"an even prime"
Prelude> describe 7
"the number 7"Вы можете даже использовать шаблон для нескольких аргументов. Опять же, уравнения рассматриваются по порядку. Вот повторная реализация ранее использованной функции login:
login :: String -> String -> String
login "unicorn73" "f4bulous!" = "unicorn73 logged in"
login "unicorn73" _ = "wrong password"
login _ _ = "unknown user"В Haskell все виды циклов реализуются с помощью рекурсии. Вызовы функций очень эффективны, поэтому вам не нужно беспокоиться о производительности. (О производительности мы поговорим позже).
Изучение того, как выполнять простые действия с помощью рекурсии в Haskell, поможет вам позже использовать рекурсию для решения более сложных задач. Рекурсия также часто является полезным способом решения более сложных задач.
Вот наша первая рекурсивная функция, вычисляющая факториал. В математике факториал — это произведение n первых положительных целых чисел и записывается как n!. Определение факториала:
n! = n * (n-1) * … * 1
Например, 4! = 4*3*2*1 = 24. В любом случае, вот реализация факториала на Haskell:
factorial :: Int -> Int
factorial 1 = 1
factorial n = n * factorial (n-1)Вот как это работает. Мы используем ==> для обозначения «оценивает».
factorial 3
==> 3 * factorial (3-1)
==> 3 * factorial 2
==> 3 * 2 * factorial 1
==> 3 * 2 * 1
==> 6Что происходит, когда вы оцениваете factorial (-1)?
Вот еще один пример:
-- compute the sum 1^2+2^2+3^2+...+n^2
squareSum 0 = 0
squareSum n = n^2 + squareSum (n-1)Функция может вызывать себя рекурсивно несколько раз. В качестве примера рассмотрим последовательность Фибоначчи из математики. Последовательность Фибоначчи — это последовательность целых чисел со следующим определением.
Последовательность начинается с 1, 1. Чтобы получить следующий элемент последовательности, просуммируйте два предыдущих элемента последовательности.
Первые элементы последовательности Фибоначчи — это 1, 1, 2, 3, 5, 8, 13 и так далее. Вот функция fibonacci, которая вычисляет n -й элемент последовательности Фибоначчи. Обратите внимание, как это отражает математическое определение.
-- Fibonacci numbers, slow version
fibonacci 1 = 1
fibonacci 2 = 1
fibonacci n = fibonacci (n-2) + fibonacci (n-1)Вот как оценивается fibonacci 5:
fibonacci 5
==> fibonacci 3 + fibonacci 4
==> (fibonacci 1 + fibonacci 2) + fibonacci 4
==> ( 1 + 1 ) + fibonacci 4
==> ( 1 + 1 ) + (fibonacci 2 + fibonacci 3)
==> ( 1 + 1 ) + (fibonacci 2 + (fibonacci 1 + fibonacci 2))
==> ( 1 + 1 ) + ( 1 + ( 1 + 1 ))
==> 5Обратите внимание, что fibonacci 3 оценивается дважды, а fibonacci 2 трижды. Это не самая эффективная реализация функции fibonacci. Мы вернемся к этому в следующей лекции. Другой способ подумать об оценке функции Фибоначчи — представить ее в виде дерева (мы сокращаем fibonacci как fib):
Тогда это дерево точно соответствует выражению (1 + 1) + (1 + (1 + 1)). Рекурсия часто может создавать цепочечные, древовидные, вложенные или цикличные структуры и вычисления. Рекурсия — один из основных методов функционального программирования, поэтому стоит потратить некоторые усилия на ее изучение.
Наконец, вот полный модуль Haskell, который использует ifs, сопоставление с образцом, локальные определения и рекурсию. Модуль интересуется Гипотезой Коллатца, известной открытой задачей математики. Он спрашивает:
Достигает ли последовательность Коллатца в конечном итоге 1 для всех начальных значений положительных целых чисел?
Последовательность Коллатца определяется путем принятия любого числа в качестве начального значения, а затем многократного выполнения следующей операции:
Например, последовательность Коллатца для 3: 3, 10, 5, 16, 8, 4, 2, 1, 4, 2, 1, 4, 2, 1… Как видите, как только число достигает 1, оно попадает в цикл.
module Collatz where
-- one step of the Collatz sequence
step :: Integer -> Integer
step x = if even x then down else up
where down = div x 2
up = 3*x+1
-- collatz x computes how many steps it takes for the Collatz sequence
-- to reach 1 when starting from x
collatz :: Integer -> Integer
collatz 1 = 0
collatz x = 1 + collatz (step x)
-- longest finds the number with the longest Collatz sequence for initial values
-- between 0 and upperBound
longest :: Integer -> Integer
longest upperBound = longest' 0 0 upperBound
-- helper function for longest
longest' :: Integer -> Integer -> Integer -> Integer
-- end of recursion, return longest length found
longest' number _ 0 = number
-- recursion step: check if n has a longer Collatz sequence than the current known longest
longest' number maxlength n =
if len > maxlength
then longest' n len (n-1)
else longest' number maxlength (n-1)
where len = collatz nМы можем загрузить программу в GHCi и поиграть с ней.
$ stack ghci
GHCi, version 9.2.8: https://www.haskell.org/ghc/ :? for help
Prelude> :load Collatz.hs
[1 of 1] Compiling Collatz ( Collatz.hs, interpreted )
Ok, one module loaded.
*Collatz>
Давайте проверим, что наша программа правильно вычисляет начало последовательности Коллатца для 3.
*Collatz> step 3
10
*Collatz> step 10
5
*Collatz> step 5
16
Сколько шагов нужно, чтобы число 3 достигло 1?
*Collatz> collatz 3
7
Какая самая длинная последовательность Коллатца для начального значения меньше 10? А что насчет 100?
*Collatz> longest 10
9
*Collatz> longest 100
97
Длины этих последовательностей Коллатца:
*Collatz> collatz 9
19
*Collatz> collatz 97
118
Предыдущие примеры имели причудливый отступ. В Haskell отступы имеют значение, как и в Python. Полный набор правил для отступов трудно описать, но вы должны хорошо справиться с этими практическими правилами:
Хотя вы можете обойтись без табуляции, настоятельно рекомендуется использовать пробелы для всех отступы.
Несколько примеров по порядку.
Все в порядке:
i x = let y = x+x+x+x+x+x in div y 5
-- let and in are grouped together, an expression is split
j x = let y = x+x+x
+x+x+x
in div y 5
-- the definitions of a and b are grouped together
k = a + b
where a = 1
b = 1
l = a + b
where
a = 1
b = 1Это неправильно:
-- indentation not increased even though expression split on many lines
i x = let y = x+x+x+x+x+x
in div y 5
-- indentation not increased even though expression is split
j x = let y = x+x+x
+x+x+x
in div y 5
-- grouped things are not aligned
k = a + b
where a = 1
b = 1
-- grouped things are not aligned
l = a + b
where
a = 1
b = 1
-- where is part of the equation, so indentation needs to increase
l = a + b
where
a = 1
b = 1Если вы допустите ошибку с отступом, вы обычно получите такую ошибку анализа:
Indent.hs:2:1: error: parse error on input ‘where’
Ошибка включает номер строки, поэтому просто пройдите по этой строке еще раз. Если вам не удается заставить работать отступы, попробуйте сначала поместить все в одну длинную строку.
В конце каждой лекции вы найдете подобный тест. Тесты не оцениваются, они нужны только для того, чтобы помочь вам проверить, поняли ли вы главу. Вы можете проверить свой ответ, нажав на вариант. Вы увидите зеленый фон, если вы были правы, и красный, если ошиблись. Не стесняйтесь гадать столько раз, сколько захотите, просто убедитесь, что вы понимаете, почему в конечном итоге правильный вариант оказывается правильным.
Что такое Haskell-эквивалент выражения C/Java/Python combine(prettify(lawn),construct(house,concrete))?
combine prettify (lawn) construct (house concrete)combine (prettify lawn (construct house concrete))combine (prettify lawn) (construct house concrete)send metric (double population + increase)?
send(metric(double(population+increase)))send(metric(double(population)+increase))send(metric,double(population)+increase)send(metric,double(population+increase))Какое из следующих утверждений верно в Haskell?
Какое из следующих утверждений верно в Haskell?
if всегда требует как then, так и elseЧто делает функция f x = if even (x + 1) then x + 1 else f (x - 1)?
x с наименьшим четным числом, большим или равным xx с наибольшим четным числом, меньшим или равным xПочему 3 * "F00" недействителен на Haskell?
3 и "F00" имеют разные типы"F00" нужен префикс «0x»Почему 7.0 `div` 2 дает ошибка?
div не определен для типа Doublediv не определен для типа Int`...` используется для разделения строк.Материалы курса, включая упражнения, доступны в репозитории Git на GitHub по адресу https://github.com/moocfi/haskell-mooc. Если вы не знакомы с Git, см. инструкции GitHub по клонированию репозитория.
После клонирования репозитория haskell-mooc перейдите в каталог exercises. Чтобы загрузить и построить зависимости, необходимые для запуска тестов с упражнениями (например, правильную версию GHC и различные библиотеки), выполните в терминале следующую команду:
$ stack buildОбратите внимание, что зависимости занимают несколько гигабайт, и для завершения команды потребуется некоторое время.
Примечание! Ниже приведены некоторые исправления распространенных проблем с stack build:
While building package zlib-0.6.2.3, вам необходимо установить заголовки библиотеки zlib. Правильная команда для Ubuntu — sudo apt install zlib1g-dev.cannot find -ltinfo, вам необходимо установить заголовки ncurses. Правильная команда для Ubuntu — sudo apt install libncurses-dev. Правильная команда для Fedora — dnf install ncurses-devel.Downloading lts-18.18 build plan ... RedownloadInvalidResponse, ваша версия стека слишком старая. Запустите stack upgrade, чтобы получить более новую версию.В каталоге exercises в основном находятся файлы двух типов: наборы упражнений с именем SetNX.hs и соответствующая тестовая программа для упражнений с именем SetNXTest.hs. Оба являются исходными файлами Haskell, но при решении упражнений следует редактировать только файл упражнений. Инструкции ко всем отдельным упражнениям включены в файл упражнений в виде комментариев.
Используйте файл тестов, чтобы проверить свои ответы. Например, когда вы решили некоторые упражнения из Set1.hs, выполните следующую команду:
$ stack runhaskell Set1Test.hsРезультаты тестов выглядят примерно так:
===== EXERCISE 1
+++++ Pass
===== EXERCISE 2
+++++ Pass
===== EXERCISE 3
*** Failed! Falsified (after 2 tests and 1 shrink):
quadruple 1
Expected: 4
Was: 2
----- Fail
===== EXERCISE 4
+++++ Pass
===== EXERCISE 5
+++++ Pass
===== EXERCISE 6
+++++ Pass
===== EXERCISE 7
+++++ Pass
===== EXERCISE 8
+++++ Pass
===== EXERCISE 9
+++++ Pass
===== EXERCISE 10
+++++ Pass
===== EXERCISE 11
+++++ Pass
===== EXERCISE 12
+++++ Pass
===== EXERCISE 13
+++++ Pass
===== EXERCISE 14
+++++ Pass
===== EXERCISE 15
+++++ Pass
===== EXERCISE 16
+++++ Pass
===== EXERCISE 17
+++++ Pass
===== EXERCISE 18
+++++ Pass
===== EXERCISE 19
+++++ Pass
===== TOTAL
1101111111111111111
18 / 19
В приведенном выше примере я допустил ошибку в упражнении 3.
Чтобы сделать отладку быстрее и проще, я могу загрузить файл упражнения в GHCi, что позволяет мне оценить любую функцию верхнего уровня вручную. Например, я могу проверить вышеуказанную ошибку, выполнив следующие действия:
$ stack ghci Set1.hs
GHCi, version 9.2.8: https://www.haskell.org/ghc/ :? for help
[1 of 2] Compiling Mooc.Todo ( Mooc/Todo.hs, interpreted )
[2 of 2] Compiling Set1 ( Set1.hs, interpreted )
Ok, two modules loaded.
*Set1> quadruple 1
2
Как только вы закончите с комплексом упражнений, вы можете сдать его на странице отправки на страницах курса. После этого вы сможете увидеть результаты своей заявки на странице результатов, а свой общий балл на Моей странице статуса.
Примечание! Вы можете сдавать комплекс упражнений столько раз, сколько захотите.
Примечание! Если вы не хотите использовать стек или не можете заставить его работать, вы также должны иметь возможность запускать тесты с помощью Cabal следующим образом:
$ cabal v2-build
$ cabal v2-exec runhaskell Set1Test.hsПосле успешного выполнения всех упражнений набора вы сможете просмотреть решения по моделям на Моя страница статуса. Полезно взглянуть на модельные решения, возможно, они подскажут вам прием, который вы упустили!
Maybe, EitherЧасто вам понадобятся вспомогательные переменные в рекурсии для отслеживания событий. Вы можете получить их, определив вспомогательную функцию с большим количеством аргументов. Аналогия: аргументы вспомогательной функции — это переменные, которые вы обновляете в цикле.
Вот пример того, как можно преобразовать цикл (в Java или Python) в рекурсивную вспомогательную функцию в Haskell.
Java:
public String repeatString(int n, String str) {
String result = "";
while (n>0) {
result = result+str;
n = n-1;
}
return result;
}Python:
def repeatString(n, str):
result = ""
while n>0:
result = result+str
n = n-1
return resultHaskell:
repeatString n str = repeatHelper n str ""
repeatHelper n str result = if (n==0)
then result
else repeatHelper (n-1) str (result++str)Prelude> repeatString 3 "ABC"
"ABCABCABC"Вы могли заметить, что реализации Java и Python выглядят немного странно, поскольку в них используются циклы while вместо циклов for. Это потому, что таким образом преобразование в Haskell становится более простым.
Это можно сделать немного проще, используя сопоставление с образцом вместо if:
repeatString n str = repeatHelper n str ""
repeatHelper 0 _ result = result
repeatHelper n str result = repeatHelper (n-1) str (result++str)Вот еще один пример с большим количеством переменных: эффективное вычисление чисел Фибоначчи.
Java:
public int fibonacci(int n) {
int a = 0;
int b = 1;
while (n>1) {
int c = a+b;
a=b;
b=c;
n--;
}
return b;
}Python:
def fibonacci(n):
a = 0
b = 1
while n>1:
c = a+b
a = b
b = c
n = n-1
return bHaskell:
-- fibonacci numbers, fast version
fibonacci :: Integer -> Integer
fibonacci n = fibonacci' 0 1 n
fibonacci' :: Integer -> Integer -> Integer -> Integer
fibonacci' a b 1 = b
fibonacci' a b n = fibonacci' b (a+b) (n-1)Изучите их и обратите внимание, что рекурсия Haskell имеет тот же формат, что и цикл.
Примечание: программы на Haskell часто используют апостроф для обозначения вспомогательных функций и альтернативных версий функций. Таким образом, имя fibonacci' для вспомогательной функции выше. Имена типа foo' обычно читаются как foo prime (как в математике).
Ранее я говорил, что эта версия Фибоначчи более эффективна. Вы понимаете, почему? Ответ в том, что рекурсивных вызовов становится меньше. Выражение fibonacci' _ _ n вызывает fibonacci' _ _ (n-1) один раз, и это означает, что мы можем вычислить fibonacci' _ _ n за n шагов.
Этот тип рекурсии называется tail recursion, что означает, что рекурсивный вызов происходит в хвостовой позиции, то есть после рекурсивного вызова не остается никакой работы. Поскольку после этого ничего делать не нужно, функция хвостовой рекурсии может быть оптимизирована компилятором в эффективный цикл в машинном коде.
Прежде чем мы перейдем к новым типам, давайте рассмотрим еще один фрагмент синтаксиса Haskell.
if then else часто бывает немного громоздким, особенно если у вас несколько случаев. Более простой альтернативой является условное определение или защищенное определение в Haskell. Это немного похоже на сопоставление с образцом: у вас есть несколько уравнений, но вы можете использовать произвольный код, решающий, какое уравнение использовать. Защищенные определения выглядят следующим образом:
f x y z
| condition1 = something
| condition2 = other
| otherwise = somethingotherУсловием может быть любое выражение типа Bool. Выбирается первое условие, которое имеет значение True. Слово otherwise — это просто псевдоним True. Он используется для обозначения регистра по умолчанию.
Вот несколько примеров использования средств защиты. Во-первых, у нас есть функция, описывающая данное число. Обратите внимание, насколько важно иметь регистр "Two" перед регистром "Even".
describe :: Int -> String
describe n
| n==2 = "Two"
| even n = "Even"
| n==3 = "Three"
| n>100 = "Big!!"
| otherwise = "The number "++show nВот факториал, реализованный с использованием защитных мер вместо сопоставления с образцом. В отличие от версии сопоставления с образцом, эта версия не работает вечно с отрицательными входными данными.
factorial n
| n<0 = -1
| n==0 = 1
| otherwise = n * factorial (n-1)Вы даже можете комбинировать защиту с помощью сопоставления с образцом. Вот реализация простой игры по угадыванию возраста:
guessAge :: String -> Int -> String
guessAge "Griselda" age
| age < 47 = "Too low!"
| age > 47 = "Too high!"
| otherwise = "Correct!"
guessAge "Hansel" age
| age < 12 = "Too low!"
| age > 12 = "Too high!"
| otherwise = "Correct!"
guessAge name age = "Wrong name!"Prelude> guessAge "Griselda" 30
"Too low!"
Prelude> guessAge "Griselda" 60
"Too high!"
Prelude> guessAge "Griselda" 47
"Correct!"
Prelude> guessAge "Bob" 30
"Wrong name!"
Prelude> guessAge "Hansel" 10
"Too low!"До сих пор мы всегда работали с одиночными значениями, такими как числа или логические значения. Строки содержат несколько символов, но в некотором смысле строка — это всего лишь один фрагмент информации. Чтобы иметь возможность заниматься реальным программированием, нам нужно обрабатывать переменное количество элементов. Для этого нам нужны структуры данных.
Базовая структура данных в Haskell — это список. Списки используются для хранения нескольких значений одного типа (другими словами, списки Haskell однородны). Вот как выглядит литерал списка:
[0,3,4,1+1]Тип списка записывается как [Element], где Element — тип элементов списка. Вот еще несколько выражений списков и их типы:
[True,True,False] :: [Bool]
["Moi","Hei"] :: [String]
[] :: [a] -- more about this later
[[1,2],[3,4]] :: [[Int]] -- a list of lists
[1..7] :: [Int] -- range syntax, value [1,2,3,4,5,6,7]Списки в Haskell реализованы как односвязные списки. Мы вернемся к этому позже.
Стандартная библиотека Haskell включает в себя множество функций, которые работают со списками. Вот некоторые из наиболее важных из них вместе с их типами. Через секунду мы вернемся к тому, что на самом деле означает [a], а пока вы можете себе представить, что это означает «любой список».
head :: [a] -> a -- returns the first element
last :: [a] -> a -- returns the last element
tail :: [a] -> [a] -- returns everything except the first element
init :: [a] -> [a] -- returns everything except the last element
take :: Int -> [a] -> [a] -- returns the n first elements
drop :: Int -> [a] -> [a] -- returns everything except the n first elements
(++) :: [a] -> [a] -> [a] -- lists are concatenated with the ++ operator
(!!) :: [a] -> Int -> a -- lists are indexed with the !! operator
reverse :: [a] -> [a] -- reverse a list
null :: [a] -> Bool -- is this list empty?
length :: [a] -> Int -- the length of a listПримечание: последние две операции (null и length) на самом деле имеют более общие типы, но здесь я делаю вид, что вы можете использовать их только со списками.
Списки можно сравнивать с помощью знакомого оператора ==.
Помните это из нашей первой сессии GHCI?
Prelude> :t "asdf"
"asdf" :: [Char]Это означает, что String — это просто псевдоним для [Char], что означает, что строка представляет собой список символов. Это означает, что вы можете использовать все операции над строками!
Некоторые операции со списками выполняются из модуля Data.List. Вы можете импортировать модуль в коде или в GHCi с синтаксисом import Data.List. Одним из примеров является функция sort, которая сортирует список:
Prelude> import Data.List
Prelude Data.List> sort [1,0,5,3]
[0,1,3,5]Обратите внимание, как набор импортированных модулей отображается в командной строке GHCi.
Вот несколько примеров работы со списками. В этом случае вместо того, чтобы показывать вам выходные данные GHCi, я просто использую ==>, чтобы показать, чему равно выражение.
Индексирование списка:
[7,10,4,5] !! 2
==> 4Определение функции, которая отбрасывает 3-й и 4-й элементы списка с помощью take и drop:
f xs = take 2 xs ++ drop 4 xsf [1,2,3,4,5,6] ==> [1,2,5,6]
f [1,2,3] ==> [1,2]Вращение списка путем взятия первого элемента и перемещения его в конец:
g xs = tail xs ++ [head xs]g [1,2,3] ==> [2,3,1]
g (g [1,2,3]) ==> [3,1,2]Вот пример синтаксиса диапазона:
reverse [1..4] ==> [4,3,2,1]Поскольку Haskell является чистым, это также означает, что функции не могут мутировать (изменять) свои входные данные. Мутация — это побочный эффект, и функциям Haskell разрешен вывод только через возвращаемое значение. Это означает, что функции списка Haskell всегда возвращают новый список. На практике:
Prelude> list = [1,2,3,4]
Prelude> reverse list
[4,3,2,1]
Prelude> list
[1,2,3,4]
Prelude> drop 2 list
[3,4]
Prelude> list
[1,2,3,4]Это может показаться очень неэффективным, но оказывается, что это может быть и производительно, и весьма полезно. Мы вернемся к тому, как работают структуры данных Haskell, в одной из следующих лекций.
Так что же означает такой тип, как head :: [a] -> a? Это означает, что для списка, содержащего элементы любого типа a, возвращаемое значение будет того же типа a.
В этом типе a является переменной типа. Имена переменных типа начинаются со строчной буквы, например a, b или thisIsATypeVariable. Такая переменная обозначает пока неизвестный тип, которым может оказаться любой тип. Переменные типа превращаются в конкретные типы, например Bool, в процессе вывода типов, также называемом унификацией (unification).
Давайте посмотрим на несколько примеров. Если мы применим head к списку логических значений, вывод типа сравнит тип аргумента заголовка [a] с типом фактического аргумента [Bool] и сделает вывод, что a должно быть Bool. Это означает, что тип возвращаемого значения head в этом случае также будет Bool.
head :: [a] -> a
head [True,False] :: BoolФункция tail принимает список и возвращает список того же типа. Если мы применим tail к списку логических значений, возвращаемое значение также будет списком логических значений.
tail :: [a] -> [a]
tail [True,False] :: [Bool]Если типы не совпадают, мы получаем ошибку типа. Рассмотрим оператор ++, который принимает два списка одного типа, как видно из его типа [a] -> [a] -> [a]. Если мы попытаемся применить ++ к списку логических значений и списку символов, мы получим ошибку. Вот что происходит в GHCi:
Prelude> [True,False] ++ "Moi"
<interactive>:1:16:
Couldn't match expected type `Bool' against inferred type `Char'
Expected type: [Bool]
Inferred type: [Char]
In the second argument of `(++)', namely `"Moi"'
In the expression: [True, False] ++ "Moi"
Вывод типа действительно мощный инструмент. Он использует простой процесс унификации, чтобы получить типы практически для любого выражения Haskell. Рассмотрим эти две функции:
f xs ys = [head xs, head ys]
g zs = f "Moi" zsМы можем запросить у GHCi их типы, и мы увидим, что вывод типа выявил, что два аргумента f должны иметь один и тот же тип, поскольку их головки помещаются в один и тот же список.
Prelude> :t f
f :: [a] -> [a] -> [a]Функция g, которая зафиксировала один из аргументов f в строке (т. е. [Char]), получает более узкий тип. Вывод типа решил, что аргумент от zs до g также должен иметь тип [Char], поскольку в противном случае тип f не будет соответствовать вызову f.
Prelude> :t g
g :: [Char] -> [Char]В таком типе, как [Char], мы называем Char параметром type. Тип, подобный типу списка, которому требуется параметр типа, называется параметризованным типом.
Тот факт, что такую функцию, как head, можно использовать со многими различными типами аргументов, называется полиморфизмом. Функция head называется полиморфной. Существует много форм полиморфизма, и эта форма Haskell, использующая переменные типа, называется параметрический полиморфизм.
Поскольку в Haskell есть вывод типа, вам не нужно давать какие-либо аннотации типов. Однако даже несмотря на то, что аннотации типов не требуются, существует несколько причин для их добавления:
Хорошее практическое правило — давать аннотации типов определений верхнего уровня.
MaybeПомимо типа списка в Haskell есть и другие параметризованные типы. Давайте рассмотрим очень распространенный и полезный тип: тип Maybe.
Иногда операция не имеет допустимого возвращаемого значения (например, деление на ноль). У нас есть несколько вариантов в этой ситуации. Мы можем использовать значение ошибки, например -1. Это немного некрасиво, не всегда возможно. Мы можем вызвать исключение. Это нечисто. В некоторых других языках мы возвращали бы специальное нулевое значение, которое существует (почти) во всех типах. Однако в Haskell нет нуля.
Вместо этого Haskell предлагает изменить тип возвращаемого значения на Maybe. Это чистое, безопасное и аккуратное решение. У типа Maybe a два конструктора: Nothing и Just. Nothing — константа, а Just принимает параметр. Точнее:
| Тип | Значения |
|---|---|
Maybe Bool |
Nothing, Just False, Just True |
Maybe Int |
Nothing, Just 0, Just 1, … |
Maybe [Int] |
Nothing, Just [], Just [1,1337], … |
Вы можете думать о Maybe a как о чем-то похожем на [a], за исключением того, что здесь может быть только 0 или 1 элемент, не более. В качестве альтернативы вы можете подумать о том, чтобы Maybe a вводил нулевое значение в тип a. Если вы знакомы с Java, Maybe Integer — это эквивалент Java Optional<Integer> в Haskell.
Вы можете создавать значения Maybe, указав Nothing или Just someOtherValue:
Prelude> :t Nothing
Nothing :: Maybe a
Prelude> Just "a camel"
Just "a camel"
Prelude> :t Just "a camel"
Just "a camel" :: Maybe [Char] -- the same as Maybe String
Prelude> Just True
Just True
Prelude> :t Just True
Just True :: Maybe Bool-- given a password, return (Just username) if login succeeds, Nothing otherwise
login :: String -> Maybe String
login "f4bulous!" = Just "unicorn73"
login "swordfish" = Just "megahacker"
login _ = NothingВы используете значение Maybe путем сопоставления его с образцом. Обычно вы определяете шаблоны для случаев Nothing и Just something. Некоторые примеры:
-- Multiply an Int with a Maybe Int. Nothing is treated as no multiplication at all.
perhapsMultiply :: Int -> Maybe Int -> Int
perhapsMultiply i Nothing = i
perhapsMultiply i (Just j) = i*j -- Note how j denotes the value inside the JustPrelude> perhapsMultiply 3 Nothing
3
Prelude> perhapsMultiply 3 (Just 2)
6intOrZero :: Maybe Int -> Int
intOrZero Nothing = 0
intOrZero (Just i) = i
safeHead :: [a] -> Maybe a
safeHead xs = if null xs then Nothing else Just (head xs)
headOrZero :: [Int] -> Int
headOrZero xs = intOrZero (safeHead xs)headOrZero [] ==> intOrZero (safeHead []) ==> intOrZero Nothing ==> 0
headOrZero [1] ==> intOrZero (safeHead [1]) ==> intOrZero (Just 1) ==> 1Как вы можете видеть выше, мы можем сопоставлять шаблоны конструкторов Maybe: Just и Nothing. Мы вернемся к тому, что означают конструкторы, позже. А пока достаточно отметить, что конструкторы — это специальные значения, начинающиеся с заглавной буквы, по которым можно выполнять сопоставление с образцом.
Другие конструкторы, которые мы уже видели, включают конструкторы Bool – True и False. С конструкторами типа списка мы познакомимся на следующей лекции.
Конструкторы можно использовать так же, как и значения Haskell. Конструкторы, не принимающие аргументов, такие как Nothing и False, являются просто константами. Конструкторы типа Just, принимающие аргумент, ведут себя как функции. У них даже есть типы функций!
Prelude> :t Just
Just :: a -> Maybe a
EitherИногда было бы неплохо добавить сообщение об ошибке или что-то еще в Nothing. Вот почему у нас есть тип Either. Тип Either принимает два аргумента типа. Тип Either a b имеет два конструктора: Left и Right. Оба принимают аргумент: Left — аргумент типа a, а Right — аргумент типа b.
| Тип | Значения |
|---|---|
Either Int Bool |
Left 0, Left 1, Right False, Right True, … |
Either String [Int] |
Left "asdf", Right [0,1,2], … |
Either Integer Integer |
Left 0, Right 0, Left 1, Right 1, … |
Вот простой пример: функция readInt, которая знает только пару чисел и возвращает описательную ошибку для остальных. Обратите внимание на соглашение Haskell об использовании Left для ошибок и Right для успеха.
readInt :: String -> Either String Int
readInt "0" = Right 0
readInt "1" = Right 1
readInt s = Left ("Unsupported string: " ++ s)Примечание: конструкторы Either называются Left и Right, поскольку они ссылаются на левый и правый аргументы типа Either. Обратите внимание, что в Either a b a является левым аргументом, а b — правым аргументом. Таким образом, Left содержит значение типа a, а также Right типа b. Соглашение об использовании Right для успеха, вероятно, просто потому, что правильно также означает правильно. Никакое оскорбление не предназначено для левшей.
Вот еще один пример: соответствие шаблону Either. Как и в случае с Maybe, для Either существует два шаблона, по одному для каждого конструктора.
iWantAString :: Either Int String -> String
iWantAString (Right str) = str
iWantAString (Left number) = show numberКак вы помните, списки Haskell могут содержать элементы только одного типа. Вы не можете иметь значение типа [1,"foo",2]. Однако вы можете использовать такой тип, как Either, для представления списков, которые могут содержать два разных типа значений. Например, мы могли бы отслеживать количество людей на лекции с возможностью добавления объяснения, если значение отсутствует:
lectureParticipants :: [Either String Int]
lectureParticipants = [Right 10, Right 13, Left "easter vacation", Right 17, Left "lecturer was sick", Right 3]
Мы видели сопоставление с образцом в аргументах функции, но есть также способ сопоставления с образцом в выражении. Это выглядит так:
case <value> of <pattern> -> <expression>
<pattern> -> <expression>В качестве примера давайте перепишем пример describe из первой лекции, используя case:
describe :: Integer -> String
describe 0 = "zero"
describe 1 = "one"
describe 2 = "an even prime"
describe n = "the number " ++ show ndescribe :: Integer -> String
describe n = case n of 0 -> "zero"
1 -> "one"
2 -> "an even prime"
n -> "the number " ++ show nБолее интересный пример — когда значение, по которому мы сопоставляем шаблон, не является аргументом функции. Например:
-- parse country code into country name, returns Nothing if code not recognized
parseCountry :: String -> Maybe String
parseCountry "FI" = Just "Finland"
parseCountry "SE" = Just "Sweden"
parseCountry _ = Nothing
flyTo :: String -> String
flyTo countryCode = case parseCountry countryCode of Just country -> "You're flying to " ++ country
Nothing -> "You're not flying anywhere"Prelude> flyTo "FI"
"You're flying to Finland"
Prelude> flyTo "DE"
"You're not flying anywhere"Мы могли бы написать функцию flyTo, используя вспомогательную функцию для сопоставления с образцом вместо использования выражения регистра:
flyTo :: String -> String
flyTo countryCode = handleResult (parseCountry countryCode)
where handleResult (Just country) = "You're flying to " ++ country
handleResult Nothing = "You're not flying anywhere"Фактически, выражение регистра всегда можно заменить вспомогательной функцией. Вот еще один пример, написанный обоими способами:
-- given a sentence, decide whether it is a statement, question or exclamation
sentenceType :: String -> String
sentenceType sentence = case last sentence of '.' -> "statement"
'?' -> "question"
'!' -> "exclamation"
_ -> "not a sentence"-- same function, helper function instead of case-of
sentenceType sentence = classify (last sentence)
where classify '.' = "statement"
classify '?' = "question"
classify '!' = "exclamation"
classify _ = "not a sentence"Prelude> sentenceType "This is Haskell."
"statement"
Prelude> sentenceType "This is Haskell!"
"exclamation"Вы можете спросить, какой смысл иметь другой синтаксис сопоставления с образцом. Что ж, выражения case имеют некоторые преимущества перед уравнениями, которые мы обсудим далее.
Во-первых, и, возможно, это самое главное, выражения case позволяют нам сопоставлять шаблоны с выходными данными функций. Возможно, нам захочется написать ранним утром мотивационные сообщения работающим (ленивым) хаскеллерам:
motivate :: String -> String
motivate "Monday" = "Have a nice week at work!"
motivate "Tuesday" = "You're one day closer to weekend!"
motivate "Wednesday" = "3 more day(s) until the weekend!"
motivate "Thursday" = "2 more day(s) until the weekend!"
motivate "Friday" = "1 more day(s) until the weekend!"
motivate _ = "Relax! You don't need to work today!"Используя выражение case, мы можем запустить вспомогательную функцию для сопоставления аргумента и шаблона результата:
motivate :: String -> String
motivate day = case distanceToSunday day of
6 -> "Have a nice week at work!"
5 -> "You're one day closer to weekend!"
n -> if n > 1
then show (n - 1) ++ " more day(s) until the weekend!"
else "Relax! You don't need to work today!"Кстати, есть и третий способ, защитные выражения (guards):
motivate :: String -> String
motivate day
| n == 6 = "Have a nice week at work!"
| n == 5 = "You're one day closer to weekend!"
| n > 1 = show (n - 1) ++ " more day(s) until the weekend!"
| otherwise = "Relax! You don't need to work today!"
where n = distanceToSunday dayСкоро мы увидим, как можно определить distanceToSunday с помощью уравнений и выражений case.
Во-вторых, если вспомогательную функцию необходимо использовать во многих шаблонах, уравнения не работают. Например:
area :: String -> Double -> Double
area "square" x = square x
area "circle" x = pi * square x
where square x = x * xЭто не скомпилируется, поскольку предложение where добавляется только к случаю "circle", поэтому вспомогательная функция square недоступна в случае "square". С другой стороны, мы можем написать
area :: String -> Double -> Double
area shape x = case shape of
"square" -> square x
"circle" -> pi * square x
where square x = x*xВ-третьих, выражения case могут помочь написать более краткий код в ситуации, когда (длинное) имя функции придется повторять несколько раз с использованием уравнений. Как мы видели выше, нам может понадобиться функция, измеряющая расстояние между заданным днем и воскресеньем:
distanceToSunday :: String -> Int
distanceToSunday "Monday" = 6
distanceToSunday "Tuesday" = 5
distanceToSunday "Wednesday" = 4
distanceToSunday "Thursday" = 3
distanceToSunday "Friday" = 2
distanceToSunday "Saturday" = 1
distanceToSunday "Sunday" = 0Использование выражения case приводит к гораздо более краткой реализации:
distanceToSunday :: String -> Int
distanceToSunday d = case d of
"Monday" -> 6
"Tuesday" -> 5
"Wednesday" -> 4
"Thursday" -> 3
"Friday" -> 2
"Saturday" -> 1
"Sunday" -> 0Эти три преимущества делают выражение case универсальным инструментом в наборе инструментов Haskeller. Стоит запомнить, как работает case.
(Представление дней недели в виде строк может помочь, но это не идеальное решение. Что произойдет, если мы применим motivate к "monday" (со всеми буквами в нижнем регистре) или "keskiviikko"? В Лекции 5 мы научимся лучше представлять такие вещи, как дни недели.)
Вещи, которые можно использовать в качестве шаблонов:
Int и Integer константы, такие как (-1), 0, 1, 2, …Bool значения True и FalseChar константы: 'a', 'b'String константы: "abc", ""Maybe Конструкторы: Nothing, (Just x)Either Конструкторы: (Left x), (Right y)_, который означает «что угодно, мне все равно»(Just 1)Места, где вы можете использовать шаблоны:
f :: Bool -> Maybe Int -> Int
f False Nothing = 1
f False _ = 2
f True (Just i) = i
f True Nothing = 0case of:case number of 0 -> "zero"
1 -> "one"
_ -> "not zero or one"Единственное, что вам действительно нужно, это сопоставление шаблонов получение значения внутри конструктора Just, Left или Right. Вот еще два примера:
-- getElement (Just i) gets the ith element (counting from zero) of a list, getElement Nothing gets the last element
getElement :: Maybe Int -> [a] -> a
getElement (Just i) xs = xs !! i
getElement Nothing xs = last xsPrelude> getElement Nothing "hurray!"
'!'
Prelude> getElement (Just 3) [5,6,7,8,9]
8direction :: Either Int Int -> String
direction (Left i) = "you should go left " ++ show i ++ " meters!"
direction (Right i) = "you should go right " ++ show i ++ " meters!"Prelude> direction (Left 3)
"you should go left 3 meters!"
Prelude> direction (Right 5)
"you should go right 5 meters!"Другие варианты использования (которые мы уже видели!) сопоставления с образцом также можно реализовать с помощью оператора ==. Однако такие вещи, как x==Nothing, будут работать не во всех случаях. Мы узнаем почему, когда поговорим о классах типов в лекции 4.
Сколько значений возвращает f x = [x,x]?
Почему выражение Nothing 1 вызывает ошибку типа?
Nothing не принимает аргументовNothing ничего не возвращаетNothing является конструкторомКаков тип функции f x y = if x && y then Right x else Left "foo"?
Bool -> Bool -> Either Bool StringString -> String -> Either String StringBool -> Bool -> Either String BoolКакая из следующих функций может иметь тип Bool -> Int -> [Bool]
f x y = [0, y]f x y = [x, True]f x y = [y, True]Каков тип этой функции? justBoth a b = [Just a, Just b]
a -> b -> [Maybe a, Maybe b]a -> a -> [Just a]a -> b -> [Maybe a]a -> a -> [Maybe a]Теперь со списками и полиморфизмом наш набор инструментов, мы наконец можем начать изучать функциональное программирование.
В Haskell функция — это значение, точно так же, как число или список. Функции можно передавать в качестве параметров другим функциям. Вот игрушечный пример. Функция applyTo1 принимает функцию типа Int->Int, применяет ее к числу 1 и возвращает результат.
applyTo1 :: (Int -> Int) -> Int
applyTo1 f = f 1Давайте определим простую функцию типа Int->Int и посмотрим applyTo1 в действии.
addThree :: Int -> Int
addThree x = x + 3applyTo1 addThree
==> addThree 1
==> 1 + 3
==> 4Вернёмся к аннотации типа для applyTo1.
applyTo1 :: (Int -> Int) -> IntКруглые скобки необходимы, поскольку тип Int -> Int -> Int будет типом функции, принимающей два аргумента Int. Подробнее об этом позже.
Давайте рассмотрим немного более интересный пример. На этот раз мы реализуем полиморфную функцию doTwice. Обратите внимание, как мы можем использовать его с различными типами значений и функций.
doTwice :: (a -> a) -> a -> a
doTwice f x = f (f x)doTwice addThree 1
==> addThree (addThree 1)
==> 7
doTwice tail "abcd"
==> tail (tail "abcd")
==> "cd"makeCool :: String -> String
makeCool str = "WOW " ++ str ++ "!"doTwice makeCool "Haskell"
==> "WOW WOW Haskell!!"Это было немного скучно. К счастью, существует множество полезных функций списков, которые принимают функции в качестве аргументов. Кстати, функции, которые принимают функции в качестве аргументов (или возвращают функции), часто называют функциями высшего порядка.
Самая известная из функций высшего порядка, обрабатывающих списки, — это map. Он дает вам новый список, применяя данную функцию ко всем элементам списка.
map :: (a -> b) -> [a] -> [b]map addThree [1,2,3]
==> [4,5,6]Соучастником преступления для map является filter. Вместо преобразования всех элементов списка filter удаляет некоторые элементы списка и сохраняет другие. Другими словами, filter выбирает из списка элементы, соответствующие условию.
filter :: (a -> Bool) -> [a] -> [a]Вот пример: выбор положительных элементов из списка
positive :: Int -> Bool
positive x = x>0filter positive [0,1,-1,3,-3]
==> [1,3]Обратите внимание, что обе сигнатуры типов map и filter используют полиморфизм. Они работают со всеми видами списков. Тип map даже использует два параметра типа! Вот несколько примеров вывода типа с использованием map и filter.
onlyPositive xs = filter positive xs
mapBooleans f = map f [False,True]Prelude> :t onlyPositive
onlyPositive :: [Int] -> [Int]
Prelude> :t mapBooleans
mapBooleans :: (Bool -> b) -> [b]
Prelude> :t mapBooleans not
mapBooleans not :: [Bool]И еще: помните, конструкторы были просто функциями? Это означает, что вы можете передавать их в качестве аргументов другим функциям!
wrapJust xs = map Just xsPrelude> :t wrapJust
wrapJust :: [a] -> [Maybe a]
Prelude> wrapJust [1,2,3]
[Just 1,Just 2,Just 3]Сколько «палиндромных чисел» находится между 1 и n?
-- a predicate that checks if a string is a palindrome
palindrome :: String -> Bool
palindrome str = str == reverse str
-- palindromes n takes all numbers from 1 to n, converts them to strings using show, and keeps only palindromes
palindromes :: Int -> [String]
palindromes n = filter palindrome (map show [1..n])palindrome "1331" ==> True
palindromes 150 ==>
["1","2","3","4","5","6","7","8","9",
"11","22","33","44","55","66","77","88","99",
"101","111","121","131","141"]
length (palindromes 9999) ==> 198Сколько слов в строке начинаются с буквы «а»? При этом используется функция words из модуля Data.List, которая разбивает строку на слова.
countAWords :: String -> Int
countAWords string = length (filter startsWithA (words string))
where startsWithA s = head s == 'a'countAWords "does anyone want an apple?"
==> 3Функция tails из Data.List возвращает список всех суффиксов («хвостов») списка. Мы можем использовать tails для многих задач обработки строк. Вот как работает tails:
tails "echo"
==> ["echo","cho","ho","o",""]Вот пример, в котором мы находим, какие символы идут после заданного символа в строке. Прежде всего, мы используем tails, map и take, чтобы получить все подстроки определенной длины:
substringsOfLength :: Int -> String -> [String]
substringsOfLength n string = map shorten (tails string)
where shorten s = take n ssubstringsOfLength 3 "hello"
==> ["hel","ell","llo","lo","o",""]В конце осталось несколько более коротких подстрок (понимаете почему?), но сейчас они подходят для наших целей. Теперь, когда у нас есть substringsOfLength, мы можем реализовать функцию whatFollows c k s, которая находит все вхождения символа c в строке s и выводит буквы k, следующие после этих вхождений.
whatFollows :: Char -> Int -> String -> [String]
whatFollows c k string = map tail (filter match (substringsOfLength (k+1) string))
where match sub = take 1 sub == [c]whatFollows 'a' 2 "abracadabra"
==> ["br","ca","da","br",""]При использовании функций высшего порядка вы можете обнаружить, что определяете множество небольших вспомогательных функций, таких как addThree или shorten в предыдущих примерах. В конечном итоге это немного утомительно, но, к счастью, функции Haskell ведут себя немного странно…
Начнем с GHCi:
Prelude> add a b = a+b
Prelude> add 1 5
6
Prelude> addThree = add 3
Prelude> addThree 2
5Итак, мы определили add, функцию с двумя аргументами, и присвоили ей только один аргумент. Результатом является не ошибка типа, а новая функция. Новая функция просто сохраняет (или запоминает) данный аргумент, ждет другого аргумента, а затем передает оба аргумента add.
Prelude> map addThree [1,2,3]
[4,5,6]
Prelude> map (add 3) [1,2,3]
[4,5,6]
Здесь мы видим, что нам даже не нужно давать имя функции, возвращаемой add 3. Мы можем просто использовать его везде, где ожидается функция одного аргумента.
Это называется частичное приложение. Все функции в Haskell ведут себя подобным образом. Давайте посмотрим поближе. Вот функция, которая принимает много аргументов.
between :: Integer -> Integer -> Integer -> Bool
between lo high x = x < high && x > loPrelude> between 3 7 5
True
Prelude> between 3 6 8
FalseМы можем передать between меньше аргументов и вернуть новые функции, точно так же, как мы видели с add:
Prelude> (between 1 5) 2
True
Prelude> let f = between 1 5 in f 2
True
Prelude> map (between 1 3) [1,2,3]
[False,True,False]Посмотрите на типы частичного применения between. Они ведут себя аккуратно: аргументы исчезают из типа один за другим по мере добавления значений в выражение.
Prelude> :t between
between :: Integer -> Integer -> Integer -> Bool
Prelude> :t between 1
between 1 :: Integer -> Integer -> Bool
Prelude> :t between 1 2
between 1 2 :: Integer -> Bool
Prelude> :t between 1 2 3
between 1 2 3 :: BoolНа самом деле, когда мы пишем тип типа Integer -> Integer -> Integer -> Bool, это означает Integer -> (Integer -> (Integer -> Bool)). То есть функция с несколькими аргументами — это просто функция, которая возвращает функцию. Аналогично, такое выражение, как between 1 2 3, совпадает с ((between 1) 2) 3, поэтому передача нескольких аргументов в функцию происходит посредством нескольких вызовов с одним аргументом. Такое представление функций с несколькими аргументами называется currying (в честь логика Haskell Curry). Каррирование — это то, что делает возможным частичное применение.
Вот еще один пример использования частичного приложения с map:
map (drop 1) ["Hello","World!"]
==> ["ello","orld!"]Помимо обычных функций, частичное приложение также работает с операторами. С помощью операторов вы можете выбирать, применять ли вам левый или правый аргумент. (Частично применяемые операторы также называются sections или operatorsections). Некоторые примеры:
Prelude> map (*2) [1,2,3]
[2,4,6]
Prelude> map (2*) [1,2,3]
[2,4,6]
Prelude> map (1/) [1,2,3,4,5]
[1.0,0.5,0.3333333333333333,0.25,0.2]Обычные функции Haskell применяются в префиксной записи (prefix notation): имя функции предшествует аргументам. Операторы, напротив, применяются в инфиксной записи (infix notation) — имя функции помещается между аргументами.
Инфиксный оператор можно преобразовать в префиксную функцию, добавив вокруг него круглые скобки. Например,
(+) 1 2 ==> 1 + 2 ==> 3Это особенно полезно, когда оператор необходимо передать в качестве аргумента другой функции.
Например, функция zipWith принимает два списка (двоичную функцию) и объединяет их с помощью этой функции. Мы можем использовать zipWith (+) для суммирования двух списков поэлементно:
Prelude> :t zipWith
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
Prelude> zipWith (+) [0,2,5] [1,3,3]
[1,5,8]Без возможности превратить оператор в функцию нам пришлось бы использовать вспомогательную функцию, такую как add выше.
Обратите внимание, что отсутствие круглых скобок приводит к ошибке типа:
Prelude> zipWith + [0,2,5] [1,3,3]
<interactive>:1:11: error:
• Couldn't match expected type ‘[Integer]
-> (a -> b -> c) -> [a] -> [b] -> [c]’
with actual type ‘[Integer]’
• The function ‘[0, 2, 5]’ is applied to one argument,
but its type ‘[Integer]’ has none
In the second argument of ‘(+)’, namely ‘[0, 2, 5] [1, 3, 3]’
In the expression: zipWith + [0, 2, 5] [1, 3, 3]
• Relevant bindings include
it :: (a -> b -> c) -> [a] -> [b] -> [c]
(bound at <interactive>:1:1)Причина этой странной ошибки в том, что GHCi запутался и подумал, что мы каким-то образом пытаемся сложить zipWith и [0,2,5] [1,3,3] вместе. Логично, что он пришел к выводу, что [0,2,5] должен быть функцией, поскольку он применяется к [1,3,3] (помните, что функции связаны более жестко, чем операторы).
К сожалению, сообщения об ошибках иногда могут быть неясными, поскольку компилятор не всегда может знать «настоящую» причину ошибки (в данном случае это отсутствие круглых скобок). Странные сообщения об ошибках расстраивают, но только программист знает, каков был первоначальный замысел кода.
Еще одна приятная особенность Haskell — это синтаксис применения двоичной функции так, как если бы она была инфиксным оператором, путем окружения ее обратными кавычками (`). Например:
6 `div` 2 ==> div 6 2 ==> 3
(+1) `map` [1,2,3] ==> map (+1) [1,2,3] ==> [2,3,4]Последний гаечный ключ, который нам нужен в нашем наборе инструментов функционального программирования, — это λ (лямбда). Лямбда-выражения — это анонимные функции. Рассмотрим ситуацию, когда вам нужна функция только один раз, например, в выражении типа
let big x = x>7 in filter big [1,10,100]Лямбда-выражение позволяет нам написать это напрямую, без определения имени (big) для вспомогательной функции:
filter (\x -> x>7) [1,10,100]Вот еще несколько примеров в GHCi:
Prelude> (\x -> x*x) 3
9
Prelude> (\x -> reverse x == x) "ABBA"
True
Prelude> filter (\x -> reverse x == x) ["ABBA","ACDC","otto","lothar","anna"]
["ABBA","otto","anna"]
Prelude> (\x y -> x^2+y^2) 2 3 -- multiple arguments
13Синтаксис Haskell для лямбда-выражений немного удивляет. Символ обратной косой черты (\) обозначает греческую букву лямбда (λ). Выражение Haskell \x -> x+1 пытается имитировать математическую запись λx. x+1. В других языках используется синтаксис, например x => x+1 (JavaScript) или lambda x: x+1 (Python).
Примечание! Никогда необходимо использовать лямбда-выражение. Вместо этого вы всегда можете определить функцию обычным способом, используя let или where.
Кстати, лямбда-выражения — это довольно мощные конструкции, имеющие собственную глубокую теорию, известную как Лямбда-исчисление. Некоторые даже считают чисто функциональные языки программирования, такие как Haskell, типизированными расширениями лямбда-исчисления с дополнительным синтаксисом.
. и $Двумя наиболее распространенными операторами в кодовых базах Haskell, вероятно, являются . и $. Они полезны при написании кода, использующего функции высшего порядка. Первый из них, оператор ., представляет собой оператор композиции функций. Вот его тип
(.) :: (b -> c) -> (a -> b) -> a -> cИ вот что он делает
(f.g) x ==> f (g x)
Вы можете использовать композицию функций для создания функций из других функций, не упоминая никаких аргументов. Например:
double x = 2*x
quadruple = double . double -- computes 2*(2*x) == 4*x
f = quadruple . (+1) -- computes 4*(x+1)
g = (+1) . quadruple -- computes 4*x+1
third = head . tail . tail -- fetches the third element of a listМы также можем переопределить doTwice, используя (.). Обратите внимание, что мы можем использовать doTwice как в применении только к функции, так и в применении к функции и значению.
doTwice :: (a -> a) -> a -> a
doTwice f = f . flet ttail = doTwice tail
in ttail [1,2,3,4]
==> [3,4]
(doTwice tail) [1,2,3,4] ==> [3,4]
doTwice tail [1,2,3,4] ==> [3,4]Часто композиция функций не используется при определении новой функции, а вместо этого используется для того, чтобы избежать определения вспомогательной функции. Например, рассмотрим разницу между этими двумя выражениями:
let notEmpty x = not (null x)
in filter notEmpty [[1,2,3],[],[4]]
==> [[1,2,3],[4]]filter (not . null) [[1,2,3],[],[4]]
==> [[1,2,3],[4]]Другой оператор, $, более тонкий. Давайте посмотрим на его тип.
($) :: (a -> b) -> a -> bОн принимает функцию типа a -> b и значение типа a и возвращает значение типа b. Другими словами, это оператор приложения-функции. Выражение f $ x совпадает с выражением f x. Это кажется довольно бесполезным, но это означает, что оператор $ можно использовать для удаления скобок! Эти выражения одинаковы:
head (reverse "abcd")
head $ reverse "abcd"Это не так уж впечатляет, когда оно используется для устранения одной пары скобок, но вместе . и $ могут устранить многие из них! Например, мы можем переписать
reverse (map head (map reverse (["Haskell","pro"] ++ ["dodo","lyric"])))как
(reverse . map head . map reverse) (["Haskell","pro"] ++ ["dodo","lyric"])Затем
reverse . map head . map reverse $ ["Haskell","pro"] ++ ["dodo","lyric"]Иногда операторы . и $ полезны как самостоятельные функции. Например, список функций можно применить к аргументу, используя карту и раздел $:
map ($"string") [reverse, take 2, drop 2]
==> [reverse $ "string", take 2 $ "string", drop 2 $ "string"]
==> [reverse "string", take 2 "string", drop 2 "string"]
==> ["gnirts", "st", "ring"]Если это кажется сложным, не волнуйтесь. Вам не нужно использовать . и $ в своем собственном коде, пока вы не освоитесь с ними. Однако вы встретите . и $ при чтении примеров и кода Haskell в Интернете, поэтому полезно о них знать. Эта статья также может помочь.
whatFollowsТеперь давайте перепишем приведенный ранее пример whatFollows, используя только что рассмотренные инструменты. Вот исходная версия:
substringsOfLength :: Int -> String -> [String]
substringsOfLength n string = map shorten (tails string)
where shorten s = take n s
whatFollows :: Char -> Int -> String -> [String]
whatFollows c k string = map tail (filter match (substringsOfLength (k+1) string))
where match sub = take 1 sub == [c]Для начала избавимся от вспомогательной функции substringsOfLength и переместим весь код в whatFollows:
whatFollows c k string = map tail (filter match (map shorten (tails string)))
where shorten s = take (k+1) s
match sub = take 1 sub == [c]Теперь давайте вместо определения shorten использовать частичное приложение:
whatFollows c k string = map tail (filter match (map (take (k+1)) (tails string)))
where match sub = take 1 sub == [c]Давайте используем . и $, чтобы устранить некоторые из этих скобок:
whatFollows c k string = map tail . filter match . map (take (k+1)) $ tails string
where match sub = take 1 sub == [c]Мы также можем заменить match лямбдой:
whatFollows c k string = map tail . filter (\sub -> take 1 sub == [c]) . map (take (k+1)) $ tails stringНаконец, нам вообще не нужно упоминать параметр string, поскольку мы можем просто выразить whatFollows как композицию map, filter, map и tails:
whatFollows c k = map tail . filter (\sub -> take 1 sub == [c]) . map (take (k+1)) . tailsМы можем даже пойти немного дальше, переписав лямбду, используя раздел оператора
\sub -> take 1 sub == [c]
=== \sub -> (==[c]) (take 1 sub)
=== \sub -> (==[c]) ((take 1) sub)
=== \sub -> ((==[c]) . (take 1)) sub
=== ((==[c]) . (take 1))
=== ((==[c]) . take 1)Теперь у нас осталось:
whatFollows c k = map tail . filter ((==[c]) . take 1) . map (take (k+1)) . tailsЭто несколько крайняя версия функции, но При умеренном использовании показанные здесь методы могут облегчить чтение кода.
Вот еще несколько примеров функционального программирования со списками. Начнем с введения пары новых функций списка:
takeWhile :: (a -> Bool) -> [a] -> [a] -- take elements from a list as long as they satisfy a predicate
dropWhile :: (a -> Bool) -> [a] -> [a] -- drop elements from a list as long as they satisfy a predicatetakeWhile even [2,4,1,2,3] ==> [2,4]
dropWhile even [2,4,1,2,3] ==> [1,2,3]Также есть функция elem, которую можно использовать для проверки наличия элемента в списке:
elem 3 [1,2,3] ==> True
elem 4 [1,2,3] ==> FalseИспользуя их, мы можем реализовать функцию findSubstring, которая находит самую раннюю и самую длинную подстроку в строке, состоящей только из заданных символов.
findSubstring :: String -> String -> String
findSubstring chars = takeWhile (\x -> elem x chars)
. dropWhile (\x -> not $ elem x chars)findSubstring "a" "bbaabaaaab" ==> "aa"
findSubstring "abcd" "xxxyyyzabaaxxabcd" ==> "abaa"Функция zipWith позволяет поэлементно объединять два списка:
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]zipWith (++) ["John","Mary"] ["Smith","Cooper"]
==> ["JohnSmith","MaryCooper"]
zipWith take [4,3] ["Hello","Warden"]
==> ["Hell","War"]Иногда для функций более высокого порядка полезно иметь функцию, которая ничего не делает. Функция id :: a -> a является идентификационной функцией и просто возвращает свой аргумент.
id 3 ==> 3
map id [1,2,3] ==> [1,2,3]Это кажется немного бесполезным, но вы можете использовать его, например, с filter или dropWhile:
filter id [True,False,True,True] ==> [True,True,True]
dropWhile id [True,True,False,True,False] ==> [False,True,False]Еще одна очень простая, но иногда решающая функция — это функция-константа const :: a -> b -> a. Он всегда возвращает свой первый аргумент:
const 3 True ==> 3
const 3 0 ==> 3При частичном применении его можно использовать, когда вам нужна функция, которая всегда возвращает одно и то же значение:
map (const 5) [1,2,3,4] ==> [5,5,5,5]
filter (const True) [1,2,3,4] ==> [1,2,3,4]Вот новый оператор :
Prelude> 1:[]
[1]
Prelude> 1:[2,3]
[1,2,3]
Prelude> tail (1:[2,3])
[2,3]
Prelude> head (1:[2,3])
1
Prelude> :t (:)
(:) :: a -> [a] -> [a]Оператор : создает список из головы и хвоста. Другими словами, x : xs — это то же самое, что [x] ++ xs. Зачем нам для этого нужен оператор?
На самом деле : — это конструктор для списков: он возвращает новый узел связанного списка. Другой конструктор списка — [], пустой список. Все списки создаются с использованием : и []. Знакомый синтаксис [x,y,z] на самом деле является более удобным способом записи x:y:z:[] или, более явно, x:(y:(z:[])). Фактически (++) определяется в терминах : и рекурсии в стандартной библиотеке.
Вот изображение того, как [1,2,3] структурировано в памяти:
Используя :, мы можем определить рекурсивные функции, которые создают списки. Например, вот функция, которая создает списки типа [3,2,1]:
descend 0 = []
descend n = n : descend (n-1)descend 4 ==> [4,3,2,1]Вот функция, которая создает список, повторяя функцию n раз:
iterate f 0 x = [x]
iterate f n x = x : iterate f (n-1) (f x)iterate (*2) 4 3 ==> [3,6,12,24,48]
let xs = "terve"
in iterate tail (length xs) xs
==> ["terve","erve","rve","ve","e",""]Вот более сложный пример: разбиение строки на части по заданному символу:
split :: Char -> String -> [String]
split c [] = []
split c xs = start : split c (drop 1 rest)
where start = takeWhile (/=c) xs
rest = dropWhile (/=c) xssplit 'x' "fooxxbarxquux" ==> ["foo","","bar","quu"]В прошлой лекции говорилось, что конструкторы — это вещи, которые можно сопоставить с образцом. Выше было объявлено, что конструкторами для типа списка являются : и []. Мы можем сложить одно и одно и предположить, что мы можем сопоставить шаблоны : и []. Это верно! Вот как вы можете определить свои собственные версии head и tail, используя сопоставление с образцом:
myhead :: [Int] -> Int
myhead [] = -1
myhead (first:rest) = first
mytail :: [Int] -> [Int]
mytail [] = []
mytail (first:rest) = restВы можете nest шаблоны. То есть вы можете сопоставить по шаблону более одного элемента с начала списка. В этом примере мы используем шаблон (a:b:_), который аналогичен (a:(b:_)):
sumFirstTwo :: [Integer] -> Integer
-- this equation gets used for lists of length at least two
sumFirstTwo (a:b:_) = a+b
-- this equation gets used for all other lists (i.e. lists of length 0 or 1)
sumFirstTwo _ = 0sumFirstTwo [1] ==> 0
sumFirstTwo [1,2] ==> 3
sumFirstTwo [1,2,4] ==> 3Вот пример, в котором используется множество различных шаблонов списков:
describeList :: [Int] -> String
describeList [] = "an empty list"
describeList (x:[]) = "a list with one element"
describeList (x:y:[]) = "a list with two elements"
describeList (x:y:z:xs) = "a list with at least three elements"describeList [1,3] ==> "a list with two elements"
describeList [1,2,3,4,5] ==> "a list with at least three elements"Шаблоны списков, оканчивающиеся на :[], можно напечатать как литералы списка. То есть точно так же, как [1,2,3] имеет то же значение, что и 1:2:3:[], шаблон [x,y] совпадает с шаблоном x:y:[]. Давайте перепишем предыдущий пример.
describeList :: [Int] -> String
describeList [] = "an empty list"
describeList [x] = "a list with exactly one element"
describeList [x,y] = "a list with exactly two elements"
describeList (x:y:z:xs) = "a list with at least three elements"Другой способ вложения шаблонов — это сопоставление шаблонов в заголовке и сопоставление шаблонов в списке. Например, эта функция проверяет, начинается ли список с 0:
startsWithZero :: [Integer] -> Bool
startsWithZero (0:xs) = True
startsWithZero (x:xs) = False
startsWithZero [] = FalseИспользуя сопоставление с образцом и рекурсию, мы можем рекурсивно обработать весь список. Вот как можно суммировать все числа в списке:
sumNumbers :: [Int] -> Int
sumNumbers [] = 0
sumNumbers (x:xs) = x + sumNumbers xsВот как вычисляется наибольшее число в списке, на этот раз с использованием вспомогательной функции.
myMaximum :: [Int] -> Int
myMaximum [] = 0 -- actually this should be some sort of error...
myMaximum (x:xs) = go x xs
where go biggest [] = biggest
go biggest (x:xs) = go (max biggest x) xsПримечание! , «go» — это просто милое имя для вспомогательной функции. Это не специальный синтаксис.
Часто при работе со списком удобно использовать вложенные шаблоны. Вот пример, который подсчитывает, сколько значений Nothing встречается в списке Maybe:
countNothings :: [Maybe a] -> Int
countNothings [] = 0
countNothings (Nothing : xs) = 1 + countNothings xs
countNothings (Just _ : xs) = countNothings xscountNothings [Nothing,Just 1,Nothing] ==> 2Теперь, когда мы можем создавать и использовать списки, давайте сделаем оба этих действия одновременно. Эта функция удваивает все элементы в списке.
doubleList :: [Int] -> [Int]
doubleList [] = []
doubleList (x:xs) = 2*x : doubleList xsОн оценивается следующим образом:
doubleList [1,2,3]
=== doubleList (1:(2:(3:[])))
==> 2*1 : doubleList (2:(3:[]))
==> 2*1 : (2*2 : doubleList (3:[]))
==> 2*1 : (2*2 : (2*3 : doubleList []))
==> 2*1 : (2*2 : (2*3 : []))
=== [2*1, 2*2, 2*3]
==> [2,4,6]Если вы знаете сопоставление с образцом для списков, вы можете легко определить map и filter. На самом деле, давайте просто посмотрим на реализации стандартной библиотеки GHC. Вот карта:
map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (x:xs) = f x : map f xsи вот фильтр:
filter :: (a -> Bool) -> [a] -> [a]
filter _pred [] = []
filter pred (x:xs)
| pred x = x : filter pred xs
| otherwise = filter pred xs(Примечание! Присвоение имени аргументу _pred — это способ сообщите читателю кода, что этот аргумент не используется. Это также могло быть просто _.)
Когда рекурсивная функция вычисляет новый вызов той же функции с другими аргументами, она вызывается. хвостовая рекурсия. (Говорят, что рекурсивный вызов находится в хвостовой позиции.) Это тип рекурсии, соответствующий императивному циклу. Мы уже видели много примеров функций с хвостовой рекурсией, но еще не сравнивали два способа написания одной и той же функции. Это sumNumbers из ранее в этой лекции:
-- Not tail recursive!
sumNumbers :: [Int] -> Int
sumNumbers [] = 0
sumNumbers (x:xs) = x + sumNumbers xsВо втором уравнении функция + находится на верхнем уровне, т. е. в хвостовой позиции. Рекурсивный вызов sumNumbers является аргументом +. Это sumNumbers, написанное с использованием вспомогательной функции хвостовой рекурсии:
-- Tail recursive version
sumNumbers :: [Int] -> Int
sumNumbers xs = go 0 xs
where go sum [] = sum
go sum (x:xs) = go (sum+x) xsОбратите внимание на второе уравнение go: оно имеет рекурсивный вызов go на верхнем уровне, т. е. в хвостовой позиции. + теперь является аргументом go.
Для такой функции, как sumNumbers, которая выдает одно значение (число), не имеет особого значения, какую форму рекурсии вы выберете. Нехвостовую рекурсивную функцию легче читать, а хвостовую рекурсивную проще придумать. Вы можете попробовать написать функцию обоими способами. Форма с хвостовой рекурсией может быть более эффективной, но это зависит от многих деталей. Мы поговорим больше о производительности Haskell во второй части этого курса.
Однако, когда вы возвращаете список, между этими двумя формами существует большая разница. Рассмотрим функцию doubleList, рассмотренную ранее. Вот оно снова, реализованное сначала напрямую, а затем через вспомогательную функцию хвостовой рекурсии.
-- Not tail recursive!
doubleList :: [Int] -> [Int]
doubleList [] = []
doubleList (x:xs) = 2*x : doubleList xs-- Tail recursive version
doubleList :: [Int] -> [Int]
doubleList xs = go [] xs
where go result [] = result
go result (x:xs) = go (result++[2*x]) xsЗдесь прямой вариант гораздо эффективнее. Оператор (:) работает за постоянное время, тогда как оператору (++) необходимо пройти весь список, что требует линейного времени. Таким образом, прямая версия использует линейное время (O(n)) относительно длины списка, а версия с хвостовой рекурсией — квадратичную (O(n²))!
Может возникнуть соблазн исправить это, используя (:) в версии с хвостовой рекурсией, но тогда список будет генерироваться в обратном порядке. Это можно было бы исправить с помощью приложения reverse, но это сделало бы результирующую функцию довольно сложной.
Есть еще одна причина предпочесть прямой вариант: лень. К лени мы вернемся во второй части курса, а пока вам достаточно знать, что прямой способ генерации списка проще, эффективнее и идиоматичнее. Вам следует постараться отработать это на упражнениях. Посмотрите реализации стандартных библиотек map и filter выше, даже они создают список напрямую, без хвостовой рекурсии!
В Haskell есть list Comprehensions, удобный синтаксис для определения списков, сочетающий в себе возможности map и filter. Возможно, вы уже знакомы со списками Python. Haskell работают примерно так же, но их синтаксис немного отличается.
Отображение:
[2*i | i<-[1,2,3]]
==> [2,4,6]Фильтрация:
[i | i <- [1..7], even i]
==> [2,4,6]В общем, эти две формы эквивалентны:
[f x | x <- lis, p x]
map f (filter p lis)Понимание списков может сделать еще больше. Вы можете перебирать несколько списков:
[ first ++ " " ++ last | first <- ["John", "Mary"], last <- ["Smith","Cooper"] ]
==> ["John Smith","John Cooper","Mary Smith","Mary Cooper"]Вы можете создавать локальные определения:
[ reversed | word <- ["this","is","a","string"], let reversed = reverse word ]
==> ["siht","si","a","gnirts"]Вы даже можете выполнять сопоставление с образцом при анализе списков!
firstLetters string = [ char | (char:_) <- words string ]firstLetters "Hello World!"
==> "HW"В Haskell operator — это что-то, построенное из символов !#$%&*+./<=>?@\^|-~. Операторы могут быть определены так же, как функции (обратите внимание на немного другую аннотацию типа):
(<+>) :: [Int] -> [Int] -> [Int]
xs <+> ys = zipWith (+) xs ys(+++) :: String -> String -> String
a +++ b = a ++ " " ++ bИногда при написании Haskell может быть сложно найти выражения, имеющие правильный тип. К счастью, компилятор может помочь вам здесь! Функция Typed Holes позволяет вам оставлять пробелы в коде, и компилятор сообщит вам, какой тип выражения в пробеле должно быть.
Пробелы могут выглядеть как _ или _name. Их можно спутать с шаблоном «все идет» _, но разница в том, что дыра возникает на правой стороне =, тогда как шаблон «все идет» возникает на левой стороне =.
Начнем с простого примера в GHCi:
Prelude> filter _hole [True,False]
<interactive>: error:
• Found hole: _hole :: Bool -> Bool
Or perhaps ‘_hole’ is mis-spelled, or not in scope
• In the first argument of ‘filter’, namely ‘_hole’
In the expression: filter _hole [True, False]
In an equation for ‘it’: it = filter _hole [True, False]
• Relevant bindings include
it :: [Bool] (bound at <interactive>:5:1)
Valid hole fits include
not :: Bool -> Bool
(imported from ‘Prelude’
(and originally defined in ‘ghc-prim-0.6.1:GHC.Classes’))
id :: forall a. a -> a
with id @Bool
(imported from ‘Prelude’ (and originally defined in ‘GHC.Base’))Важной частью этого сообщения является самая первая строка. Это говорит вам, какой тип Haskell ожидает для дыры.
<interactive>: error:
• Found hole: _hole :: Bool -> Bool
В остальной части сообщения об ошибке предлагаются некоторые варианты значения _hole, например id и not.
Давайте рассмотрим более длинный пример, где мы пытаемся реализовать функцию, которая фильтрует список, используя список логических значений:
keepElements [5,6,7,8] [True,False,True,False] ==> [5,7]Мы начнем с zip, поскольку мы знаем, что он хорошо объединяет элементы двух списков в пары. Мы добавляем типизированное отверстие _doIt и вызываем его с результатом zip, чтобы увидеть, что нам нужно делать дальше.
keepElements :: [a] -> [Bool] -> [a]
keepElements xs bs = _doIt (zip xs bs)<interactive>: error:
• Found hole: _doIt :: [(a, Bool)] -> [a]
...Похоже, это можно сделать с помощью map. Посмотрим, что произойдет:
keepElements :: [a] -> [Bool] -> [a]
keepElements xs bs = map _f (zip xs bs)<interactive>: error:
• Found hole: _f :: (a, Bool) -> a
...
Valid hole fits include
fst :: forall a b. (a, b) -> aОтлично! GHC напомнил нам о функции fst, которая извлекает первое из пары. Мы закончили?
keepElements :: [a] -> [Bool] -> [a]
keepElements xs bs = map fst (zip xs bs)Prelude> keepElements [5,6,7,8] [True,False,True,False]
[5,6,7,8]Да, да, мы забыли выполнить фильтрацию. Давайте снова попробуем типизированную дыру:
keepElements :: [a] -> [Bool] -> [a]
keepElements xs bs = map fst (filter _predicate (zip xs bs))<interactive>: error:
• Found hole: _predicate :: (a, Bool) -> Bool
...
Valid hole fits include
snd :: forall a b. (a, b) -> b
...
... lots of other suggestionsИ снова GHC напомнил нам о функции, которая, кажется, делает правильную вещь: просто извлекает второй элемент из кортежа. Теперь наша функция завершена и работает как положено.
keepElements :: [a] -> [Bool] -> [a]
keepElements xs bs = map fst (filter snd (zip xs bs))Prelude> keepElements [5,6,7,8] [True,False,True,False]
[5,7]Помните о пробелах в вводе, если при работе с упражнениями возникают ошибки ввода! Попробуйте заменить функцию или переменную типизированной дырой. Возможно, это поможет вам понять, что вам нужно.
Каков тип этой функции? both p q x = p x && q x
a -> Bool -> a -> Bool -> a -> Bool(a -> Bool) -> (a -> Bool) -> a -> Bool(a -> Bool) -> (b -> Bool) -> c -> BoolКаков (наиболее общий) тип этой функции? applyInOut f g x = f (g (f x))
(a -> b) -> (b -> a) -> a -> b(a -> b) -> (b -> c) -> a -> c(a -> a) -> (a -> a) -> a -> aКакая из следующих функций добавляет свой первый аргумент ко второму?
f x x = x + xf x = \y -> x + yf = \x y -> x + xКакая из следующих функций не удовлетворяет f 1 ==> 1?
f x = (\y -> y) xf x = \y -> yf x = (\y -> x) xКакая из следующих функций введена правильно?
f x y = not x; f :: (Bool -> Bool) -> Boolf x = x ++ "a"; f :: Char -> Stringf x = 'a' : x; f :: String -> StringСколько аргументов принимает drop 2?
Что делает эта функция? f (_:x:_) = x
Каков результат reverse $ take 5 . tail $ "This is a test"?
"i sih""set a"Если f :: a -> b, то какой тип map (.f)?
[b -> c] -> [a -> c][c -> a] -> [c -> b](b -> c) -> [a -> c][a] -> [b]Каков тип самого левого id в id id?
a
a -> a(a -> a) -> (a -> a)Какой тип const const?
(c -> a -> b) -> ac -> (a -> b -> a)a -> b -> c -> aNo instance for (Eq a) arising from a use of ‘==’Вероятно, вы пытались использовать x==Nothing, чтобы проверить, соответствует ли значение Nothing. Вместо этого используйте сопоставление с образцом. Причина этой ошибки в том, что значения типа Maybe a не могут сравниваться, поскольку Haskell не знает, как сравнивать значения полиморфного типа a. Подробнее об этом вы узнаете в следующей лекции. На данный момент используйте сопоставление с образцом вместо ==.
Прежде чем мы углубимся в классы типов, давайте представим последний оставшийся встроенный тип данных в Haskell: кортеж. Tuples или pairs (или тройки, четверки и т. д.) — это способ объединения нескольких значений разных типов вместе. Вы можете думать о кортежах как о списках фиксированной длины (точно так же, как кортежи в Python). В отличие от списков, каждый элемент кортежа может иметь разный тип. Типы элементов отражаются в типе кортежа. Вот несколько примеров типов и значений кортежей:
| Тип | Пример значения |
|---|---|
(String,String) |
("Hello","World!") |
(Int,Bool) |
(1,True) |
(Int,Int,Int) |
(4,0,3) |
Чтобы получить значения из кортежей, вы можете использовать функции fst и snd:
fst :: (a, b) -> a
snd :: (a, b) -> bВы также можете использовать шаблонное сопоставление кортежей. Часто это наиболее удобный способ, который также работает для кортежей большего размера. Функции fst и snd работают только с парами.
Кортежи очень полезны в сочетании со списками. Вот несколько примеров использования функций zip, unzip и partition из модуля Data.List.
zip :: [a] -> [b] -> [(a, b)] -- two lists to list of pairs
unzip :: [(a, b)] -> ([a], [b]) -- list of pairs to pair of lists
partition :: (a -> Bool) -> [a] -> ([a], [a]) -- elements that satisfy and don't satisfy a predicatezip [1,2,3] [True,False,True]
==> [(1,True),(2,False),(3,True)]
unzip [("Fred",1), ("Jack",10), ("Helen",13)]
==> (["Fred","Jack","Helen"],[1,10,13])
partition (>0) [-1,1,-4,3,2,0]
==> ([1,3,2],[-1,-4,0])Вот пример сопоставления с образцом для кортежей:
swap :: (a,b) -> (b,a)
swap (x,y) = (y,x)Вот пример сопоставления с образцом для кортежей и списков одновременно:
-- sum all numbers that are paired with True
sumIf :: [(Bool,Int)] -> Int
sumIf [] = 0
sumIf ((True,x):xs) = x + sumIf xs
sumIf ((False,_):xs) = sumIf xssumIf [(True,1),(False,10),(True,100)]
==> 101Рассмотрим функции sumNumbers :: [Int] -> Int, myMaximum :: [Int] -> Int и еще раз countNothings :: [Maybe a] -> Int.
sumNumbers :: [Int] -> Int
sumNumbers [] = 0
sumNumbers (x:xs) = x + sumNumbers xs
myMaximum :: [Int] -> Int
myMaximum [] = 0
myMaximum (x:xs) = go x xs
where go biggest [] = biggest
go biggest (x:xs) = go (max biggest x) xs
countNothings :: [Maybe a] -> Int
countNothings [] = 0
countNothings (Nothing : xs) = 1 + countNothings xs
countNothings (Just _ : xs) = countNothings xsУ них есть одна общая характеристика. Они берут список и выдают значение, которое зависит от значений элементов в данном списке. Они «сжимают» или сворачивают список множества значений в одно значение.
В Prelude есть функция foldr, которая выполняет правую ассоциативную складку над типом данных Foldable. Скоро мы узнаем больше о Foldable. На данный момент достаточно подумать о списках, поэтому мы определяем
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f y [] = y
foldr f y (x:xs) = f x (foldr f y xs)Это определение говорит о том, что для пустого списка [] :: [a] foldr возвращает значение по умолчанию y :: b. Для любого другого списка x : xs, foldr применяет f к x и результату foldr f y xs (т. е. сворачивается поверх остальной части списка). Это простое определение с помощью рекурсии.
Другими словами, foldr повторно вызывает свою функцию аргумента f с двумя аргументами.
f вернул для остальной части списка.Рассмотрим список [1,2,3]:
Выражение foldr (+) 0 [1,2,3] оценивается следующим образом:
foldr (+) 0 [1,2,3] ==> foldr (+) 0 (1:2:3:[])
==> 1 + (foldr (+) 0 (2:3:[]))
==> 1 + (2 + (foldr (+) 0 (3:[])))
==> 1 + (2 + (3 + (foldr (+) 0 [])))
==> 1 + (2 + (3 + 0))Результат можно представить в виде дерева:
Один из способов представления foldr f y xs состоит в том, что оно заменяет (:) операция с f и [] с y. В данном случае f было (+), а y было 0. Если вы напишите, как ведет себя sumNumbers [1,2,3], вы заметите, что он выполняет те же вычисления, что и foldr (+) 0 [1,2,3]! В более общем плане:
sumNumbers xs == foldr (+) 0 xsТе, кто более опытен в математике, могут заметить, что мы можем доказать это утверждение с помощью induction: во-первых, sumNumbers [] ==> 0 и foldr (+) 0 [] ==> 0, то есть в базовом случае sumNumbers [] == foldr (+) 0 []. Далее мы можем предположить в качестве индукционной гипотезы, что sumNumbers xs == foldr (+) 0 xs для любого списка xs. Тогда для списка x:xs у нас есть sumNumbers (x:xs) ==> x + sumNumbers xs. Следовательно, foldr (+) 0 (x:xs) ==> x + foldr (+) 0 xs ==> x + sumNumbers xs по предположению индукции. Следовательно, по индукции уравнение справедливо.
В этом курсе вам не нужно читать, писать или понимать индукционные доказательства, но, возможно, вас обнадежит знание того, что свойства и равенства функций в Haskell можно (в принципе) проанализировать математически, потому что Haskell — такой хороший язык. (Равенства и свойства можно анализировать на любом языке программирования, но для Haskell этот анализ особенно удобен, поскольку Haskell является чистым.)
Другой пример свёртки — функция map:
map g xs = foldr helper [] xs
where helper y ys = g y : ysЧтобы понять, почему это работает, рассмотрим, что делает foldr helper [] [x1,x2,..,xn]:
Теперь, поскольку helper x xs ==> g x : xs для каждого x и xs, мы получаем следующее:
Результирующий список [ g x1, g x2, g x3, ..., g xn ] — это именно то, что мы получили бы с map g xs. (Это также можно было доказать по индукции, как мы это сделали для sumNumbers.) Урок, который следует извлечь, заключается в том, что свёртка — это частный, но довольно общий способ рекурсивного применения некоторого преобразования к некоторой структуре (например, списку).
Как + в Haskell может работать как с Int, так и с Double? Почему я могу сравнивать всякие вещи с ==? Ранее мы кратко упомянули ограниченные типы. Давайте посмотрим, что они на самом деле означают. Давайте посмотрим на типы == и +.
(==) :: (Eq a) => a -> a -> BoolТип (Eq a) => a -> a -> Bool означает: для всех типов a, принадлежащих классу Eq, это функция типа a -> a -> Bool. То есть, если тип a является членом класса Eq, вы можете передать два значения типа a в == и получить результат Bool.
(+) :: (Num a) => a -> a -> aАналогично, тип (Num a) => a -> a -> a означает: для всех типов a, принадлежащих классу Num, это функция типа a -> a -> a. То есть вы можете передать два значения одного и того же типа a в + и получить третье значение типа a, если a является членом Num.
Num и Eq — это классы типов. Класс type — это способ группировки типов, поддерживающих схожие операции.
Примечание! Класс типов — это коллекция типов. Это не имеет особого отношения к классам объектно-ориентированного программирования! В некоторых ситуациях классы типов могут действовать как interfaces в объектно-ориентированном программировании. К сожалению, функции в классе типов часто называют methods, что еще больше запутывает.
PS. помните, как использование переменных типа для полиморфизма называлось параметрический полиморфизм? Причудливое слово для обозначения того, чего достигают классы типов, — это ad-hoc полиморфизм. Разница в том, что при параметрическом полиморфизме функция (например, head) имеет одинаковую реализацию для всех типов, тогда как при специальных полиморфизмах существует несколько реализаций (рассмотрим == для чисел и строк).
Когда вы работаете с конкретным типом (а не с переменной типа), вы можете просто использовать функции класса типа (в данном случае (==)):
f :: (Int -> Int) -> Int -> Bool
f g x = x == g xКонечно, если рассматриваемый тип не является членом правильный класс, вы получите ошибку. Например:
addTrue :: Bool -> Bool
addTrue b = b + Trueerror:
• No instance for (Num Bool) arising from a use of ‘+’
• In the expression: b + True
In an equation for ‘addTrue’: addTrue b = b + True
Однако в функцию polymorphic вам необходимо добавить ограничения type. Это не работает:
f :: (a -> a) -> a -> Bool
f g x = x == g xК счастью, ошибка приятная:
error:
• No instance for (Eq a) arising from a use of ‘==’
Possible fix:
add (Eq a) to the context of
the type signature for:
f :: (a -> a) -> a -> Bool
• In the expression: x == g x
In an equation for ‘f’: f g x = x == g x
Чтобы указать, что f работает только с типами, которые являются членами класса Eq, мы добавляем ограничение типа (Eq a) => в аннотацию типа.
f :: (Eq a) => (a -> a) -> a -> Bool
f g x = x == g xЕсли у вас нет аннотации типа, type inference может предоставить ограничения!
Prelude> f g x = x == g x
Prelude> :type f
f :: (Eq a) => (a -> a) -> a -> BoolВы также можете иметь несколько ограничений:
bothPairsEqual :: (Eq a, Eq b) => a -> a -> b -> b -> Bool
bothPairsEqual left1 left2 right1 right2 = left1 == left2 && right1 == right2Вот некоторые стандартные классы типов Haskell, о которых вам следует знать.
EqМы уже видели класс Eq для сравнения на равенство. Вот основные операции класса Eq и несколько примеров их использования. Как видите, почти все типы, которые мы видели до сих пор, за исключением функций, являются членами Eq.
(==) :: Eq a => a -> a -> Bool
(/=) :: Eq a => a -> a -> BoolPrelude> 1 == 2
False
Prelude> 1 /= 2
True
Prelude> "Foo" == "Bar"
False
Prelude> [[1,2],[3,4]] == [[1,2],[3,4]]
True
Prelude> (\x -> x+1) == (\x -> x+2)
<interactive>:5:1: error:
• No instance for (Eq (Integer -> Integer))
arising from a use of ‘==’
(maybe you haven't applied a function to enough arguments?)
• In the expression: (\ x -> x + 1) == (\ x -> x + 2)
In an equation for ‘it’: it = (\ x -> x + 1) == (\ x -> x + 2)Есть и другие полезные функции, использующие класс Eq, например nub из модуля Data.List.
Prelude> import Data.List
Prelude Data.List> :t nub
nub :: Eq a => [a] -> [a]
Prelude Data.List> nub [3,5,3,1,1] -- eliminates duplicates
[3,5,1]OrdКласс Ord предназначен для упорядочивания (меньше, больше). Опять же, вот основные операции и несколько примеров их использования. Обратите внимание на новый тип Ordering. Он имеет значения LT для «меньше чем», EQ для «равно» и GT для «больше чем».
compare :: Ord a => a -> a -> Ordering
(<) :: Ord a => a -> a -> Bool
(>) :: Ord a => a -> a -> Bool
(>=) :: Ord a => a -> a -> Bool
(<=) :: Ord a => a -> a -> Bool
max :: Ord a => a -> a -> a
min :: Ord a => a -> a -> aPrelude> compare 1 1 -- 1 is EQual to 1
EQ
Prelude> compare 1 3 -- 1 is Less Than 3
LT
Prelude> compare 1 0 -- 1 is Greater Than 0
GT
Prelude> min 5 3
3
Prelude> max 5 3
5
Prelude> "aardvark" < "banana" -- strings are compared alphabetically
True
Prelude> [1,2,3] > [2,5] -- lists are compared like strings
False
Prelude> [1,2,3] > [1,1]
TrueКогда мы можем сравнивать значения, мы также можем сортировать их списки. Функция sort из Data.List работает со всеми типами, принадлежащими классу Ord.
Prelude> import Data.List
Prelude Data.List> :t sort
sort :: Ord a => [a] -> [a]
Prelude Data.List> sort [6,1,4,8,2]
[1,2,4,6,8]
Prelude Data.List> sort "black sphinx of quartz, judge my vow!" -- remember, strings are lists!
" !,aabcdefghijklmnoopqrstuuvwxyz"В качестве последнего примера давайте отсортируем список списков по длине. Нам понадобятся две вспомогательные функции:
-- from the module Data.Ord
-- compares two values "through" the function f
comparing :: (Ord a) => (b -> a) -> b -> b -> Ordering
comparing f x y = compare (f x) (f y)
-- from the module Data.List
-- sorts a list using the given comparison function
sortBy :: (a -> a -> Ordering) -> [a] -> [a]Теперь реализация sortByLength проста:
-- sorts lists by their length
sortByLength :: [[a]] -> [[a]]
sortByLength = sortBy (comparing length)sortByLength [[1,2,3],[4,5],[4,5,6,7]] ==> [[4,5],[1,2,3],[4,5,6,7]]Num, Integral, Fractional, FloatingКласс Num содержит целочисленную арифметику:
(+) :: Num a => a -> a -> a
(-) :: Num a => a -> a -> a
(*) :: Num a => a -> a -> a
negate :: Num a => a -> a -- 0-x
abs :: Num a => a -> a -- absolute value
signum :: Num a => a -> a -- -1 for negative values, 0 for 0, +1 for positive values
fromInteger :: Num a => Integer -> aNum также отображается в типах целочисленных литералов:
Prelude> :t 12
12 :: Num p => pЭто означает, что литерал типа 12 может интерпретироваться как член любого введите реализацию Num. Когда GHC считывает числовой литерал, например 12, он создает код, соответствующий fromIntegral 12.
Prelude> 1 :: Int
1
Prelude> 1 :: Double
1.0
Prelude> fromIntegral 1 :: Double
1.0Integral — это класс типов, представляющих целые числа, например Int и Integer. Наиболее интересными функциями являются div и mod для целочисленного деления и остатка. Все типы, принадлежащие Integral, также принадлежат Num.
div :: Integral a => a -> a -> a
mod :: Integral a => a -> a -> aFractional — это класс типов, имеющих деление. Все типы, принадлежащие Fractional, также принадлежат Num.
(/) :: Fractional a => a -> a -> aFloating содержит некоторые дополнительные операции, которые имеют смысл только для чисел с плавающей запятой. Все типы, принадлежащие Floating, также принадлежат Fractional (и Num).
sqrt :: Floating a => a -> a
sin :: Floating a => a -> aRead и ShowКлассы Show и Read предназначены для функций show и read, которые преобразуют значения в строки и обратно.
show :: Show a => a -> String
read :: Read a => String -> aPrelude> show 3
"3"
Prelude> read "3" :: Int
3
Prelude> read "3" :: Double
3.0Как вы можете видеть выше, вам часто необходимо использовать аннотацию типа с read, чтобы компилятор мог выбрать правильную реализацию.
FoldableИ еще! Возможно, вы помните, что ранее упоминалось, что тип length — это не [a] -> Int, а нечто более общее. Давайте посмотрим:
Prelude> :t length
length :: Foldable t => t a -> IntЭтот тип выглядит немного иначе, чем те, которые мы видели раньше. Переменная типа t имеет аргумент a. Мы рассмотрим подобные классы типов более подробно во второй части, а здесь — ускоренный курс.
Foldable представляет собой типы, которые можно сворачивать. Истинный тип foldr:
foldr :: Foldable t => (a -> b -> b) -> b -> t a -> bМы успешно использовали тот факт, что списки имеют вид Foldable, поскольку нам удалось использовать в списках length и foldr. Однако Maybe также является Foldable! Экземпляр Foldable для Maybe просто делает вид, что значения Maybe a подобны спискам длиной 0 или 1:
foldr (+) 1 Nothing ==> 1
foldr (+) 1 (Just 3) ==> 4
length Nothing ==> 0
length (Just 'a') ==> 1Далее мы познакомимся с еще несколькими складными типами.
Теперь, когда мы знакомы со стандартными классами типов, мы можем рассмотреть одно из их приложений: структуры данных Map и Array.
Data.MapМодуль Data.Map определяет тип Map. Карты — это деревья поиска для пар ключ-значение. Один из способов взглянуть на это состоит в том, что значение типа Map k v примерно совпадает со значением типа [(k,v)], списком пар. Однако операции с картой более эффективны, чем операции со списком.
Поскольку Data.Map содержит некоторые функции с теми же именами, что и функции Prelude, пространство имен необходимо импортировать qualified:
import qualified Data.Map as MapТеперь мы можем ссылаться на тип карты как Map.Map, а также на различные функции карты, например Map.insert. Вот наиболее важные функции для карт:
-- Create a Map from a list of key-value pairs
Map.fromList :: Ord k => [(k, a)] -> Map.Map k a
-- Insert a value into a map. Overrides any previous value with the same key.
-- Returns a new map. Does not mutate the given map.
Map.insert :: Ord k => k -> a -> Map.Map k a -> Map.Map k a
-- Get a value from a map using a key. Returns Nothing if the key was not present in the map.
Map.lookup :: Ord k => k -> Map.Map k a -> Maybe a
-- An empty map
Map.empty :: Map.Map k aОграничение Ord для типа ключа карты необходимо, поскольку карты реализованы как упорядоченные двоичные деревья поиска.
Обратите внимание, что, как и все значения Haskell, карты являются immutable, что означает, что вы не можете изменить карту после ее определения. Однако операции с картой, такие как insert, создают карту new. Чтобы выполнить несколько операций с картой, вам необходимо повторно использовать возвращаемое значение. Вот сеанс GHCi, работающий на карте.
Prelude> import qualified Data.Map as Map
Prelude Map> values = Map.fromList [("z",3),("w",4)]
Prelude Map> Map.lookup "z" values
Just 3
Prelude Map> Map.lookup "banana" values
Nothing
Prelude Map> Map.insert "x" 7 values
fromList [("w",4),("x",7),("z",3)]
Prelude Map> values -- note immutability!
fromList [("w",4),("z",3)]
Prelude Map> Map.insert "x" 1 (Map.insert "y" 2 values) -- two insertions
fromList [("w",4),("x",1),("y",2),("z",3)]
Prelude Map>Вот пример представления банка как Map String Int (сопоставление имени счета с балансом счета) и снятия некоторой суммы денег со счета:
withdraw :: String -> Int -> Map.Map String Int -> Map.Map String Int
withdraw account amount bank =
case Map.lookup account bank of
Nothing -> bank -- account not found, no change
Just sum -> Map.insert account (sum-amount) bank -- set new balanceВот как вы можете использовать функцию withdraw в GHCi. Обратите внимание, как карты печатаются как вызовы fromList. Также обратите внимание, что вызов withdraw ... bank возвращает банк new и не меняет существующий банк.
GHCi> bank = Map.fromList [("Bob",100),("Mike",50)]
GHCi> withdraw "Bob" 80 bank
fromList [("Bob",20),("Mike",50)]
GHCi> bank -- note immutability
fromList [("Bob",100),("Mike",50)]
GHCi> withdraw "Bozo" 1000 bank
fromList [("Bob",100),("Mike",50)]Data.Map определяет все виды полезных функций высшего порядка для обновления карт. Мы можем переписать функцию withdraw, используя Data.Map.adjust:
withdraw :: String -> Int -> Map.Map String Int -> Map.Map String Int
withdraw account amount bank = Map.adjust (\x -> x-amount) account bankПримечание! Существуют отдельные реализации Data.Map.Strict и Data.Map.Lazy. Когда вы импортируете Data.Map, вы получаете Data.Map.Lazy. Вы можете найти документацию по всем функциям Data.Map в документации для Data.Map.Lazy. Мы не будем здесь вдаваться в их различия, но в основном вам следует использовать Data.Map.Strict в реальном коде.
Data.ArrayДругой тип, который работает как список, но более эффективен для некоторых операций, — это массив. Массивы знакомы из многих других языков программирования, но массивы Haskell немного отличаются.
В отличие от модуля Data.Map, Data.Array можно импортировать обычным способом:
import Data.ArrayТеперь мы можем посмотреть на тип функции array, которая создает массив.
array :: Ix i => (i, i) -> [(i, e)] -> Array i eЗдесь следует обратить внимание на пару вещей. Прежде всего, тип Array параметризуется двумя типами : типом индекса и типом элемента. Большинство других языков программирования параметризуют массивы только с помощью типа элемента, но тип индекса всегда int. В Haskell мы можем иметь, например, Array Char Int: массив, индексированный символами, или Array Bool String, массив, индексированный логическими значениями, или даже Array (Int,Int) Int, двумерный массив целых чисел.
Не все типы могут быть индексными типами. Подходят только типы, похожие на целые числа. В этом причина ограничения класса Ix i. Класс Ix собирает все типы, которые можно использовать в качестве индексов массива.
Во-вторых, функция array принимает дополнительный параметр (i,i). Это минимальный и максимальный индексы массива. В отличие от некоторых других языков, где массивы всегда начинаются с индекса 0 или 1, в Haskell вы можете определить массив, который начинается с 7 и заканчивается 11. Итак, вот этот массив:
myArray :: Array Int String
myArray = array (7,11) [(7,"seven"), (8,"eight"), (9,"nine"), (10,"ten"), (11,"ELEVEN")]Перечисление всех индексов и элементов по порядку может быть немного громоздким, поэтому существует также конструктор listArray, который просто принимает список элементов по порядку:
listArray :: Ix i => (i, i) -> [e] -> Array i emyArray :: Array Int String
myArray = listArray (7,11) ["seven", "eight", "nine", "ten", "ELEVEN"]Массивы используются с двумя новыми операторами:
-- Array lookup
(!) :: Ix i => Array i e -> i -> e
-- Array update
(//) :: Ix i => Array i e -> [(i, e)] -> Array i eВот пример сеанса GHCi:
Prelude> import Data.Array
Prelude Data.Array> myArray = listArray (7,11) ["seven", "eight", "nine", "ten", "ELEVEN"]
Prelude Data.Array> myArray
array (7,11) [(7,"seven"),(8,"eight"),(9,"nine"),(10,"ten"),(11,"ELEVEN")]
Prelude Data.Array> myArray ! 8
"eight"
Prelude Data.Array> myArray // [(8,"ocho"),(9,"nueve")]
array (7,11) [(7,"seven"),(8,"ocho"),(9,"nueve"),(10,"ten"),(11,"ELEVEN")]Вам может быть интересно, почему оператор (//) выполняет несколько обновлений одновременно. Причина — главная слабость массивов Haskell: неизменяемость. Поскольку массивы не могут быть изменены на месте, (//) должен скопировать весь массив. Вот почему в Haskell часто предпочтительнее использовать списки или карты для хранения данных, которые необходимо обновить. Однако массивы по-прежнему могут быть полезны, если они созданы один раз и затем используются для большого количества поисков. Мы вернемся к тому, как работают структуры данных Haskell, в следующей лекции.
Примечание! В этом курсе мы будем использовать только Array, простой тип массива, определенный в стандарте Haskell. Существует множество других типов массивов, таких как изменяемый IOArray и несколько малоизвестный DiffArray. Существуют также классы типов для массивов, такие как IArray и MArray. В дополнение к массивам существует широкое семейство Vector типов , которое может быть более практичным, чем Array, для реальных программ.
Типы Map и Array являются экземплярами Foldable, как и списки! Это означает, что вы можете использовать для них такие функции, как length и foldr:
length (array (7,11) [(7,"seven"),(8,"eight"),(9,"nine"),(10,"ten"),(11,"ELEVEN")])
==> 5
foldr (+) 0 (Map.fromList [("banana",3),("egg",7)])
==> 10Библиотеки Haskell, как правило, содержат довольно хорошую документацию. Ранее мы связывались с документами через Hackage (https://hackage.haskell.org), но важно также знать, как найти документы самостоятельно. Инструмент для создания документации Haskell называется Haddock, поэтому иногда документы Haskell называют haddocks.
Hackage — это репозиторий пакетов Haskell (точно так же, как PyPI для Python, Maven Central для Java или NPM для JavaScript). Помимо самих пакетов, здесь хранится документация к ним. Большинство модулей, которые мы используем в этом курсе, находятся в пакете base. Вы можете просмотреть документацию базового пакета по адресу https://hackage.haskell.org/package/base-4.16.4.0/.
Если вы не совсем уверены, где находится искомая функция, вам может помочь Hoogle (https://hoogle.haskell.org/). Hoogle — поисковая система по документации Haskell. Это отличный ресурс, когда вам нужно проверить тип foldr или какие пакеты содержат функцию с именем reverse.
Наконец, поскольку в этом курсе используется инструмент stack, вы также можете просмотреть документацию по стеку библиотек, установленному для вас, с помощью команд
stack haddock --open
stack haddock --open <package>
Это дает дополнительное преимущество: вы получаете именно нужную версию документации.
Вкратце, вот основные способы чтения документации библиотеки Haskell:
stack, вы можете использовать stack haddock --open или stack haddock --open <package>, чтобы открывать документы в браузере.Какой тип swap . swap?
(a, b) -> (a, b)(a, b) -> (b, a)a -> aКакой тип \f g x -> (f x, g x)?
(a -> b) -> (c -> d) -> (a,c) -> (b, d)(a -> b) -> (a -> c) -> a -> (b, c)(a -> b) -> (b -> a) -> a -> (b, a)Какой тип \t -> (fst . fst $ t, (snd . fst $ t, snd t))?
(a, (b, c)) -> (a, (b, c))(a, (b, c)) -> ((a, b), c)((a, b), c) -> (a, (b, c))Что делает функция foldr (\x xs -> xs ++ [x]) []?
Что делает функция foldr (\(x, y) zs -> x : y : zs) []?
Какой тип foldr (\n b -> n == 3 && b)?
(Foldable t, Eq a, Num a) => Bool -> t a -> Bool(Foldable t, Eq a, Num a, Bool b) => b -> t a -> b(Foldable t, Eq a, Num a) => Bool -> [ a ] -> BoolКакой тип \x -> case x of (True, "Foo") -> show True ++ "Foo"?
Either Bool String -> String(Bool, String) -> StringShow a => (Bool, String) -> aВ Haskell есть система algebraic datatypes для определения новых типов. Это звучит замысловато, но на самом деле это просто. Давайте углубимся в изучение стандартных библиотечных определений некоторых знакомых типов:
data Bool = True | False
data Ordering = LT | EQ | GTС помощью этого синтаксиса вы также можете определять типы:
-- definition of a type with three values
data Color = Red | Green | Blue
-- a function that uses pattern matching on our new type
rgb :: Color -> [Double]
rgb Red = [1,0,0]
rgb Green = [0,1,0]
rgb Blue = [0,0,1]Prelude> :t Red
Red :: Color
Prelude> :t [Red,Blue,Green]
[Red,Blue,Green] :: [Color]
Prelude> rgb Red
[1.0,0.0,0.0]Такие типы, как Bool, Ordering и Color, которые просто перечисляют набор констант, называются enumerations или enums в Haskell и других языках. Перечисления полезны, но вам нужны и другие типы. Здесь мы определяем тип отчетов, содержащий идентификационный номер, заголовок и тело:
data Report = ConstructReport Int String StringВот как вы создаете отчет:
Prelude> :t ConstructReport 1 "Title" "This is the body."
ConstructReport 1 "Title" "This is the body." :: ReportВы можете получить доступ к полям с сопоставлением шаблонов:
reportContents :: Report -> String
reportContents (ConstructReport id title contents) = contents
setReportContents :: String -> Report -> Report
setReportContents contents (ConstructReport id title _contents) = ConstructReport id title contentsЭлементы в правой части объявления data называются constructors. True, False, Red и ConstructReport — примеры конструкторов. Тип может иметь несколько конструкторов, а конструктор может иметь ноль или более полей.
Вот тип данных для стандартной игральной карты. Он имеет пять конструкторов, из которых Joker не имеет полей, а остальные имеют одно поле.
data Card = Joker | Heart Int | Club Int | Spade Int | Diamond IntКонструкторы с полями имеют тип функции и могут использоваться везде, где функции могут:
Prelude> :t Heart
Heart :: Int -> Card
Prelude> :t Club
Club :: Int -> Card
Prelude> map Heart [1,2,3]
[Heart 1,Heart 2,Heart 3]
Prelude> (Heart . (\x -> x+1)) 3
Heart 4Кстати, в нашем типе Card чего-то не хватает. Посмотрите, как он ведет себя по сравнению с Ordering и Bool:
Prelude> EQ
EQ
Prelude> True
True
Prelude> Joker
<interactive>:1:0:
No instance for (Show Card)
arising from a use of `print' at <interactive>:1:0-4
Possible fix: add an instance declaration for (Show Card)
In a stmt of a 'do' expression: print itПроблема в том, что Haskell не знает, как печатать определенные нами типы. Как говорится в ошибке, они не являются частью класса Show. Простое решение — просто добавить deriving Show после определения типа:
data Card = Joker | Heart Int | Club Int | Spade Int | Diamond Int
deriving ShowPrelude> Joker
JokerСинтаксис deriving — это способ автоматически сделать ваш класс членом определенных классов базовых типов, в первую очередь Read, Show и Eq. Подробнее о том, что это значит, мы поговорим позже.
Так почему же эти типы данных называются алгебраическими? Это связано с тем, что теоретически каждый тип данных может быть sum конструкторов, а каждый конструктор — product полей. Имеет смысл рассматривать их как суммы и произведения по многим причинам, одна из которых заключается в том, что мы можем подсчитать возможные значения каждого типа следующим образом:
data Bool = True | False -- corresponds to 1+1. Has 2 possible values.
data TwoBools = TwoBools Bool Bool -- corresponds to Bool*Bool, i.e. 2*2. Has 4 possible values.
data Complex = Two Bool Bool | One Bool | None
-- corresponds to Bool*Bool+Bool+1 = 2*2+2+1 = 7. Has 7 possible values.Существует богатая теория алгебраических типов данных. Если вам интересно, вы можете найти дополнительную информацию здесь или здесь.
Мы представили параметры типа и параметрический полиморфизм при знакомстве со списками в лекции 2. С тех пор мы видели и другие параметризованные типы, такие как Maybe и Either. Теперь мы узнаем, как определять собственные параметризованные типы.
Определение Maybe следующее:
data Maybe a = Nothing | Just aЧто такое a? Мы определяем параметризованный тип, указав переменную type (в данном случае a) слева от знака =. Затем мы можем использовать переменную того же типа в полях наших конструкторов. Это аналогично полиморфным функциям. Вместо определения отдельных функций
headInt :: [Int] -> Int
headBool :: [Bool] -> Boolи т. д. мы определяем одну функцию head :: [a] -> a, которая работает для всех типов a. Аналогично, вместо определения нескольких типов
data MaybeInt = NothingInt | JustInt Int
data MaybeBool = NothingBool | JustBool Boolмы определяем один тип Maybe a, который работает для всех типов a.
Вот наш первый собственный параметризованный тип Described. Значения типа Described a содержат значение типа a и описание String.
data Described a = Describe a String
getValue :: Described a -> a
getValue (Describe x _) = x
getDescription :: Described a -> String
getDescription (Describe _ desc) = descPrelude> :t Describe
Describe :: a -> String -> Described a
Prelude> :t Describe True "This is true"
Describe True "This is true" :: Described Bool
Prelude> getValue (Describe 3 "a number")
3
Prelude> getDescription (Describe 3 "a number")
"a number"В приведенных выше определениях мы использовали a в качестве переменной типа. Однако подойдет любое слово, начинающееся со строчной буквы. Мы могли бы определить Maybe следующим образом:
data Maybe theType = Nothing | Just theTypeПравила для идентификаторов Haskell:
a, map, xs)Maybe, Just, Card, Heart).Обратите внимание, что тип и его конструктор могут иметь одно и то же имя. Это очень часто встречается в коде Haskell для типов, имеющих только один конструктор. В данном материале мы стараемся избегать этого во избежание путаницы. Вот несколько примеров:
data Pair a = Pair a a
data Report = Report Int String StringPrelude> :t Pair
Pair :: a -> a -> Pair aОстерегайтесь путать типы и конструкторы. К счастью, типы и конструкторы никогда не могут встречаться в одном и том же контексте, поэтому вы получаете приятную ошибку:
Prelude> Maybe -- trying to use a type name as a value
<interactive>:1:1: error:
• Data constructor not in scope: Maybe
Prelude> undefined :: Nothing -- trying to use a constructor as a type
<interactive>:2:14: error:
Not in scope: type constructor or class ‘Nothing’Типы могут иметь несколько параметров типа. Синтаксис аналогичен определению функций со многими аргументами. Вот определение стандартного типа Either:
data Either a b = Left a | Right bДо сих пор все определенные нами типы имели постоянный размер. Мы можем представить один отчет или один цвет, но как мы можем представить коллекцию вещей? Конечно, мы могли бы использовать списки, но можем ли мы сами определить тип списка?
Как и функции Haskell, типы данных Haskell могут быть recursive. Это не более странно, чем наличие объекта в Java или Python, который ссылается на другой объект того же класса. Вот как вы определяете список целых чисел:
data IntList = Empty | Node Int IntList
deriving Show
ihead :: IntList -> Int
ihead (Node i _) = i
itail :: IntList -> IntList
itail (Node _ t) = t
ilength :: IntList -> Int
ilength Empty = 0
ilength (Node _ t) = 1 + ilength tМы можем использовать функции, определенные выше, для работы со списками целых чисел:
Prelude> ihead (Node 3 (Node 5 (Node 4 Empty)))
3
Prelude> itail (Node 3 (Node 5 (Node 4 Empty)))
Node 5 (Node 4 Empty)
Prelude> ilength (Node 3 (Node 5 (Node 4 Empty)))
3Обратите внимание, что мы не можем помещать значения, отличные от Int, внутри нашего IntList:
Prelude> Node False Empty
<interactive>:3:6: error:
• Couldn't match expected type ‘Int’ with actual type ‘Bool’
• In the first argument of ‘Node’, namely ‘False’
In the expression: Node False Empty
In an equation for ‘it’: it = Node False EmptyЧтобы иметь возможность поместить любой тип элемента в наш список, давайте сделаем следующее: то же самое с параметром типа. Это то же самое, что встроенный тип [a], но с несколько более громоздким синтаксисом:
data List a = Empty | Node a (List a)
deriving ShowОбратите внимание, как нам нужно передавать параметр типа a далее в рекурсии. Нам нужно написать Node a (List a) вместо Node a List. Конструктор Node имеет два аргумента. Первый имеет тип a, а второй — тип List a. Вот переопределения некоторых стандартных функций списка для нашего типа List:
lhead :: List a -> a
lhead (Node h _) = h
ltail :: List a -> List a
ltail (Node _ t) = t
lnull :: List a -> Bool
lnull Empty = True
lnull _ = False
llength :: List a -> Int
llength Empty = 0
llength (Node _ t) = 1 + llength tPrelude> lhead (Node True Empty)
True
Prelude> ltail (Node True (Node False Empty))
Node False Empty
Prelude> lnull Empty
TrueОбратите внимание, что, как и в случае с обычными списками Haskell, мы не можем содержать в одном списке элементы разных типов:
Prelude> Node True (Node "foo" Empty)
<interactive>:5:12: error:
• Couldn't match type ‘[Char]’ with ‘Bool’
Expected type: List Bool
Actual type: List [Char]
• In the second argument of ‘Node’, namely ‘(Node "foo" Empty)’
In the expression: Node True (Node "foo" Empty)
In an equation for ‘it’: it = Node True (Node "foo" Empty)Как и список, мы можем представить двоичное дерево:
data Tree a = Node a (Tree a) (Tree a) | EmptyНаше дерево содержит узлы, содержащие значение типа a, два дочерних дерева и пустые деревья.
Если вы не знакомы с двоичными деревьями, это структура данных, которая часто используется в качестве основы для других структур данных (Data.Map основана на деревьях!). Бинарные деревья часто изображаются в виде (перевернутых) изображений, например:
Самый высокий узел в дереве называется корнем (в данном случае
(0), а узлы без дочерних элементов в этом случае называются leaves (2, 3 и 4). Мы можем определить это дерево, используя наш тип Tree следующим образом:
example :: Tree Int
example = (Node 0 (Node 1 (Node 2 Empty Empty)
(Node 3 Empty Empty))
(Node 4 Empty Empty))Высота двоичного дерева — это длина самого длинного пути от корня до листа. В терминах Haskell это количество вложенных уровней конструкторов Node, необходимых для построения дерева. Высота нашего примера дерева равна 3. Вот функция, которая вычисляет высоту дерева:
treeHeight :: Tree a -> Int
treeHeight Empty = 0
treeHeight (Node _ l r) = 1 + max (treeHeight l) (treeHeight r)treeHeight Empty ==> 0
treeHeight (Node 2 Empty Empty)
==> 1 + max (treeHeight Empty) (treeHeight Empty)
==> 1 + max 0 0
==> 1
treeHeight (Node 1 Empty (Node 2 Empty Empty))
==> 1 + max (treeHeight Empty) (treeHeight (Node 2 Empty Empty))
==> 1 + max 0 1
==> 2
treeHeight (Node 0 (Node 1 Empty (Node 2 Empty Empty)) Empty)
==> 1 + max (treeHeight (Node 1 Empty (Node 2 Empty Empty))) (treeHeight Empty)
==> 1 + max 2 0
==> 3Если вы знакомы с деревьями двоичного поиска, вот определения операций поиска и вставки для двоичного дерева поиска. Если вы не понимаете, о чем я говорю, вам не обязательно это понимать.
lookup :: Int -> Tree Int -> Bool
lookup x Empty = False
lookup x (Node y l r)
| x < y = lookup x l
| x > y = lookup x r
| otherwise = True
insert :: Int -> Tree Int -> Tree Int
insert x Empty = Node x Empty Empty
insert x (Node y l r)
| x < y = Node y (insert x l) r
| x > y = Node y l (insert x r)
| otherwise = Node y l rЕсли к некоторым полям необходимо часто обращаться, может быть удобно иметь вспомогательные функции для чтения этих полей. Например, тип Person может иметь несколько полей:
data Person = MkPerson String Int String String String deriving ShowСписок людей может выглядеть следующим образом:
people :: [Person]
people = [ MkPerson "Jane Doe" 21 "Houston" "Texas" "Engineer"
, MkPerson "Maija Meikäläinen" 35 "Rovaniemi" "Finland" "Engineer"
, MkPerson "Mauno Mutikainen" 27 "Turku" "Finland" "Mathematician"
]Предположим, что нам нужно найти всех инженеров из Финляндии:
query :: [Person] -> [Person]
query [] = []
query ((MkPerson name age town state profession):xs)
| state == "Finland" && profession == "Engineer" =
(MkPerson name age town state profession) : query xs
| otherwise = query xsТаким образом,
query people ==> [MkPerson "Maija Meikäläinen" 35 "Rovaniemi" "Finland" "Engineer"]Обратите внимание, что типы полей дают мало информации о том, какое содержимое этих полей предполагается. Нам нужно помнить во всех местах кода, что town идет перед state, а не наоборот.
В Haskell есть функция синтаксис записи, которая полезна в подобных случаях. Тип данных Person можно определить как запись:
data Person = MkPerson { name :: String, age :: Int, town :: String, state :: String, profession :: String}
deriving ShowМы по-прежнему можем определять значения Person обычным образом, но экземпляр Show печатает для нас имена полей:
Prelude> MkPerson "Jane Doe" 21 "Houston" "Texas" "Engineer"
MkPerson {name = "Jane Doe", age = 21, town = "Houston", state = "Texas", profession = "Engineer"}Однако мы также можем определять значения, используя синтаксис записи. Обратите внимание, что теперь полям не обязательно располагаться в каком-то определенном порядке, поскольку у них есть имена.
Prelude> MkPerson {name = "Jane Doe", town = "Houston", profession = "Engineer", state = "Texas", age = 21}
MkPerson {name = "Jane Doe", age = 21, town = "Houston", state = "Texas", profession = "Engineer"}Самое главное: мы бесплатно получаем функции доступа для полей:
Prelude> :t profession
profession :: Person -> String
Prelude> profession (MkPerson "Jane Doe" 21 "Houston" "Texas" "Engineer")
"Engineer"Теперь мы можем переписать функцию запроса, используя эти функции доступа:
query :: [Person] -> [Person]
query [] = []
query (x:xs)
| state x == "Finland" && profession x == "Engineer" =
x : query xs
| otherwise = query xsВы, вероятно, согласитесь, что теперь код выглядит более приятным.
data TypeName = ConstructorName FieldType FieldType2 | AnotherConstructor FieldType3 | OneMoreConsdata TypeName variable = Cons1 variable Type1 | Cons2 Type2 variablefoo (ConstructorName a b) = a+b
foo (AnotherConstructor _) = 0
foo OneMoreCons = 7ConstructorName :: FieldType -> FieldType2 -> TypeName
Cons1 :: a -> Type1 -> TypeName adata TypeName = Constructor { field1 :: Field1Type, field2 :: Field2Type }Это бесплатно предоставляет вам функции доступа, такие как field1 :: TypeName -> Field1Type.
Помимо ключевого слова data, в Haskell есть два дополнительных способа определения типов.
Ключевое слово newtype работает аналогично data, но у вас может быть только один конструктор с одним полем. Иногда бывает разумно использовать newtype из соображений производительности, но мы вернемся к этому в части 2.
Ключевое слово type вводит псевдоним типа (type alias). Псевдонимы не влияют на проверку типов, а лишь дают сокращённую запись. Например, знакомый тип String является псевдонимом [Char]:
type String = [Char]Это означает, что всякий раз, когда компилятор читает String, он сразу же заменяет его на [Char]. Псевдонимы типов кажутся полезными, но они могут легко затруднить чтение ошибок типа.
Помните, как списки представлялись в памяти в виде связанных списков? Давайте подробнее рассмотрим, как выглядят алгебраические типы данных в памяти.
Данные Haskell формируют в памяти ориентированные графы. Каждый конструктор — это узел, каждое поле — это ребро. Имена (переменных) являются указателями на этот граф. Разные имена могут совмещать части структуры. Вот пример со списками. Обратите внимание, что два последних элемента x используются совместно с y и z.
let x = [1,2,3,4]
y = drop 2 x
z = 5:yТо, что происходит, когда вы создаете новую версию структуры данных, называется path copy. Поскольку данные Haskell неизменяемы, измененные части структуры данных копируются, а неизмененные части могут использоваться совместно старой и новой версиями.
Рассмотрим определение ++:
[] ++ ys = ys
(x:xs) ++ ys = x:(xs ++ ys)Мы создаем копию первого аргумента во время его обхода. Для каждого конструктора : в первом входном списке мы создаем новый конструктор : в выходном списке. Второй аргумент можно разделить. Он вообще не используется в рекурсии. Визуально:
Еще один способ подумать об этом: мы хотим изменить указатель tail элемента списка (3:). Это означает, что нам нужно создать новый (3:). Однако (2:) указывает на (3:), поэтому нам также нужна новая копия (2:). Аналогично для (1:).
Графики, которые мы получаем при работе со списками, довольно просты. В качестве более сложного примера, вот что происходит в памяти, когда мы запускаем пример вставки двоичного дерева, приведенный ранее в этой лекции.
insert :: Int -> Tree Int -> Tree Int
insert x Empty = Node x Empty Empty
insert x (Node y l r)
| x < y = Node y (insert x l) r
| x > y = Node y l (insert x r)
| otherwise = Node y l r
Обратите внимание, что старое и новое дерево делят поддерево с 3 и 4, поскольку оно не было изменено, но узел 7, который был «изменен», и все узлы выше него копируются.
Почему мы не можем map Nothing?
Nothing не принимает аргументы.Nothing ничего не возвращаетNothing является конструктором.Если мы определим data Boing = Frick String Boing (Int -> Bool), каков будет тип Frick?
BoingString -> Boing -> Int -> Bool -> BoingString -> Boing -> (Int -> Bool) -> BoingЕсли мы определим data ThreeLists a b c = ThreeLists [a] [b] [c], каков тип конструктора ThreeLists?
[a] -> [b] -> [c] -> ThreeListsa -> b -> c -> ThreeLists a b c[a] -> [b] -> [c] -> ThreeLists a b c[a] -> [b] -> [c] -> ThreeLists [a] [b] [c]Если мы определим data TwoLists a b = TwoList {aList :: [a], bList :: [b]}, каков тип функции aList?
aList — это не функция, это полеTwoLists a b -> [a][a] -> TwoLists a b[a]Мы видели ограничения классов, такие как Eq a =>, в типах. Мы знаем, как использовать существующие классы с существующими типами. Но как нам использовать существующие классы с нашими собственными типами? Как мы можем определить наши собственные классы?
Вот как сделать свой собственный тип членом класса Eq:
data Color = Black | White
instance Eq Color where
Black == Black = True
White == White = True
_ == _ = FalseЭкземпляр класса — это блок instance, содержащий определения функций этого класса. Здесь мы определяем, как == работает на Color.
Класс типа определяется с использованием синтаксиса class. Функциям в классе присваиваются типы. Вот класс Size, который содержит одну функцию, size:
class Size a where
size :: a -> IntЭкземпляры класса определяются с помощью синтаксиса instance, который мы только что видели. Вот как мы делаем Int и [a] членами класса Size:
instance Size Int where
size x = abs x
instance Size [a] where
size xs = length xsНаш класс Size ведет себя так же, как существующие классы типов. Мы можем использовать size везде, где можно использовать функцию, и Haskell может выводить за нас типы с ограничениями Size:
Prelude> :t size
size :: Size a => a -> Int
Prelude> size [True,False]
2
Prelude> sizeBoth a b = [size a, size b]
Prelude> :t sizeBoth
sizeBoth :: (Size a1, Size a2) => a1 -> a2 -> [Int]Класс может содержать несколько функций и даже константы. Здесь мы определяем новую версию класса Size с большим содержанием.
class Size a where
empty :: a
size :: a -> Int
sameSize :: a -> a -> Bool
instance Size (Maybe a) where
empty = Nothing
size Nothing = 0
size (Just a) = 1
sameSize x y = size x == size y
instance Size [a] where
empty = []
size xs = length xs
sameSize x y = size x == size yВы заметили, что в предыдущем примере мы дали sameSize одно и то же определение в обоих случаях? Это очень распространенное явление, и именно поэтому классы Haskell могут иметь реализации default. В качестве первого примера приведем класс типов Example для предоставления примеров значений типов.
class Example a where
example :: a -- the main example for the type `a`
examples :: [a] -- a short list of examples
examples = [example] -- ...defaulting to just the main example
instance Example Int where
example = 1
examples = [0,1,2]
instance Example Bool where
example = TrueВот как работает Example. Обратите внимание, что реализация examples по умолчанию использовалась в случае Bool, но не в случае Int. Также обратите внимание на необходимость явных подписей типов, чтобы сообщить GHCi, какой экземпляр нас интересует. Без них мы бы получили ошибку «Переменная неоднозначного типа».
Prelude> example :: Bool
True
Prelude> example :: Int
1
Prelude> examples :: [Bool]
[True]
Prelude> examples :: [Int]
[0,1,2]Классы стандартных типов используют множество реализаций по умолчанию, чтобы упростить реализацию классов. Вот стандартные определения Eq (отформатированные для удобства чтения).
class Eq a where
(==) :: a -> a -> Bool
x == y = not (x /= y)
(/=) :: a -> a -> Bool
x /= y = not (x == y)Обратите внимание, что обе операции имеют реализацию по умолчанию с точки зрения другой. Это означает, что мы могли бы определить экземпляр Eq вообще без содержимого, но результирующие функции будут просто рекурсивно работать вечно. На практике мы хотим определить хотя бы один из == и /=.
Когда существует множество реализаций по умолчанию, может быть сложно понять, какие функции вам нужно реализовать самостоятельно. По этой причине в документации класса обычно упоминается минимальное полное определение. Для Eq, в документации указано «Минимальное полное определение: либо ==, либо /=».
Далее посмотрим на Ord. Ord имеет 7 операций, каждая из которых имеет реализацию по умолчанию относительно друг друга. Кстати, обратите внимание на необычный способ одновременного определения нескольких сигнатур типов. Ничего страшного, это особенность Haskell, именно так Ord определен в стандарте. (Скоро мы вернемся к тому, что означает часть (Eq a) =>.)
class (Eq a) => Ord a where
compare :: a -> a -> Ordering
(<), (<=), (>=), (>) :: a -> a -> Bool
max, min :: a -> a -> a
compare x y | x == y = EQ
| x <= y = LT
| otherwise = GT
x <= y = compare x y /= GT
x < y = compare x y == LT
x >= y = compare x y /= LT
x > y = compare x y == GT
max x y | x <= y = y
| otherwise = x
min x y | x <= y = x
| otherwise = yПри таком определении действительно сложно понять, что такое минимальное полное определение. К счастью, в документации говорится «Минимально полное определение: либо сравнение, либо <=».
В заключение о реализациях по умолчанию: если нет необходимости переопределять определение по умолчанию, функцию можно для простоты удалить из класса. Рассмотрим такой класс, как Combine ниже:
class Combine a where
combine :: a -> a -> a
combine3 :: a -> a -> a -> a
combine3 x y z = combine x (combine y z)Трудно представить себе случай, когда combine3 будет иметь какое-либо другое определение, так почему бы не переместить его из класса:
class Combine a where
combine :: a -> a -> a
combine3 :: Combine a => a -> a -> a -> a
combine3 x y z = combine x (combine y z)В качестве примера приведем экземпляры Eq и Ord для простого парного типа. Обратите внимание, что в определении используются правила минимального полного определения, определяя только == и <=.
data IntPair = IntPair Int Int
deriving Show
instance Eq IntPair where
IntPair a1 a2 == IntPair b1 b2 = a1==b1 && a2==b2
instance Ord IntPair where
IntPair a1 a2 <= IntPair b1 b2
| a1<b1 = True
| a1>b1 = False
| otherwise = a2<=b2*Main> (IntPair 1 2) < (IntPair 2 3)
True
*Main> (IntPair 1 2) > (IntPair 2 3)
False
*Main> compare (IntPair 1 2) (IntPair 2 3)
LT
*Main Data.List> sort [IntPair 1 1,IntPair 1 4,IntPair 2 1,IntPair 2 2]
[IntPair 1 1,IntPair 1 4,IntPair 2 1,IntPair 2 2]Как мы уже видели много раз, deriving — это способ получить автоматически сгенерированные экземпляры классов. Классы Read и Show почти всегда должны быть производными, чтобы получить стандартное поведение. Обычно вам нужен производный экземпляр Eq. Требуется соответствие конструкторов и полей.
Производный экземпляр Ord может оказаться не тем, что вам нужно. Он упорядочивает конструкторы слева направо, а затем сравнивает поля внутри конструкторов слева направо. Пример:
data Person = Dead | Alive String Int
deriving (Show, Eq, Ord)Prelude> Dead < Alive "Bob" 35 -- constructors are ordered left-to-right
True
Prelude> Alive "Barbara" 35 < Alive "Clive" 17 -- names are compared before ages
True
Prelude> Alive "Clive" 17 < Alive "Clive" 30 -- finally, ages are compared if names match
TrueВы можете использовать команду :info в GHCi для получения содержимого и экземпляров класса. Сегодня информация включает даже минимальное полное определение (см. прагму MINIMAL). Например:
Prelude> :info Num
class Num a where
(+) :: a -> a -> a
(-) :: a -> a -> a
(*) :: a -> a -> a
negate :: a -> a
abs :: a -> a
signum :: a -> a
fromInteger :: Integer -> a
{-# MINIMAL (+), (*), abs, signum, fromInteger, (negate | (-)) #-}
-- Defined in ‘GHC.Num’
instance Num Word -- Defined in ‘GHC.Num’
instance Num Integer -- Defined in ‘GHC.Num’
instance Num Int -- Defined in ‘GHC.Num’
instance Num Float -- Defined in ‘GHC.Float’
instance Num Double -- Defined in ‘GHC.Float’И классы, и экземпляры могут образовывать hierarchies. Это означает, что класс или экземпляр зависит от другого класса или экземпляра.
Давайте попробуем определить экземпляр Eq для простого парного типа:
data Pair a = MakePair a a
deriving Show
instance Eq (Pair a) where
(MakePair x y) == (MakePair a b) = x==a && y==berror:
• No instance for (Eq a) arising from a use of ‘==’
Possible fix: add (Eq a) to the context of the instance declaration
• In the first argument of ‘(&&)’, namely ‘x == a’
In the expression: x == a && y == b
In an equation for ‘==’:
(MakePair x y) == (MakePair a b) = x == a && y == bКомпилятор пытается сообщить нам, что нашему экземпляру Eq (Pair a) для работы необходим экземпляр Eq a. Как мы можем сравнивать пары значений типа a, если мы не можем сравнивать значения типа a? Чтобы решить эту проблему, нам нужно добавить ограничение типа в объявление экземпляра, точно так же, как мы добавили ограничения типа в определения функций.
instance Eq a => Eq (Pair a) where
(MakePair x y) == (MakePair a b) = x==a && y==bТеперь мы можем сравнивать пары, если тип элемента сопоставим. Однако мы не можем сравнивать, скажем, пары функций, поскольку у функций нет экземпляра Eq.
Prelude> MakePair 1 1 == MakePair 1 1
True
Prelude> MakePair reverse reverse == MakePair reverse reverse
<interactive>:15:1: error:
• No instance for (Eq ([a0] -> [a0])) arising from a use of ‘==’
(maybe you haven't applied a function to enough arguments?)
• In the expression:
MakePair reverse reverse == MakePair reverse reverse
In an equation for ‘it’:
it = MakePair reverse reverse == MakePair reverse reverseПродолжим другой пример. Вот простой класс типов и экземпляр
class Check a where
check :: a -> Bool
instance Check Int where
check x = x > 0Теперь мы можем написать функцию, которая проверяет список. Мы используем стандартную библиотечную функцию and :: [Bool] -> Bool, которая проверяет, состоит ли список из всех True.
checkAll :: Check a => [a] -> Bool
checkAll xs = and (map check xs)Чтобы превратить это в экземпляр Check [a], нам нужно добавить ограничение в объявление экземпляра. Наш Check [a] основан на экземпляре Check a.
instance Check a => Check [a] where
check xs = and (map check xs)Это означает, что наш экземпляр Check [a] действителен только тогда, когда существует соответствующий экземпляр Check a. Например, если мы попытаемся вызвать экземпляр Check [Bool], мы получим ошибку об отсутствующем экземпляре Check Bool:
Prelude> check [True,False]
<interactive>:1:1: error:
• No instance for (Check Bool) arising from a use of ‘check’
• In the expression: check [True, False]
In an equation for ‘it’: it = check [True, False]Кроме того, если мы попытаемся определить экземпляр Check [a] без ограничения, мы получим ошибку (очень хорошее предложение!)
• No instance for (Check a) arising from a use of ‘check’
Possible fix:
add (Check a) to the context of the instance declarationНаконец, иногда необходимо несколько ограничений. Рассмотрим, например, экземпляр Eq для Either:
instance (Eq a, Eq b) => Eq (Either a b) where
Left x == Left y = x==y
Right x == Right y = x==y
_ == _ = FalseСоответственно, класс может зависеть от другого класса. Это полезно, например, когда вы хотите использовать функции из другого класса в своих реализациях по умолчанию:
class Size a where
size :: a -> Int
class Size a => SizeBoth a where
sizeBoth :: a -> a -> Int
sizeBoth x y = size x + size yВ подобных случаях мы говорим, что SizeBoth является subclass из Size. Еще раз обратите внимание на путаницу с объектно-ориентированным программированием. Примеры подклассов в стандартной библиотеке включают:
class Eq a => Ord a where
...
class Num a => Fractional a where
...Другой способ взглянуть на подклассы заключается в том, что если у вас есть class Main a => Sub a, вы должны предоставить instance Main MyType, чтобы иметь возможность объявить instance Sub MyType.
Какие функции есть в классе Eq?
(==), (/=)(==)(==), (<), (>)Для каких из следующих классов мы можем получить автоматические экземпляры с помощью deriving?
NumOrdSizeУчитывая следующее определение класса BitOperations
class BitOperations a where
bitNot :: a -> a
bitNot x = bitNand x x
bitAnd :: a -> a -> a
bitAnd x y = bitNot (bitOr (bitNot x) (bitNot y))
bitOr :: a -> a -> a
bitOr x y = bitNot (bitAnd (bitNot x) (bitNot y))
bitNand :: a -> a -> a
bitNand x y = bitNot (bitAnd x y)Какой набор операций является not минимальным полным определением BitOperations?
bitNand, bitAndbitAnd, bitOrbitAnd, bitNotbitNot, bitOrОбъявление instance Num a => Eq (Pair a) сообщает мне, что
Num являются экземплярами EqPair a являются экземплярами Eq, если a является экземпляром NumEq (Pair a) наследует экземпляр Num aОбъявление class Num a => Fractional a сообщает мне, что
Fractional должны быть экземплярами NumNum должны быть экземплярами FractionalFractional, я также получаю экземпляр для NumNum, я также получаю экземпляр для FractionalЭта лекция представляет собой введение в шаблоны проектирования для типизированного функционального программирования. Эти шаблоны полезны при написании программ на Haskell и предлагают хорошую площадку для отработки навыков, полученных в предыдущих лекциях.
Иногда вам не нужен новый тип, а вместо этого можно просто повторно использовать стандартный тип. Например, представление регистрационных номеров автомобилей с помощью String. Однако, если ваш код полон String, его можно легко случайно перепутать, например. модель автомобиля и регистрация в функции типа registerCar :: String -> String -> CarRegistry -> CarRegistry.
Для подобных ситуаций обычно создается новый тип, который содержит только String («упакованную» строку):
data Plate = Plate String
deriving (Show, Eq)Теперь для registerCar можно указать более удобный тип String -> Plate -> CarRegistry -> CarRegistry. Кроме того, операции над Plate можно ограничить подмножеством строковых операций. Например, складывать регистрационные номера двух автомобилей незачем, поэтому функция concatPlates :: Plate -> Plate -> Plate не нужна. Можно также определить умный конструктор (smart constructor) для Plate, проверяющий формат регистрационного номера:
parsePlate :: String -> Maybe Plate
parsePlate string
| correctPlateNumber string = Just (Plate string)
| otherwise = NothingВот еще один пример: представление денег. Если мы просто будем хранить деньги как Int, компилятор не защитит нас от ошибок, таких как умножение денег на деньги. Если вместо этого мы реализуем наш собственный тип Money, который обертывает Int, мы получим безопасность типов. Кроме того, мы можем инкапсулировать тот факт, что деньги представлены как целое число центов.
data Money = Money Int
deriving Show
renderMoney :: Money -> String
renderMoney (Money cents) = show (fromIntegral cents / 100)
(+!) :: Money -> Money -> Money
(Money a) +! (Money b) = Money (a+b)
scale :: Money -> Double -> Money
scale (Money a) x = Money (round (fromIntegral a * x))
addVat :: Money -> Money
addVat m = m +! scale m 0.24renderMoney (Money 100 +! Money 150)
==> "2.5"
scale (Money 299) 0.24
==> Money 72
addVat (Money 299)
==> Money 371Примечание! Если вы знакомы с объектно-ориентированным программированием, это немного похоже на инкапсуляцию.
Алгебраические типы данных Haskell действительно эффективны при моделировании вещей на основе cases. Часто бывает полезно думать о типах как о определяющих набор возможных случаев, а о функциях handling этих случаях (часто посредством сопоставления с образцом). Давайте рассмотрим два примера.
Поскольку в Haskell так легко определять собственные типы, довольно удобно использовать более описательные типы вместо логических значений или строк. Рассмотрим список лиц. В каком-то другом языке, если вы хотите отсортировать людей по возрастанию имен, вы можете использовать вызов типа sortPersons(persons, "name", true). Вместо этого в Haskell вы можете сделать это:
data Person = Person {name :: String, age :: Int}
deriving Show
data SortOrder = Ascending | Descending
data SortField = Name | Age
sortByField :: SortField -> [Person] -> [Person]
sortByField Name ps = sortBy (comparing name) ps
sortByField Age ps = sortBy (comparing age) ps
sortPersons :: SortField -> SortOrder -> [Person] -> [Person]
sortPersons field Ascending ps = sortByField field ps
sortPersons field Descending ps = reverse (sortByField field ps)
persons = [Person "Fridolf" 73, Person "Greta" 60, Person "Hans" 65]sortPersons Name Ascending persons
==> [Person {name = "Fridolf", age = 73},Person {name = "Greta", age = 60},Person {name = "Hans", age = 65}]
sortPersons Age Descending persons
==> [Person {name = "Fridolf", age = 73},Person {name = "Hans", age = 65},Person {name = "Greta", age = 60}]Обратите внимание, что вы не можете случайно напечатать имя поля (в отличие от строк) и что вам не нужно запоминать, относится ли true к возрастающему или убывающему порядку.
Перейдем к следующему примеру. Многие функции Haskell не работают с пустыми списками (например, head []). Если вы пишете код, который должен отслеживать, являются ли списки пустыми или гарантированно не пустыми, вы можете использовать тип NonEmpty из модуля Data.List.NonEmpty.
Рассмотрим определение NonEmpty:
data NonEmpty a = a :| [a]Здесь тип представляет случаи отсутствия. Тип NonEmpty a всегда будет состоять из значения типа a и некоторых дополнительных a, собранных в список. Вот несколько примеров значений NonEmpty Int:
1 :| [2,3,4]
1 :| []Кстати, это также пример конструктора infix. Ранее мы уже встречались с другим инфиксным конструктором — конструктором списка (:). Любой оператор, начинающийся с двоеточия (символ :), может использоваться в качестве инфиксного конструктора. Мы можем сопоставить шаблон с (:|) так же, как с (:), как вы увидите в примерах ниже.
Вот функции, которые преобразуют обычные списки в непустые списки. Обратите внимание, что у нас не может быть функции [a] -> NonEmpty a, а вместо этого мы должны использовать Maybe, чтобы представить возможность того, что список действительно был пустым. Обратите также внимание на то, что toList имеет только одно уравнение, у нас не может быть ситуации toList [] из-за типа NonEmpty.
nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty (x:xs) = Just (x :| xs)
toList :: NonEmpty a -> [a]
toList (x :| xs) = x : xsnonEmpty [1,2,3] ==> Just (1 :| [2,3])
nonEmpty [1] ==> Just (1 :| [])
nonEmpty [] ==> Nothing
toList (1 :| [2,3]) ==> [1,2,3]Вот head и last, реализованные для NonEmpty:
neHead (x :| _) = x
neLast (x :| []) = x
neLast (_ :| xs) = last xsneHead (1:|[2,3]) ==> 1
neLast (1:|[2,3]) ==> 3Кстати, эти функции доступны как Data.List.NonEmpty.head и Data.List.NonEmpty.last, а также множество других полезных функций.
Подводя итог, если вы пишете типы, представляющие все возможные варианты ваших значений, а затем пишете функции, обрабатывающие эти случаи, ваш код будет простым и правильным.
В функциональном программировании на удивление часто встречается шаблон monoid (не путать с monad!). Объяснения моноидов часто очень математические, но идея проста: объединение вещей.
Многие используемые нами функции и операторы являются associative. Это просто причудливый способ сказать, что скобки не нужны. Например, все эти выражения имеют значение 16, поскольку сложение ассоциативно:
(1 + 3) + (5 + 7)
1 + (3 + (5 + 7))
1 + 3 + 5 + 7Примеры ассоциативных операций легко найти в Haskell. Например, оператор ++ для объединения списков является ассоциативным: не имеет значения, выполняете ли вы ([1] ++ [2,3]) ++ [4] или [1] ++ ([2,3] ++ [4]) — результатом будет [1,2,3,4].
Еще один замечательный пример — оператор композиции функций. И (head . tail) . tail, и head . (tail . tail) вычисляют третий элемент списка.
Однако не все операторы ассоциативны. Наиболее известные примеры — вычитание и возведение в степень. (1-2)-3 — это -4, а 1-(2-3) — это 2. Аналогично, (2^3)^2 — это 64, а 2^(3^2) — это 512. Следует быть осторожным со скобками при использовании неассоциативных операторов.
Еще один неассоциативный оператор — это конструктор списка (:). На этот раз причина еще более фундаментальна: хотя с True:(False:[]) все в порядке, (True:False):[] даже не печатает! Чтобы операция была ассоциативной, она должна принимать два аргумента одного типа.
Помимо операторов, ассоциативными могут быть и функции. Синтаксис выглядит немного иначе, но функция f является ассоциативной, если они одинаковы:
f x (f y z)
f (f x y) zДве широко используемые ассоциативные функции — это функции min и max:
min 2 (min 1 3) ==> 1
min (min 2 1) 3 ==> 1
max 2 (max 1 3) ==> 3
max (max 2 1) 3 ==> 3С математической точки зрения ассоциативная функция (или оператор) образует semigroup. В Haskell есть класс типов Semigroup (определенный в модуле Data.Semigroup), который можно использовать, когда тип имеет одну явную ассоциативную операцию.
class Semigroup a where
-- An associative operation.
(<>) :: a -> a -> aСписки являются экземпляром Semigroup с (++) как (<>):
[1] <> [2,3] <> [4] ==> [1,2,3,4]Типы, имеющие несколько различных ассоциативных операторов, обычно не становятся экземпляром Semigroup. Примером является Int, который имеет множество ассоциативных функций, таких как +, * и max. Вместо этого стандартная библиотека Haskell использует бокс (см. ранее в этой лекции). Вот определения для Sum и Product:
data Sum a = Sum a
instance Num a => Semigroup (Sum a) where
Sum a <> Sum b = Sum (a+b)
data Product a = Product a
instance Num a => Semigroup (Product a) where
Product a <> Product b = Product (a*b)Кстати, это еще одно преимущество упаковки: возможность объявлять экземпляры классов разных типов!
Обратите внимание, что ограничение Num a позволяет нам использовать операции Num, такие как + и *, над содержащимся типом a. У нас могут быть значения типа Sum "abc" :: Sum String, но у них не будет экземпляра Semigroup!
Аналогично, у нас есть типы ящиков Min и Max. Давайте немного поиграемся с GHCi:
Prelude> import Data.Semigroup
Prelude Data.Semigroup> Product (2::Int) <> Product 3 <> Product 1
Product {getProduct = 6}
Prelude Data.Semigroup> Sum 3 <> Sum 5 <> Sum 7
Sum {getSum = 15}
Prelude Data.Semigroup> Product 2 <> Product 3 <> Product 1
Product {getProduct = 6}
Prelude Data.Semigroup> Min 4 <> Min 3 <> Min 5
Min {getMin = 3}
Prelude Data.Semigroup> Max 4 <> Max 3 <> Max 5
Max {getMax = 5}Если мы снова послушаем математиков, monoid представляет собой полугруппу с нейтральный элемент. Нейтральный элемент — это ноль: элемент, который ничего не делает в сочетании с другими элементами. Вот несколько примеров:
-- 0 is the neutral element of (+)
3 + 0 ==> 3
0 + 3 ==> 3
-- 1 is the neutral element of (*)
1 * 5 ==> 5
5 * 1 ==> 5
-- [] is the neutral element of (++)
[] ++ [1,2] ==> [1,2]
[1,2] ++ [] ==> [1,2]Класс типов Haskell Monoid (из модуля Data.Monoid) представляет моноиды.
class Semigroup a => Monoid a where
-- The neutral element
mempty :: aВот экземпляры Monoid, соответствующие нашим трем примерам нейтральных элементов:
instance Num a => Monoid (Sum a) where
mempty = Sum 0
instance Num a => Monoid (Product a) where
mempty = Product 1
instance Monoid [] where
mempty = []Итак, что такое моноид для программиста? Тип образует моноид, если существует способ объединить два элемента типа вместе, чтобы круглые скобки не имели значения, а также существует «пустой элемент», который можно комбинировать с вещами, не изменяя их. Если так подумать, моноиды встречаются в программировании довольно часто!
Для чего нужен этот класс Monoid? Разве мы не можем просто написать 1 + 2 вместо Sum 1 <> Sum 2? Да, можем, но некоторые библиотечные функции работают со всеми типами Monoid.
Причина, по которой нам нужен и нейтральный элемент, и ассоциативный бинарный оператор, заключается в том, что это именно те две вещи, которые нам нужны, чтобы reduce или fold несколько элементов в одно значение. Это работа:
mconcat :: Monoid a => [a] -> aПримечание: один из способов определения mconcat — это foldr (<>) mempty. Помните foldr?
Давайте посмотрим, почему нам нужны свойства Monoid для реализации mconcat. Во-первых, нам нужен mempty для обработки пустых списков:
mconcat [] :: Sum Int ==> Sum 0Во-вторых, нам нужна ассоциативность, чтобы иметь возможность свести список [x,y,z] к уникальному значению. Если бы <> не был ассоциативным, у нас было бы два возможных значения для mconcat [x,y,z], а именно (x<>y)<>z и x<>(y<>z).
Самая полезная функция Monoid — это foldMap:
foldMap :: (Foldable t, Monoid m) => (a -> m) -> t a -> mЭта сигнатура типа выглядит устрашающе, но в конкретных случаях все проще:
foldMap Max [1::Int,4,2] ==> Max 4
foldMap Product [1::Int,4,2] ==> Product 8
-- We need the ::Int to avoid an "Ambiguous type variable" error when printing the resultДавайте разберем этот тип. Мы знаем, что примером типа Foldable t => t a является [a], поэтому мы можем переписать тип как
foldMap' :: Monoid m => (a -> m) -> [a] -> mМы можем построить эту функцию из уже известных нам функций:
foldMap' f xs = mconcat (map f xs)Да, кстати, благодаря экземпляру (Monoid a, Monoid b) => Monoid (a,b) мы даже можем вычислить максимум и произведение за один проход:
foldMap (\x -> (Max x, Product x)) [1::Int,4,2] ==> (Max 4, Product 8)Обратите внимание: вам не обязательно использовать моноиды в своем собственном коде, но вы рано или поздно столкнетесь с ними при использовании библиотек Haskell, поэтому полезно знать, что они из себя представляют.
По различным историческим причинам и причинам производительности определения классов Monoid и Semigroup не просто
class Semigroup a where
(<>) :: a -> a -> a
class Semigroup a => Monoid a where
mempty :: aХотя в основном вы можете притворяться, что это так. Фактические определения таковы:
class Semigroup a where
-- | An associative operation.
(<>) :: a -> a -> a
-- Combine elements of a nonempty list with <>
sconcat :: NonEmpty a -> a
sconcat as = ... -- default implementation omitted
-- Combine a value with itself using <>, n times
stimes :: Integral b => b -> a -> a
stimes n x = ... -- default implementation omittedclass Semigroup a => Monoid a where
mempty :: a
mappend :: a -> a -> a
mappend = (<>)
-- Combine elements of a list with <>
mconcat :: [a] -> a
mconcat = ... -- default implementation omittedКак видите, все операции, кроме <> и mempty, имеют определения по умолчанию, поэтому обычное объявление экземпляра Monoid выглядит примерно так:
instance Semigroup MyType where
x <> y = ...
instance Monoid MyType where
mempty = ...Часто начинающие программисты на Haskell задают (или, по крайней мере, должны задавать!) вопрос: когда мне следует использовать классы типов? В этом разделе предлагается один ответ.
Давайте рассмотрим конкретный пример. Транспортным средством может быть как автомобиль, так и самолет. Мы можем смоделировать это с помощью алгебраических типов данных (как мы видели ранее в этой главе), а также с помощью классов типов. Вот версия типа данных:
data Vehicle = Car String | Airplane String
sound :: Vehicle -> String
sound (Car _) = "brum brum"
sound (Airplane _) = "zooooom"Вот версия класса. Обратите внимание, что каждый случай получает свой собственный тип данных, которые собираются вместе в классе типов.
data Car = Car String
data Airplane = Airplane String
class VehicleClass a where
sound :: a -> String
instance VehicleClass Car where
sound (Car _) = "brum brum"
instance VehicleClass Airplane where
sound (Airplane _) = "zooooom"В чем разница между этими решениями? Решение на основе данных — closed, что означает, что набор случаев фиксирован, и мы можем обрабатывать их все в одном месте. Решение на основе классов — open, что означает, что мы можем добавлять новые случаи даже в другие модули.
Открытая абстракция хороша, когда нам нужна расширяемость. В решении на основе классов другой модуль может определить велосипед:
data Bike = Bike String
instance VehicleClass Bike where
sound (Bike _) = "whirrr"Закрытая абстракция хороша, когда мы хотим знать, что мы обработали все случаи, рассмотрим, например, функцию canCollide, которая проверяет, могут ли два транспортных средства столкнуться:
canCollide :: Vehicle -> Vehicle -> Bool
canCollide (Car _) (Car _) = True
canCollide (Airplane _) (Airplane _) = True
canCollide _ _ = FalseЭто было бы очень сложно надежно реализовать в решении на основе классов. Рассмотрим, например, как будут обрабатываться проверки коллизий между Bike s и Car s.
Иногда полезно реализовать мини-язык программирования для описания частей вашего программного обеспечения. Причудливый термин для них — Встроенный доменно-ориентированный язык (EDSL). Haskell хорошо подходит для моделирования и интерпретации языков. Выражения языка представлены с использованием (часто рекурсивных) алгебраических типов данных. Язык может быть интерпретирован (то есть оценен или запущен) с помощью рекурсивной функции.
Вот пример языка для описания расчета цен на продукты в интернет-магазине.
data Discount = DiscountPercent Int -- A percentage discount
| DiscountConstant Int -- A constant discount
| MinimumPrice Int -- Set a minimum price
| ForCustomer String Discount -- Discounts can be conditional
| Many [Discount] -- Apply a number of discounts in rowЭтот язык интерпретируется функцией applyDiscount, которая принимает имя клиента, цену, скидку и возвращает цену.
applyDiscount :: String -> Int -> Discount -> Int
applyDiscount _ price (DiscountPercent percent) = price - (price * percent) `div` 100
applyDiscount _ price (DiscountConstant discount) = price - discount
applyDiscount _ price (MinimumPrice minPrice) = max price minPrice
applyDiscount customer price (ForCustomer target discount)
| customer == target = applyDiscount customer price discount
| otherwise = price
applyDiscount customer price (Many discounts) = go price discounts
where go p [] = p
go p (d:ds) = go (applyDiscount customer p d) dsЗдесь мы применяем цепочку скидок -50%, -30 долларов США с минимальной ценой 35 долларов США:
applyDiscount "Bob" 120 (DiscountPercent 50)
==> 60
applyDiscount "Bob" 60 (DiscountConstant 30)
==> 30
applyDiscount "Bob" 30 (MinimumPrice 35)
==> 35
applyDiscount "Bob" 120 (Many [DiscountPercent 50, DiscountConstant 30, MinimumPrice 35])
==> 35Здесь у нас есть разные скидки для Сары и Ивонны:
applyDiscount "Yvonne" 100 (Many [ForCustomer "Yvonne" (DiscountConstant 10), ForCustomer "Ssarah" (DiscountConstant 20)])
==> 90
applyDiscount "Ssarah" 100 (Many [ForCustomer "Yvonne" (DiscountConstant 10), ForCustomer "Ssarah" (DiscountConstant 20)])
==> 80Как видите, даже простой тип Discount может генерировать сложное поведение, поскольку он ссылается на самого себя. (рекурсивный). Используя Discount, мы можем представить логику скидок нашего интернет-магазина как data вместо написания кода.
Существует множество причин для представления логики в виде данных, а не кода. В отличие от кода, данные можно легко хранить в файле или базе данных или даже передавать по сети. Мы также можем использовать одни и те же данные для нескольких целей, например, мы можем визуализировать правила скидок в пользовательском интерфейсе администрирования.
Этот курс посвящен чисто функциональному программированию. Мы выполнили множество арифметических операций, перевернули списки, поработали с двоичными деревьями, но пока что нам не удалось повлиять на мир за пределами нашего GHCi.
Такие вещи, как чтение входных данных, запись в файл или общение по сети, являются побочными эффектами. Побочные эффекты невозможно представить с помощью чистого функционального кода. Функция вроде
readInputFromTheUser :: String -> Stringне может быть чистой: иначе readInputFromUser "What is your name?" всегда должна была бы возвращать один и тот же результат. Тем не менее побочные эффекты и нечистые вычисления можно представить в чистом языке. Существует несколько подходов; Haskell использует монады (monads).
Монады, по общему мнению, сложны для понимания. Вероятно, это потому, что они настолько абстрактны. Я думаю, что лучше всего сначала сосредоточиться на практических и конкретных случаях. Вот пример монады IO, которую вы можете использовать для всевозможных побочных эффектов в Haskell.
Начнем!
Prelude> :t getLine
getLine :: IO String
Prelude> line <- getLineanother line
Prelude> :t line
line :: String
Prelude> line
"another line"
Prelude> reverse line
"enil rehtona"Здесь мы видели действие IO getLine. Он имеет тип IO String. Это означает, что GHCi может выполнить действие для создания значения типа String. Когда мы вводим line <- getLine в GHCi, мы имеем в виду:
Выполните действие ввода-вывода
getLineи присвойте результату имяline.
После того, как мы получили line, это чистое значение String, и мы можем с ним нормально работать.
Некоторые действия ввода-вывода принимают параметры. Например, putStrLn :: String -> IO () принимает String и возвращает действие IO, которое печатает эту строку. Тип () — это специальный тип, имеющий только одно значение: (). В этом случае IO () означает, что этот ввод-вывод всегда выдает одно и то же пустое значение (). Вы можете запускать действия ввода-вывода всего лишь с помощью
Prelude> :t putStrLn
putStrLn :: String -> IO ()
Prelude> :t putStrLn "hello"
putStrLn "hello" :: IO ()
Prelude> val <- putStrLn "hello"
hello
Prelude> val
()Если вам не нужно возвращаемое значение действия ввода-вывода, вы можете запустить его в GHCi без <-:
Prelude> putStrLn "hello"
helloВы можете создавать свои собственные действия ввода-вывода, комбинируя другие действия с do-нотацией. Блок do перечисляет действия ввода-вывода, которые выполняются по порядку.
printTwoThings :: IO ()
printTwoThings = do
putStrLn "Hello!"
putStrLn "How are you?"
greet :: IO ()
greet = do
putStrLn "What's your name?"
name <- getLine
putStrLn ("Hello, " ++ name)Prelude> printTwoThings
Hello!
How are you?
Prelude> greet
What's your name?
Seraphim
Hello, SeraphimТакое ощущение, что с помощью этих действий ввода-вывода мы можем просто создавать побочные эффекты где захотим. Однако важно помнить разницу между определением действия ввода-вывода и выполнением его.
Давайте попробуем напечатать при отображении списка
printAndIncrement :: Int -> Int
printAndIncrement x = x+1
where action = putStrLn "got a number!"Prelude> map printAndIncrement [1,2,3]
[2,3,4]Это ничего не напечатало, потому что, хотя мы и определили наш action, он не был передан GHCI для выполнения. Поскольку printAndIncrement возвращает Int, он не может вернуть действие. Хорошо, давайте попробуем другой подход:
Prelude> length (map putStrLn ["string1","string2"])
2Это тоже ничего не напечатало! Давайте разберемся, почему:
Prelude> :t map putStrLn ["string1","string2"]
map putStrLn ["string1","string2"] :: [IO ()]
Prelude> :t length (map putStrLn ["string1","string2"])
length (map putStrLn ["string1","string2"]) :: IntМы сгенерировали список действий ввода-вывода и вычислили длину списка. Определение действий ввода-вывода является чистым, именно запуск вызывает побочные эффекты. Поскольку тип нашего выражения был Int, никакие действия IO не могли попасть в GHCi и быть выполнены.
Если вместо этого мы возвращаем действие ввода-вывода, оно запускается:
Prelude> :t head (map putStrLn ["string1","string2"])
head (map putStrLn ["string1","string2"]) :: IO ()
Prelude> head (map putStrLn ["string1","string2"])
string1Здесь также код, который создает действие putStrLn "string1", является чистым, только после того, как действие ввода-вывода выполняется GHCi, мы видим напечатанную строку. И, как вы можете видеть, другое действие ввода-вывода, putStrLn "string2", так и не было запущено.
Если это кажется сложным, не волнуйтесь. Мы вернемся к этому во второй части курса.
Мы знаем, что GHCi может выполнять действия ввода-вывода. А как насчет реальных программ на Haskell? Программы на Haskell работают следующим образом: действие ввода-вывода main выполняется при запуске программы. Вспомните наш пример программы из лекции 1.
module Gold where
-- The golden ratio
phi :: Double
phi = (sqrt 5 + 1) / 2
polynomial :: Double -> Double
polynomial x = x^2 - x - 1
f x = polynomial (polynomial x)
main = do
print (polynomial phi)
print (f phi)Здесь мы видим чистый код и действие ввода-вывода main, которое печатает две вещи (print — это просто putStrLn в сочетании с show).
Мы можем поместить этот код в файл с именем Gold.hs, скомпилировать его в исполняемый файл и запустить:
$ ghc -main-is Gold Gold.hs
[1 of 1] Compiling Gold ( Gold.hs, Gold.o )
Linking Gold ...
$ ./Gold
0.0
-1.0До сих пор мы узнали о синтаксисе и типах Haskell, довольно немного функциональное программирование и некоторые особенности языка, такие как классы типов.
Мы также познакомились с типоориентированным программированием и даже попробовали ввод-вывод в Haskell.
Теперь вы знаете, как написать настоящую компьютерную программу на Haskell, но вам еще предстоит многому научиться.
Часть 2 курса уже доступна! Во второй части будут рассмотрены такие темы, как монады, ввод-вывод и то, как Haskell работает под капотом. Нам также предстоит заняться реальным программированием с сетями и базами данных. Да, и тестирование на Haskell также рассматривается.
Если вам сейчас не хочется сразу переходить ко второй части, вот еще несколько ресурсов по Haskell, которыми вы можете воспользоваться прямо сейчас:
Я также рекомендую поработать над некоторыми проблемами программирования на Haskell, например, из:
Вы также можете продолжать расширять свой окончательный проект и, возможно, создавать интересные произведения искусства на Haskell.
В любом случае – большое спасибо, что присоединились к нам, и надеемся, что вы хорошо проведете остаток года!
Откройте файл упражнения Set8.hs и следуйте инструкциям. Веселиться!
Этот курс стал возможным благодаря Nitor, который пожертвовал часы и часы рабочего времени Джоэла для этого проекта. Спасибо! Ознакомьтесь с нашими открытыми вакансиями, если вы заинтересованы в работе там, где ценится непрерывное обучение.
Спасибо всей команде Haskell MOOC, особенно