#intro

Вступление

Навигация
0

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

1

Целевая аудитория данной книги — это люди, заинтересованные в изучении программирования, но не имеющие никакого предыдущего опыта программирования.

Для имеющих опыт императивного программирования автор рекомендует к прочтению «Learn You a Haskell for Great Good».

2

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

Яндекс.Словари

#workenv

Рабочая среда

Навигация
0

Любая профессиональная деятельность требует некий инструментарий: дизайнерам нужен графический редактор, музыкантам — средства записи и обработки звука, а программистам — компилятор и текстовый редактор.

1

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

2

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

3

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

4

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

5

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

6

К сожалению, рассмотреть установку и использование всех возможных инструментов на всех возможных операционных системах в одной книге было бы невозможно. Кроме того, качество работы и удобство использования различных средств варьируется от одной операционной системы к другой. Исторически сложилось так, что средства для программирования более развиты на UNIX-подобных операционных системах, к которым относятся OS X, FreeBSD, дистрибутивы GNU/Linux.

7

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

#ubuntu

Ubuntu GNU/Linux

Навигация
0

Ubuntu — это современная операционная система, один из самых популярных дистрибутивов GNU/Linux. Далее в книге предполагается, что компьютер читателя работает под управлением Ubuntu 14.04 или новее. Пользователи других операционных систем могут установить Ubuntu в качестве второй операционной системы или на виртуальную машину.

#filesys

Файловая иерархия

Навигация
0

Практически во всех дистрибутивах GNU/Linux, в том числе в Ubuntu, принята стандартизированная иерархия файлов FHS.

1

Согласно FHS, все директории (папки) и файлы находятся в т.н. корневой директории, обозначаемой знаком /. В корневой директории находятся системные директории (такие как /bin, /dev, /usr, /etc, и т.д.), но нам интереснее директория /home. В /home содержатся домашние директории пользователей. Например, если аккаунт пользователя называется username, то его домашняя директория — это /home/username.

2

В Ubuntu для работы с файлами установлен графический файловый менеджер Nautilus. При его запуске вы увидите содержание вашей домашней директории. Нажав сочетание клавиш Ctrl+L, вы можете убедиться, что путь к домашней директории — это действительно /home/username (см. изображение).

3
Отображение домашней директории в Nautilus
4

В боковой панели Nautilus есть вкладка Computer, которая открывает корневую директорию /. Перейдя в нее, вы найдете директорию /home (среди других системных директорий), а внутри /home свою домашнюю директорию.

5

У каждого файла или директории есть родительская директория. Например, родительская директория /home/username — это /home, а родительская директория /home — это /.

0

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

1

Для работы с текстовыми программами в Ubuntu по умолчанию установлено специальное приложение — эмулятор терминала Terminal. Настоящие текстовые терминалы — это физические устройства, которые включают в себя клавиатуру для ввода текста и экран для вывода текста (см. изображение). Так как настоящие терминалы давно вышли из использования, то под терминалом обычно понимают эмулятор терминала. Пользователям Windows терминал может быть знаком как интерфейс командной строки (cmd.exe).

2
Терминал VT-100
3

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

#bash

Основы работы в терминале

Навигация
0

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

1
username@computer:~$
2

username — это имя вашего аккаунта, а computer — название компьютера, заданное при установке Ubuntu. После двоеточия выводится текущая директория, которая может повлиять на поведение выполняемых команд. Знак ~ (тильда) — это специальное название для домашней директории. Знак $ означает, что ваш аккаунт не обладает правами администратора.

3

Такая строка называется приглашающей строкой (prompt), так как ее смысл в том, чтобы показать, что Bash готов к выполнению команды.

4

Рассмотрим несколько команд:

5
username@computer:~$ pwd
/home/username
username@computer:~$ ls
Desktop  Documents  Downloads  Music  Pictures  Public  Templates  Videos
6

Команда pwd (print working directory) выводит текущую директорию полностью, без сокращений. Отсюда видно, что ~ и /home/username — это разные обозначения одной директории. Команда ls (list) выводит список файлов и директорий, содержащихся в текущей директории.

7

Командам можно передавать параметры (т.н. аргументы командной строки). Каждый параметр отделяется одним или несколькими пробелами:

8
username@computer:~$ ls -Q -1
"Desktop"
"Documents"
"Downloads"
"Music"
"Pictures"
"Public"
"Templates"
"Videos"
9

В этом примере мы передаем команде ls параметры -Q и -1. Параметр -Q означает, что все названия должны заключаться в кавычки, а -1 — что каждый файл или директория должны выводиться на отдельной строке.

10

Следующая важная команда — это cd (change directory). Она меняет текущую директорию на переданную ей в качестве параметра:

11
username@computer:~$ cd Documents
username@computer:~/Documents$ pwd
/home/username/Documents
12

Обратите внимание, как после выполнения cd изменилась директория, отображаемая в приглашающей строке, а также вывод команды pwd.

13

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

14
$ cd ~
$ pwd
/home/username
15

Команда echo выводит на экран переданные ей параметры:

16
$ echo Jingle       bells
Jingle bells
$ echo Jingle all   the way
Jingle all the way
17

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

18

Рассмотренные команды выводят всю информацию непосредственно в терминал, но Bash позволяет перенаправить вывод в файл:

19
$ echo Hello > filename
20

Первое, на что стоит обратить внимание — это использование знака >, означающего перенаправление вывода. Может показаться, что echo здесь принимает три параметра (Hello, >, filename), но это не так. На самом деле Bash обрабатывает знак > до того, как разделяет параметры, так что echo вызывается с единственным параметром Hello, а вывод этой команды перенаправляется в файл с названием filename.

21

Перенаправление > всегда создает новый файл. Если файл с указанным именем уже существует, то его содержимое будет потеряно.

22

Заметьте, что при выполнении команды echo в терминал выведено ничего не было, так как весь вывод оказался перенаправлен в файл filename. Убедимся, что файл был действительно создан, перечислив файлы в текущей директории, а затем выведем содержимое созданного файла командой cat (concatenate):

23
$ ls
filename
$ cat filename
Hello
24

Команде cat в параметрах можно передать более одного файла, в таком случае их содержимое будет последовательно объединено:

25
$ echo World > filename2
$ cat filename filename2
Hello
World
26

Перемещать и переименовывать файлы можно командой mv (move). Первым параметром она принимает исходное имя файла, а вторым — новое:

27
$ mv filename filename1
$ ls
filename1  filename2
28

Для удаления есть команда rm (remove):

29
$ rm filename2
$ ls
filename1
30

Для работы с директориями служат команды mkdir (make directory) и rmdir (remove directory). По названиям легко догадаться, что mkdir создает новую директорию, а rmdir — удаляет существующую. Проверим это на практике:

31
$ mkdir Office
$ cd Office
$ pwd
/home/username/Documents/Office
32

В директории Documents мы создали директорию Office и перешли в нее (сделали ее текущей директорией). Теперь вернемся назад в директорию Documents и удалим Office:

33
$ cd ~/Documents
$ ls
filename1  Office
$ rmdir Office
$ ls
filename1
34

Чтобы перейти назад в директорию Documents мы ввели ее полный путь ~/Documents, что, согласно определению ~, равносильно /home/username/Documents, а затем мы удалили директорию Office.

35

В приведенных выше примерах использовались как абсолютные, так и относительные пути к файлам и директориям. Абсолютные пути не зависят от текущей директории: /home/username/Documents всегда обозначает одну и ту же директорию.

36

Относительные пути меняют свой смысл в зависимости от текущей директории. Когда вы находитесь в директории /home/username/Documents, команда mkdir Office создает директорию /home/username/Documents/Office. Выполнение той же самой команды в директории /home/username/Pictures приведет к созданию /home/username/Pictures/Office, поэтому Office считается относительным путем, а /home/username/Documents/Office — абсолютным.

37

Две точки .. обозначают родительскую директорию. Пути /home/username и /home/username/Documents/.. эквивалентны. Этим можно воспользоваться для быстрой навигации по директориям:

38
$ pwd
/home/username/Pictures
$ cd ../Documents
$ pwd
/home/usrename/Documents
*

Вопросы и задания

  1. Используя только текстовые команды в терминале, создайте несколько файлов и директорий в домашней папке. С помощью графического файлового менеджера убедитесь, что они действительно созданы.
  2. Что будет выведено в терминале при выполнении следующей последовательности команд? echo Double Double Toil and Trouble > Shakespeare; echo Fire burn and caldron bubble > Shakespeare; cat Shakespeare
  3. Что будет выведено в терминале при выполнении следующей последовательности команд? echo Scale of dragon, tooth of wolf > Shakespeare; cat Shakespeare Shakespeare > William; cat William
#gedit

Текстовый редактор GEdit

Навигация
0

Для редактирования текстовых файлов в Ubuntu по умолчанию установлен текстовый редактор GEdit. Используя терминал, создадим текстовый файл:

1
$ cd ~/Documents
$ echo Today was a good day > Diary
2

Затем откроем папку Documents в графическом файловом менеджере и двойным нажатием откроем файл Diary. Перед нами должно открыться окно GEdit, а в нем содержимое файла (см. изображение).

3
Файл Diary, открытый в редакторе GEdit
4

Изменив содержимое файла и сохранив изменения, мы можем убедиться, что файл действительно изменен, выведя его командой cat в терминале. Сохранить файл можно сочетанием клавиш Ctrl+S.

5

Для редактирования нескольких файлов одновременно GEdit поддерживает вкладки. Сочетание клавиш Ctrl+N создает новую вкладку, а для навигации между вкладками можно использовать сочетания клавиш Alt+1, Alt+2, Alt+3, и т.д. Закрыть вкладку можно сочетанием клавиш Ctrl+W.

#stack

Установка Stack

Навигация
0

В отличие от текстового редактора и терминала, компилятор и система сборки для Haskell не установлены в Ubuntu по умолчанию. Для установки нам придется познакомиться с несколькими административными командами.

1

apt-get — это пакетный менеджер Ubuntu. Пакет — это просто набор системных файлов, которые можно установить, обновить или удалить. Практически все программы для Ubuntu распространяются в виде пакетов.

2

Для начала, попробуем установить удобную, но отсутствующую по умолчанию программу tree:

3
$ apt-get install tree
E: Could not open lock file /var/lib/dpkg/lock - open (13: Permission denied)
E: Unable to lock the administration directory (/var/lib/dpkg/), are you root?
4

Подкоманда install означает запрос на установку, после которого следует название программы. Однако установку выполнить не удалось: из сообщения об ошибке видно, что для выполнения операции нет прав.

5

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

6

Для выполнения административных операций есть команда sudo. В качестве параметров она принимает другую команду, а затем выполняет ее с правами администратора. Для подтверждения операции понадобится ввести пароль (при вводе не отображается):

7
$ sudo apt-get install tree
[sudo] password for username:
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following NEW packages will be installed:
  tree
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 0 B/40,6 kB of archives.
After this operation, 138 kB of additional disk space will be used.
Selecting previously unselected package tree.
(Reading database ... 395694 files and directories currently installed.)
Preparing to unpack .../tree_1.7.0-3_amd64.deb ...
Unpacking tree (1.7.0-3) ...
Processing triggers for man-db (2.7.0.2-5) ...
Setting up tree (1.7.0-3) ...
8

При запуске apt-get с правами администратора посредством sudo, мы можем успешно установить программу tree, о чем apt-get выводит подробные информационные сообщения.

9

Теперь при вводе команды tree в терминале вы сможете наблюдать содержимое текущей директории и ее поддиректорий в виде дерева.

10

Программа tree достаточно полезна, но нас интересует установка программы stack — с ее помощью мы будем компилировать код на Haskell.

11

К сожалению, установка stack несколько затруднена тем, что этой программы нет даже в стандартном репозитории (хранилище пакетов) Ubuntu. В связи с этим необходимо предварительно добавить отдельный репозиторий компании FPComplete, которая участвует в разработке stack.

12

Для этого необходимо выполнить две команды, описанные на странице об установке, а именно: добавление ключа и добавление репозитория. На странице представлено несколько вариантов второй команды, в зависимости от используемой версии Ubuntu.

13

После этого достаточно обновить базу данных пакетов и установить stack по аналогии с tree:

14
$ sudo apt-get update
$ sudo apt-get install stack
15

Убедитесь, что программа stack успешно установлена, вызвав ее с запросом о версии:

16
$ stack --version
Version 0.1.2.0
17

Далее осталось установить компилятор и базовые пакеты. Они устанавливаются в директорию ~/.stack, поэтому права администратора для этого не требуются:

18
$ stack setup
#hs-basics

Основы Haskell

Навигация
0

Теперь, когда рабочая среда для программирования на Haskell готова, можно, наконец-то, приступить к изучению этого языка. Подобно тому, как в Bash можно вводить команды, и они немедленно выполняются, с Haskell можно работать в таком же режиме — это называется REPL (read-eval-print loop).

#ghci

Работа с GHCi

Навигация
0

Из названия можно понять, что REPL — это цикл (loop) из трех действий:

  1. read — считывание команды пользователя
  2. eval — вычисление результата
  3. print — вывод результата на экран

1

Среди средств, которые мы установили через stack, есть компилятор GHC (Glasgow Haskell Compiler) и интерпретатор GHCi (GHC interactive). Компилятор применяется для сборки полноценных программ, а знакомиться с языком гораздо удобнее используя интерпретатор — через него можно работать в режиме REPL.

2
$ stack ghci
Using resolver: lts-3.0 from
  global config file: /home/username/.stack/global/stack.yaml
Configuring GHCi with the following packages:
GHCi, version 7.10.2: http://www.haskell.org/ghc/  :? for help
Prelude>
3

После вывода вспомогательной информации, мы видим приглашающую строку (prompt)Prelude>. В то время как в Bash в приглашающей строке выводится информация о пользователе, компьютере и текущей директории, в GHCi выводится список импортированных модулей (по умолчанию — только Prelude). О модулях будет рассказано позднее.

4

Для выхода из GHCi, очистите строку ввода и нажмите сочетание клавиш Ctrl-D. Вы вернетесь к Bash. Запустить GHCi всегда можно вновь командой stack ghci.

#exprs

Выражения, функции и числа

Навигация
0

Выражение — это все, что можно вычислить. Результат вычисления называют значением выражения. Самый простой пример выражения — это число:

1
Prelude> 42
42
2

Результат вычисления числа — это само число, без изменений, поэтому числа также являются и значениями. Другой пример выражения — это применение функции.

3

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

4Таблица значений функции succ (фрагмент)
АргументРезультат
......
-2-1
-10
01
12
23
34
......
5

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

6
Prelude> succ 68
69
7

Аналогично, функция pred (predecessor) дает число на единицу меньше:

8
Prelude> pred 0
-1
9

Если взять выражение в скобки, его смысл не изменится:

10
Prelude> (42)
42
Prelude> (succ 68)
69
11

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

12
Prelude> succ (succ (succ 0))
3
13

Рассмотрим, как получен результат 3, по шагам:

  1. succ (succ (succ 0))
  2. succ (succ 1)
  3. succ 2
  4. 3

14

Все перечисленные выражения эквивалентны, так как их вычисление приводит к получению одного и того же значения — 3.

15

Будьте осторожны с вводом отрицательных чисел — их всегда лучше заключать в скобки. Выражение succ -1 ошибочно, а succ (-1) — корректно. Причина этой особенности станет понятна, как только мы рассмотрим инфиксную нотацию.

16

Самостоятельно исследуйте, что делают следующие функции, применяя их к различным числам: negate, abs, id.

17

Любому выражению можно присвоить имя, чтобы использовать его позднее:

18
Prelude> x = abs (-10)
Prelude> succ x
11
Prelude> x
10
19

Знак = разделяет имя и выражение. После присваивания x = abs (-10), выражения x и abs (-10) означают одно и то же — они полностью взаимозаменяемы. Рассмотрим вычисление succ x по шагам:

  1. succ x
  2. succ (abs (-10))
  3. succ 10
  4. 11

20

Так как выражения становятся взаимозаменяемыми, ничто не мешает присвоить им больше имен (хотя в этом мало практического смысла):

21
Prelude> a = 299
Prelude> b = a
Prelude> s = succ
Prelude> s b
300
22

Из этого примера видно, что функция сама по себе — это тоже выражение. На самом деле succ — это только имя функции (так же как a и b здесь — имена для числа 299).

23

Что ж, если succ — это просто имя, то как же выглядит сама функция? Если мы введем a, то увидим значение выражения под этим именем:

24
Prelude> a
299
25

Попробуем ввести succ по аналогии:

26
Prelude> succ
    No instance for (Show (a0 -> a0))
      (maybe you haven't applied enough arguments to a function?)
      arising from a use of ‘print’
    In the first argument of ‘print’, namely ‘it’
    In a stmt of an interactive GHCi command: print it
27

GHCi выводит сообщение об ошибке. Это вызвано исключительно тем, что показать, как выглядит функция, возможно далеко не всегда. В выражении succ как таковом ошибки нет, но результат вычисления вывести на экран нельзя, о чем и сообщает GHCi в сообщении. Иными словами, в последовательности read-eval-print проблема возникает на этапе print.

28

Тем не менее, функция под именем succ ничем не хуже числа под именем a — это тоже значение, которое может оказаться результатом вычисления. То, что функции в Haskell являются значениями, сильно сказывается на стиле программирования и становится видно даже на простых примерах.

29

Например, рассмотрим функции max и min. Функция max вычисляет максимальное из двух чисел, а min — минимальное. Но как передать функции два числа?

30

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

31
Prelude> m5 = max 5
Prelude> m5 2
5
Prelude> m5 12
12
Prelude> m5 (-3)
5
Prelude> m5 (succ 7)
8
32

Передав функции max число 5, мы получили функцию, которую назвали m5. Эта новая функция принимает другое число, и если оно оказывается больше 5, то возвращает его, а если меньше — возвращает 5.

33

Однако давать имя этой функции вовсе не обязательно. Как мы выяснили выше, выражение и его имя всегда взаимозаменяемы:

34
Prelude> (max 10) 8
10
Prelude> (max 10) 16
16
35

Более того, так как функции приходится очень часто применять к нескольким значениям, скобки в этом случае можно опустить:

36
Prelude> max 2 3
3
37

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

38

Когда мы разобрались, как функциям передавать более одного значения, можно взглянуть на несколько функций, которые всем известны — это сложение и умножение. В Haskell они обозначаются знаками + и * соответственно.

39

Чтобы использовать знаковые имена так же, как буквенные, их нужно заключить в скобки:

40
Prelude> (+) 10 6
16
Prelude> (*) 6 7
42
41

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

42
Prelude> 10 + 6
16
43

Нужно понимать, что это всего лишь разница в способе записи — смысл выражений (+) 10 6 и 10 + 6 абсолютно идентичен. Когда имя функции указывается перед ее аргументами — это называется префиксной нотацией, а когда между аргументами — инфиксной. Функции с буквенными именами можно использовать в инфиксной нотации, заключив их имя между двумя обратными апострофами:

Обратный апостроф обычно находится в верхнем левом углу клавиатуры и отличается от прямого апострофа: '

44
Prelude> 2 `max` 3
3
45

При использовании нескольких функций в инфиксной нотации, на них распротраняются стандартные правила приоритета (умножение приоритетнее сложения). Поэтому выражение 2 + 3 * 2 означает 2 + (3 * 2), что, в свою очередь, эквивалентно (+) 2 ((*) 3 2).

46

Внутри GHCi существует специальное имя it, которым обозначается последнее вычисленное выражение:

47
Prelude> max (2 + 3 * 2) (2 * 2 + 3)
8
Prelude> it
8
Prelude> max it 16
16
Prelude> it
16
48

Из знакомых функций над числами также есть вычитание (знак -), деление (знак /) и возведение в степень (знак **). Теперь вы можете использовать GHCi как калькулятор (что, конечно, явно не раскрывает все его возможности).

*

Вопросы и задания

  1. Что такое REPL? Какая программа позволяет работать с Haskell в режиме REPL?
  2. Что такое функция? Приведите примеры функций, доступных в Haskell по умолчанию.
  3. Что такое каррирование? Для чего оно используется?
  4. Каков ваш возраст в секундах? Для рассчетов используйте GHCi как калькулятор.
  5. На каком расстоянии (в километрах) Земля находится от Солнца? Свет летит от Солнца до Земли 8 минут со скоростью 300000 км/с. Для рассчетов используйте GHCi как калькулятор.
#bools

Сравнения, логические значения

Навигация
0

Помимо арифметических операций, над числами можно выполнять сравнения:

1Операции сравнения
ОператорОтношение
==равно
/=не равно
>больше
<меньше
>=больше или равно
<=меньше или равно
2

Например:

3
Prelude> (2 * 3) == (7 - 2)
True
Prelude> 6 * 77 == 14 * 33
True
Prelude> 2 > 3
False
4

True (истина) и False (ложь) — это не просто ответы, это полноценные значения. Ранее мы работали с числовыми значениями и функциями, а True и False — это логические значения.

5

Более того, для работы с логическими значениями есть специальные функции: логическое И (&&), логическое ИЛИ (||) и отрицание (not).

6

Отрицание находит противоположное логическое значение:

7
Prelude> not True
False
Prelude> not False
True
Prelude> 2 == 2
True
Prelude> not (2 == 2)
False
8

Логическое ИЛИ определяет, истинно ли хотя бы одно значение из двух:

9
Prelude> 2 == 2 || 3 == 3
True
Prelude> 2 == 2 || 3 == 5
True
Prelude> 2 == 5 || 3 == 5
False
10

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

11Таблица значений логического ИЛИ
xyx || y
TrueTrueTrue
FalseTrueTrue
TrueFalseTrue
FalseFalseFalse
*

Вопросы и задания

  1. Разберитесь, почему (||) False и (&&) True — это эквивалентные функции.
#types

Строгая и слабая типизации

Навигация
0

Многие операции имеют смысл далеко не для всех значений. Например, логическое отрицание применимо только к логическим значениям, но не к числам или функциям. Каковы могут быть значения выражений not 5, not (-3) или not not? Как на счет True && 5 + 1 * False? Ответить на подобные вопросы не так легко, и есть два подхода к этой проблеме:

1

Слабая типизация. В языках программирования со слабой типизацией одни значения преобразуются в другие, пока операции не приобретут смысл. Например, на C++ выражение true && 5 + 1 * false оказывается равно 1, а на JavaScript — 5. Причины такого изысканного поведения читателю предлагается исследовать самостоятельно.

2

Строгая типизация. В языках со строгой типизацией бессмысленные выражения просто не вычисляются — вместо этого выводится сообщение об ошибке.

3

Haskell — язык со строгой типизацией, поэтому каждое значение (и каждое выражение) имеет некий тип. Рассмотрим несколько часто встречающихся типов:

4Примеры типов и их значений
ТипЗначенияВыражения
BoolTrue, FalseTrue || False, not True
Integer1, 42, (-83), 6662 * 5 - 17, (7 * 7) + 0
Double0, 3.1415, 2.71821 / 2, 1.3 * 3.1 - 0.03
Char'0', '!', 'Q', 'λ'chr 105, chr (30 * 2)

Для использования функции chr необходимо импортировать модуль Data.Char (введите import Data.Char в GHCi).

5

Bool — тип логических значений, True и False. Типу Integer принадлежат все целые числа, а Double — дробные числа с определенной погрешностью. Для символов применяется тип Char (символы записываются в одинарных кавычках). Стоит отметить, что символ '7' и число 7 — это разные вещи. Например, если мы попытаемся сложить символы, то получим сообщение об ошибке:

6
Prelude> '2' + '7'
<interactive>:3:5:
    No instance for (Num Char) arising from a use of ‘+’
    In the expression: '2' + '7'
    In an equation for ‘it’: it = '2' + '7'
7

Тип выражений в Haskell практически всегда определяется автоматически, но его можно указать явно с помощью двух двоеточий: 2 + 3 :: Integer, 7.1 * 5 :: Double, 'Σ' :: Char. В GHCi также можно узнать тип выражения командой :type (или, сокращенно, :t):

8
Prelude> :type 'H'
'H' :: Char
Prelude> :type True
True :: Bool
Prelude> :type not True
not True :: Bool
9

Функции тоже имеют тип, обозначаемый стрелкой. Например, not :: Bool -> Bool. Слева от стрелки тип принимаемого значения, а справа — тип возвращаемого. Так как функции, принимающие несколько значений, реализуются через возвращение промежуточных функций, то это отражено в типе: (&&) :: Bool -> (Bool -> Bool). Функция (&&) принимает Bool, а возвращает функцию Bool -> Bool. Напомню, что такая методика называется каррированием. Так как оно применяется в Haskell повсеместно, то скобки в таких типах можно опустить, поэтому обычно записывают просто (&&) :: Bool -> Bool -> Bool.

10

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

11
Prelude> :type id
id :: a -> a
12

Здесь a — это переменная типа. В отличие от обычных типов, переменные типа записываются с маленькой буквы. Переменные типа обозначают некий неизвестный тип, однако рано или поздно он окажется известным. Иными словами, при определении функции id неизвестно, к значению какого типа она будет применяться, поэтому тип аргумента обозначается переменной типа a (разумеется, можно было выбрать любое другое название: b, c, parameterType, и т.д. — это не принципиально). Однако во время применения мы узнаем, чему оказывается равна эта переменная. Например, в выражении id True мы знаем, что переменная типа a равна Bool (то есть id :: Bool -> Bool в данном случае), а в выражении id 'x'a равна Char (то есть id :: Char -> Char).

13

Также стоит отметить, что 17 :: Integer и 17 :: Double — это разные значения, так как они имеют разный тип. Однако 17 :: Double и 17.0 :: Double — это одно значение, но разные способы записи.

14

Если мы попытаемся узнать тип числового выражения, то мы получим нечто более интересное, чем Integer или Double:

15
Prelude> :type 10
10 :: Num a => a
16

Разберем подробнее, что означает тип Num a => a. a — это уже знакомая нам переменная типа. Стрелка => имеет иной смысл по сравнению со стрелкой функции ->, хотя как мы обсудим позже, у них есть и много общего. Слева от => описывается набор ограничений, а справа — непосредственно тип. Ограничение Num a означает, что к значениям типа a могут применяться арифметические операции, такие как сложение и умножение. Подобно тому, как id :: a -> a может принять более конкретные типы id :: Char -> Char или id :: Bool -> Bool, число 10 :: Num a => a может принять конкретные типы 10 :: Integer или 10 :: Double. Однако 10 не может принять конкретный тип Char, так как ограничение Num Char не выполняется — символы нельзя складывать или умножать.

17

Из вышеописанного можно догадаться, какой тип имеют арифметические операции:

18
Prelude> :type (+)
(+) :: Num a => a -> a -> a
Prelude> :type (*)
(*) :: Num a => a -> a -> a
*

Вопросы и задания

  1. Что такое тип? Чем строгая типизация отличается от слабой?
  2. Какой тип имеют функции сравнения ==, /=, >, <, >=, <=?
#lists

Списки и списковые операции

Навигация
0

Список — это тип данных, с помощью которого можно работать с последовательностями значений одного типа. Список значений некого типа a обозначается с помощью квадратных скобок как [a]. Например, [Integer] — это список целых чисел, а [Char] — список символов.

1

Значения списковых типов тоже записываются с помощью квадратных скобок, но при этом разделяются запятыми: [1, 2, 3] :: [Integer].

2

Списки символов [Char] также называют строками, так как с их помощью можно представлять строки текста: ['H', 'a', 's', 'k', 'e', 'l', 'l']. Для удобства записи строк их также можно записывать, поместив символы в двойные кавычки: "Haskell". Приведенные выражения эквивалентны и являются разными способами записи одного и того же списка.

3
Prelude> :type "To be or not to be?"
"To be or not to be?" :: [Char]
4

Функция map позволяет применить функцию ко всем элементам списка:

5
Prelude> map succ [1, 5, 7]
[2,6,8]
Prelude> map ((*) 2) [1, 5, 7]
[2,10,14]
Prelude> map even [1,3,5,8,10,11,12]
[False,False,False,True,True,False,True]

Функция even определяет, является ли число четным, а odd — нечетным.

6

Функция (++) последовательно соединяет два списка:

7
Prelude> [1,2] ++ [3,4] ++ [5,6]
[1,2,3,4,5,6]
8

Длину (количество элементов) списка можно определить функцией length:

9
Prelude> length []
0
Prelude> length [15]
1
Prelude> length [7, 3, 1, 21]
4
10

Развернуть порядок элементов в списке можно функцией reverse:

11
Prelude> reverse [1,2,4,8]
[8,4,2,1]
Prelude> reverse "Haskell."
".lleksaH"
12

Сумму элементов списка вычисляет функция sum, а произведение — product:

13
Prelude> sum [10, 15, 200]
225
Prelude> product [2,3,5]
30
14

Рассмотрим типы приведенных функций.

15
Prelude> :type reverse
reverse :: [a] -> [a]
16

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

17
Prelude> :type (++)
(++) :: [a] -> [a] -> [a]
18

Тип функции (++) похож на тип reverse, но на вход принимается два списка, а не один (как обычно, посредством каррирования).

19

Стоит обратить внимание на то, что квадратные скобки на уровне значений и на уровне типов имеют разную природу. На уровне значений квадратные скобки объединяют несколько значений в один список, в то время как на уровне типов они представляют из себя скорее функцию, которая преобразует тип значений в тип последовательностей значений. Это становится нагляднее, если записать [a] как [] a — обе записи имеют один смысл, но во втором варианте видно, что [] применяется подобно функции. Запись [] 10 на уровне значений смысла не имеет, так же как [Integer, Char, Bool] не имеет смысла на уровне типов.

20
Prelude> :type length
length :: Foldable t => t a -> Int
21

Тип length вызывает два вопроса: что за Foldable и почему возвращается Int, а не Integer?

22

Foldable — это ограничение, подобное Num. Этому ограничению удовлетворяют все типы, которые можно рассмотреть как набор значений, и, само собой, список этому ограничению удовлетворяет. Таким образом, переменную типа t можно инстанцировать как [] (список), и тогда тип length становится [a] -> Int.

Инстанцированием называется подстановка конкретного типа на место переменной типа.

23

Запись t a подобна применению функции, но происходит на уровне типов: например, при инстанцировании t как [] и a как Char мы получаем [] Char, что эквивалентно [Char].

24

Если все это выглядит запутывающим, то не стоит в данный момент сосредотачивать слишком много усилий: в прошлых версиях Haskell тип length был именно [a] -> Int, но потом многие функции на списках (включая length) были обобщены до Foldable.

25

Касательно того, почему вместо Integer возвращается Int ответ достаточно прост — производительность. Int — это тоже тип целых чисел, но его минимальные и максимальные значения ограничены (от -9223372036854775808 до 9223372036854775807), поэтому компьютер может выполнять операции над Int значительно быстрее, чем над Integer. Длина списка не может превышать лимит Int, так как иначе такой список не поместился бы в оперативной памяти компьютера, так что в данном случае это ограничение безопасно.

26
Prelude> :type sum
sum :: (Foldable t, Num a) => t a -> a
Prelude> :type product
product :: (Foldable t, Num a) => t a -> a
27

Функции sum и product имеют одинаковый тип: они преобразуют набор элементов (Foldable t) типа a в один элемент типа a. Такие функции называют функциями свёртки: они "сворачивают" набор значений в одно значение, используя заданные операции (sum использует (+), product(*)). Здесь мы также видим, что если ограничений несколько, то они заключаются в круглые скобки и разделяются запятыми.

28
Prelude> :type map
map :: (a -> b) -> [a] -> [b]
29

Функция map принимает некую функцию типа a -> b, список значений [a], и применяет функцию к каждому из значений, возвращая список значений [b].

30

Стоит обратить внимание, как в типе map расставлены скобки. Если бы мы записали просто a -> b -> [a] -> [b], то это означало бы a -> (b -> ([a] -> [b])), то есть функцию, которая поочередно принимает a, b, [a], и возвращает [b]. Однако в действительности a -> b заключено в скобки, поэтому тип означает (a -> b) -> ([a] -> [b]), то есть функцию, которая поочередно принимает a -> b, [a], и возвращает [b].

31

Когда мы расставляем скобки как map :: (a -> b) -> ([a] -> [b]), то нам становится видна альтернативная интерпретация map: это функция, которая преобразует функцию на значениях a -> b в функцию на списках значений [a] -> [b].

#numconv

Конверсии числовых типов

Навигация
0

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

1

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

2
Prelude> temperatures = [21.4, 22.3, 22.7, 18.1, 27.1, 26.5, 23.3]
3

Для нахождения среднего арифметического нужно сумму чисел разделить на количество чисел:

4
Prelude> sum temperatures / length temperatures
<interactive>:3:18:
    No instance for (Fractional Int) arising from a use of ‘/’
    In the expression: sum temperatures / length temperatures
    In an equation for ‘it’:
        it = sum temperatures / length temperatures
5

На казалось бы корректное выражение мы получаем сообщение об ошибке. Чтобы понять, что происходит, взглянем на типы (/), sum temperatures и length temperatures:

6
Prelude> :type (/)
(/) :: Fractional a => a -> a -> a
Prelude> :type sum temperatures
sum temperatures :: Fractional a => a
Prelude> :type length temperatures
length temperatures :: Int
7

Ограничение Fractional означает, что тип может представлять дроби (и, в том числе, поддерживает деление). Ожидаемо, что (/) принимает два значения некого типа, удовлетворяющего этому ограничению, и возвращает результат такого же типа.

8

Самый распространенный тип, удовлетворяющий Fractional — это Double. Другой — это Float, который подобен Double, но точность вычислений с ним значительно ниже, а скорость выше.

9

Тип sum temperatures — это просто некая переменная a, которую мы можем инстанцировать любым типом, удовлетворяющим ограничению Fractional. Например, аннотации sum temperatures :: Double или sum temperatures :: Float корректно устанавливают a = Double и a = Float соответственно.

10

Однако тип length temperatures — это Int. Мало того, что Int не совпадает ни с Double, ни с Float, так он и вовсе не удовлетворяет ограничению Fractional.

11

Итак, (/) :: Fractional a => a -> a -> a. Когда мы применяем деление к сумме температур, то мы получаем функцию типа Fractional a => a -> a:

12
Prelude Data.Ratio> :type (/) (sum temperatures)
(/) (sum temperatures) :: Fractional a => a -> a
13

Следующим параметром мы пытаемся передать length temperatures :: Int, инстанцируя таким образом a = Int. Так как на a наложено ограничение Fractional, то инстанцирование невозможно произвести и мы видим сообщение об ошибке, которое содержит именно эту информацию.

14

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

15

На самом деле, в Haskell нет числовых типов (т.е. типов, удовлетворяющих ограничению Num), которые не могли бы представить целые числа. Поэтому мы должны быть способны преобразовать значения типа Int в значения любого Num a => a.

16

Более того, мы можем применить такое же рассуждение к Integer. То есть мы должны иметь возможность преобразовать любые значения типа Integer в значения любого Num a => a.

17

Для того, чтобы обобщить целочисленные типы (Int и Integer) есть ограничение Integral. Таким образом, мы должны иметь возможность преобразовать значения любого типа, удовлетворяющего ограничению Integral, в значения любого типа, удовлетворяющего ограничению Fractional. Что ж, для этого есть функция:

18
Prelude> :type fromIntegral
fromIntegral :: (Integral a, Num b) => a -> b
19

После вышеприведенного рассуждения тип fromIntegral не должен вызывать затруднений. Мы можем преобразовать значения любого типа a в значения типа любого b, до тех пор пока a удовлетворяет ограничению Integral (то есть на практике a = Int или a = Integer), а b удовлетворяет ограничению Num (то есть b = Double, b = Float, b = Int и т.д.).

20

Итак, применив fromIntegral к length temperatures, мы получаем значение произвольного типа b, удовлетворяющего ограничению Num:

21
Prelude> :type fromIntegral (length temperatures)
fromIntegral (length temperatures) :: Num b => b
22

Теперь у нас есть функция (/) (sum temperatures) :: Fractional a => a -> a и значение fromIntegral (length temperatures) :: Num b => b. Если мы применяем функцию с типом Fractional a => a -> a к значению с типом Num b => b, то переменные типов a и b унифицируются (то есть принимаются равными), а их ограничения складываются, то есть мы получаем значение с типом c :: (Fractional c, Num c) => c. Для избежания путаницы приведено новое имя переменной типа c, и подразумевается, что c = a и c = b, но имена переменных, как обычно, не принципиальны. Более того, ограничение Fractional более строгое, чем Num, и по своему определению всегда подразумевает Num, поэтому тип оказывается несколько проще: Fractional c => c. Убедимся в этом на практике:

23
Prelude> :type (/) (sum temperatures) (fromIntegral (length temperatures))
(/) (sum temperatures) (fromIntegral (length temperatures))
  :: Fractional a => a
24

В данном случае GHCi выбрал имя a, а не c, как это сделали мы, но, как уже было сказано, имена не принципиальны и не меняют смысла. Тип именно тот, который мы ожидали.

25

Теперь мы можем, наконец-то, выяснить среднюю дневную температуру воздуха за последнюю неделю:

26
Prelude> sum temperatures / fromIntegral (length temperatures)
23.057142857142857
27

Внимательный читатель может задаться вопросом: в конечном итоге мы инстанцировали переменную типа как Double или Float? Какую точность имеет результат вычислений, высокую (если Double) или низкую (если Float)? Ответ в том, что по умолчанию для вычислений с дробными числами применяется Double, но мы можем явно указать и другой тип:

28
Prelude> sum temperatures / fromIntegral (length temperatures) :: Float
23.057142
29

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

30

Для перехода между типами дробных чисел (Double и Float) также есть функция realToFrac, а для перехода от дробных к целочисленным есть floor (округление в меньшую сторону), ceiling (округление в большую сторону) и round (округление в ближайшую сторону):

31
Prelude> round 0.8
1
Prelude> round 0.2
0
Prelude> ceiling 0.2
1
Prelude> floor 0.8
0