Мягкое введение в Haskell

Часть 1

Авторы: Пол Хьюдак
Джон Петерсон
Yale University
Джозеф Фасел
Los Alamos National Laboratory

Перевод: Денис Москвин
SoftJoys Computer Academy

Источник: A Gentle Introduction To Haskell
Материал предоставил: RSDN Magazine #4-2006
Опубликовано: 03.03.2007
Версия текста: 1.0
1 Введение
2 Значения, типы и прочие вкусности
2.1 Полиморфные типы
2.2 Определяемые пользователем типы
2.3 Синонимы типов
2.4 Встроенные типы не отличаются от прочих
3 Функции
3.1 Лямбда-абстракции
3.2 Инфиксные операторы
3.3 Функции являются не строгими
3.4 «Бесконечные» структуры данных
3.5 Функция error
4 Case-выражения и сопоставление с образцом
4.1 Семантика сопоставления с образцом
4.2 Пример
4.3 Case-выражения
4.4 Ленивые образцы
4.5 Лексическая область видимости и вложенные формы
4.6 Отбивка текста
5 Классы типов и перегрузка
6 Снова о типах
6.1 Объявление newtype
6.2 Метки полей
6.3 Строгие конструкторы данных
7 Ввод-вывод
7.1 Основные операции ввода-вывода
7.2 Программирование с действиями
7.3 Обработка исключений
7.4 Файлы, каналы и дескрипторы
7.5 Haskell и императивное программирование

1 Введение

При написании этого руководства нашей целью не являлось ни обучение программированию, ни даже обучение функциональному программированию. Оно скорее служит дополнением к Описанию Языка Haskell (Haskell Report [4]), которое само по себе является довольно сжатым техническим описанием. Наша задача – обеспечить «мягкое» введение в Haskell для имеющих опыт программирования, по крайней мере, на одном языке, желательно функциональном (даже если это «почти функциональный» язык, такой как ML или Scheme). Если читатель желает узнать больше о функциональном стиле программирования, мы настоятельно рекомендуем [1] или [2]. Полезный обзор языков и техник функционального программирования, включающий некоторые принципы дизайна языка, использованные в Haskell, можно найти в [3].

С момента своего появления на свет в 1987 году язык Haskell существенно изменился. В этом руководстве используется Haskell 98. Более ранние версии языка теперь считаются устаревшими; рекомендуется использовать Haskell 98. Существует также множество расширений Haskell 98, которые присутствуют в разнообразных реализациях языка. Они к настоящему моменту не являются формальной частью Haskell и не описываются в данном руководстве.

Наша общая стратегия представления возможностей языка такова: мотивация идеи, определение терминологии, некоторое количество примеров и ссылки на Описание (Haskell Report) для ознакомления с подробностями. Мы, однако, предполагаем, что читатель полностью проигнорирует эти подробности, до тех пор, пока полностью не прочитает Введение в Haskell. С другой стороны, библиотека Standard Prelude языка Haskell (см. Приложение A, «Описания») и другие стандартные библиотеки (см. [5]) содержат много полезных примеров кода на Haskell; желательно прочесть их после завершения изучения этого руководства. Это не только даст читателю возможность почувствовать, как выглядит прикладной код на Haskell, но также познакомит его со стандартным набором предопределённых функций и типов.

И, наконец, сайт языка, http://haskell.org, изобилует информацией о языке Haskell и его реализациях.

ПРИМЕЧАНИЕ

Мы также решили не начинать изложение с полного свода лексических правил. Вместо этого мы вводим их последовательно, в соответствии с требованиями примеров, и заключаем в скобки, как этот абзац. Это полностью противоположно организации Описания, однако Описание остаётся авторитетным источником информации (ссылки вида «§2.1» ссылаются на разделы Описания).

Haskell представляет собой сильно типизированный (typeful, термин Luca Cardelli) язык программирования: типизация повсеместна, и новичка с самого начала следует предупредить о мощности и сложности системы типов языка. Для тех, чей опыт ограничивается относительно «слабо типизированными» (в оригинале "untypeful" – прим. ред.) языками вроде Perl, Tcl или Scheme, привыкание может оказаться довольно сложным. Для знакомых с Java, C, Modula или даже с ML «притирка» может оказаться более лёгкой, но вряд ли незаметной, поскольку система типов Haskell отлична от систем типов большинства языков и в чём-то богаче. В любом случае «сильно-типизированное программирование» – часть опыта программирования на Haskell, и избежать этого невозможно.

2 Значения, типы и прочие вкусности

Поскольку Haskell представляет собой чисто функциональный язык, все вычисления осуществляются с помощью исчисления выражений (синтаксических термов), производящих значения (абстрактные сущности, которые мы рассматриваем как ответы). Каждое значение имеет связанный с ним тип (на интуитивном уровне мы можем рассматривать типы как множества значений). Примеры выражений включают как атомарные значения (атомы), такие как целое число 5, символ 'a' и функция \x -> x+1, так и структурированные значения, такие как список [1,2,3] или пара ('b',4).

Так же, как выражения обозначают значения, выражения типа являются синтаксическими термами, которые обозначают значения типа (или просто типы). Примерами выражений типа могут служить атомарные типы Integer (целые неограниченного диапазона), Char (символы), Integer->Integer (функции, отображающие Integer в Integer), а также структурированные типы [Integer] (однородный список целых) и (Char,Integer) (пара из символа и целого).

Все значения в Haskell представляют собой сущности «первого класса»: они могут передаваться в функции как аргументы, возвращаться из них как результаты, размещаться в структурах данных и т.п. С другой стороны, типы в Haskell – не первоклассные сущности. Типы, по сути, описывают значения; связывание некоторого значения с его типом называется типизацией. Для приведённых выше примеров значений и типов описания типизации выглядит так:

           5 :: Integer
         'a' :: Char
         inc :: Integer -> Integer
     [1,2,3] :: [Integer]
     ('b',4) :: (Char,Integer)

Оператор «::» можно читать как «имеет тип».

Функции в Haskell обычно определяются как набор уравнений. Например, функция inc может быть определена одним уравнением:

inc n = n + 1

Уравнение является примером объявления (declaration). Другим видом объявления является объявление сигнатуры типа (§4.4.1), с помощью которого мы можем объявить явную типизацию для inc:

inc :: Integer -> Integer

Более подробное обсуждение определения функций будет приведено в Разделе 3.

В педагогических целях, когда мы хотим указать, что выражение e1 сводится или «редуцируется» к другому выражению или значению e2, мы будем писать:

e1 => e2

Отметим, например, что:

inc (inc 3) => 5

Статическая система типов языка Haskell определяет формальную связь между типами и значениями (§4.1.4). Статическая система типов гарантирует, что программы на Haskell типобезопасны, то есть что программист не допустил какой-либо путаницы в типах. Например, мы, вообще говоря, не можем сложить два символа, поэтому выражение 'a'+'b' является неверно типизированным. Основное достоинство статически типизированных языков общеизвестно: все ошибки типизации выявляются во время компиляции. Система типов перехватывает не все ошибки; такое выражение, как 1/0, является верным по типу, но его вычисление приведёт к ошибке во время исполнения. Тем не менее, система типов обнаруживает многие ошибки в программах во время компиляции, помогая пользователю в его рассуждениях о программах, а также позволяя компилятору генерировать более эффективный код (к примеру, не требуется никаких меток типов или проверок времени исполнения).

Система типов также гарантирует, что пользовательские сигнатуры типов верны. Фактически, система типов Haskell достаточно мощна, чтобы позволить вообще не писать никаких сигнатур типов (за некоторыми исключениями, которые будут описаны позже); мы говорим, что система типов выводит (infer) правильные типы за нас. Несмотря на это, указание сигнатуры типа (как это было сделано нами для inc) часто является хорошей идеей, поскольку сигнатуры типов представляют собой весьма эффективную форму документирования и помогают выявлять ошибки.

Обратим внимание читателя на то, что идентификаторы, обозначающие конкретные типы, такие как Integer и Char, начинаются с заглавной буквы, а идентификаторы, обозначающие значения, такие как inc, – со строчной. Это не просто неформальное соглашение, но и требование синтаксиса Haskell. Регистр других символов тоже имеет значение: все три идентификатора foo, fOo и fOO различны.

2.1 Полиморфные типы

В состав Haskell включены также полиморфные типы – типы, которые некоторым образом содержат квантор общности над всеми типами. Выражения с полиморфными типами по существу описывают семейства типов. Например, (forall a)[a] – это семейство типов, состоящее из типов списка с элементами из a, для любого типа a. Список целых (например, [1,2,3]), список символов (['a','b','c']), даже список списков целых и т.д. – все являются членами этого семейства (отметим, однако, что [2,'b'] не является правильным примером, поскольку не существует одного типа, который включал бы и 2 и 'b').

Идентификаторы, подобные упомянутому выше a, называют переменными типа и записывают в нижнем регистре, чтобы отличать их от конкретных типов, таких как Int. Более того, поскольку в Haskell имеются лишь типы, стоящие под квантором общности, нет необходимости явно записывать символ этого квантора, и мы, таким образом, просто пишем [a] в приведённом выше примере. Другими словами – все переменные типа неявно трактуются как заданные обобщённым образом.

Списки – широко используемая структура данных в функциональных языках. Они представляют собой хорошее средство для объяснения принципов полиморфизма. Список [1,2,3] в Haskell на самом деле является сокращённой формой записи для списка 1:(2:(3:[])), где [] – это пустой список, а « – инфиксный оператор, добавляющий свой первый аргумент в начало своего второго аргумента (некоторого списка). (Операторы « и «[]» соответствуют cons и nil в языке Lisp.) Поскольку оператор « правоассоциативен, мы можем также записать этот список как 1:2:3:[].

В качестве примера пользовательской функции, работающей со списками, рассмотрим задачу подсчёта количества элементов в списке:

length                  :: [a] -> Integer
length []               =  0
length (x:xs)           =  1 + length xs

Это определение практически объясняет само себя. Мы можем прочитать эти уравнения так: «Длина пустого списка равна 0, а длина списка, чей первый элемент – это x, а остаток – xs, равна 1 плюс длина xs.» (Обратите внимание на использованное здесь соглашение об именовании: xs – это множественное число для x и должно читаться соответственно.)

Несмотря на интуитивную ясность, этот пример подчёркивает важный аспект Haskell, который ещё предстоит объяснить, а именно сопоставление с образцом (pattern matching). Левые части уравнений содержат образцы, такие как [] и x:xs. При применении функции эти образцы сопоставляются с фактическими параметрами интуитивно ясным образом ([] соответствует только пустому списку, а x:xs будет успешно сопоставлен с любым списком, содержащим хотя бы один элемент, при этом x связывается с первым элементом, а xs – с оставшейся частью списка). Если сопоставление прошло успешно, правая часть равенства вычисляется и возвращается как результат применения функции. Если сопоставление неудачно, пробуется следующее уравнение, а если все уравнения ведут к неудачному сопоставлению, то результатом будет ошибка.

Определение функций через сопоставление с образцом – достаточно обычная практика для Haskell. Пользователям полезно познакомиться с различными видами допустимых образцов; мы вернёмся к этому вопросу в Разделе 4.

Функция length также представляет собой пример полиморфной функции. Она может быть применена к списку, содержащему элементы произвольного типа, например [Integer], [Char] или [[Integer]].

length [1,2,3]       => 3
length ['a','b','c'] => 3
length [[1],[2],[3]] => 3

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

head        :: [a] -> a
head (x:xs) =  x

tail        :: [a] -> [a]
tail (x:xs) =  xs

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

Изучая полиморфные типы, мы обнаруживаем, что некоторые типы, строго говоря, являются более общими, чем другие, в том смысле, что множество задаваемых ими значений шире. Например, тип [a] является более общим, чем [Char]. Другими словами, последний тип может быть выведен из первого с помощью подходящей подстановки для a. В отношении этого порядка обобщений, система типов Haskell обладает двумя важными свойствами: во-первых, для любого правильно типизированного выражения гарантируется существование единственного основного типа (principal type, пояснения далее), и, во-вторых, основной тип может быть выведен автоматически (§4.1.4). При сравнении с мономорфно типизированными языками, такими как C, читатель обнаружит, что полиморфизм повышает выразительность, а вывод типов снимает ношу типизации с программиста.

Основной тип выражения или функции – это наиболее общий тип, который, говоря на интуитивном уровне, «содержит все экземпляры выражения». Например, основной тип функции head – это [a]->a; типы [b]->a, b->a (в исходном тексте опечатка, a->a – прим.пер.) или даже a являются верными, но избыточно общими, в то время как типы вроде [Integer]->Integer являются слишком частными. Существование и единственность основного типа являются отличительным признаком системы типов Хиндли-Милнера, которая лежит в основе систем типов Haskell, ML, Miranda («Miranda» является торговой маркой Research Software, Ltd.) и некоторых других (по большей части функциональных) языков.

2.2 Определяемые пользователем типы

Мы можем определять свои собственные типы в Haskell с помощью объявления data, которое мы представим с помощью серии примеров (§4.2.1).

Важным встроенным типом в Haskell является тип значений истинности:

data Bool = False | True

Тип, определяемый здесь, – это Bool, он имеет ровно два значения: True и False. Тип Bool представляет собой пример конструктора типа (нуль-арного), а True и False являются конструкторами данных или, для краткости, просто конструкторами (тоже нуль-арными).

Аналогично можно определить цветовой тип:

data Color = Red | Green | Blue | Indigo | Violet

И Bool, и Color являются примерами перечислимых типов, поскольку они состоят из конечного числа нуль-арных конструкторов данных.

Следующий пример вводит тип, у которого всего один конструктор данных:

data Point a = Pt a a

По причине единственности конструктора такие типы, как Point, часто называют типами кортежей (tuple), поскольку они, по существу, представляют просто декартово произведение (в данном случае бинарное) других типов (кортежи представляют собой нечто подобное записям в других языках). В противоположность этому типы со многими конструкторами, такие как Bool и Color, называют типами (разъединённых, disjoint) объединений или сумм.

Более важно, однако, что Point – это пример полиморфного типа: для любого типа t он определяет тип декартовой точки, которая использует t в качестве типа координат. Из этих соображений ясно, что тип Point является унарным конструктором типа, поскольку для данного типа t он конструирует новый тип Point t. (Вспоминая пример со списками, приводившийся выше, можно сказать, что [] в этом смысле тоже является конструктором типа. Для любого данного типа t мы можем «применить» [], чтобы произвести новый тип [t]. Синтаксис языка Haskell позволяет записать [] t как [t]. Аналогично, -> тоже является конструктором типа: для любых двух типов t и u, t->u есть тип функции, отображающей элементы типа t в элементы типа u.)

Отметим, что тип бинарного конструктора данных Pt это a -> a -> Point a; таким образом, верны следующие описания:

Pt 2.0 3.0           :: Point Float
Pt 'a' 'b'           :: Point Char
Pt True False        :: Point Bool

С другой стороны такое выражение как Pt 'a' 1 имеет неправильный тип, поскольку типы 'a' и 1 различны.

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

Конструкторы типов, такие как Point, и конструкторы данных, такие как Pt, находятся в разных пространствах имён. Это позволяет использовать одно и то же имя и для конструктора типов, и для конструктора данных, как в следующем примере:

data Point a = Point a a
ПРИМЕЧАНИЕ

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

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

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

data Tree a        = Leaf a | Branch (Tree a) (Tree a)

Здесь мы определяем полиморфный тип бинарного дерева, элементами которого являются либо узлы-листья, содержащие значения типа a, либо внутренние узлы («ветви»), содержащие (рекурсивно) два поддерева.

При чтении объявлений data, подобных этому, помните, что Tree – это конструктор типа, в то время как Branch и Leaf – это конструкторы данных. Помимо установления связи между этими конструкторами, данное объявление по существу является определением типов для Branch и Leaf:

Branch                     :: Tree a -> Tree a -> Tree a
Leaf                       :: a -> Tree a

В этом примере мы определили тип, достаточно богатый, чтобы позволить определить некоторые интересные (рекурсивные) функции, которые его используют. Предположим, например, что мы хотим определить функцию fringe, которая возвращает список всех элементов в листьях дерева слева направо. Обычно бывает полезно сначала записать тип новой функции: в данном случае мы видим, что её тип должен быть Tree a -> [a]. Таким образом, fringe – полиморфная функция, отображающая дерево элементов типа a в список элементов типа a. Вот подходящее определение:

fringe                     :: Tree a -> [a]
fringe (Leaf x)            = [x]
fringe (Branch left right) = fringe left ++ fringe right

Здесь ++ представляет собой инфиксный оператор, конкатенирующий два списка (его полное определение будет дано в Разделе 9.1). Как и в приведённом ранее примере с length, функция fringe определена с использованием сопоставления с образцом, за тем исключением, что здесь мы сталкиваемся с образцами, включающими определённые пользователем конструкторы: Leaf и Branch.

ПРИМЕЧАНИЕ

Отметим, что формальные параметры легко обнаружить – они начинаются с символа в нижнем регистре.

2.3 Синонимы типов

Из соображений удобства в Haskell обеспечивается возможность определять синонимы типов (type synonyms), то есть имена для часто используемых типов. Синонимы типов создаются с помощью объявления type (§4.2.2). Вот некоторые примеры:

type String  = [Char]
type Person  = (Name,Address)
type Name    = String
data Address = None | Addr String

Синонимы типов не определяют новых типов, а просто задают новые имена для существующих. Например, тип Person -> Name в точности эквивалентен типу (String,Address) -> String. Новые имена часто короче, чем типы, синонимами которых они являются, но это – не единственное назначение синонимов типов: они также повышают читабельность программы, будучи более мнемоническими, приведённые выше примеры подчёркивают это. Мы можем даже давать новые имена полиморфным типам:

type AssocList a b         = [(a,b)]

Это тип «ассоциативного списка», который ассоциирует значения типа a со значениями типа b.

2.4 Встроенные типы не отличаются от прочих

Ранее мы ввели некоторые «встроенные» типы, такие как списки, кортежи, целые и символы. Мы также показали, как могут быть определены новые, пользовательские типы. Отличаются ли встроенные типы от пользовательских чем-либо, кроме специального синтаксиса? Ответ: нет. Специальный синтаксис вводится для удобства и для соответствия с исторически сложившимися соглашениями, но не имеет семантических последствий.

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

data Char       = 'a' | 'b' | 'c' | ... –- Не является верным
                | 'A' | 'B' | 'C' | ... –- кодом на языке Haskell!
                | '1' | '2' | '3' | ...
                ...

Эти имена конструкторов не верны синтаксически. Чтобы исправить их, мы могли бы написать что-то вроде:

data Char       = Ca | Cb | Cc | ...
                | CA | CB | CC | ...
                | C1 | C2 | C3 | ...
                ...

Хотя такие конструкторы и являются более краткими, они совершенно непривычны для представления символов.

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

Этот пример также демонстрирует использование комментариев в Haskell; символы -- (два последовательно идущих «-») и все последующие символы до конца строки игнорируются. Haskell также допускает вложенные комментарии, которые имеют вид {-...-} и допустимы в любом месте (§2.2).

Таким же образом мы могли бы определить Int (целые фиксированного диапазона) и Integer:

data Int     = -65532 | ... | -1 | 0 | 1 | ... | 65532 –- ещё псевдо-код
data Integer = ... -2 | -1 | 0 | 1 | 2 ...

где -65532 и 65532 – это максимальное и минимальное целое фиксированного диапазона в данной реализации. Тип Int представляет собой существенно более широкое перечисление, чем Char, однако по-прежнему конечное! В противоположность этому псевдо-код для типа Integer предназначен для описания бесконечного перечисления.

Кортежи так же легко определить, продолжая эту игру:

data (a,b)             = (a,b) -- ещё псевдо-код
data (a,b,c)           = (a,b,c)
data (a,b,c,d)         = (a,b,c,d)
 .                        .
 .                        .
 .                        .

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

Со списками также легко управиться, и, что более интересно, они рекурсивны:

data [a]               = [] | a : [a] -- ещё псевдо-код

Теперь становятся яснее предыдущие объяснения относительно списков: [] – пустой список, а : – инфиксный конструктор списка; таким образом, [1,2,3] должно быть эквивалентно списку 1:2:3:[] (оператор « правоассоциативен). Типом конструктора [] является [a], а типом конструктора « является a->[a]->[a].

Использование «:», здесь синтаксически верно – инфиксные конструкторы допустимы в объявлениях data и отличаются от случаев, когда двоеточие используется в выражениях сопоставления с образцом, благодаря тому требованию, что инфиксные конструкторы должны начинаться с символа «:» (само «:» удовлетворяет этому требованию тривиальным образом).

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

Для (e1, e2, ..., en); n ( 2 если ti – тип ei, тогда тип кортежа – это (t1, t2, ..., tn).

Для [e1, e2, ..., en]; n ( 0 каждый ei должен иметь один и тот же тип t, и тип списка – это [t].

2.4.1 List comprehension и арифметические последовательности

Как и в диалектах языка Lisp, в Haskell списки применяются повсеместно, и, как и в других функциональных языках, синтаксический сахар для их создания имеется в изобилии. Помимо только что обсуждённых конструкторов списков, Haskell обеспечивает выражение, известное как list comprehension, которое лучше объяснить на примере:

[ f x | x <- xs ]
ПРИМЕЧАНИЕ

Лучшие умы форума «Проблемы перевода» долго бились над выражением list comprehension, но так и не пришли к общему мнению. Поэтому мы решили оставить его без перевода. – прим. ред.

Интуитивно это выражение можно прочитать так: «список всех f x, таких, что x извлечён из xs». Близость к нотации множеств не является совпадением. Фраза x <- xs называется генератором; допускается наличие нескольких генератора, как в:

[ (x,y) | x <- xs, y <- ys ]

Это list comprehension формирует декартово произведение двух списков xs и ys. Элементы выбираются, как если бы генераторы были «вложены» слева направо (более правый генератор изменяется чаще); таким образом, если xs это [1,2] и ys это [3,4], то результатом будет [(1,3),(1,4),(2,3),(2,4)].

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

quicksort []           =  []
quicksort (x:xs)       =  quicksort [y | y <- xs, y<x ]
                       ++ [x]
                       ++ quicksort [y | y <- xs, y>=x]

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

   [1..10]     =>    [1,2,3,4,5,6,7,8,9,10]
   [1,3..10]   =>    [1,3,5,7,9]
   [1,3..]     =>    [1,3,5,7,9, ... (бесконечная последовательность)

Арифметические последовательности подробнее рассматриваются Разделе 8.2, а «бесконечные списки» – в Разделе 3.4.

2.4.2 Строки

В качестве другого примера синтаксического сахара для встроенных типов отметим, что строка символов "hello" в действительности является сокращённой формой записи для списка символов ['h','e','l','l','o']. На самом деле типом "hello" является String, а String – это предварительно определённый синоним типа (который мы уже приводили в качестве примера):

type String = [Char]

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

"hello" ++ " world" => "hello world"

3 Функции

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

Для начала рассмотрим определение функции, которая складывает два своих аргумента:

add                :: Integer -> Integer -> Integer
add x y            =  x + y

Это пример каррированной (curried) функции. (Термин каррированный происходит от фамилии человека, который популяризировал эту идею, а именно Хаскелла Карри. Чтобы достичь эффекта некаррированной (uncurried) функции, мы должны использовать кортеж, как здесь:

add (x,y) = x + y

Однако в этом случае мы обнаруживаем, что эта версия add в действительности представляет собой просто функцию с одним аргументом!) Применение первого варианта функции add имеет вид add e1 e2 и эквивалентно (add e1) e2, поскольку применение функции левоассоциативно. Другими словами, применение add к первому аргументу порождает новую функцию, которая затем применяется ко второму аргументу. Это соответствует типу функции add, Integer->Integer->Integer, который эквивалентен Integer->(Integer->Integer); то есть -> правоассоциативна. На самом деле, используя add, мы можем определить inc способом, отличным от того, которым мы делали это ранее:

inc = add 1

Этот пример частичного применения (partial application) каррированной функции представляет один из способов вернуть функцию как значение. Рассмотрим случай, в котором окажется полезным передать функцию как аргумент. Широко известная (в узких кругах любителей функционального программирования – прим. ред.) функция map служит прекрасным примером:

map                :: (a->b) -> [a] -> [b]
map f []           =  []
map f (x:xs)       =  f x : map f xs

Применение функции имеет более высокий приоритет, чем любой инфиксный оператор, поэтому правая часть второго уравнения синтаксически разбирается как (f x) : (map f xs).

Функция map является полиморфной, и её тип ясно указывает на то, что её первый аргумент – это функция; отметим также, что оба вхождения a должны при вызове быть замещены одним и тем же типом (то же требуется и для b). В качестве примера использования map можно привести увеличение элементов списка:

        map (add 1) [1,2,3]      =>      [2,3,4]

Эти примеры иллюстрируют первоклассную природу функций.

3.1 Лямбда-абстракции

Вместо использования уравнений для определения функции мы также можем определять их «анонимно», через лямбда-абстракцию. Например, функция, эквивалентная inc, может быть записана как \x -> x+1. Аналогично, функция add эквивалентна \x -> \y -> x+y. Вложенная лямбда-абстракция, подобная этой, может быть записана с использованием эквивалентной сокращённой формы: \x y -> x+y. Фактически уравнения:

inc x   =  x+1
add x y =  x+y

на самом деле являются сокращениями для:

inc                  =  \x -> x+1
add                  =  \x y -> x+y

Позже мы подробнее обсудим эту эквивалентность.

В общем случае, если x имеет тип t1, а exp имеет тип t2, то \x->exp имеет тип t1->t2.

3.2 Инфиксные операторы

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

(++)                 :: [a] -> [a] -> [a]
[]     ++ ys         =  ys
(x:xs) ++ ys         =  x : (xs++ys)

Лексически инфиксные операторы должны целиком состоять из знаков (то есть не букв, цифр и пробелов – прим.ред.), в противоположность обычным идентификаторам, которые являются буквенно-цифровыми (§2.4). Haskell не имеет префиксных операторов, за исключением минуса (-), который является и инфиксным, и префиксным.

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

(.)                  :: (b->c) -> (a->b) -> (a->c)
f . g                =  \ x -> f (g x)

3.2.1 Сечения

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

(x+) = \y -> x+y
(+y) = \x -> x+y
 (+) = \x y -> x+y
ПРИМЕЧАНИЕ

Скобки обязательны.

Последняя форма сечения из приведённых выше по существу превращает инфиксный оператор в эквивалентное функциональное значение и является удобной при передаче инфиксного оператора в качестве аргумента функции, как, например, в map (+) [1,2,3] (обратите внимание, что возвращается список функций!). Эта форма также необходима при задании сигнатуры типа функции, как в приведённых ранее примерах (++) и (.).

Теперь можно заметить, что определённая ранее add – это просто (+), а inc – это просто (+1)! И действительно, такие определения прекрасно могли бы выглядеть так:

inc               = (+ 1)
add               = (+)

Мы можем заставить инфиксный оператор выступать как функциональное значение, а можно ли решить обратную задачу? Да, мы просто должны заключить идентификатор, связанный с функциональной величиной, в обратные одинарные кавычки. Например, x `add` y – это то же самое что и add x y. (Обратите внимание, что add заключается в обратные кавычки, а не в апострофы, которые используются в синтаксисе символов; т.е. 'f' – это символ, тогда как `f` – это инфиксный оператор.) Некоторые функции лучше читаются при подобной записи. Примером может служить предопределённый предикат членства в списке elem; выражение x `elem` xs может быть интуитивно прочтено, как «x является элементом xs».

Имеется несколько специальных правил, касающихся сечений, включающих префиксный/инфиксный оператор «; см. (§3.5, §3.4).

К этому моменту читатель может сбит с толку множеством способов определения функции! Решение реализовать все эти механизмы частично отражает исторические соглашения, а частично – требование согласованности (например, в трактовке инфиксные vs. обычные функции).

3.2.2 Объявление приоритетов операторов (Fixity Declarations)

Приоритеты операторов могут быть заданы для любого инфиксного оператора или конструктора (включая те, которые производятся из обычных идентификаторов, вроде `elem`). Эти объявления указывают уровень приоритета от 0 до 9 (приоритет 9 – самый высокий; обычное применение функции трактуется как имеющее уровень приоритета, равный 10) и лево- или правоассоциативность, или отсутствие таковой. Например, объявления приоритетов операторов для «++» и «.» таковы:

infixr 5 ++
infixr 9 .

Оба они объявлены как правоассоциативные, первый – с уровнем приоритета 5, второй – 9. Левоассоциативность задаётся с помощью infixl, отсутствие ассоциативности – с помощью infix. В одном и том же объявлении может быть задан приоритет нескольких операторов. Если для данного оператора отсутствует объявление приоритета, по умолчанию принимается infixl 9. (См. §4.4.2 для детального определения правил ассоциативности.)

3.3 Функции являются не строгими

Пусть функция bot определена так:

bot = bot

Другими словами, bot – это незавершаемое выражение. Для абстрактных рассуждений мы обозначаем значение незавершаемого выражения через _|_ (читается «bottom»). Выражения, которые имеют своим результатом ошибку времени исполнения некоторого рода, вроде 1/0, тоже имеют это значение. Подобные ошибки не поддаются исправлению: после них программа не будет продолжена. Ошибки, вызванные подсистемой ввода-вывода, такие как достижение конца файла, являются исправимыми и обрабатываются другим образом (подобная ошибка ввода-вывода в действительности не ошибка, а исключение; мы подробнее обсудим исключения в Разделе 7).

Функция называется строгой, если её применение к незавершающемуся вычислению ведёт к тому, что она тоже никогда не завершится. Другими словами, f – строгая, если значение f bot это _|_. В большинстве языков программирования все функции строгие. Однако в Haskell это не так. В качестве простого примера рассмотрим функцию const1, возвращающую 1, определённую так:

const1 x = 1

Значением const1 bot в Haskell является 1. Рассуждая императивно, поскольку const1 не «нуждается» в значении своего аргумента, она никогда не делает попыток вычислить его, и, таким образом, никогда не оказывается вовлечённой в незавершающееся вычисление. По этим соображениям нестрогие функции также называют «ленивыми функциями» и говорят, что они вычисляют свои аргументы «лениво» или «по необходимости».

Поскольку ошибки и не завершающиеся значения являются семантически эквивалентными в Haskell, приведённые аргументы верны также и для ошибок. Например, const1 (1/0) также возвращает 1.

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

Другой способ объяснения понятия нестрогих функций заключается в том, что в Haskell выражение типа:

v = 1/0

следует читать как «определим v как 1/0», а не как «вычислим 1/0 и сохраним результат в v» (как в большинстве традиционных языков). Только если значение (определение) v потребуется, произойдёт ошибка деления на ноль. Само по себе это объявление не вызывает никаких вычислений. Программирование с присваиваниями требует обращать пристальное внимание на их порядок: смысл программы зависит от порядка, в котором присваивания выполняются. В противоположность этому, определения существенно проще: они могут располагаться в произвольном порядке, который не влияет на смысл программы.

3.4 «Бесконечные» структуры данных

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

Нестрогие конструкторы допускают создание бесконечных (концептуально) структур данных. Вот бесконечный список единиц:

ones                   = 1 : ones

Возможно, больший интерес представляет функция numsFrom:

numsFrom n             = n : numsFrom (n+1)

То есть numsFrom n – это бесконечный список последовательных целых чисел, начиная с n. С её помощью мы можем получить бесконечный список квадратов:

squares                = map (^2) (numsFrom 0)

(Обратите внимание на использование сечения; ^ представляет собой инфиксный оператор возведения в степень.)

Безусловно, в конце концов, мы предполагаем выделить некоторую конечную часть списка для фактических вычислений, и в Haskell имеется множество предопределённых функций, которые осуществляют подобное: take, takeWhile, filter и т.д. Определение языка Haskell включает широкий набор предопределённых функций и типов – они носят название «Standard Prelude». Полностью Standard Prelude описана в Приложении A Описания; многие полезные функции для работы со списками находятся в разделе PreludeList. Например, take n выбирает первые n элементов списка:

take 5 squares => [0,1,4,9,16]

Приведённое выше определение ones – пример циклического списка (circular list). В большинстве случаев ленивость даёт существенный вклад в эффективность, поскольку реализация может рассчитывать на реализацию списка, как действительно циклической структуры, тем самым экономя память.

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

fib = 1 : 1 : [ a+b | (a,b) <- zip fib (tail fib) ]

где zip – это функция из Standard Prelude, которая возвращает список пар элементов из двух своих аргументов-списков:

zip (x:xs) (y:ys) = (x,y) : zip xs ys
zip  xs     ys    = []

Отметим, что бесконечный список fib определён в терминах самого себя, как будто бы он «охотится на собственный хвост». Мы можем проиллюстрировать вычисления, как это сделано на рисунке 1.


Рисунок 1. Циклическая последовательность Фибоначчи.

См. Раздел 4.4 для знакомства с другими применениями бесконечных списков.

3.5 Функция error

В Haskell имеется встроенная функция error, тип которой String->a. Это несколько странная функция: по её типу кажется, что она возвращает значение полиморфного типа, о котором ничего не известно, поскольку она никогда не получает значение этого типа в качестве аргумента!

В действительности существует одна величина, «разделяемая» всеми типами: _|_. Семантически это в точности то значение, которое всегда возвращается функцией error (напомним, что все ошибки имеют значение _|_). Мы, однако, можем ожидать, что разумная реализация будет печатать строковой аргумент error в диагностических целях. Таким образом, эта функция полезна, когда мы желаем прервать выполнение программы, если что-то «пошло не так». Например, настоящее определение head из Standard Prelude имеет вид

head (x:xs) = x
head  []    = error "head{PreludeList}: head []"

4 Case-выражения и сопоставление с образцом

Ранее мы привели несколько примеров сопоставления с образцом при определении функций, например, length и fringe. В этом разделе мы рассмотрим процесс сопоставления с образцом гораздо подробнее (§3.17) (сопоставление с образцом в Haskell отличается от этой процедуры в логических языках программирования, таких как Prolog; в частности, его можно рассматривать как сопоставление «единственным способом», в то время как Prolog допускает сопоставления «двумя способами» (через унификацию), вместе с неявным перебором с возвратами в его механизме вычислений.)

Образцы не являются «первоклассными» сущностями; существует лишь ограниченный набор различных видов образцов. Мы уже видели несколько примеров образцов конструкторов данных; и length, и fringe, определённые ранее, используют такие образцы, первая – для конструктора «встроенного» типа (список), вторая – для конструктора типа, определённого пользователем (Tree). Конечно, сопоставление допустимо для конструктора любого типа, пользовательского или встроенного. Сюда входят кортежи, строки, числа, символы и т.д. Вот, например, затейливая функция, которая сопоставляется с кортежем «констант»:

contrived :: ([a], Char, (Int, Float), String, Bool) -> Bool
contrived    ([],  'b',  (1,   2.0),  "hi",    True) = False

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

С технической точки зрения формальные параметры (в Описании они называются переменными) – тоже образцы, с той спецификой, что сопоставление с ними никогда не завершается неудачей. В качестве «побочного эффекта» успешного сопоставления происходит связывание формального параметра со значением, с которым проводится сопоставление. По этой причине для образца в любом отдельно взятом уравнении не допускается наличия более одного вхождения того же самого формального параметра (свойство, называемое линейность, см. §3.17, §3.3, §4.4.3).

Образцы, подобные формальным параметрам, сопоставление с которыми всегда успешно, называют неопровержимыми (irrefutable), в противоположность опровержимым (refutable) образцам, сопоставление с которыми может завершиться неудачей. Существует ещё три вида неопровержимых образцов, два из которых мы сейчас опишем (третий подождёт до Раздела 4.4).

As-образцы (As-patterns). Иногда удобно присвоить образцу имя для использования в правой части равенства. Например, функция, дублирующая первый элемент списка, может быть записана так:

f (x:xs) = x:x:xs

(Напомним, что «:» правоассоциативен.) Заметим, что x:xs появляется и как образец в левой части равенства, и как выражение – в правой. Для улучшения читабельности мы можем предпочесть записывать x:xs только один раз; этого можно достигнуть, используя as-образец, как здесь:

f s@(x:xs) = x:s

С технической точки зрения as-образец всегда приводит к успешному сопоставлению, хотя под-образцы (в данном случае x:xs) могут, конечно, привести к неудаче.

Another advantage to doing this is that a naive implementation might completely reconstruct x:xs rather than re-use the value being matched against.

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

Подстановочный символ (Wildcards). Часто встречается другая ситуация, когда сопоставление происходит со значением, которое в действительности нас совершенно не интересует. Например, функции head и tail, определённые в Разделе 2.1, могут быть переписаны так:

head (x:_)              = x
tail (_:xs)             = xs

Здесь мы «объявляем» о своём безразличии к тому, что представляет собой некоторая часть ввода. Каждый подстановочный символ (независимо от других) сопоставляется с чем угодно, но в отличие от формальных параметров связывания не происходит; по этой причине допускается наличие более чем одного _ в данном уравнении.

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

Итак, мы обсудили, как происходит сопоставление с конкретными образцами, каким образом некоторые из них оказываются опровержимыми, а некоторые – неопровержимыми. Но что управляет всем процессом целиком? В каком порядке происходят попытки сопоставления? Что будет, если ни одно сопоставление не окажется удачным? В этом разделе мы обсудим эти вопросы.

Сопоставление с образцом может завершиться неудачей (fail) или успехом (succeed), или быть отклонено (diverge). Успешное сопоставление с образцом связывает формальные параметры образца. Отклонение происходит, когда значение, необходимое для образца, содержит ошибку (_|_). Сам процесс сопоставления происходит «сверху вниз, слева направо». Неудача в любом месте в данном уравнении приводит к неудаче всего уравнения, после этого попытка сопоставления предпринимается для следующего уравнения. Если все уравнения ведут к неудаче, значение применения функции – _|_, что приводит к ошибке времени исполнения.

Например, если [1,2] сопоставляется с [0,bot], то сопоставление 1 с 0 завершается неудачей, то есть результат – неудачное сопоставление. (Напомним, что определённый ранее bot является переменной, связанной с _|_.) Но если [1,2] сопоставляется с [bot,0], то сопоставление 1 с bot будет отклонено (то есть результат – это _|_).

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

sign x | x > 0  =  1
       | x == 0 =  0
       | x < 0  = -1

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

4.2 Пример

Правила сопоставления с образцом могут оказывать тонкие воздействия на смысл функций. Рассмотрим, например, следующее определение take:

take 0 _      = []
take _ []     = []
take n (x:xs) = x : take (n-1) xs

и эту, слегка отличную, версию (первые два уравнения переставлены местами):

take1 _ []     = []
take1 0 _      = []
take1 n (x:xs) = x : take1 (n-1) xs

Обратим теперь внимание на следующее:

take  0 bot       =>       []
take1 0 bot       =>       (
take  bot []      =>       (
take1 bot []      =>       []

Мы видим, что take «лучше определена» по отношению к своему второму аргументу, тогда как take1 «лучше определена» по отношению к своему первому аргументу. В данном случае трудно сказать, какое определение предпочтительнее. Просто помните, что при некоторых применениях функций может реализовываться различное поведение. (Standard Prelude включает определение, соответствующее take.)

4.3 Case-выражения

Сопоставление с образцом обеспечивает способ «диспетчеризации управления», основанной на структурных свойствах величины. Во многих ситуациях мы не захотим определять функцию каждый раз, когда такая диспетчеризация необходима, однако до сих пор мы показывали, как использовать сопоставление с образцом лишь для определения функции. Case-выражения в Haskell обеспечивают способ решения этой проблемы. В действительности смысл сопоставления с образцом в определении функции специфицирован в Описании в терминах case-выражений, которые рассматриваются как более примитивные. В частности, определение функции вида:

f p11 . . . p1k = e1
. . .
f pn1 . . . pnk = en

где каждый pij – это некоторый образец, семантически эквивалентно:

f x1 x2 . . . xk = case (x1, . . ., xk) of (p11, . . ., p1k) -> e1
                                           . . .
                                           (pn1, . . . , pnk) -> en

где xi – это новые идентификаторы. (О более общей трансляции, включающей предохранители, см. §4.4.3.) Например, данное выше определение take эквивалентно такому:

take m ys           = case (m,ys) of
                           (0,_)        -> []
                           (_,[])       -> []
                           (n,x:xs)     -> x : take (n-1) xs

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

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

if e1 then e2 else e3

которая в действительности является сокращением для:

case e1 of True  -> e2
           False -> e3

Из последней формы записи становится ясным, что e1 должно иметь тип Bool, а e2 и e3 должны иметь один и тот же (но в остальном произвольный) тип. Иными словами, if-then-else может рассматриваться как функция, имеющая тип Bool->a->a->a.

4.4 Ленивые образцы

Есть ещё один вид образцов, допустимый в Haskell. Он называется ленивым образцом (lazy pattern) и имеет вид ~pat. Ленивые образцы неопровержимы, сопоставление значения « с образцом ~pat всегда успешно, независимо от pat. Иначе говоря, если идентификатор из pat в дальнейшем будет использован в правой части, он будет связан либо с соответствующей частью получившегося значения, если сопоставление « с pat завершится успешно, либо с _|_ в противном случае.

Ленивые образцы полезны в тех случаях, когда бесконечные структуры данных определяются рекурсивно. Бесконечные списки являются прекрасным движком для написания программ-симуляторов, в этом контексте бесконечные списки часто называют потоками (streams). Рассмотрим простой случай симуляции взаимодействия между серверным процессом server и клиентским процессом client. Клиент client посылает последовательность запросов (requests) к серверу server, а сервер отвечает на каждый запрос ответом (response) определенного типа. Эта модель графически изображена на рисунке 2. (Отметим, что клиент также принимает инициализирующее сообщение в качестве аргумента.)


Рисунок 2. Модель взаимодействия клиент-сервер.

При использовании потоков для симуляции последовательности сообщений код на Haskell, соответствующий диаграмме, имеет вид:

reqs  = client init resps
resps = server reqs

Эти рекурсивные уравнения являются прямой лексической транслитерацией диаграммы.

Предположим далее, что структура сервера и клиента такова:

client init (resp:resps) = init : client (next resp) resps
server      (req:reqs)   = process req : server reqs

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

К несчастью эта программа имеет серьёзный недостаток: она не будет ничего делать! Проблема заключается в том, что client, в том виде, в котором он используется в рекурсивных вызовах reqs и resps, пытается выполнить сопоставление с образцом списка ответов до того, как он получит первый ответ от сервера. Иными словами сопоставление с образцом происходит «слишком рано». Один из способов справиться с этой проблемой заключается в изменении функции client следующим образом:

client init resps = init : client (next (head resps)) (tail resps)

Это решение работает, но оно менее «читабельно» по сравнению с предыдущим. Лучшим решением является использование ленивого образца:

client init ~(resp:resps) = init : client (next resp) resps

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

В качестве примера этой программы «в действии»: при таких определениях:

init        = 0
next resp   = resp
process req = req+1

ясно, что:

take 10 reqs      =>      [0,1,2,3,4,5,6,7,8,9]

В качестве другого примера использования ленивых образцов рассмотрим определение последовательности Фибоначчи, данное ранее:

fib = 1 : 1 : [ a+b | (a,b) <- zip fib (tail fib) ]

Можно попытаться переписать его, используя as-образец:

fib@(1:tfib) = 1 : 1 : [ a+b | (a,b) <- zip fib tfib ]

Эта версия fib имеет (небольшое) преимущество, заключающееся в отсутствии вызова tail справа, поскольку этот хвост доступен в «разобранной» форме в левой части – это tfib.

Этот вид уравнений называется связыванием образцов (pattern binding), поскольку здесь имеется уравнение верхнего уровня, в котором вся левая часть представляет собой образец, то есть и fib, и tfib оказываются связанными в области объявления.

Теперь, ссылаясь на те же причины, что и несколько раньше, мы должны были бы заключить, что эта программа ничего не будет делать. Удивительно, однако, что будет, и причина проста: в Haskell при связывании образца предполагается наличие неявного символа ~ перед ним. Это отражает наиболее типичное поведение, ожидаемое от связывания образцов, и позволяет избежать некоторых аномальных ситуаций, обсуждение которых выходит за рамки данного руководства. Мы видим, что ленивые образцы играют важную роль в Haskell, пусть и неявно.

4.5 Лексическая область видимости и вложенные формы

Часто желательно создать вложенную область видимости внутри выражения с целью породить локальные связывания, не видимые извне – то есть своего рода «блочно-структурные» формы. В Haskell есть два способа сделать это.

Let-выражения. в Haskell let-выражения удобно использовать всякий раз, когда требуется вложенный набор связываний. В качестве примера рассмотрим:

let y   = a * b
    f x = (x + y) / y
in f c + f d

Набор связываний, порождаемых let-выражениями, является взаимно рекурсивным, а связываемые образцы трактуются как ленивые (то есть несущие неявную ~). Единственными допустимыми видами объявлений являются сигнатуры типов, связывания функций и связывания образцов.

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

f x y    |    y>z  = ...
         |    y==z = ...
         |    y<z  = ...
       where z = x*x

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

Две эти формы вложенных областей видимости кажутся очень похожими, однако следует помнить, что выражение let – это выражение, в то время как конструкция where – нет; она является частью синтаксиса объявлений функций и case-выражений.

4.6 Отбивка текста

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

let y   = a * b
    f x = (x + y) / y
in f c + f d

Как синтаксическому анализатору узнать, что не следует осуществлять разбор так:

let y = a * b f
    x = (x + y) / y
in f c + f d

?

Ответ заключается в том, что Haskell использует двумерный синтаксис, называемый отбивкой (layout), который, по существу, основывается на декларации, звучащей как «выравнивайте по столбцам». Заметьте, что в верхнем примере y и f находятся в одном столбце. Правила отбивки детально изложены в Описании (§2.7, §B.3), но на практике при использовании отбивки достаточно доверять интуиции. Просто помните две вещи.

Во-первых, символ, следующий за любым из ключевых слов where, let или of, определяет начальный столбец для объявлений в where-конструкции, let-выражении или case-выражении. (Это правило также применимо к конструкции where, используемой в объявлении класса или воплощения, которые будут введены в Разделе 5.) Таким образом, мы можем начинать объявления на той же строке, что и ключевое слово, на следующей строке и т.д. (Ключевое слово do, которое мы обсудим позже, также использует отбивку.)

Во-вторых, следует следить за тем, чтобы текущий начальный столбец был правее, чем начальный столбец для объемлющей конструкции (в противном случае возникнет неоднозначность). «Завершение» объявления происходит, когда что-либо появляется на уровне или левее по отношению к начальному столбцу для данной связываемой формы. (В Haskell соблюдается соглашение о том, что табуляция состоит из 8 пробелов; таким образом, следует быть осторожным при использовании редакторов текста, которые могут следовать иным соглашениям.)

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

let { y = a*b
    ; f x = (x+y)/y
    }
in f c + f d

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

let y   = a*b; z = a/b
    f x = (x+y)/z
in f c + f d

Другие примеры развёртывания отбивок в явные разделители см. в §2.7.

Использование отбивок сильно уменьшает синтаксический хаос, связанный со списками объявлений, тем самым повышая «читабельность». Отбивке легко научиться, и ее использование приветствуется.

5 Классы типов и перегрузка

Имеется одно критическое свойств системы типов Haskell, которое выделяет его среди других языков программирования. Тот вид полиморфизма, который обсуждался выше, обычно называется параметрическим полиморфизмом. Есть и другой вид, называемый специальным (ad hoc) полиморфизмом, более известный как перегрузка (overloading). Вот несколько примеров специального полиморфизма:

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

Начнём с простого, но важного примера: равенства. Есть много типов, для которых мы хотели бы определить равенство, однако для некоторых типов мы хотели бы не делать этого. Например, сравнение на равенство функций, вообще говоря, представляется вычислительно неразрешимой задачей, в то время как желание сравнить два списка на предмет равенства возникает часто. (Вид равенства, который здесь обсуждается, это «равенство значений» в противоположность «равенству указателей», имеющему место, например, для оператора == в Java. Равенство указателей не является прозрачным по ссылкам, и поэтому плохо подходит для чисто функциональных языков.) Чтобы выявить проблему, рассмотрим следующее определение функции elem, которая проверяет вхождение в список:

x `elem` []             = False
x `elem` (y:ys)         = x==y || (x `elem` ys)

Из стилистических соображений, обсуждавшихся в Разделе 3.1, мы определяем elem в инфиксной форме. Инфиксные операторы == и || определяют ‘равенство’ и ‘логическое или’ соответственно.

На первый взгляд тип elem должен быть таков: a->[a]->Bool. Однако из этого следует, что == имеет тип a->a->Bool, хотя мы только что сказали, что не предполагаем определять == для всех типов.

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

Классы типов (type classes) успешно решают обе эти проблемы. Они позволяют нам объявить, какие типы являются воплощениями (instances) данного класса и дать определения перегруженных операций, связанных с этим классом. Определим, например, класс типа, содержащий оператор равенства:

class Eq a where
(==)                    :: a -> a -> Bool

Здесь Eq представляет собой имя определяемого класса, а == является единственной операцией этого класса. Это объявление может читаться так: «тип a является воплощением класса Eq, если для него определена (перегруженная) операция == подходящего типа». (Отметим, что == определена только для пар объектов одного и того же типа.)

Ограничение, что тип a должен быть воплощением класса Eq, записывается так: Eq a. Таким образом, Eq a не является выражением типа, но выражает ограничение на тип, и называется контекстом. Контексты располагают перед выражениями типа. Например, эффектом приведённого выше объявления класса служит присвоение следующего типа оператору == :

(==) :: (Eq a) => a -> a -> Bool

Это следует читать так: «Для любого типа a, который является воплощением класса Eq, оператор == имеет тип a->a->Bool». Это тот тип, который будет использован для оператора == в примере с elem, и, конечно, ограничение, налагаемое контекстом, распространяется на основной тип функции elem:

elem :: (Eq a) => a -> [a] -> Bool

Это читается так: «Для любого типа a, который является воплощением класса Eq, функция elem имеет тип a->[a]->Bool». Это именно то, что мы хотели – здесь выражается тот факт, что elem определена не для всех типов, а только для тех, для которых известно, как сравнивать элементы на предмет равенства.

Пока все в порядке. Но как нам указать, какие типы являются воплощениями класса Eq, и задать специфическое поведение == для каждого из этих классов? Это делают с помощью объявлений воплощений (instance declaration). Например:

instance Eq Integer where
    x == y              = x `integerEq` y

Такое определение == называется методом. Функция integerEq является «примитивной» функцией, сравнивающей целые на предмет равенства, но в общем случае в правой части равенства допускается любое правильное выражение, ровно так же как в определении любой другой функции. Всё это объявление по существу означает: «Тип Integer является воплощением класса Eq, и вот определение метода, соответствующего операции ==». Имея это объявление, мы теперь можем сравнивать целые числа на предмет равенства, используя ==. Аналогично,

instance Eq Float where
    x == y              = x `floatEq` y

позволяет нам сравнивать числа с плавающей точкой, используя ==.

Так же можно поступить и с рекурсивными типами, подобными определённому ранее Tree:

instance (Eq a) => Eq (Tree a) where
    Leaf a         == Leaf b          =  a == b
    (Branch l1 r1) == (Branch l2 r2)  =  (l1==l2) && (r1==r2)
    _              == _               =  False

Обратите внимание на контекст Eq a в первой строке – он необходим, поскольку элементы листьев (типа a) сравниваются на равенство во второй строке. Это дополнительное ограничение, в сущности, утверждает, что мы можем сравнивать на равенство деревья, хранящие объекты типа a, в том и только в том случае, когда мы знаем, как сравнивать на равенство объекты типа a. Если этот контекст будет опущен при объявлении воплощения, произойдёт статическая ошибка типизации.

Описание языка Haskell, особенно библиотека Prelude, содержит множество полезных примеров классов типов. В действительности определённый там класс Eq несколько шире, чем тот, который мы определили выше:

class Eq a where
    (==), (/=)          :: a -> a -> Bool
    x /= y              =  not (x == y)

Это пример класса с двумя операциями: одна для равенства, другая – для неравенства. Он также демонстрирует, использование метода по умолчанию, в данном случае для операции неравенства /=. Если метод для некоторой операции опущен в объявлении воплощения, тогда вместо него используется метод по умолчанию определённый в объявлении класса, если таковой существует. Например, три воплощения класса Eq, определённые выше, будут работать абсолютно правильно и при новом объявлении класса, выводя именно то правильное определение неравенства, которое мы хотели бы, а именно – логическое отрицание равенства.

В Haskell также поддерживается понятие расширения классов (class extension). Например, мы можем пожелать определить класс Ord, который наследует все операции из Eq, а в дополнение к этому имеет набор операций сравнения и функции определения минимума и максимума:

class (Eq a) => Ord a where
    (<), (<=), (>=), (>) :: a -> a -> Bool
    max, min             :: a -> a -> a

Обратите внимание на контекст в объявлении класса. Мы говорим, что Eq является суперклассом по отношению к Ord (и наоборот, Ord является подклассом Eq), и любой тип являющийся воплощением Ord должен быть также воплощением Eq. (В следующем разделе мы дадим более полное определение Ord, взятое из Prelude.)

Одним из преимуществ такого вложения классов является сокращение контекста: выражение типа для функции, использующей операции и из класса Eq и из класса Ord, может использовать контекст (Ord a), а не (Eq a, Ord a), поскольку Ord «содержит» Eq. Более важно, что методы для операций подкласса могут предполагать наличие методов для операций суперкласса. Например, объявление Ord в Standard Prelude содержит такой метод по умолчанию для (<):

x < y                   = x <= y && x /= y

Примером использования класса Ord, может служить основной тип функции quicksort, определённой в Разделе 2.4.1:

quicksort               :: (Ord a) => [a] -> [a]

Другими словами, quicksort работает только со списками величин упорядочиваемых типов. Такая типизация для quicksort возникает из-за использования < и >= в её определении.

Haskell также допускает множественное наследование, поскольку классы могут иметь более одного суперкласса. Например, объявление:

class (Eq a, Show a) => C a where ...

создаёт класс C, который наследует операции и из Eq, и из Show.

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

Контексты также допустимы в объявлениях data; см. §4.2.1.

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

class C a where
m :: Show b => a -> b

метод m требует, чтобы тип b был из класса Show. Однако метод m не может накладывать никаких дополнительных ограничений класса на тип a. Такие ограничения вместо этого должны быть частью контекста в объявлении класса.

Итак, мы используем типы «первого порядка». Например, конструктор типа Tree всегда составляет пару со своим аргументом, как в Tree Integer (дерево, содержащее целые величины) или Tree a (представляющее семейство деревьев, содержащих величины a). Но Tree само по себе является конструктором типа и, по этой причине, принимает некоторый тип в качестве аргумента и возвращает тип в качестве результата. В Haskell нет значений, которые имеют такой тип, однако такие типы «высшего порядка» могут быть использованы в объявлениях классов.

Для начала рассмотрим следующий класс Functor (взятый из Prelude):

class Functor f where
fmap                    :: (a -> b) -> f a -> f b

Функция fmap обобщает функцию map, использовавшуюся ранее. Обратите внимание, что тип переменной f применяется к другим типам в f a и f b. Таким образом, мы можем ожидать, что он будет связываться с некоторым типом, так же как Tree, который может быть применён к аргументу. Воплощение класса Functor для типа Tree может быть таким:

instance Functor Tree where
    fmap f (Leaf x)       = Leaf (f x)
    fmap f (Branch t1 t2) = Branch (fmap f t1) (fmap f t2)

Это объявление воплощения декларирует, что именно Tree, а не Tree a, является воплощением класса Functor. Такое свойство весьма полезно, здесь оно демонстрирует возможность описания обобщённых «контейнерных» типов, позволяющих функциям, таким как fmap, единообразно работать с произвольными деревьями, списками и другими подобными типами данных.

Применения типов записываются таким же образом, как и применения функций. Тип T a b синтаксически разбирается как (T a) b. Типы, использующие особый синтаксис, например, кортежи, могут быть записаны в альтернативном стиле, допускающем каррирование. Для функций (->) – это конструктор типа; типы f -> g и (->) f g – это одно и то же. Аналогично, типы [a] и [] a – это одно и то же. Для кортежей конструкторы типов (равно как и конструкторы данных) – это (,), (,,) и т.д.

Как мы знаем, система типов определяет ошибки типизации в выражениях. А что можно сказать об ошибках в неверно записанных выражениях типа? Выражение (+) 1 2 3 приводит к ошибке типизации, поскольку (+) принимает только два аргумента. Аналогично, тип Tree Int Int должен привести к некоторому сорту ошибки, поскольку тип Tree принимает только один аргумент. Итак, как же Haskell определяет неправильные выражения типа? Ответом служит вторая система типов, которая проверяет корректность типов! Каждый тип имеет связанный с ним вид (kind), который обеспечивает правильность использования типов.

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

Конструктор типа Tree имеет вид * -> *; тип Tree Int имеет вид *. Все члены класса Functor обязаны иметь вид * -> *; результатом объявления, подобного этому:

instance Functor Integer where ...

будет ошибка «видизации», поскольку Integer имеет вид *.

Виды не появляются явным образом в программах на Haskell. Компилятор выводит виды, прежде чем осуществлять проверку типов, не нуждаясь ни в каких «объявлениях видов». Виды в Haskell остаются в тени, за исключением того случая, когда ошибочная сигнатура типа приводит к ошибке «видизации». Виды достаточно просты для того, чтобы компилятор был способен обеспечить содержательное сообщение об ошибке при возникновении конфликта видов. См. §4.1.1 и §4.6 для более подробной информации о видах.

Другие точки зрения. Прежде чем перейти к другим примерам использования классов типов, стоит представить две других точки зрения на классы типов в Haskell. Первая проводит аналогию с объектно-ориентированным программированием (ООП). В следующем общем утверждении об ООП просто подставьте класс типа вместо класса и тип вместо объекта, при этом получится правильное резюме о механизме классов типов в Haskell:

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

Следует отдавать себе отчёт в том, что, в отличие от ООП, типы – это не объекты, и, в частности, отсутствует представление о внутреннем состоянии объекта или типа, которое могло бы изменяться. Преимущество перед некоторыми другими ОО-языками заключается в том, что методы в Haskell полностью типобезопасны: любая попытка применить метод к значению, чей тип не принадлежит требуемому классу, будет замечена при компиляции, а не во время исполнения. Другими словами, методы не «просматриваются» во время исполнения, а просто передаются как функции высшего порядка.

Другая точка зрения может быть получена, если учитывать связь между параметрическим и специальным полиморфизмом. Мы показали, насколько параметрический полиморфизм полезен при определении семейств типов, содержащих все типы под квантором общности. Однако иногда квантор общности слишком «широк» – мы хотим уметь ставить квантор некоторого более узкого множества типов, например, тех типов, чьи элементы могут сравниваться на предмет равенства. Мы увидели, что классы типов обеспечивают структурированный способ делать именно это. На самом деле о параметрическом полиморфизме тоже можно думать, как о некотором виде перегрузки! Просто эта перегрузка происходит неявно и для всех типов, а не для ограниченного множества типов (то есть для класса типа).

Сравнение с другими языками. Классы, используемые в Haskell, похожи на классы в других объектно-ориентированных языках, таких как C++ и Java. Однако существуют некоторые важные отличия:

6 Снова о типах

Здесь мы исследуем некоторые более продвинутые аспекты объявлений типов.

6.1 Объявление newtype

В программировании общепринятой является практика определения типа, чьё представление идентично уже существующему типу, но который по тем или иным причинам должен отдельно присутствовать в системе типов. В Haskell новый тип из уже существующего создаёт объявление newtype. Например, натуральные числа могут быть представлены через тип Integer с помощью следующего объявления:

newtype Natural = MakeNatural Integer

Здесь создаётся совершенно новый тип Natural, чей единственный конструктор содержит единичный Integer. Конструктор MakeNatural осуществляет преобразования между Natural и Integer:

toNatural               :: Integer -> Natural
toNatural x | x < 0     =  error "Отрицательные натуральные не существуют!"
            | otherwise =  MakeNatural x

fromNatural             :: Natural -> Integer
fromNatural (MakeNatural i) = i

Следующее объявление воплощения «принимает» Natural в класс Num:

instance Num Natural where
    fromInteger         = toNatural
    x + y               = toNatural (fromNatural x + fromNatural y)
    x - y               = let r = fromNatural x - fromNatural y in
                            if r < 0 then error "Ненатуральное вычитание!"
                            else toNatural r
    x * y               = toNatural (fromNatural x * fromNatural y)

Без этого объявления тип Natural не будет принадлежать классу Num. Воплощения класса, объявленные для старого типа, не переносятся на новый. Действительно, полное назначение типа Natural заключается в создании отдельного воплощения класса Num. Это было бы невозможным, если бы Natural был определён как синоним типа для Integer.

Всё это работает, если вместо объявления newtype использовать объявление data. Однако объявление data внесёт дополнительные накладные расходы в представление значений типа Natural. Использование newtype позволяет избежать дополнительного уровня косвенности (вызываемой ленивостью), которую внесло бы объявление data. Более подробно связь между объявлениями newtype, data и type разобрана в §4.2.3.

За исключением ключевого слова объявление newtype использует тот же синтаксис, что и объявление data с единственным конструктором, содержащим единственное поле. Это так, поскольку типы, определённые с помощью newtype, почти идентичны типам, созданным через обычное объявление data.

6.2 Метки полей

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

data Point = Pt Float Float

Две координаты точки типа Point – это первый и второй аргументы конструктора данных Pt. Функции, подобные следующей:

pointx                  :: Point -> Float
pointx (Pt x _)         =  x

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

Конструкторы в объявлении data могут быть объявлены с ассоциированными именами полей, заключёнными в фигурные скобки. Эти имена идентифицируют компоненты конструктора по имени, а не по позиции. Вот альтернативный способ определить Point:

data Point              =  Pt {pointx, pointy :: Float}

Этот тип данных идентичен типу Point, определённому выше. Конструктор Pt один и тот же в обоих случаях. Однако это определение также задаёт два имени поля: pointx и pointy. Эти имена полей могут быть использованы как функции-селекторы, выбирающие компоненты из структуры. В данном примере эти селекторы таковы:

pointx                  ::  Point -> Float
pointy                  ::  Point -> Float

Вот пример функции, использующей эти селекторы:

absPoint                :: Point -> Float
absPoint p              =  sqrt (pointx p * pointx p +
                                 pointy p * pointy p)

Метки полей могут также использоваться для создания новых значений. Выражение Pt {pointx=1, pointy=2} идентично Pt 1 2. Использование имен полей в объявлении конструктора данных не мешает доступу к полям в позиционном стиле: и Pt {pointx=1, pointy=2}, и Pt 1 2 равно допустимы. При конструировании значений с использованием именованных полей некоторые поля могут быть опущены; эти отсутствующие поля являются неопределёнными.

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

absPoint (Pt {pointx = x, pointy = y}) = sqrt (x*x + y*y)

Функция обновления использует значения полей в существующей структуре, чтобы заполнить компоненты в новой структуре. Если p – это Point, тогда p {pointx=2} – это точка с тем же pointy, что и p, но с pointx, заменённым на 2. Это не разрушающее обновление: функция обновления просто создаёт новую копию объекта, заполняя указанные поля новыми значениями.

ПРИМЕЧАНИЕ

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

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

Метки полей разделяют пространство имён верхнего уровня с обычными переменными и методами классов. Имя поля не может использоваться более чем для одного типа данных в заданной области видимости. Однако для некоторого типа данных одно и то же имя поля может использоваться более чем в одном конструкторе, до тех пор, пока это поле имеет один и тот же тип для всех случаев такого использования. Например, у этого типа данных:

data T = C1 {f :: Int, g :: Float}
       | C2 {f :: Int, h :: Bool}

имя поля f применяется в обоих конструкторах T. Таким образом, если x имеет тип T, то x {f=5} будет работать для значений, созданных с помощью любого конструктора данных типа T.

Именованные поля не изменяют основной природы алгебраических типов данных; они просто предоставляют удобный синтаксис для доступа к компонентам структур данных по имени, а не по позиции. Они делают конструкторы со многими компонентами более управляемыми, поскольку поля могут быть добавлены или удалены без внесения изменений в каждое обращение к конструктору. Полное описание меток полей и их семантики см. в §4.2.1.

6.3 Строгие конструкторы данных

Структуры данных в Haskell, вообще говоря, являются ленивыми: компоненты не вычисляются, пока в них не возникает необходимость. Это позволяет существовать структурам, содержащим элементы, которые при попытке вычисления привели бы к ошибке или не завершаемому вычислению. Ленивые структуры данных повышают выразительность языка и являются существенным аспектом стиля программирования на Haskell.

На внутреннем уровне каждое поле ленивого объекта данных, обёрнуто в структуру, на которую обычно ссылаются как на «переходник» (thunk), который инкапсулирует вычисления, определяющие значение поля. Вход в этот «переходник» не осуществляется, пока значение не потребуется; «переходник», который содержит ошибку (_|_), не влияет на остальные элементы структуры данных. Например, в Haskell кортеж ('a', _|_) представляет собой допустимое значение. Элемент 'a' может использоваться безо всякого беспокойства о других компонентах кортежа. Большинство языков программирования являются строгими, а не ленивыми: то есть все компоненты структуры данных преобразуются к их значениям, прежде чем быть помещёнными в структуру.

С «переходниками» связан ряд накладных расходов: на их конструирование и вычисление затрачивается время, они занимают место в динамической памяти, и они заставляют сборщик мусора сохранять другие структуры, нуждающиеся в вычислении «переходников». Чтобы избежать накладных расходов, в объявлении data используются флаги строгости (strictness flags). Они позволяют вычислять поля конструктора немедленно, выборочно подавляя ленивость. Поле, помеченное « в объявлении data, вычисляется при создании структуры, вместо откладывания вычисления в «переходник».

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

Например, библиотека комплексных чисел определяет тип Complex так:

data RealFloat a => Complex a = !a :+ !a

Отметим инфиксное определение конструктора :+.

Это определение помечает два компонента, вещественную и мнимую часть комплексного числа, как строгие. Это более компактное представление комплексных чисел, но это приводит к тому, что комплексные числа с неопределённым компонентом, например 1 :+ _|_, становятся полностью неопределёнными (_|_). Поскольку на практике нет необходимости в частично неопределённых комплексных числах, имеет смысл использовать флаги строгости, чтобы достичь более эффективного представления.

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

Флаг строгости ! может присутствовать только в объявлении data. Он не может использоваться ни в других сигнатурах типа, ни в любых других определениях типа. Не имеется аналогичного способа пометить аргумент функции, как строгий, хотя такого эффекта можно достичь, используя функции seq или !$. См. §4.2.1 для дальнейших подробностей.

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

7 Ввод-вывод

Система ввода-вывода в Haskell является чисто функциональной, хотя обладает всей той выразительной мощью, которую можно обнаружить в традиционных языках программирования. В императивных языках программы выполняются посредством действий (action), которые исследуют и изменяют текущее состояние. Типичные действия – это считывание и установка глобальных переменных, запись в файлы, чтение ввода и открытие окон. Подобного рода действия являются также и частью Haskell, однако они явно отделены от чисто функционального ядра языка.

Ввод-вывод в Haskell выстроен на устрашающем математическом основании, имя которому – монады. К счастью, понимание лежащей в основе монад теории не является необходимым для программирования с использованием системы ввода-вывода. Скорее монады являются концептуальной структурой, которая оказалась точно соответствующей вводу-выводу. Понимать теорию монад для осуществления ввода-вывода на Haskell необходимо не более, чем понимать теорию групп для выполнения простых арифметических действий. Подробное обсуждение монад может быть найдено в Разделе 9.

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

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

Действия либо атомарны (атомарность определяется системными примитивами), либо представляют собой последовательную композицию других действий. Монада ввода-вывода содержит примитивы, из которых создаются составные действия; этот процесс похож на соединение инструкций в последовательном порядке с использованием «;» в других языках. Таким образом, монады выступают в качестве «клея», который связывает действия в программе друг с другом.

7.1 Основные операции ввода-вывода

Каждое действие ввода-вывода возвращает значение. В системе типов возвращаемое значение «помечено» типом IO, для того чтобы отличать действия от прочих значений. Например, тип функции getChar таков:

getChar :: IO Char

Тип IO Char указывает на то, что getChar, будучи вызван, выполняет некоторые действия, которые возвращают символ. Действия, которые не возвращают ничего интересного, используют тип unit – (). Например, функция putChar:

putChar :: Char -> IO ()

принимает символ в качестве аргумента, но не возвращает ничего полезного. Тип unit похож на тип void в других языках.

Действия собираются в последовательность с помощью оператора, имеющего несколько криптографический вид: >>= (читается «связать»). Вместо прямого использования этого оператора, обычно используется некоторый синтаксический сахар, называемый do-нотацией, который позволяет спрятать операторы, монтирующие последовательность, за синтаксисом, имеющим сходство с общепринятыми языками. do-нотация тривиально раскрывается в >>=, как описано в §3.14.

Ключевое слово do вводит последовательность инструкций, которые выполняются в определённом порядке. Инструкция – это либо действие (образец связывается с результатом действия с помощью <-), либо множество локальных определений, вводимых с использованием let. do-нотация использует отбивку таким же образом, как и let или where, таким образом, мы можем опустить фигурные скобки и точки с запятой, воспользовавшись отбивкой. Ниже приведена простая программа, читающая и печатающая символ:

main                    :: IO ()
main                    =  do c <- getChar
                              putChar c

Использование имени main является важным: main определяется как точка входа в программу на Haskell (аналогично функции main в C) и должна иметь тип IO, обычно IO (). (Имя main имеет специальный смысл только в модуле Main; позже мы ещё обсудим тему модулей.) Эта программа выполняет два действия, причём последовательно: во-первых, она читает символ, связывая результат с переменной «, во-вторых, печатает символ. В отличие от выражения let, при использовании которого областью видимости переменной становится всё определение, переменные, определённые через <-, доступны только в следующих инструкциях.

Одно место пока остаётся неясным. Мы можем вызывать действия и проверять их результаты, используя do, однако как нам вернуть значение из последовательности действий. Рассмотрим, например, функцию ready, которая читает символ и возвращает True, если символ - это «y»:

ready                   :: IO Bool
ready                   =  do c <- getChar
                              c == 'y' -- Плохо!!!

Это не работает, потому что вторая инструкция в do – это просто булево значение, а не действие. Нам нужно взять это булево значение и создать действие, которое не делает ничего, кроме возврата этого булева значения в качестве своего результата. Функция return делает именно это:

return                  :: a -> IO a

Наличие функции return делает полным множество примитивов, работающих с последовательностями действий. Последняя строка функции ready должна иметь вид return (c == 'y').

Теперь мы готовы рассмотреть более сложные функции ввода-вывода. Начнём с функции getLine:

getLine                 :: IO String
getLine                 =  do c <- getChar
                              if c == '\n'
                                   then return ""
                                   else do l <- getLine
                                           return (c:l)

Обратите внимание на второе do в else. Каждое do вводит отдельную цепочку инструкций. Любая «вмешивающаяся» конструкция, наподобие if, должна использовать новое do для инициирования дальнейшей последовательности действий.

Функция return допускает обычные значения, такие как булевы, в область действий ввода-вывода. А в обратном направлении? Можем ли мы вызвать некоторое действие ввода-вывода внутри обычного выражения? Например, можно ли сказать x + print y в некотором выражении так, чтобы y напечаталось при вычислении этого выражения? Ответ таков: нет! Нет возможности прокрасться в императивную вселенную, находясь среди функционального кода. Каждое значение, «заражённое» императивным миром, должно быть помечено как таковое. Функция, подобная:

f :: Int -> Int -> Int

абсолютно неспособна выполнять никакой ввод-вывод, поскольку IO не присутствует в её возвращаемом значении. Этот факт часто причиняет страдания программистам, активно размещающим инструкцию print в своём коде во время отладки. В действительности существуют некоторые небезопасные функции, позволяющие решить эту проблему, но лучше оставить их для продвинутых программистов. Отладочные пакеты (подобные Trace) позволяют активно использовать эти «опасные» функции совершенно безопасным образом.

7.2 Программирование с действиями

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

todoList                :: [IO ()]

todoList                =  [putChar 'a',
                            do putChar 'b'
                               putChar 'c',
                            do c <- getChar
                               putChar c]

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

sequence_               :: [IO ()] -> IO ()
sequence_ []            =  return ()
sequence_ (a:as)        =  do a
                              sequence_ as

Заметив, что do x; y разворачивается в x >> y (см. Раздел 9.1), можно провести упрощение. Имеющийся здесь паттерн рекурсии охватывается функцией foldr (определение foldr – см. в библиотеке Prelude); лучшее определение sequence_ таково:

sequence_               :: [IO ()] -> IO ()
sequence_               =  foldr (>>) (return ())

Нотация do – полезный инструмент, но в данном случае более подходит скрытый за ней монадический оператор >>. Понимание операторов, на основе которых построена do-нотация, весьма полезно для Haskell-программистов.

Функция sequence_ может быть использована для конструирования putStr из putChar:

putStr                  :: String -> IO ()
putStr s                =  sequence_ (map putChar s)

Одно из различий между Haskell и общепринятым императивным программированием можно увидеть из примера с putStr. В императивном языке отображения (mapping) императивной версии putChar на строку достаточно для её печати. В Haskell, однако, функция map не производит никаких действий. Вместо этого она создаёт список действий – по одному на каждый символ в строке. С помощью функции foldr операции из sequence_ объединяются в одно действие с использованием функции (>>). Использованный здесь вызов return () совершенно необходим – функции foldr требуется действие-заглушка (null action) в конце цепочки действий (особенно если в строке нет ни одного символа!).

Библиотека Prelude и другие библиотеки содержат много функций, полезных для организации последовательностей действий ввода-вывода. Обычно они обобщены для произвольных монад; любая функция с контекстом, включающим Monad m =>, подходит для работы с типом IO.

7.3 Обработка исключений

До сих пор мы избегали проблемы исключений, возникающих во время операций ввода-вывода. Что произойдёт, если getChar достигнет конца файла? (Мы используем термин ошибка для _|_: состояния, которое не может быть обработано из-за незавершаемости или неудачи сопоставления с образцом. Исключения, с другой стороны, могут быть перехвачены и обработаны внутри монады ввода-вывода.) Для работы с исключительными ситуациями, например, «файл не найден», внутри монады ввода-вывода используется механизм обработки, по функциональности схожий с механизмом, применяемым в стандартном ML. Нет никакого специального синтаксиса или семантики; обработка исключений является частью определения операций последовательного ввода-вывода.

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

isEOFError              :: IOError -> Bool

определяет, произошла ли ошибка из-за достижения конца файла. То, что IOError сделан абстрактным, позволяет добавлять новые виды ошибок в систему без заметных изменений типа данных. Функция isEOFError определена в отдельной библиотеке, IO, и должна быть явно импортирована в программу.

Тип обработчика исключенийIOError -> IO a. Функция catch связывает обработчик исключений с действием или множеством действий:

catch                    :: IO a -> (IOError -> IO a) -> IO a

Аргументами catch служат действие и обработчик. Если действие успешно, его результат возвращается без вызова обработчика. Если происходит ошибка, она передаётся в обработчик, как значение типа IOError, а затем вызывается действие, связанное с обработчиком. Например, эта версия getChar возвращает символ новой строки, когда происходит ошибка:

getChar'                :: IO Char
getChar'                =  getChar `catch` (\e -> return '\n')

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

getChar'                :: IO Char
getChar'                =  getChar `catch` eofHandler where
    eofHandler e = if isEofError e then return '\n' else ioError e

Функция ioError, использованная здесь, генерирует исключение для следующего обработчика исключений. Тип ioError таков:

ioError                 :: IOError -> IO a

Это похоже на return, за исключением того, что управление передаётся следующему обработчику исключений, вместо обработки следующего действия ввода-вывода. Допустимы вложенные вызовы catch, это приводит к вложенным обработчикам исключений.

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

getLine'  :: IO String
getLine'  =  catch getLine'' (\err -> return ("Error: " ++ show err))
    where
        getLine'' = do c <- getChar'
                    if c == '\n' then return ""
                                 else do l <- getLine'
                                         return (c:l)

Вложенные обработчики исключений позволяют getChar' отловить конец файла, тогда как любые другие ошибки вернут из getLine' строку, начинающуюся с "Error: ".

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

7.4 Файлы, каналы и дескрипторы

За исключением монад ввода-вывода и механизма обработки исключений, инструментарий ввода-вывода Haskell по большей части весьма схож с подобным инструментарием других языков. Многие из функций ввода-вывода находятся в библиотеке IO, а не в Prelude, и, таким образом, должны явно импортироваться, чтобы попасть в область видимости (модули и импорт обсуждаются в Разделе 11). Многие из этих функций обсуждаются в Library Report, а не в основном Описании.

Открытие файла создаёт дескриптор (типа Handle), используемый в транзакциях ввода-вывода. Закрытие дескриптора закрывает связанный с ним файл:

type FilePath  =  String -- путь к файлу в файловой системе
openFile       :: FilePath -> IOMode -> IO Handle
hClose         :: Handle -> IO ()
data IOMode    =  ReadMode | WriteMode | AppendMode | ReadWriteMode

Дескрипторы также могут быть связаны с каналами: коммуникационные порты не привязываются к файлам напрямую. Исходно определено несколько каналов, включая stdin (стандартный канал ввода), stdout (стандартный канал вывода) и stderr (стандартный канал вывода сообщений об ошибках). Операции ввода вывода символьного уровня включают hGetChar и hPutChar, которые принимают дескриптор как аргумент. Использовавшаяся ранее функция getChar может быть определена так:

getChar                 = hGetChar stdin

Haskell также позволяет вернуть в единственную строку всё содержимое файла или канала:

getContents             :: Handle -> IO String

Здравый смысл подсказывает, что getContents должен немедленно прочитать весь файл или канал, приводя при некоторых условиях к неэффективному использованию памяти и времени. Однако это не так. Ключевым моментом является то, что getContents возвращает «ленивый» (то есть нестрогий) список символов (напомним, что строки в Haskell – это просто списки символов), чьи элементы считываются «по требованию», так же как и в любых других списках. От реализации можно ожидать, что она воплотит это «управляемое требованиями» поведение, и будет считывать из файла по одному символу за раз в тот момент, когда они требуются для вычислений.

В следующем примере программа на Haskell копирует один файл в другой:

main = do fromHandle <- getAndOpenFile "Copy from: " ReadMode
          toHandle   <- getAndOpenFile "Copy to: " WriteMode
          contents   <- hGetContents fromHandle
          hPutStr toHandle contents
          hClose toHandle
          putStr "Done."

getAndOpenFile             :: String -> IOMode -> IO Handle

getAndOpenFile prompt mode =
    do putStr prompt
       name <- getLine
       catch (openFile name mode)
             (\_ -> do putStrLn ("Cannot open "++ name ++ "\n")
                       getAndOpenFile prompt mode)

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

7.5 Haskell и императивное программирование

Завершая тему, заметим, что программирование операций ввода-вывода приводит нас к важному результату: этот стиль выглядит подозрительно похожим на обычное императивное программирование. Например, функция getLine:

getLine                 = do c <- getChar
                             if c == '\n'
                             then return ""
                             else do l <- getLine
                                     return (c:l)

поразительно похожа на следующий императивный код (на неком условном императивном языке):

function getLine() {
    c := getChar();
    if c == `\n` then return ""
                 else {l := getLine();
                       return c:l}}

Так что же, в конце концов, Haskell просто открыл заново императивное колесо?

В некотором смысле это так. Монада ввода-вывода образует маленький императивный подъязык внутри Haskell, и, таким образом, часть программы, связанная с вводом-выводом, может оказаться похожей на обычный императивный код. Имеется, однако, одно важное отличие. Нет никакой специальной семантики, с которой пользователю нужно иметь дело. В частности, Haskell не идёт на компромисс, сохраняя запись своих функций в виде уравнений. Ощущение императивности от монадического кода в программе не умаляет функциональности Haskell. Опытный функциональный программист должен быть способен ограничить императивную часть программы, используя монаду ввода-вывода только в ограниченном количестве высокоуровневого кода. Эта монада ясно разделяет функциональную и императивную части программы. Для сравнения, императивные языки с функциональным подмножеством обычно не имеют такого строго прочерченного барьера между чисто функциональным и императивным мирами.

Окончание в следующем номере...


Эта статья опубликована в журнале RSDN Magazine #4-2006. Информацию о журнале можно найти здесь