Haskell MOOC, часть 1

Джоэла Каасинена (Nitor) и Джона Лонга (Университет Хельсинки)

1 Лекция 1: …И все начинается

1.1 О курсе

Это онлайн-курс по функциональному программированию, в котором используется язык программирования Haskell. Вы можете учиться в своем собственном темпе. Весь материал и упражнения находятся в открытом доступе.

Этот курс предназначен для новичков, желающих изучить функциональное программирование, а также для людей, имеющих опыт функционального программирования и желающих изучить, в частности, Haskell. Курс не предполагает никаких предварительных знаний, но предварительное знание хотя бы одного языка программирования облегчит курс.

Работа над упражнениями предполагает знание того, как использовать командную строку и основы использования системы контроля версий Git.

Это первая часть курса, состоящего из двух частей. Часть 1 охватывает основы синтаксиса и возможностей Haskell. Вы узнаете о рекурсии, функциях высшего порядка, алгебраических типах данных и некоторых расширенных функциях Haskell. Однако часть 1 будет придерживаться чистого функционального программирования без побочных эффектов. Ввод-вывод и монады будут представлены во второй части.

Курс разделен на 8 лекций. Они примерно одинакового размера, но в некоторых лекциях больше материала, чем в других. Каждый блок лекций завершается 10-30 небольшими упражнениями по программированию по темам лекции.

1.2 Полезные материалы

В дополнение к материалам курса следующие источники могут быть полезны, если вы чувствуете, что вам не хватает примеров или объяснений.

1.3 Haskell

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 по функциональному программированию.

1.3.1 Возможности

Вот демонстрация некоторых интересных возможностей 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 * radius

Lists – В отличие от многих языков, 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, …).

1.3.2 Немного истории

Краткая хронология Haskell:

  • 1930-е годы: лямбда-исчисление
  • 1950-1970-е годы: Lisp, Pattern Сопоставление, схемы, ML
  • 1978 Джон Бэкус: Можно ли программирование освободить от стиля фон Неймана?
  • 1987 Было принято решение объединить область чисто функциональных языков
  • 1990 Haskell 1.0
  • 1991 Haskell 1.1 (let-синтаксис, разделы)
  • 1992 Haskell 1.2, GHC
  • 1996 Haskell 1.3 (Монады, синтаксис do, улучшения системы типов)
  • 1999 Haskell 98
  • 2000-е: разработка GHC, множество расширений языка
  • 2009 Стандарт Haskell 2010
  • 2010-е: разработка GHC, платформа Haskell, стек Haskell

Слово «haskel» на иврите означает мудрость, но название языка программирования Haskell происходит от логика Хаскелла Карри. Название Haskell происходит от древнескандинавских слов áss (бог) и ketill (шлем).

1.3.3 Использование Haskell

Вот несколько примеров программных проектов, написанных на Haskell.

  • Darcs распределенная система контроля версий
  • Инструмент предотвращения спама Sigma на Facebook
  • Реализации Языки программирования PureScript и Elm написаны на Haskell.
  • Инструмент Pandoc для преобразования различных форматов документов – он также используется для создания материалов этого курса.
  • Сервер PostgREST, который предоставляет HTTP REST API для базы данных PostgreSQL.
  • Функциональные консалтинговые компании, такие как Galois и Well-Typed имеет долгую историю разработки критически важных систем для клиентов на Haskell

Дополнительную информацию см. в The Haskell Wiki и этой записи блога!

1.4 Запуск Haskell

Самый простой способ получить 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. Дополнительную информацию здесь.

1.5 Начнем!

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.

1.6 Выражения и типы

Как мы видели в примере GHCi выше, expressions и types являются хлебом с маслом Хаскелл. Фактически, почти все в программе на Haskell является выражением. В частности, здесь нет statements, как в Python, Java или C.

Выражение имеет value и type. Пишем выражение и его тип так: expression :: type. Вот несколько примеров:

Выражение Тип Значение
True Bool True
not True Bool False
"as" ++ "df" [Char] "asdf"

1.6.1 Синтаксис выражений

Выражения состоят из функций 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.

1.6.2 Синтаксис типов

Вот несколько основных типов 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 -> returnType
  • … двух аргументов: argument1Type -> argument2Type -> returnType
  • … из трех аргументов: argument1Type -> argument2Type -> argument3Type -> returnType

Выглядит немного странно, правда? Мы еще вернемся к этому.

1.6.3 Примечание относительно вводящих в заблуждение типов

Иногда типы, которые вы видите в 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] при описании типов.

1.7 Структура программы на Haskell

Вот простая программа на 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-монаду. Мы вернемся к ним во второй части курса.

1.8 Работа с примерами

Когда вы видите пример определения, подобный этому

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

После запуска примера попробуйте изменить его или создать другую похожую, но отличающуюся функцию. Вы учитесь программированию, программируя, а не читая!

1.8.1 Обработка ошибок

Поскольку Haskell является типизированным языком, вы довольно быстро столкнетесь с ошибками типа. Вот пример ошибки во время сеанса GHCi:

Prelude> "string" ++ True

<interactive>:1:13: error:
Couldn't match expected type ‘[Char]’ with actual typeBool
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)

Есть много способов вызвать ее. Возможно, вы где-то упускаете какие-то символы. Мы вернемся к отступам позже в этой лекции.

1.8.2 Арифметика

В арифметике 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 / 2
error:
    • 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, когда речь идет о классах типов.

1.9 Как мне что-нибудь сделать?

До сих пор вы видели некоторые арифметические действия, переворачивание строки и т. д. Как писать настоящие программы на Haskell? Многие из обычных программных конструкций, таких как циклы, операторы и присваивание, отсутствуют в Haskell. Далее мы рассмотрим основные строительные блоки программ на Haskell:

  • Условные выражения
  • Локальные определения
  • Сопоставление с образцом
  • Рекурсия

1.9.1 Условные выражения

На других языках 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!

1.9.1.1 Функции, возвращающие 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.

1.9.1.2 Примеры

checkPassword password = if password == "swordfish"
                         then "You're in."
                         else "ACCESS DENIED!"
absoluteValue n = if n < 0 then -n else n
login user password = if user == "unicorn73"
                      then if password == "f4bulous!"
                           then "unicorn73 logged in"
                           else "wrong password"
                      else "unknown user"

1.9.2 Локальные определения

Haskell имеет два разных способа создания локальных определений: let...in и where.

where добавляет к определению локальные определения:

circleArea :: Double -> Double
circleArea r = pi * rsquare
    where pi = 3.1415926
          rsquare = r * r

let...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 * x
circleArea r = let pi = 3.1415926
                   square x = x * x
               in pi * square r

Мы вернемся к различиям между let и where, но в большинстве случаев вы можете использовать то, что захотите.

1.9.3 Несколько слов о неизменяемости

Хотя такие вещи, как 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 a
error:
    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

1.9.4 Сопоставление с образцом

Определение (функции) может состоять из нескольких уравнения. Уравнения сопоставляются по порядку с аргументами, пока не будет найдено подходящее. Это называется соответствие шаблону.

Сопоставление шаблонов в 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"

1.9.5 Рекурсия

В 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)). Рекурсия часто может создавать цепочечные, древовидные, вложенные или цикличные структуры и вычисления. Рекурсия — один из основных методов функционального программирования, поэтому стоит потратить некоторые усилия на ее изучение.

1.10 Теперь все вместе!

Наконец, вот полный модуль 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

1.11 Несколько слов об отступах

Предыдущие примеры имели причудливый отступ. В Haskell отступы имеют значение, как и в Python. Полный набор правил для отступов трудно описать, но вы должны хорошо справиться с этими практическими правилами:

  1. Вещи, сгруппированные вместе, начинаются с одного и того же столбца
  2. Если выражение (или уравнение) необходимо разбить на несколько строк, увеличьте отступ

Хотя вы можете обойтись без табуляции, настоятельно рекомендуется использовать пробелы для всех отступы.

Несколько примеров по порядку.

Все в порядке:

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’

Ошибка включает номер строки, поэтому просто пройдите по этой строке еще раз. Если вам не удается заставить работать отступы, попробуйте сначала поместить все в одну длинную строку.

1.12 Викторина

В конце каждой лекции вы найдете подобный тест. Тесты не оцениваются, они нужны только для того, чтобы помочь вам проверить, поняли ли вы главу. Вы можете проверить свой ответ, нажав на вариант. Вы увидите зеленый фон, если вы были правы, и красный, если ошиблись. Не стесняйтесь гадать столько раз, сколько захотите, просто убедитесь, что вы понимаете, почему в конечном итоге правильный вариант оказывается правильным.

Что такое Haskell-эквивалент выражения C/Java/Python combine(prettify(lawn),construct(house,concrete))?

  1. combine prettify (lawn) construct (house concrete)
  2. combine (prettify lawn (construct house concrete))
  3. combine (prettify lawn) (construct house concrete)
Что такое C/Java/Python эквивалент выражения Haskell send metric (double population + increase)?
  1. send(metric(double(population+increase)))
  2. send(metric(double(population)+increase))
  3. send(metric,double(population)+increase)
  4. send(metric,double(population+increase))

Какое из следующих утверждений верно в Haskell?

  1. Каждое значение имеет тип
  2. Каждый тип имеет значение
  3. Каждый оператор имеет тип

Какое из следующих утверждений верно в Haskell?

  1. Невозможно повторно использовать имя переменной
  2. Можно переприсвоить значение переменной
  3. if всегда требует как then, так и else

Что делает функция f x = if even (x + 1) then x + 1 else f (x - 1)?

  1. Сопоставляет каждое значение x с наименьшим четным числом, большим или равным x
  2. Сопоставляет каждое значение x с наибольшим четным числом, меньшим или равным x
  3. Сопоставляет каждое значение самому себе

Почему 3 * "F00" недействителен на Haskell?

  1. 3 и "F00" имеют разные типы
  2. Все числовые значения нуждаются в десятичной точке
  3. "F00" нужен префикс «0x»

Почему 7.0 `div` 2 дает ошибка?

  1. Потому что div не определен для типа Double
  2. Потому что div не определен для типа Int
  3. Потому что `...` используется для разделения строк.

1.13 Работа над упражнениями

Материалы курса, включая упражнения, доступны в репозитории 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

1.13.1 Эталонные решения

После успешного выполнения всех упражнений набора вы сможете просмотреть решения по моделям на Моя страница статуса. Полезно взглянуть на модельные решения, возможно, они подскажут вам прием, который вы упустили!

1.14 Упражнения

2 Лекция 2: Либо умри героем…

  • Подробнее о рекурсии
  • Защитные выражения (guards)
  • Дополнительные типы: списки, Maybe, Either
  • Полиморфизм

2.1 Рекурсия и вспомогательные функции

Часто вам понадобятся вспомогательные переменные в рекурсии для отслеживания событий. Вы можете получить их, определив вспомогательную функцию с большим количеством аргументов. Аналогия: аргументы вспомогательной функции — это переменные, которые вы обновляете в цикле.

Вот пример того, как можно преобразовать цикл (в 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 result

Haskell:

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 b

Haskell:

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

2.2 Защитные выражения (guards)

Прежде чем мы перейдем к новым типам, давайте рассмотрим еще один фрагмент синтаксиса Haskell.

if then else часто бывает немного громоздким, особенно если у вас несколько случаев. Более простой альтернативой является условное определение или защищенное определение в Haskell. Это немного похоже на сопоставление с образцом: у вас есть несколько уравнений, но вы можете использовать произвольный код, решающий, какое уравнение использовать. Защищенные определения выглядят следующим образом:

f x y z
  | condition1 = something
  | condition2 = other
  | otherwise  = somethingother

Условием может быть любое выражение типа Bool. Выбирается первое условие, которое имеет значение True. Слово otherwise — это просто псевдоним True. Он используется для обозначения регистра по умолчанию.

2.2.1 Примеры

Вот несколько примеров использования средств защиты. Во-первых, у нас есть функция, описывающая данное число. Обратите внимание, насколько важно иметь регистр "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!"

2.3 Списки

До сих пор мы всегда работали с одиночными значениями, такими как числа или логические значения. Строки содержат несколько символов, но в некотором смысле строка — это всего лишь один фрагмент информации. Чтобы иметь возможность заниматься реальным программированием, нам нужно обрабатывать переменное количество элементов. Для этого нам нужны структуры данных.

Базовая структура данных в 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 реализованы как односвязные списки. Мы вернемся к этому позже.

2.3.1 Операции со списками

Стандартная библиотека 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.

2.3.2 Примеры

Вот несколько примеров работы со списками. В этом случае вместо того, чтобы показывать вам выходные данные GHCi, я просто использую ==>, чтобы показать, чему равно выражение.

Индексирование списка:

[7,10,4,5] !! 2
  ==> 4

Определение функции, которая отбрасывает 3-й и 4-й элементы списка с помощью take и drop:

f xs = take 2 xs ++ drop 4 xs
f [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]

2.4 Несколько слов о неизменяемости

Поскольку 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, в одной из следующих лекций.

2.5 Несколько слов о выводе типов и полиморфизме

Так что же означает такой тип, как 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]

2.5.1 Примечание: немного терминологии

В таком типе, как [Char], мы называем Char параметром type. Тип, подобный типу списка, которому требуется параметр типа, называется параметризованным типом.

Тот факт, что такую ​​функцию, как head, можно использовать со многими различными типами аргументов, называется полиморфизмом. Функция head называется полиморфной. Существует много форм полиморфизма, и эта форма Haskell, использующая переменные типа, называется параметрический полиморфизм.

2.5.2 Примечание: аннотации типов

Поскольку в Haskell есть вывод типа, вам не нужно давать какие-либо аннотации типов. Однако даже несмотря на то, что аннотации типов не требуются, существует несколько причин для их добавления:

  1. Они действуют как документация
  2. Они действуют как утверждения, которые проверяет компилятор: помогают обнаружить ошибки
  3. Вы можете использовать аннотации типов, чтобы придать функции более узкий тип, чем предполагает Haskell.

Хорошее практическое правило — давать аннотации типов определений верхнего уровня.

2.6 Тип 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 Just
Prelude> perhapsMultiply 3 Nothing
3
Prelude> perhapsMultiply 3 (Just 2)
6
intOrZero :: 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

2.7 Примечание: конструкторы

Как вы можете видеть выше, мы можем сопоставлять шаблоны конструкторов Maybe: Just и Nothing. Мы вернемся к тому, что означают конструкторы, позже. А пока достаточно отметить, что конструкторы — это специальные значения, начинающиеся с заглавной буквы, по которым можно выполнять сопоставление с образцом.

Другие конструкторы, которые мы уже видели, включают конструкторы BoolTrue и False. С конструкторами типа списка мы познакомимся на следующей лекции.

Конструкторы можно использовать так же, как и значения Haskell. Конструкторы, не принимающие аргументов, такие как Nothing и False, являются просто константами. Конструкторы типа Just, принимающие аргумент, ведут себя как функции. У них даже есть типы функций!

Prelude> :t Just
Just :: a -> Maybe a

2.8 Тип 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]

2.9 Выражение case

Мы видели сопоставление с образцом в аргументах функции, но есть также способ сопоставления с образцом в выражении. Это выглядит так:

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 n
describe :: 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"

2.9.1 Когда использовать выражения case

Вы можете спросить, какой смысл иметь другой синтаксис сопоставления с образцом. Что ж, выражения 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 мы научимся лучше представлять такие вещи, как дни недели.)

2.10 Резюме: сопоставление с образцом

Вещи, которые можно использовать в качестве шаблонов:

  • Int и Integer константы, такие как (-1), 0, 1, 2, …
  • Bool значения True и False
  • Char константы: '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  = 0
  • В выражении case 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 xs
Prelude> getElement Nothing "hurray!"
'!'
Prelude> getElement (Just 3) [5,6,7,8,9]
8
direction :: 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.

2.11 Викторина

Сколько значений возвращает f x = [x,x]?

  1. Ноль
  2. Один
  3. Два

Почему выражение Nothing 1 вызывает ошибку типа?

  1. Потому что Nothing не принимает аргументов
  2. Потому что Nothing ничего не возвращает
  3. Потому что Nothing является конструктором

Каков тип функции f x y = if x && y then Right x else Left "foo"?

  1. Bool -> Bool -> Either Bool String
  2. String -> String -> Either String String
  3. Bool -> Bool -> Either String Bool

Какая из следующих функций может иметь тип Bool -> Int -> [Bool]

  1. f x y = [0, y]
  2. f x y = [x, True]
  3. f x y = [y, True]

Каков тип этой функции? justBoth a b = [Just a, Just b]

  1. a -> b -> [Maybe a, Maybe b]
  2. a -> a -> [Just a]
  3. a -> b -> [Maybe a]
  4. a -> a -> [Maybe a]

2.12 Упражнения

3 Лекция 3: Катаморфизм

  • Списки, списки, списки
  • Функциональность
  • Немного о типах

3.1 Наконец-то функциональное программирование

Теперь со списками и полиморфизмом наш набор инструментов, мы наконец можем начать изучать функциональное программирование.

В Haskell функция — это значение, точно так же, как число или список. Функции можно передавать в качестве параметров другим функциям. Вот игрушечный пример. Функция applyTo1 принимает функцию типа Int->Int, применяет ее к числу 1 и возвращает результат.

applyTo1 :: (Int -> Int) -> Int
applyTo1 f = f 1

Давайте определим простую функцию типа Int->Int и посмотрим applyTo1 в действии.

addThree :: Int -> Int
addThree x = x + 3
applyTo1 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!!"

3.1.1 Функциональное программирование на списках

Это было немного скучно. К счастью, существует множество полезных функций списков, которые принимают функции в качестве аргументов. Кстати, функции, которые принимают функции в качестве аргументов (или возвращают функции), часто называют функциями высшего порядка.

Самая известная из функций высшего порядка, обрабатывающих списки, — это 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>0
filter 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 xs
Prelude> :t wrapJust
wrapJust :: [a] -> [Maybe a]
Prelude> wrapJust [1,2,3]
[Just 1,Just 2,Just 3]

3.1.2 Примеры функционального программирования на списках

Сколько «палиндромных чисел» находится между 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 s
substringsOfLength 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",""]

3.2 Частичное применение

При использовании функций высшего порядка вы можете обнаружить, что определяете множество небольших вспомогательных функций, таких как 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 > lo
Prelude> 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]

3.3 Префиксная и инфиксная запись

Обычные функции 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]

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, типизированными расширениями лямбда-исчисления с дополнительным синтаксисом.

3.5 Примечание: операторы . и $

Двумя наиболее распространенными операторами в кодовых базах 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 . f
let 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 в Интернете, поэтому полезно о них знать. Эта статья также может помочь.

3.6 Пример: перезапись 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

Это несколько крайняя версия функции, но При умеренном использовании показанные здесь методы могут облегчить чтение кода.

3.7 Дополнительные примеры функциональной обработки списков

Вот еще несколько примеров функционального программирования со списками. Начнем с введения пары новых функций списка:

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 predicate
takeWhile 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]

3.8 Списки и рекурсия

Вот новый оператор :

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.8.1 Построение списка

Используя :, мы можем определить рекурсивные функции, которые создают списки. Например, вот функция, которая создает списки типа [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) xs
split 'x' "fooxxbarxquux"   ==>   ["foo","","bar","quu"]

3.8.2 Сопоставление с образцом для списков

В прошлой лекции говорилось, что конструкторы — это вещи, которые можно сопоставить с образцом. Выше было объявлено, что конструкторами для типа списка являются : и []. Мы можем сложить одно и одно и предположить, что мы можем сопоставить шаблоны : и []. Это верно! Вот как вы можете определить свои собственные версии 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 _       = 0
sumFirstTwo [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

3.8.3 Использование списка

Используя сопоставление с образцом и рекурсию, мы можем рекурсивно обработать весь список. Вот как можно суммировать все числа в списке:

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 xs
countNothings [Nothing,Just 1,Nothing]  ==>  2

3.8.4 Создание и использование списка

Теперь, когда мы можем создавать и использовать списки, давайте сделаем оба этих действия одновременно. Эта функция удваивает все элементы в списке.

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 — это способ сообщите читателю кода, что этот аргумент не используется. Это также могло быть просто _.)

3.8.5 Хвостовая рекурсия и списки

Когда рекурсивная функция вычисляет новый вызов той же функции с другими аргументами, она вызывается. хвостовая рекурсия. (Говорят, что рекурсивный вызов находится в хвостовой позиции.) Это тип рекурсии, соответствующий императивному циклу. Мы уже видели много примеров функций с хвостовой рекурсией, но еще не сравнивали два способа написания одной и той же функции. Это 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 выше, даже они создают список напрямую, без хвостовой рекурсии!

3.9 Что-то забавное: генераторы списков

В 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"

3.10 Что-то интересное: пользовательские операторы

В Haskell operator — это что-то, построенное из символов !#$%&*+./<=>?@\^|-~. Операторы могут быть определены так же, как функции (обратите внимание на немного другую аннотацию типа):

(<+>) :: [Int] -> [Int] -> [Int]
xs <+> ys = zipWith (+) xs ys
(+++) :: String -> String -> String
a +++ b = a ++ " " ++ b

3.11 Что-то полезное: типизированные дыры

Иногда при написании 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 inGHC.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]

Помните о пробелах в вводе, если при работе с упражнениями возникают ошибки ввода! Попробуйте заменить функцию или переменную типизированной дырой. Возможно, это поможет вам понять, что вам нужно.

3.12 Викторина

Каков тип этой функции? both p q x = p x && q x

  1. a -> Bool -> a -> Bool -> a -> Bool
  2. (a -> Bool) -> (a -> Bool) -> a -> Bool
  3. (a -> Bool) -> (b -> Bool) -> c -> Bool

Каков (наиболее общий) тип этой функции? applyInOut f g x = f (g (f x))

  1. (a -> b) -> (b -> a) -> a -> b
  2. (a -> b) -> (b -> c) -> a -> c
  3. (a -> a) -> (a -> a) -> a -> a

Какая из следующих функций добавляет свой первый аргумент ко второму?

  1. f x x = x + x
  2. f x = \y -> x + y
  3. f = \x y -> x + x

Какая из следующих функций не удовлетворяет f 1 ==> 1?

  1. f x = (\y -> y) x
  2. f x = \y -> y
  3. f x = (\y -> x) x

Какая из следующих функций введена правильно?

  1. f x y = not x; f :: (Bool -> Bool) -> Bool
  2. f x = x ++ "a"; f :: Char -> String
  3. f x = 'a' : x; f :: String -> String

Сколько аргументов принимает drop 2?

  1. Ноль
  2. Один
  3. Два

Что делает эта функция? f (_:x:_) = x

  1. Возвращает первый элемент списка
  2. Возвращает произвольный элемент списка
  3. Возвращает все элементы списка, кроме первого и последнего
  4. Возвращает второй элемент списка

Каков результат reverse $ take 5 . tail $ "This is a test"?

  1. "i sih"
  2. "set a"
  3. Ошибка типа

Если f :: a -> b, то какой тип map (.f)?

  1. [b -> c] -> [a -> c]
  2. [c -> a] -> [c -> b]
  3. (b -> c) -> [a -> c]
  4. [a] -> [b]

Каков тип самого левого id в id id?

  1. не указано
  2. a
  3. a -> a
  4. (a -> a) -> (a -> a)

Какой тип const const?

  1. не указано
  2. (c -> a -> b) -> a
  3. c -> (a -> b -> a)
  4. a -> b -> c -> a

3.13 Упражнения

  • Set3a: обычные упражнения со списком
  • Set3b: рекурсия списка упражнения

3.13.1 Распространенные ошибки

No instance for (Eq a) arising from a use of==

Вероятно, вы пытались использовать x==Nothing, чтобы проверить, соответствует ли значение Nothing. Вместо этого используйте сопоставление с образцом. Причина этой ошибки в том, что значения типа Maybe a не могут сравниваться, поскольку Haskell не знает, как сравнивать значения полиморфного типа a. Подробнее об этом вы узнаете в следующей лекции. На данный момент используйте сопоставление с образцом вместо ==.

4 Лекция 4: Всё о классах (Real Classy)

  • Кортежи
  • Классы типов
  • Структуры данных: Карта, Массив

4.1 Примечание: кортежи

Прежде чем мы углубимся в классы типов, давайте представим последний оставшийся встроенный тип данных в 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 predicate
zip [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 xs
sumIf [(True,1),(False,10),(True,100)]
  ==> 101

4.2 Интерлюдия: свёртка

Рассмотрим функции 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.) Урок, который следует извлечь, заключается в том, что свёртка — это частный, но довольно общий способ рекурсивного применения некоторого преобразования к некоторой структуре (например, списку).

4.3 Классы типов

Как + в 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) имеет одинаковую реализацию для всех типов, тогда как при специальных полиморфизмах существует несколько реализаций (рассмотрим == для чисел и строк).

4.4 Ограничения типа

Когда вы работаете с конкретным типом (а не с переменной типа), вы можете просто использовать функции класса типа (в данном случае (==)):

f :: (Int -> Int) -> Int -> Bool
f g x = x == g x

Конечно, если рассматриваемый тип не является членом правильный класс, вы получите ошибку. Например:

addTrue :: Bool -> Bool
addTrue b = b + True
error:
    • 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

4.5 Классы стандартных типов

Вот некоторые стандартные классы типов Haskell, о которых вам следует знать.

4.5.1 Eq

Мы уже видели класс Eq для сравнения на равенство. Вот основные операции класса Eq и несколько примеров их использования. Как видите, почти все типы, которые мы видели до сих пор, за исключением функций, являются членами Eq.

(==) :: Eq a => a -> a -> Bool
(/=) :: Eq a => a -> a -> Bool
Prelude> 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]

4.5.2 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 -> a
Prelude> 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]]

4.5.3 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 -> a

Num также отображается в типах целочисленных литералов:

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.0

Integral — это класс типов, представляющих целые числа, например Int и Integer. Наиболее интересными функциями являются div и mod для целочисленного деления и остатка. Все типы, принадлежащие Integral, также принадлежат Num.

div :: Integral a => a -> a -> a
mod :: Integral a => a -> a -> a

Fractional — это класс типов, имеющих деление. Все типы, принадлежащие Fractional, также принадлежат Num.

(/) :: Fractional a => a -> a -> a

Floating содержит некоторые дополнительные операции, которые имеют смысл только для чисел с плавающей запятой. Все типы, принадлежащие Floating, также принадлежат FractionalNum).

sqrt :: Floating a => a -> a
sin :: Floating a => a -> a

4.5.4 Read и Show

Классы Show и Read предназначены для функций show и read, которые преобразуют значения в строки и обратно.

show :: Show a => a -> String
read :: Read a => String -> a
Prelude> show 3
"3"
Prelude> read "3" :: Int
3
Prelude> read "3" :: Double
3.0

Как вы можете видеть выше, вам часто необходимо использовать аннотацию типа с read, чтобы компилятор мог выбрать правильную реализацию.

4.5.5 Примечание: 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

Далее мы познакомимся с еще несколькими складными типами.

4.6 Дополнительные структуры данных

Теперь, когда мы знакомы со стандартными классами типов, мы можем рассмотреть одно из их приложений: структуры данных Map и Array.

4.6.1 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 в реальном коде.

4.6.2 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 e
myArray :: 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, для реальных программ.

4.6.3 Примечание: свёртка карт & Массивы

Типы 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

4.7 Чтение документации

Библиотеки 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:

  • Если вы знаете имя пакета, вы просматриваете документацию через https://hackage.haskell.org/.
  • Если вы знаете имя функции, вы можете найти ее, используя https://hoogle.haskell.org/.
  • Если вы используете stack, вы можете использовать stack haddock --open или stack haddock --open <package>, чтобы открывать документы в браузере.

4.8 Викторина

Какой тип swap . swap?

  1. (a, b) -> (a, b)
  2. (a, b) -> (b, a)
  3. a -> a

Какой тип \f g x -> (f x, g x)?

  1. (a -> b) -> (c -> d) -> (a,c) -> (b, d)
  2. (a -> b) -> (a -> c) -> a -> (b, c)
  3. (a -> b) -> (b -> a) -> a -> (b, a)

Какой тип \t -> (fst . fst $ t, (snd . fst $ t, snd t))?

  1. (a, (b, c)) -> (a, (b, c))
  2. (a, (b, c)) -> ((a, b), c)
  3. ((a, b), c) -> (a, (b, c))

Что делает функция foldr (\x xs -> xs ++ [x]) []?

  1. Он вообще не меняет свой список ввода
  2. Меняет ассоциативность списка слева направо.
  3. Он переворачивает свой входной список

Что делает функция foldr (\(x, y) zs -> x : y : zs) []?

  1. Он превращает список пар в пару списков
  2. Он превращает пару списков в список пар.
  3. Он превращает список пар в список элементов.

Какой тип foldr (\n b -> n == 3 && b)?

  1. (Foldable t, Eq a, Num a) => Bool -> t a -> Bool
  2. (Foldable t, Eq a, Num a, Bool b) => b -> t a -> b
  3. (Foldable t, Eq a, Num a) => Bool -> [ a ] -> Bool

Какой тип \x -> case x of (True, "Foo") -> show True ++ "Foo"?

  1. Either Bool String -> String
  2. (Bool, String) -> String
  3. Show a => (Bool, String) -> a

4.9 Упражнения

  • Set4a: классы типов
  • Set4b: складки

5 Лекция 5: Чтобы завязать узел, нужна строка (You Need String for a Knot)

  • Типовая система
  • Определение пользовательских типов

5.1 Алгебраические типы данных

В 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]

5.1.1 Поля

Такие типы, как 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

5.1.2 Конструкторы

Элементы в правой части объявления 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

5.1.3 Примечание: автоматический вывод экземпляров (deriving)

Кстати, в нашем типе 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 Show
Prelude> Joker
Joker

Синтаксис deriving — это способ автоматически сделать ваш класс членом определенных классов базовых типов, в первую очередь Read, Show и Eq. Подробнее о том, что это значит, мы поговорим позже.

5.1.4 Алгебраический?

Так почему же эти типы данных называются алгебраическими? Это связано с тем, что теоретически каждый тип данных может быть 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.

Существует богатая теория алгебраических типов данных. Если вам интересно, вы можете найти дополнительную информацию здесь или здесь.

5.2 Параметры типа

Мы представили параметры типа и параметрический полиморфизм при знакомстве со списками в лекции 2. С тех пор мы видели и другие параметризованные типы, такие как Maybe и Either. Теперь мы узнаем, как определять собственные параметризованные типы.

5.2.1 Определение параметризованных типов

Определение 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) = desc
Prelude> :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"

5.2.2 Синтаксическое примечание

В приведенных выше определениях мы использовали 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 String
Prelude> :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 classNothing

5.2.3 Примечание: несколько параметров типа

Типы могут иметь несколько параметров типа. Синтаксис аналогичен определению функций со многими аргументами. Вот определение стандартного типа Either:

data Either a b = Left a | Right b

5.3 Рекурсивные типы

До сих пор все определенные нами типы имели постоянный размер. Мы можем представить один отчет или один цвет, но как мы можем представить коллекцию вещей? Конечно, мы могли бы использовать списки, но можем ли мы сами определить тип списка?

Как и функции 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 typeInt’ with actual typeBool
In the first argument ofNode’, 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 t
Prelude> 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 ofNode’, namely ‘(Node "foo" Empty)’
      In the expression: Node True (Node "foo" Empty)
      In an equation for ‘it’: it = Node True (Node "foo" Empty)

5.3.1 Пример: выращивание дерева

Как и список, мы можем представить двоичное дерево:

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

5.4 Синтаксис записи

Если к некоторым полям необходимо часто обращаться, может быть удобно иметь вспомогательные функции для чтения этих полей. Например, тип 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

Вы, вероятно, согласитесь, что теперь код выглядит более приятным.

5.5 Алгебраические типы данных: сводка

  • Типы определяются так
data TypeName = ConstructorName FieldType FieldType2 | AnotherConstructor FieldType3 | OneMoreCons
  • … или так, если мы используем переменные типа
data TypeName variable = Cons1 variable Type1 | Cons2 Type2 variable
  • У вас может быть один или несколько конструкторов
  • Каждый конструктор может иметь ноль или более полей
  • Конструкторы начинаются с верхнего регистра, переменные типа — с нижнего регистра
  • Значения обрабатываются с помощью сопоставления с образцом:
foo (ConstructorName a b) = a+b
foo (AnotherConstructor _) = 0
foo OneMoreCons = 7
  • Конструкторы — это просто функции:
ConstructorName :: FieldType -> FieldType2 -> TypeName
Cons1 :: a -> Type1 -> TypeName a
  • Вы также можете определять типы данных, используя синтаксис записи:
data TypeName = Constructor { field1 :: Field1Type, field2 :: Field2Type }

Это бесплатно предоставляет вам функции доступа, такие как field1 :: TypeName -> Field1Type.

5.6 Примечание: другие способы определения типов

Помимо ключевого слова data, в Haskell есть два дополнительных способа определения типов.

Ключевое слово newtype работает аналогично data, но у вас может быть только один конструктор с одним полем. Иногда бывает разумно использовать newtype из соображений производительности, но мы вернемся к этому в части 2.

Ключевое слово type вводит псевдоним типа (type alias). Псевдонимы не влияют на проверку типов, а лишь дают сокращённую запись. Например, знакомый тип String является псевдонимом [Char]:

type String = [Char]

Это означает, что всякий раз, когда компилятор читает String, он сразу же заменяет его на [Char]. Псевдонимы типов кажутся полезными, но они могут легко затруднить чтение ошибок типа.

5.7 Как работают алгебраические типы данных?

Помните, как списки представлялись в памяти в виде связанных списков? Давайте подробнее рассмотрим, как выглядят алгебраические типы данных в памяти.

Данные 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, который был «изменен», и все узлы выше него копируются.

5.8 Викторина

Почему мы не можем map Nothing?

  1. Потому что Nothing не принимает аргументы.
  2. Потому что Nothing ничего не возвращает
  3. Потому что Nothing является конструктором.

Если мы определим data Boing = Frick String Boing (Int -> Bool), каков будет тип Frick?

  1. Boing
  2. String -> Boing -> Int -> Bool -> Boing
  3. String -> Boing -> (Int -> Bool) -> Boing

Если мы определим data ThreeLists a b c = ThreeLists [a] [b] [c], каков тип конструктора ThreeLists?

  1. [a] -> [b] -> [c] -> ThreeLists
  2. a -> b -> c -> ThreeLists a b c
  3. [a] -> [b] -> [c] -> ThreeLists a b c
  4. [a] -> [b] -> [c] -> ThreeLists [a] [b] [c]

Если мы определим data TwoLists a b = TwoList {aList :: [a], bList :: [b]}, каков тип функции aList?

  1. aList — это не функция, это поле
  2. TwoLists a b -> [a]
  3. [a] -> TwoLists a b
  4. [a]

5.9 Упражнения

  • Set5a: использование и определение алгебраических типов данных
  • Set5b: игра с двоичными деревьями

6 Лекция 6: Герой рабочего класса

Мы видели ограничения классов, такие как Eq a =>, в типах. Мы знаем, как использовать существующие классы с существующими типами. Но как нам использовать существующие классы с нашими собственными типами? Как мы можем определить наши собственные классы?

Вот как сделать свой собственный тип членом класса Eq:

data Color = Black | White

instance Eq Color where
  Black == Black  = True
  White == White  = True
  _     == _      = False

Экземпляр класса — это блок instance, содержащий определения функций этого класса. Здесь мы определяем, как == работает на Color.

6.1 Синтаксис классов и экземпляров

Класс типа определяется с использованием синтаксиса 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

6.2 Реализации по умолчанию

Вы заметили, что в предыдущем примере мы дали 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]

6.3 Полезные материалы

6.3.1 Вывод

Как мы уже видели много раз, 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

6.3.2 Запрос GHCi о классах

Вы можете использовать команду :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’

6.4 Иерархии

И классы, и экземпляры могут образовывать hierarchies. Это означает, что класс или экземпляр зависит от другого класса или экземпляра.

6.4.1 Иерархии экземпляров

Давайте попробуем определить экземпляр Eq для простого парного типа:

data Pair a = MakePair a a
  deriving Show

instance Eq (Pair a) where
  (MakePair x y) == (MakePair a b)   =   x==a && y==b
error:
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

6.4.2 Иерархия классов

Соответственно, класс может зависеть от другого класса. Это полезно, например, когда вы хотите использовать функции из другого класса в своих реализациях по умолчанию:

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.

6.5 Викторина

Какие функции есть в классе Eq?

  1. (==), (/=)
  2. (==)
  3. (==), (<), (>)

Для каких из следующих классов мы можем получить автоматические экземпляры с помощью deriving?

  1. Num
  2. Ord
  3. Size

Учитывая следующее определение класса 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?

  1. bitNand, bitAnd
  2. bitAnd, bitOr
  3. bitAnd, bitNot
  4. bitNot, bitOr

Объявление instance Num a => Eq (Pair a) сообщает мне, что

  1. Все экземпляры Num являются экземплярами Eq
  2. Pair a являются экземплярами Eq, если a является экземпляром Num
  3. Экземпляр Eq (Pair a) наследует экземпляр Num a

Объявление class Num a => Fractional a сообщает мне, что

  1. Все экземпляры Fractional должны быть экземплярами Num
  2. Все экземпляры Num должны быть экземплярами Fractional
  3. Если я определяю экземпляр для Fractional, я также получаю экземпляр для Num
  4. Если я определяю экземпляр для Num, я также получаю экземпляр для Fractional

6.6 Упражнения

  • Set6: определение классов и экземпляров

7 Лекция 7: Новые комбинации

Эта лекция представляет собой введение в шаблоны проектирования для типизированного функционального программирования. Эти шаблоны полезны при написании программ на Haskell и предлагают хорошую площадку для отработки навыков, полученных в предыдущих лекциях.

7.1 Моделирование с помощью блоков

Иногда вам не нужен новый тип, а вместо этого можно просто повторно использовать стандартный тип. Например, представление регистрационных номеров автомобилей с помощью 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.24
renderMoney (Money 100 +! Money 150)
  ==> "2.5"

scale (Money 299) 0.24
  ==> Money 72

addVat (Money 299)
  ==> Money 371

Примечание! Если вы знакомы с объектно-ориентированным программированием, это немного похоже на инкапсуляцию.

7.2 Моделирование с помощью случаев

Алгебраические типы данных 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 : xs
nonEmpty [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 xs
neHead (1:|[2,3])  ==>  1
neLast (1:|[2,3])  ==>  3

Кстати, эти функции доступны как Data.List.NonEmpty.head и Data.List.NonEmpty.last, а также множество других полезных функций.

Подводя итог, если вы пишете типы, представляющие все возможные варианты ваших значений, а затем пишете функции, обрабатывающие эти случаи, ваш код будет простым и правильным.

7.3 Моноиды

В функциональном программировании на удивление часто встречается шаблон monoid (не путать с monad!). Объяснения моноидов часто очень математические, но идея проста: объединение вещей.

7.3.1 Ассоциативные операции

Многие используемые нами функции и операторы являются 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

7.3.2 Полугруппы

С математической точки зрения ассоциативная функция (или оператор) образует 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}

7.3.3 Наконец, моноиды

Если мы снова послушаем математиков, 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 = []

Итак, что такое моноид для программиста? Тип образует моноид, если существует способ объединить два элемента типа вместе, чтобы круглые скобки не имели значения, а также существует «пустой элемент», который можно комбинировать с вещами, не изменяя их. Если так подумать, моноиды встречаются в программировании довольно часто!

7.3.4 Почему?

Для чего нужен этот класс 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, поэтому полезно знать, что они из себя представляют.

7.3.5 Как?

По различным историческим причинам и причинам производительности определения классов 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 omitted
class 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 = ...

7.4 Открытые и закрытые абстракции

Часто начинающие программисты на 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.

7.5 Моделирование с помощью языков

Иногда полезно реализовать мини-язык программирования для описания частей вашего программного обеспечения. Причудливый термин для них — Встроенный доменно-ориентированный язык (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 вместо написания кода.

Существует множество причин для представления логики в виде данных, а не кода. В отличие от кода, данные можно легко хранить в файле или базе данных или даже передавать по сети. Мы также можем использовать одни и те же данные для нескольких целей, например, мы можем визуализировать правила скидок в пользовательском интерфейсе администрирования.

7.6 Упражнения

8 Лекция 8: Послевкусие

8.1 Знакомство с вводом-выводом

Этот курс посвящен чисто функциональному программированию. Мы выполнили множество арифметических операций, перевернули списки, поработали с двоичными деревьями, но пока что нам не удалось повлиять на мир за пределами нашего GHCi.

Такие вещи, как чтение входных данных, запись в файл или общение по сети, являются побочными эффектами. Побочные эффекты невозможно представить с помощью чистого функционального кода. Функция вроде

readInputFromTheUser :: String -> String

не может быть чистой: иначе readInputFromUser "What is your name?" всегда должна была бы возвращать один и тот же результат. Тем не менее побочные эффекты и нечистые вычисления можно представить в чистом языке. Существует несколько подходов; Haskell использует монады (monads).

Монады, по общему мнению, сложны для понимания. Вероятно, это потому, что они настолько абстрактны. Я думаю, что лучше всего сначала сосредоточиться на практических и конкретных случаях. Вот пример монады IO, которую вы можете использовать для всевозможных побочных эффектов в Haskell.

Начнем!

Prelude> :t getLine
getLine :: IO String
Prelude> line <- getLine
another 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

8.1.1 А как насчет чистоты?

Такое ощущение, что с помощью этих действий ввода-вывода мы можем просто создавать побочные эффекты где захотим. Однако важно помнить разницу между определением действия ввода-вывода и выполнением его.

Давайте попробуем напечатать при отображении списка

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", так и не было запущено.

Если это кажется сложным, не волнуйтесь. Мы вернемся к этому во второй части курса.

8.1.2 А как насчет программ на Haskell?

Мы знаем, что 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

8.2 Резюме

До сих пор мы узнали о синтаксисе и типах Haskell, довольно немного функциональное программирование и некоторые особенности языка, такие как классы типов.

Мы также познакомились с типоориентированным программированием и даже попробовали ввод-вывод в Haskell.

Теперь вы знаете, как написать настоящую компьютерную программу на Haskell, но вам еще предстоит многому научиться.

8.3 Что дальше?

Часть 2 курса уже доступна! Во второй части будут рассмотрены такие темы, как монады, ввод-вывод и то, как Haskell работает под капотом. Нам также предстоит заняться реальным программированием с сетями и базами данных. Да, и тестирование на Haskell также рассматривается.

Если вам сейчас не хочется сразу переходить ко второй части, вот еще несколько ресурсов по Haskell, которыми вы можете воспользоваться прямо сейчас:

Я также рекомендую поработать над некоторыми проблемами программирования на Haskell, например, из:

  • Advent of Code – хорошие разнообразные головоломки, начните с легкого и усложнитесь
  • Sphere Online Judge – алгоритмические задачи, большое разнообразие трудностей
  • Project Euler – головоломки по математическому программированию

Вы также можете продолжать расширять свой окончательный проект и, возможно, создавать интересные произведения искусства на Haskell.

В любом случае – большое спасибо, что присоединились к нам, и надеемся, что вы хорошо проведете остаток года!

8.4 Итоговый проект: графика

Откройте файл упражнения Set8.hs и следуйте инструкциям. Веселиться!

8.5 Благодарности

Этот курс стал возможным благодаря Nitor, который пожертвовал часы и часы рабочего времени Джоэла для этого проекта. Спасибо! Ознакомьтесь с нашими открытыми вакансиями, если вы заинтересованы в работе там, где ценится непрерывное обучение.

Спасибо всей команде Haskell MOOC, особенно

  • Джону Лонгу за помощь с материалом.
  • Антти Лааксонену за организацию курса и помощь в его организации.