1 История функционального программирования

advertisement
Функциональное программирование
на языке Haskell
Конспект лекций
РЫБИНСК 2010
Содержание
Введение .............................................................................................................................. 3
1 История функционального программирования ............................................................ 4
2 Особенности функциональных языков .......................................................................... 5
2.1 Основные свойства................................................................................................ 5
2.2 Преимущества ....................................................................................................... 9
2.3 Недостатки ........................................................................................................... 11
3 Обзор существующих языков ....................................................................................... 13
4 Базовые принципы языка Haskell ................................................................................. 16
4.1 Интерактивная среда ........................................................................................... 16
4.2 Структура программы ......................................................................................... 18
4.3 Типы функций...................................................................................................... 22
4.4 Условные вычисления (ветвление) .................................................................... 24
4.5 Сопоставление с образцом ................................................................................. 27
4.6 Списки .................................................................................................................. 29
4.7 Локальные определения ..................................................................................... 33
4.8 Дополнительные возможности интерактивной среды .................................... 35
4.9 Функции высшего порядка ................................................................................. 36
4.10 Бесконечные структуры данных ...................................................................... 37
5 Типы данных и модули .................................................................................................. 40
5.1 Пользовательские типы и структуры данных .................................................. 40
5.2 Модули.................................................................................................................. 44
6 Классы и монады ............................................................................................................ 47
6.1 Классы .................................................................................................................. 47
6.2 Ввод-вывод ........................................................................................................... 49
7 Примеры .......................................................................................................................... 53
Заключение........................................................................................................................ 54
Список использованных источников .............................................................................. 55
2
Введение
Перед описанием непосредственно функционального программирования
обратимся к истории программирования вообще. В 1940-х годах появились первые
цифровые компьютеры, которые программировались переключением различного
рода тумблеров, проводков и кнопок [1]. Число таких переключений достигало
порядка нескольких сотен и росло с усложнением программ. Потому следующим
шагом развития программирования стало создание всевозможных ассемблерных
языков с простой мнемоникой.
Но даже ассемблеры не могли стать тем инструментом, которым смогли бы
пользоваться многие люди, поскольку мнемокоды всё ещё оставались слишком
сложными, а всякий ассемблер был жёстко связан с архитектурой, на которой
исполнялся.
Следующим
шагом после
ассемблера
стали
так
называемые
императивные языки высокого уровня: Бейсик, Паскаль, Си, Ада и прочие, включая
объектно-ориентированные. Императивными («предписывающими») такие языки
названы потому, что ориентированы на последовательное исполнение инструкций,
работающих с памятью (т. е. присваиваний), и итеративные циклы [1]. Вызовы
функций и процедур, даже рекурсивные, не избавляли такие языки от явной
императивности.
В парадигме функционального программирования краеугольным камнем
является функция. Математические функции выражают связь между исходными
данными и итоговым продуктом некоторого процесса. Процесс вычисления также
имеет вход и выход, поэтому функция — вполне подходящее и адекватное средство
описания вычислений [1]. Именно этот простой принцип положен в основу
функциональной
парадигмы
и
функционального
стиля
программирования.
Функциональная программа представляет собой набор определений функций.
Функции определяются через другие функции или рекурсивно через самих себя. На
функциональном языке программист должен только описать желаемый результат
как систему функций.
3
1
История функционального
программирования
Как известно, теоретические основы императивного программирования были
заложены ещё в 1930-х годах Аланом Тьюрингом и Джоном фон Нейманом [1,2].
Теория, положенная в основу функционального подхода, также родилась в 20-х —
30-х годах. В числе разработчиков математических основ функционального
программирования можно назвать Моисея Шейнфинкеля и Хаскелла Карри,
разработавших комбинаторную логику, а также Алонзо Чёрча, создателя λисчисления (лямбда-исчисления).
Теория так и оставалась теорией, пока в начале 1950-х годов Джон Маккарти
не разработал язык Лисп (Lisp), который стал первым почти функциональным
языком программирования и многие годы оставался единственным таковым. Лисп
всё ещё используется, после многих лет эволюции он удовлетворяет современным
запросам [1].
В связи с все возрастающей сложности программного обеспечения всё
большую роль начинает играть типизация. В конце 70-х — начале 80-х годов XX
века
интенсивно
разрабатываются
модели
типизации,
подходящие
для
функциональных языков. Большинство этих моделей включали в себя поддержку
таких мощных механизмов как абстракция данных и полиморфизм [1]. Появляется
множество типизированных функциональных языков: ML, Scheme, Hope, Miranda,
Clean и многие другие. Вдобавок постоянно увеличивается число диалектов.
Практически каждая группа, занимающаяся функциональным программированием,
использовала
собственный
язык.
Это
препятствовало
дальнейшему
распространению этих языков и порождало многие более мелкие проблемы. Чтобы
исправить положение, объединённая группа ведущих исследователей в области
функционального программирования решила воссоздать достоинства различных
языков в новом универсальном функциональном языке. Первая реализация этого
языка, названного Haskell в честь Хаскелла Карри, была создана в начале 90-х годов.
4
Особенности функциональных языков
2
Как любая группа языков программирования, функциональные языки
обладают некоторыми особенностями, которые выделяют их среди остальных
языков и делают их именно языками функционального программирования.
2.1
Основные свойства
Среди свойств функциональных языков обычно выделяют следующие:
– краткость и простота;
– строгая типизация;
– функции – это значения;
– чистота (отсутствие побочных эффектов);
– отложенные (ленивые) вычисления;
– модульность.
Рассмотрим каждое из этих свойств подробнее.
Краткость и простота
Программы на функциональных языках обычно короче и проще, чем те же
самые программы на императивных языках. Одним из самых распространенных
примеров является быстрая сортировка Хоара.
На императивном языке C быстрая сортировка обычно реализуется
следующим образом:
void qsort (int a[], int l, int r)
{
int i = l, j = r, x = a[(l + r) / 2];
do
{
while (a[i] < x) i++;
while (x < a[j]) j--;
if (i <= j)
5
{
int temp = a[i];
a[i++] = a[j];
a[j--] = temp;
}
}
while (i <= j);
if (l < j) qsort (a, l, j);
if (i < r) qsort (a, i, r);
}
На функциональном языке Haskell эта же сортировка записывается гораздо
короче и нагляднее:
qsort [] = []
qsort (x:xs) = qsort [y | y <- xs, y < x]
++ [x] ++ qsort [y | y <- xs, y >= x]
Этот пример следует читать так: если сортируемый список пуст, то
результатом сортировки также будет является пустой список, иначе выделяются
голова и хвост списка (первый элемент списка и список из оставшихся элементов,
возможно пустой), и результатом будет являться конкатенация (сращивание)
отсортированного списка из всех элементов хвоста, которые меньше головы, списка
из самой головы и списка из всех элементов хвоста, которые больше либо равны
голове.
Код на функциональном языке выигрывает по размеру кода и его наглядности.
Кроме того, приведенная реализация на языке C сортирует массив, элементы
которого имеют целочисленный тип (int), а реализация на Haskell может сортировать
списки элементов любых типов, на которых определена операция сравнения.
Строгая типизация
Большинство языков функционального программирования имеют строгую
типизацию.
Строгая
типизация
подразумевает
6
выполнение
следующих
обязательных условий [3]:
– каждое значение, переменная, аргумент и возвращаемое значение
функции на этапе проектирования программы безусловно привязывается
к определённому типу данных, который не может быть изменён во время
выполнения программы;
– функции могут принимать и возвращать значения, имеющие строго тот
же тип данных, что указан при описании функции;
– каждая операция требует аргументов строго определённых типов;
– неявное преобразование типов не допускается (то есть транслятор
воспринимает любую попытку использовать значение не того типа,
который был описан для переменной, аргумента, функции или операции,
как ошибку).
В теории программирования строгая типизация является непременным
элементом обеспечения надёжности разрабатываемых программных средств [3].
При правильном применении (подразумевающем, что в программе объявляются и
используются отдельные типы данных для логически несовместимых значений) она
защищает программиста от простых, но труднообнаруживаемых ошибок, связанных
с совместным использованием логически несовместимых значений, возникающих
иногда просто из-за элементарной описки. Подобные ошибки выявляются ещё на
этапе компиляции программы, тогда как при возможности неявного приведения
практически любых типов друг к другу (как, например, в классическом языке Си)
эти ошибки выявляются только при тестировании, причём не все и не сразу.
Функции как значения
В функциональных языках программирования функции могут использоваться
как любые другие объекты, они могут передаваться другим функциям в качестве
аргументов, возвращаться в качестве результата других функций, храниться в
списках и других структурах данных. Функции, принимающие в качестве
аргументов или возвращающие как результат другие функции называются
функциями высших порядков.
7
Использование функций высших порядков позволяет создавать более гибкие
функции, повышая тем самым возможность повторного использования кода. В
качестве примера обычно приводится передача функции сравнения элементов в
функцию сортировки.
Чистота
Чистота заключается в отсутствии побочных эффектов при вычислении
значений функции. Побочный эффект функции — это возможность в процессе
вычисления своих вычислений чтения и изменения значений глобальных
переменных,
осуществления
операций
ввода/вывода,
реагирования
на
исключительные ситуации, изменение значений входных переменных. Поэтому,
если вызвать такую функцию дважды с одним и тем же набором значений входных
аргументов, то результаты вычисления могут различаться, а также может измениться
состояние некоторых глобальных объектов (например, значения переменных). Такие
функции называются недетерминированными функциями с побочными эффектами.
В чистом функциональном программировании одна и та же функция при
одних и тех же аргументах возвращает всегда один и тот же результат. Созданные
объекты нельзя изменять или уничтожать, можно только создавать новые на основе
уже существующих. Благодаря чистоте, программы не только становятся понятнее,
но и упрощается организация параллелизма в них, так как функции могут
вычисляться независимо друг от друга. Если результат чистой функции не
используется, то ее вычисление может опускаться без вреда для других выражений.
И если нет никакой зависимости по данным между двумя чистыми функциями, то
можно поменять порядок их вычисления или вычислить их параллельно. В таком
случае компилятор может использовать любую политику вычисления. Это
предоставляет свободу компилятору комбинировать и реорганизовывать вычисление
выражений в программе.
Отложенные вычисления
В традиционных языках перед вызовом функции вычисляются значения всех
8
ее аргументов. Этот метод вызова функций называется «вызовом по значению».
Если же какие-то из аргументов не используются, то вычисления были произведены
впустую. Во многих функциональных языках используется другой метод вызова
функций — «вызов по необходимости». При этом каждый аргумент функции
вычисляется только в том случае, если его значение необходимо для вычисления
результата функции. Например, операция конъюнкции из языка C++ (&&) не
требует вычисления значение второго аргумета, если первый имеет ложное
значение.
Ленивые вычисления в некоторых случаях позволяют ускорить выполнение
программы, а так же позволяют использовать различные бесконечные структуры
данных.
Модульность
Механизм модульности позволяет разделять программы на несколько
сравнительно независимых частей (или модулей) с четко определенными связями
между ними. Тем самым облегчается процесс проектирования и последующей
поддержки больших программных систем. Поддержка модульности не является
свойством
именно
функциональных
языков
программирования,
однако
поддерживается большинством таких языков.
2.2
Преимущества
Языки функционального программирования обладают рядом преимуществ по
сравнению с императивными языками. Использование функциональной парадигмы
программирования повышает надежность программ, скорость их написания,
удобство модульного тестирования, возможности оптимизации при компиляции и
возможности автоматической организации параллелизма.
Надежность программирования
Отсутствие
побочных
эффектов
делает
невозможным
многие
труднообнаруживаемые ошибки, такие как случайное присваивание неверного
значения глобальной переменной. Строгая статическая типизация позволяет на
9
этапе компиляции отловить большое количество ошибок, связанных с некорректным
использованием некоторых данных.
Интересная особенность функциональных языков в том, что они поддаются
математическому анализу. Поскольку функциональный язык – это просто
реализация формальной системы, все математические операции, которые могли бы
быть сделаны на бумаге, также применимы к программам, написанным на таком
языке.
Например,
компилятор
может
преобразовывать
фрагменты
кода
в
эквивалентные, но более эффективные фрагменты, математически доказав
эквивалентность фрагментов. Более того, вы можете использовать эти технологии,
чтобы доказать, что определённые участки вашей программы корректны [2].
Удобство модульного тестирования
Поскольку функция в функциональном программировании не может
порождать побочные эффекты, менять объекты нельзя как внутри области
видимости, так и снаружи (в отличие от императивных программах, где одна
функция может изменить какую-нибудь внешнюю переменную, считываемую
второй функцией). Единственным эффектом от вычисления функции является
возвращаемый ей результат, и единственный фактор, оказывающий влияние на
результат – это значения аргументов.
Таким образом имеется возможность протестировать каждую функцию в
программе, просто вычислив её от различных наборов значений аргументов. При
этом можно не беспокоиться ни о вызове функций в правильном порядке, ни о
правильном формировании внешнего состояния. Если любая функция в программе
проходит модульные тесты, то можно быть уверенным в качестве всей программы.
В
императивных
программах
проверка
возвращаемого
значения
функции
недостаточна: функция может модифицировать внешнее состояние, которое тоже
нужно проверять, чего не нужно делать в функциональных программах
Возможности оптимизации
Описание программы в виде набора функций без явного указания порядка их
10
вычисления и чистота функций дают возможность применять к функциональным
программам
достаточно
сложные
и
эффективные
методы
автоматической
оптимизации.
Ещё одним преимуществом функциональных программ является то, что они
предоставляют широчайшие возможности для автоматического распараллеливания
вычислений. Поскольку отсутствие побочных эффектов гарантировано, в любом
вызове функции всегда допустимо параллельное вычисление двух различных
аргументов – порядок их вычисления не может оказать влияния на результат вызова.
Доказательство свойств функций
Так как вычисление функций в функциональном программировании не
вызывает побочных эффектов, то к анализу таких функций применимы
математические методы (например, метод математической индукции). Это позволяет
без тестирования доказать правильность работы функций, либо какие-то другие их
свойства.
2.3
Недостатки
Недостатки функционального программирования вытекают из тех же самых
его особенностей. Отсутствие присваиваний и замена их на порождение новых
данных приводят к необходимости постоянного выделения и автоматического
освобождения памяти, поэтому в системе исполнения функциональной программы
обязательным компонентом становится высокоэффективный сборщик мусора. Для
эффективности сборки мусора необходимо отслеживать ссылки на данные, что
также требует затрат времени и памяти. Отсутствие циклов для промышленных
программ
является
довольно
серьёзным
ограничением,
поскольку
многие
алгоритмы требуют очень длинных или даже формально бесконечных циклов,
которые неэффективно или даже невозможно представлять в рекурсивном виде из-за
слишком большой требуемой глубины рекурсии. В некоторой мере последний
недостаток может быть обойдён путём автоматического преобразования рекурсии в
цикл, выполняемого некоторыми трансляторами функциональных языков для
11
специфического случая хвостовой рекурсии , но не все формы рекурсии допускают
такое преобразование (впрочем, те из них, которые такому преобразованию не
подлежат, не могут быть оформлены в виде простого цикла и в императивных
языках).
Для преодоления этих недостатков многие функциональные языки включают
в себя средства императивного программирования, позволяющие явно задавать
порядок действий, использовать циклы и побочные эффекты (например, операции
ввода и вывода).
12
3
Обзор существующих языков
В настоящее время разработано большое количество функциональных языков
программирования.
Все
они
обладают
уникальными
особенностями,
положительными и отрицательными качествами. Самыми распространенными и
эффективными на сегодняшний день являются следующие языки или семейства
языков:
– Lisp,
– Haskell,
– ML,
– Erlang.
Рассмотрим особенности каждого из этих языков:
– Lisp. Получил название от английского LISt Processing – обработка
списков. Lisp является одним из самых первых языков функционального
программирования. Программы и данные в Lisp представляются
системами линейных списков символов. Язык Lisp, наряду с языком Ada,
прошел процесс фундаментальной стандартизации для использования в
военном деле и промышленности, в результате чего появился стандарт
Common Lisp. Его реализации существуют для большинства платформ.
Первые области применения Лиспа были связаны с символьной
обработкой данных и процессами принятия решений. Наиболее
популярный сегодня диалект Common Lisp является универсальным
языком программирования. Он широко используется в самых разных
проектах: Интернет-серверы и службы, серверы приложений и клиенты,
взаимодействующие с базами данных, научные расчёты и игровые
программы.
– Haskell. Является одним из самых распространённых ленивых языков
программирования. Имеет очень развитую систему типизации. В
последнее время расширяется набор прикладных библиотек, язык
13
интегрируется в распространённые программные системы, что делает
язык всё более и более привлекательным для профессиональных
программистов.
Основные
использования
особенности
лямбда-абстракции;
языка:
функции
возможность
высшего
порядка;
частичное применение; недопустимость побочных эффектов (чистота
языка); ленивые вычисления (lazy evaluation); сопоставление по образцу,
функциональные
образцы
(pattern
matching);
параметрический
полиморфизм и полиморфизм классов типов; статическая типизация;
автоматическое выведение типов (основано на модели типизации
Хиндли – Милнера); алгебраические типы данных; типы данных с
параметрами; рекурсивные типы данных; абстрактные типы данных
(инкапсуляция); списочные включения (list comprehensions); охраняющие
выражения (guards); возможность писать программы с побочными
эффектами
без
нарушения
парадигмы
функционального
программирования с помощью монад; возможность интеграции с
программами,
реализованными
на
императивных
языках
программирования посредством открытых интерфейсов (стандартное
расширение языка Foreign Function Interface).
– ML (Meta Language) – семейство строгих языков функционального
программирования
с
развитой
полиморфной
системой
типов
и
параметризуемыми модулями. ML преподаётся во многих западных
университетах. Сильно типизированный язык со статическим контролем
типов и аппликативным выполнением программ. Основные достоинства
ML –
высокая
верифицируемость программ,
простота
отладки,
потенциал для высокой оптимизации, уникальная краткость записи.
Основные недостатки – сложность синтаксиса, непривычность принятых
соглашений
и
ограничений,
практическая
невозможность
макротрансформаций.
– Erlang – функциональный язык программирования, позволяющий
писать программы для разного рода распределённых систем. Разработан
14
и поддерживается компанией Ericsson. Язык включает в себя средства
порождения параллельных процессов и их коммуникации с помощью
посылки асинхронных сообщений. Программа транслируется в байт-код,
исполняемый виртуальной машиной, что обеспечивает переносимость.
Главное в Erlang – его модель легковесных процессов. Перефразируя для
Erlang слоган «Everything is an object» («Всё является объектом»), можно
сказать «Everything is a process» («Всё является процессом»). Процессы
дёшевы, создание процесса занимает не больше ресурсов, чем вызов
функции. Единственным способом взаимодействия процессов является
асинхронный обмен сообщениями.
Из перечисленных языков Haskell является наиболее ярким представителем
языков функционального программирования. Он обладает простым синтаксисом и
всеми перечисленными выше свойствами функциональных языков.
15
Базовые принципы языка Haskell
4
Как было сказано, язык программирования Haskell является функциональным
языком, и он обладает всеми вышеперечисленными свойствами
Для начала рассмотрим необходимый инструментарий для работы. Самым
распространенным и эффективным на сегодняшний день является компилятор GHC
(Glasgow Haskell Compiler). Он распространяется под открытой лицензией, и любой
желающий может загрузить его исходные коды или скомпилированную версию для
популярных операционных систем с официального сайта http://haskell.org/ghc/
(кроме того, на сайте http://haskell.org/ можно найти много дополнительной
информации по языку).
Кроме самого компилятора в GHC входит интерактивная среда GHCi (GHC
interactive) — интерпретатор Haskell, позволяющий вычислять любые выражения и
интерпретировать написанные программы.
К сожалению, полнофункциональной среды разработки для Haskell еще не
разработано (кроме, возможно, Leksah — среды разработки для Haskell, написанной
на Haskell, и нескольких плагинов для Visual Studio и Eclipse), но зачастую хватает
возможностей лишь расширенного текстового редактора (например, Notepad++,
gedit, kate) с подсветкой синтаксиса и некоторыми другими возможностями.
4.1
Интерактивная среда
Интерактивная среда GHCi может вычислять любые выражения на языке
Haskell. Рассмотрим основы работы с этой средой. Для ее запуска (после установки
GHC или Haskell-Platform) достаточно запустить в консоли программу ghci (либо
выбрать соответствующую программу в списке всех программ). После запуска в
консоли появится приглашение:
Prelude>
Prelude
означает
имя
текущего
модуля,
по-умолчанию
загружается
стандартный модуль Prelude, содержащий все основные функции и типы данных.
Здесь мы можем вычислить любое выражение, например обыкновенное
16
арифметическое выражение:
Prelude> 2+2
4
Как мы видим, интерактивная среда вычислила результат выражения и вывела
его в новой строчке. Попробуем вычислить выражение посложнее:
Prelude> 1-2*(4-3^2)
11
Возведение в степень (^) является стандартным оператором, определенным в
стандартном модуле Prelude наравне с операциями сложения и умножения.
Кроме арифметических выражений в интерактивной среде возможно
вычислять любые другие функции, определенные в стандартном модуле, либо в
любых других, загруженных пользователем.
Для вычисления значения функции записывается ее имя и указываются
аргументы, разделенные пробелами. Например, в стандартном модуле определена
функция
max,
выбирающая
максимальное
значение
из
двух
аргументов.
Использовать ее можно так:
Prelude> max 7 100
100
В данном примере вычисляется максимальное из двух чисел — семи и ста.
Как мы видим, результатом вычисления функции является число 100.
В качестве аргументов функции могут выступать любые выражения (но только
соответствующего типа; подробнее типы рассматриваются в других разделах),
например:
Prelude> max (2^10) (10^3)
1024
В этом примере определяется, что два в десятой степени превышает десять в
третьей степени. Заметим, что заключение аргументов в скобки является
обязательным для того, чтобы выделить какая часть выражения является
аргументом. Например, если опустить скобки второго аргумента, то получим
немного неожиданный результат:
17
Prelude> max (2^10) 10^3
1073741824
Без скобок данное выражение интерпретируется как максимум из двух чисел
(1024 и 10), возведенный в третью степень.
Кроме того, интерактивная среда GHCi может автоматически дополнять имена
вводимых функций. Если набрать только начальную часть имени функции и нажать
на клавиатуре клавишу «Tab», то GHCi попытается дополнить имя функции до
имеющегося среди доступных определений (из стандартного модуля, либо
подключенных пользователем). Например, если набрать «maxi» и нажать «Tab», то
GHCi дополнит имя функции до «maximum». В том случае, если однозначно
дополнить невозможно (есть несколько подходящих вариантов), то выводятся все
возможные варианты:
Prelude> max
max
maxBound
maximum
Prelude> max
Теперь можно уточнить имя функции (дописав несколько букв) и снова нажать
клавишу «Tab».
Автодополнение очень полезно при использовании большого количества
функций с длинными именами.
4.2
Структура программы
Компиляторы и интерпретаторы языка Haskell работают с файлами с
расширением *.hs, содержащими текст программы. Текст программы имеет
следующую структуру:
1. в начале программы может быть указано имя текущего модуля и
экспортируемые определения;
2. далее могут быть указаны импортируемые модули;
3. оставшуюся часть программы занимают различные определения —
определения функций, типов данных и классов.
Простейшая программа может содержать только определения функций (в
18
таком случае импортируется только стандартный модуль Prelude, содержащий
большинство стандартных функций и типов данных; имя модуля задается поумолчанию как Main).
Далее рассмотрим подробнее определение функций.
Простейшая функция может вообще не принимать аргументов, а лишь
возвращать некоторое значение. В таком случае определение состоит из имени
функции, знака равенства «=», и некоторого выражения, по которому будет
вычисляться значение данной функции. В привычных терминах такую функцию
можно назвать переменной или константой, однако это не совсем корректно. В
функциональном
программировании
функции
без
аргументов
именуются
символами. Рассмотрим пример:
five = 5
В данном примере объявляется символ с именем five, который имеет значение,
равное целому числу 5. Имена в Haskell чувствительны к регистру, то есть Five и
five являются различными именами. Кроме того, вводится дополнительное
ограничение на первую букву имени — имена функций и их аргументов могут
начинаться только со строчной буквы (five, max, min, x, y), а имена типов данных
(Bool, Integer, Double), модулей (Main, Test) и классов (Eq, Ord, Num) — только с
прописной (заглавной).
Рассмотрим пример посложнее:
one = 1
two = 2
three = one + two
Здесь объявляется три символа — one, two, three. Как видно из примера,
каждое определение занимает одну строчку и разделяются они только концом
строки (пустые строчки будут игнорироваться). Символы one и two определены
также, как и символ five в предыдущем примере, а при определении символа three
используются уже существующие определения. Как несложно догадаться, символ
three будет иметь значение 3.
Порядок определений не важен, то есть следующий пример будет совершенно
19
аналогичен предыдущему:
three = one + two
one = 1
two = 2
Загрузим наш пример в интерактивную среду GHCi. Для этого достаточно при
запуске ghci в качестве параметра командной строки указать имя файла с текстом
программы (например, Test.hs) (в ОС семейства Windows достаточно просто открыть
файл, установленный GHC автоматически назначает ghci для открытия файлов *.hs).
Если программа не содержит ошибок, то мы увидим уже знакомое приглашение:
*Main>
Здесь Main — имя текущего модуля (подробнее модули рассматриваются в
соответствующей главе). GHCi позволяет вычислять любые функции из текущего
модуля. Например, вычислим наш символ three:
*Main> three
3
Более сложные выражения также возможны:
*Main> (three+two)^2
25
Или:
*Main> max one two
2
Далее рассмотрим функции с аргументами. В отличие от привычных языков
программирования для передачи аргументов не требуется записывать их в скобках и
через запятую. Вызов функции происходит в следующем виде: func x1 x2 x3… xN, где
func – имя функции, а xi – i-й аргумент. Результатом функции будет являться какойлибо объект, например, число, список, функция, лямбда-выражение либо любая
другая структура данных.
Описание функции с аргументами практически не отличается от описания
символов в предыдущих примерах. Определение функции располагается на
отдельной строчке и имеет следующий вид: func x1 x2 x3… xN = expression, где func
20
— имя новой функции, xi – имя i-го аргумента, expression — выражение.
Например, добавим функцию, складывающую два числа, в существующий
файл Test.hs.
plus x y = x+y
Теперь мы можем перезагрузить в интерактивной среде измененный модуль.
Для этого достаточно перезапустить ghci, либо воспользоваться стандартной
командой «:r»:
*Main> :r
[1 of 1] Compiling Main
( Test.hs, interpreted )
Ok, modules loaded: Main.
*Main>
После этого новая функция становится доступной из интерактивной среды:
*Main> plus one 8
9
Еще один пример функции с аргументами:
f x = 2 * sin x
Каждое определение занимает одну строчку, однако, если для наглядности
удобнее разделить определение на несколько строк, то можно использовать
следующую особенность: каждая строчка, начинающаяся с отступа большего, чем
предыдущая, считается ее продолжением. Например, можем записать функцию plus
в две строки таким образом:
plus x y
= x+y
Однострочные комментарии в языке Haskell начинаются с двух тире:
plus x y = x+y --функция сложения
Блочный комментарий начинается с “{-” и заканчивается “-}”:
inc x = x+1
{- эта функция возвращает число на 1
большее
чем полученное в качестве аргумента 21
}
4.3
Типы функций
Haskell — строго типизированный язык, любые функции в нем имеют строго
определенный тип. Однако, в предыдущих примерах нами тип не указывался, так
как в интерпретаторах и компиляторах языка Haskell реализован мощный механизм
вывода типов. Тип функций (и их аргументов) в большинстве случаев может быть
выведен, исходя из определений этих функций. Некоторые функции могут иметь
полиморфные типы (такие функции могут применяться к аргументам различных
типов), например, ранее приведенная функция plus может суммировать целые числа
и вещественные.
Как было сказано ранее, имена типов начинаются с прописных букв.
Перечислим некоторые стандартные типы:
Тип
Описание
Int
целое число от -2147483648 до 2147483647
Integer
длинное целое число, без границ (предусмотрено использование
встроенной длинной арифметики)
Float
вещественное число
Double
вещественное число удвоенной точности
[a]
список элементов некоторого типа a, например, список целых чисел
записывается как [Integer]
Char
символ
String
строка (или список символов), эквивалент [Char]
Bool
логический тип (принимает значения True или False)
(a,b)
кортеж из двух элементов типа a и b (например, (String,Bool))
(a,b,c)
кортеж из трех элементов типа a, b и c (например, (String,Bool,Int))
Если программист желает сам указать тип функции и ее аргументов, либо
автоматический вывод типа этой функции невозможен, то он должен сделать
дополнительное определение, используя оператор указания типа “::”. Например,
22
чтобы описать функцию, возвращающую вещественное значение, можно написать
следующий код:
pi :: Double
pi = 3.1415926535897932384626433832795
Первая строчка означает, что функция pi имеет тип Double, затем идет само
определение функции, означающее, что функция pi возвращает приближенное
значение числа пи. Если же функция имеет один аргумент, то ее тип описывается
следующим образом:
inc :: Integer -> Integer
inc x = x+1
Данная запись означает, что функция inc преобразует аргумент типа Integer в
результат типа Integer.
Если функция имеет два аргумента, то ее описание выглядит так:
power :: Double -> Integer -> Double
power x n = x^n
Функция power принимает два аргумента – вещественное основание x и целая
степень n, результатом же вычисления функции является вещественное число.
В общем виде описание типа функции выглядит так так:
name :: X1 -> X2 -> ... ->XN -> Y
Здесь name – имя функции, Xi – тип i-го аргумента, Y – тип функции.
Как было сказано ранее, указание типов необязательно, но его наличие может
обеспечить дополнительный контроль корректности построенных выражений в
объявлении функции. Кроме того в некоторых случаях применения полиморфных
типов невозможно определить тип функции без явного указания типа.
Перечислим некоторые стандартные функции.
Функции
Описание
+,-,*
традиционные арифметические операции
/
деление вещественных чисел
^^
возведение числа в целочисленную положительную степень
23
**
возведение числа в вещественную степень
div, mod
целочисленное деление и остаток от деления целых чисел
sqrt
квадратный корень
sin, cos, tan
тригонометрические функции
asin, acos, atan
обратные тригонометрические функции
==, /=
сравнение на равенство и неравенство
>, <, >=, <=
сравнение
||, &&
логические операции
fst
первый элемент пары (кортежа из двух элементов)
snd
второй элемент пары
head
голова (первый элемент) списка
tail
хвост (все элементы кроме первого) списка
4.4
В
Условные вычисления (ветвление)
традиционных
языках
программирования
основными
способами
организации ветвления являются условный оператор (if then else) и оператор выбора
(case или switch). Кроме них в языке Haskell используется сопоставление с образцом
в определениях функций и так называемые охраняющие выражения. Рассмотрим
подробнее каждый из этих способов.
Конструкция if-then-else
Синтаксическая конструкция «if-then-else» позволяет вычислять различные
выражения в зависимости от результатов некоторого условия:
if <условие> then <выражение1> else
<выражение2>
Здесь <условие> — некоторое выражение, имеющее тип Bool. В отличие от
императивных языков, в языке Haskell конструкция «if-then-else» является
выражением, которое обязательно должно иметь какой-либо результат. В связи с
этим ветка else является обязательной и типы выражений <выражение1> и
24
<выражение2> должны совпадать.
Рассмотрим в качестве примера функцию вычисления максимального из двух
чисел:
max a b = if a>b then a else b
Как уже было сказано, конструкция «if-then-else» является выражением,
которое имеет результат. Следовательно, ее можно использовать как часть другого
выражения:
*Main> 5 + if False then 1 else 0
5
*Main> (if True then 1 else 0) + 5
6
Заметим, что в последнем примере скобки обязательны. Без скобок выражение
будет интерпретироваться иначе:
*Main> if True then 1 else 0 + 5
1
Все, что записано после слова «else» относится к выражению ветки else.
Конструкция case
Рассмотрим в качестве примера функцию вычисления заданного числа
Фибоначчи:
fib n = case n of
1 -> 1
2 -> 1
_ -> fib (n-1) + fib (n-2)
Данную запись можно читать так: если n равняется 1 или 2, то результатом
функции является 1, иначе результатом функции является сумма предыдущих чисел
Фибоначчи.
Также как и условное выражение «if-then-else» выражение case имеет
результат и, следовательно, может быть частью других выражений.
Рассмотрим case выражение в общем виде:
25
case <выражение0> of
<образец1> -> <выражение1>
<образец2> -> <выражение2>
...
<образецN> -> <выражениеN>
Здесь результат вычисления <выражение0> последовательно сопоставляется с
образцами. При удачном сопоставлении с i-м образцом, результатом всего case будет
являться результат i-го выражения. Подробнее сопоставление с образцом будет
рассматриваться в соответствующем разделе, а пока его можно рассматривать как
сравнение с заданными константами.
Обратим внимание, что каждый образец должен быть записан на новой
строчке с отступом большим, чем отступ у зарезервированного слова case. Также
отступы перед образцами должны быть равны между собой.
Кроме того, допустим альтернативный способ записи case-выражений без
использования отступов:
fib n = case n of {1->1;2->1;_->fib (n-1) + fib (n-2)}
Такой способ более компактен, но менее нагляден. В общем виде:
case <выражение0> of { <образец1> ->
<выражение1> ; <образец2> ->
<выражение2> ; ... ; <образецN> ->
<выражениеN> }
Определения функций
В языке Haskell одна и та же функция может иметь несколько определений,
различаемых по записи ее аргументов. Запись аргумента называется образцом, и при
вычислении функции, выполняется сопоставление переданных аргументов с
образцами в определениях.
Рассмотрим в качестве примера функцию вычисления числа Фибоначчи.
fib 1 = 1
fib 2 = 1
26
fib n = fib (n-1) + fib (n-2)
При вычислении значения функции ее единственный аргумент сопоставляется
с образцами ее определений сверху вниз. Если аргументом было число 2, то первое
сопоставление окажется неудачным, а второе – удачным, и в результате функция
примет значение 1. Если же аргумент не сопоставится с образцами в первых двух
определениях, то он обязательно сопоставится с именем аргумента n (в данном
случае n примет значение переданного аргумента), и будет вычислена сумма двух
предыдущих чисел Фибоначчи.
Если же среди определений функции не будет найдено подходящего, то
возникнет ошибка и выполнение программы остановится.
Охраняющие выражения
Последним способом выбора альтернатив при вычислении результата
функций является так называемые охраняющие выражения [3]. Они (в отличие от ifthen-else и case-выражений) могут использоваться только в определениях функций:
<имя функции> <список аргументов
функции>
|<условие1> = <выражение1>
|<условие2> = <выражение2>
...
|<условиеN> = <выражениеN>
Все символы “|” должны начинаться на своей строке и с одного отступа. При
вычислении значения функции перебираются сверху вниз все условия, являющиеся
выражениями типа Bool. Когда найдется i-е условие, результат которого равен True,
вычисляется выражение i, результат которого будет результатом функции.
Например, запишем функцию нахождения числа Фибоначчи:
fib n
|n==1 = 1
|n==2 = 1
|otherwise = fib (n-1) + fib (n-2)
27
Здесь otherwise – это заведомо истинное условие, всегда равное True.
4.5
Сопоставление с образцом
Сопоставление с образцом (pattern matching) — это удобный способ связать
различные части некоторого значения с заданными именами (символами) [4,5].
Сопоставление с образцом используется в определениях и в case выражениях.
При определении функций сопоставление с образцом происходит в
аргументах. В простейшем случае, когда аргументы задаются только именами,
аргументы целиком связываются с этими именами. Например:
f x = fst x + snd x
Данная функция вычисляет сумму элементов кортежа. Стандартные функции
fst и snd получают первый и второй элемент кортежа соответственно.
*Main> f (2,4)
6
Используя сопоставление с образцом, мы можем выделить содержимое
аргумента этой функции более наглядным способом:
f (a,b) = a+b
При вычислении этой функции от (2,4) элементы этого кортежа будут
сопоставлены с указанным в определении функции образцом, то есть символ «a»
примет значение 2, а символ «b» примет значение 4.
Так мы можем определить аналоги функций fst и snd:
fst1 (x,y) = x
snd1 (x,y) = y
Обратим внимание, что в определении функции fst1 не используется x, а в
определении функции snd1 не используется y. В таких случаях, когда часть образца
(или целый аргумент) не используется нет необходимости указывать имя этой части
(или аргумента) — вместо имени достаточно указать символ подчеркивания «_».
Переопределим эти функции:
fst1 (x,_) = x
snd1 (_,x) = x
28
Используя такой способ записи, не приходится придумывать имена ненужным
частям образцов и при чтении определения функции сразу становится ясно, что эта
часть аргумента не используется.
В образцах могут указываться любые конструкторы (в предыдущих примере
использовался конструктор кортежа), например, конструкторы списка, кортежа, или
любых пользовательских типов данных, а также конкретные значения аргументов
(как в примере про числа Фибоначчи). В некоторых случаях сопоставление может
оказаться
неудачным,
тогда
происходит
попытка
использовать
следующее
сопоставление (из следующего определения или образца case). Рассмотрим пример:
f
_
0 = error "division by zero"
f (a,b) c = (a+b)/c
Здесь при вычислении функции f второй аргумент будет равен 0, то для
вычисления функции будет выбрано первое определение, иначе — второе, так как
сопоставление с именем всегда удачно. Функция error останавливает выполнение
программы с заданным текстом ошибки. Примеры использования описанной
функции:
*Main> f (1,2) 3
1.0
*Main> f (3,2) 1
5.0
*Main> f (5,5) 5
2.0
*Main> f (5,5) 0
*** Exception: division by zero
Кроме того, каждый образец может быть поименован для того чтобы к
структурированному аргументу был доступ и поэлементно и целиком. Для
именования образца используется оператор @. Перед ним идет имя, через которое
можно осуществлять доступ к аргументу как к единому целому, а затем сам образец.
Например,
func1 p@(a,b) = a + b + func2 p
29
func2 (a,b) = a*b
4.6
Списки
Список — одна из важнейших структур данных, в состав которой входят
элементы одного типа. Список не может содержать разнотипные элементы (в
отличие от кортежей). Для описания типа «список» применяются квадратные
скобки. Например, [Bool] является описанием типа списка из элементов логического
типа.
Списки создаются с помощью конструктора списка — операции «:». Список
может быть либо пустым, либо состоять из головы (первый элемент списка) и
хвоста (список из остальных элементов). Пустой список обозначается пустыми
квадратными скобками: «[]», а список, состоящий из головы x и хвоста y
записывается с помощью конструктора списка: «x:y».
Существует и более удобный способ записи списков: перечислением
элементов в квадратных скобках. Например, список целых чисел от 1 до 3 может
быть записан так:
[1,2,3] = 1:[2,3] = 1:2:[3] = 1:2:3:[]
Конструктор списка может использоваться при сопоставлении с образцом.
При таком сопоставлении передаваемый в качестве аргумента список разделится на
голову и хвост, доступ к которым может осуществляться через указанные в образце
символы. Если список будет пустым, то сопоставление в данном определении будет
неудачным.
Например, можно описать функцию взятия головы списка:
head (h:t) = h
В приведенной выше реализации функции head хвост списка не используется,
можно заменить его имя подчеркиванием:
head (h:_) = h
Аналогично можно описать операцию взятия хвоста списка.
tail (_:t) = t
Эти две функции (head и tail) вызовут ошибку при передачи им пустого
30
списка, так как сопоставление не будет успешным.
Рассмотрим еще пример функции работы со списками: функция вычисления
длины списка:
length [] = 0
length (_:t) = 1 + length t
Если входной список пуст, то в результате сопоставления сработает первое
определение, иначе – второе.
Строки в языке Haskell являются списками символов. Символы записываются
в апострофах (например, 'c'), а строки – в кавычках (например, "string"). Любую
строку можно представить в стандартной записи списков, например, строка "string"
аналогична списку ['s','t','r','i','n','g']. Работа со строками выполняется точно так же,
как со списками.
Доступ
к
элементам
списка
осуществляется
последовательно
путем
постепенного отсечения головы и хвоста. Например, чтобы получить n-й элемент
списка (начиная с 0), можно написать функцию:
getN (x:_) 0 = x
getN (_:t) n = getN t (n-1)
Взятие n-го элемента списка реализовано в стандартной функции "!!". Она
используется так:
list !! n
Здесь list – список, n – номер искомого элемента. Нумерация элементов
начинается с нуля. Если длина списка окажется меньше или рана номеру искомого
элемента, вычисление этой функции приведет к ошибке.
Кроме того, возможно задание списков из перечислимых элементов
(например, целые числа или символы) следующим образом:
[X1..X2]
Здесь X1 – начало арифметической прогрессии, а X2 – ее конец. Например,
[1..10] является списком [1,2,3,4,5,6,7,8,9,10]. Таким способом можно задавать
только возрастающие списки с шагом равным единице. Если нужно задать
последовательность с другим шагом, то можно использовать следующую запись:
31
[X1,X2..X3]
В этом варианте X1 – первый элемент последовательности, X2 – второй, X3 –
возможный последний. Шаг последовательности выбирается как X2-X1, и
последовательность содержит элементы, расположенные только между X1 и X3.
Например, [16,13..5] является списком [16,13,10,7].
Также существует способ задания списков на основе уже существующих. В
общем виде этот способ можно записать так:
[<выражение> | <образец> <- <список>,
<ограничение1>, ..., <ограничениеN>]
Из некоторого списка выбираются элементы, сопоставляемые с образцом и
удовлетворяющие всем ограничениям, и формируется список из элементов,
вычисляемых выражением, использующим этот образец. Например,
[ x^2 | x <- [1,3..30],mod x 3 == 0]
Данное выражение конструирует список из квадратов нечетных чисел от
одного до 30, которые делятся на 3. Результатом будет:
[9,81,225,441,729]
Кроме того, очень полезной при работе со списками является функция map,
определенная в стандартном модуле. Она применяет к заданному списку некоторую
функцию и возвращает список, полученный в результате применения этой функции
к элементам исходного списка. Функция map может быть реализована следующим
образом:
map :: (a -> b) -> [a] -> [b]
map f xs = [f x | x <- xs]
То
есть,
она
принимает
в
качестве
первого
аргумента
функцию,
преобразующую объекты типа a в объекты типа b, а в качестве второго аргумента –
список из элементов типа a. Результатом функции map является список элементов
типа b.
При использовании функции map удобно применять безымянные функции,
которые описываются в виде λ-выражений (рассматриваются подробнее в разделе о
функциях высшего порядка). Например, список квадратов чисел от 1 до 10 может
32
быть описан следующим образом:
map (\x->x*x) [1..10]
Результатом данной функции является список:
[1,4,9,16,25,36,49,64,81,100]
Так как в языке Haskell есть встроенная операция возведения в степень, то
данный список можно получить так:
map (^2) [1..10]
Также, можно применять и любые другие функции, например:
map inc [1,3..19]
Рассмотрим несколько функций, определенных в стандартном модуле,
предназначенных для работы со списками.
Имя функции Тип
Описание
head
[a] → a
голова (первый элемент) списка
tail
[a] → [a]
хвост (все кроме первого элемента) списка
last
[a] → a
последний элемент списка
init
[a] → [a]
все элементы списка кроме последнего
!!
[a] → Int → a
элемент с заданным номером
length
[a] → Int
вычисление длины списка
take
Int → [a] → [a]
взять из списка n первых элементов
drop
Int → [a] → [a]
отбросить из списка n первых элементов
reverse
[a] → [a]
разворачивание списка в обратном порядке
sum
(Num a) => [a] → a сумма элементов списка
product
(Num a) => [a] → a произведение элементов списка
++
[a] → [a] → [a]
конкатенация списков
33
Локальные определения
4.7
Haskell является чистым функциональным языком программирования,
следовательно в нем не может быть глобальных переменных, более того, в нем
вообще нет переменных. Никакой объект не может быть изменен, может быть
получен только новый, с использованием старого. Некоторым “заменителем”
переменных могут служить параметры функций и локальные функции.
Существует два способа описания локальных функций: с помощью
зарезервированного слова where и let. Определение локальных функций с помощью
where может использоваться только в определении функции. Определим функцию
вычисления среднего арифметического трех чисел:
average3 x y z = s / 3
where s = x+y+z
В общем виде использование where может быть записано так:
<имя функции> <аргументы> = <выражение>
where
<определение 1>
<определение 2>
...
<определение N>
Все определения должны находиться на одном и том же отступе, но большем
чем строка, содержащее where. При желании можно использовать следующий
синтаксис:
<имя функции> <аргументы> = <выражение>
where { <определение 1> ; <определение 2>
; ... ; <определение N> }
В таком случае отступ игнорируется.
Второй способ (с использованием let) может быть использован в любом
выражении (не только при определении функций). В отличие от where определения
локальных функций должны быть записаны до самого выражения Перепишем
предыдущий пример:
34
average3 x y z =
let a = x+y+z
in a / 3
В общем виде этот способ выглядит так:
let
<определение 1>
<определение 2>
...
<определение N>
in
<выражение>
Все определения должны находиться на одном и том же отступе, но большем
чем строка, содержащее let. Слово in должно находится на отступе не меньшем чем
let. При желании можно использовать следующий синтаксис:
let { <определение 1> ; <определение 2> ; ...
; <определение N> } in <выражение>
В таком случае отступ игнорируется.
4.8
Дополнительные возможности интерактивной среды
В интерактивной среде GHCi кроме вычисления функций или выражений есть
множество дополнительных возможностей. Многие из них доступны через команды,
начинающиеся с символа «:». Например, написав в строке GHCi команду «:?» можно
увидеть
список
всех
остальных
команд
(по
командам
также
работает
автодополнение). Рассмотрим наиболее полезные команды.
:r — перезагрузка всех текущих модулей (если в исходном коде что-то
изменилось).
:t — получение типа указанного выражения. Например:
*Main> :t reverse (tail "mama")
reverse (tail "mama") :: [Char]
:i — получение информации о функции (тип, в каком модуле или классе
35
определена). Например:
*Main> :i reverse
reverse :: [a] -> [a]
-- Defined in GHC.List
:l — загрузить указанный модуль и сделать его текущим.
:m — загрузить или выгрузить указанный модуль.
:q — закрыть GHCi.
Еще
одной
полезной
особенностью
является
возможность
задания
определений функций прямо в GHCi. Для этого используется упрощенная
конструкция let. Например:
*Main> let f x = x+2*x
*Main> f 7
21
Полная же конструкция let действует только в пределах своего выражения:
*Main> let z = 10 in z+z
20
*Main> z
<interactive>:1:0: Not in scope: `z'
GHCi сообщает, что данное имя не определено в текущей области видимости.
4.9
Функции высшего порядка
В языке Haskell функции могут быть использованы как любые другие объекты
— их можно хранить в списках и кортежах, передавать в другие функции,
возвращать в качестве результата работы других функций.
Функции, принимающие или возвращающие другие функции называются
функциями высшего порядка. Одной из таких функций, например, является функция
map, применяющая заданную функцию ко всем элементам списка.
Дописать как следует этот раздел
Рассмотрим функцию сложения двух чисел:
36
plus x y = x + y
Функцию plus можно описать иначе:
plus = (+)
Операция +, будучи заключенной в скобки, является функцией 2х переменных,
следовательно, применив к ней два параметра, в результате получим их сумму. Если
же к этой функции применить только один аргумент, например число 3, получим
функцию одного аргумента, которая задает прибавление к числу 3 этого аргумента:
plus3 = (3+)
или:
plus3 = (+) 3
Такой процесс постепенного разделения функций на функцию, принимающую
один параметр, и функцию, возвращающую другую функцию, вычисляющую все
остальное, называется каррированием функций.
Еще вариант описания функции plus:
plus x = (x+)
Используя функцию plus (любую вышеприведенную реализацию), можно
написать функцию инкрементации:
inc x = plus 1 x
Используя каррирование можно получить другую реализацию этой же
функции:
inc = plus 1
Получается, что функция inc возвращает функцию, прибавляющую 1 к одному
своему параметру.
Кроме такого способа записи функций в Haskell'е существует способ задания с
помощью λ-исчислений. Например, функция plus может быть реализована
следующим образом:
plus = \x y -> x+y
Здесь “\” означает начало λ-выражения, затем перечисляются параметры (x y),
и после стрелки (->) идет выражение.
37
λ-исчисления могут использоваться для задания безымянных функций.
Например, можно использовать функцию сложения, не описывая ее отдельно:
\x y -> x+y
Это выражение является функцией. Следовательно, применив к нему
параметры, получим результат.
(\x y -> x+y) 3 6
Результатом будет число 9.
Безымянные функции полезны, например, при передаче функций в качестве
параметров
другим
функциям,
когда
не
нужно
оформлять
функцию
соответствующим образом.
Если какой-либо параметр функции не используется при вычислении ее
значения, то его имя можно опустить, заменив символом “_”. Например, функция,
возвращающая первый параметр из двух, может выглядеть так:
\x _ -> x
Либо, в обычной записи:
firstoftwo x _ = x
4.10 Бесконечные структуры данных
Haskell относится к нестрогим языкам, то есть в нем используются так
называемые отложенные (ленивые) вычисления. Как уже было сказано выше, это
означает, что вычисляются только те выражения, которые необходимы для
вычисления результата функции. Рассмотрим функцию, определенную через саму
себя:
recurs = recurs
Очевидно, что её значение не может быть вычислено. При попытке это
сделать, произойдет зацикливание. Рассмотрим еще одну функцию:
func x = 1
Её значение не зависит от параметра х. Следовательно нет необходимости его
вычислять. И вычисление выражения
func recurs
38
не приведет к зацикливанию и вернет результат равный 1.
Подобные ленивые вычисления позволяют организовывать бесконечные
структуры данных, например, бесконечные списки. Один из самых простых
способов задания бесконечных списков – это задание арифметических прогрессий.
Для этого не нужно указывать конец интервала. Например, следующие списки
являются бесконечными:
[1..]
[1,3..]
[7,3..]
Они задаются только первыми двумя элементами, по которым вычисляется
шаг прогрессий. Если второй элемент прогрессии не указан (как в первом примере),
то шаг принимается равным 1.
Над этими списками возможно выполнение тех же операций и применение тех
же функций, что и для обыкновенных списков, которые не требуют для вычисления
результата всех элементов списка. К таким функциям относятся взятия головы и
хвоста, функции получения i-го элемента списка и так далее.
Бесконечные списки оказываются полезными при решении некоторых задач.
Например, вычисление факториала может быть реализовано следующим образом:
fact n = factlist !! n
factlist = fl 1 1
where fl x n = x:(fl (x*n) (n+1))
Функция определения факториала числа n (fact n) возвращает в качестве
результата n-й элемент списка factlist, который является бесконечным списком
факториалов всех натуральных чисел. Элементы этого списка вычисляются и
занимают память только при необходимости получения их значений.
Бесконечными могут быть не только списки, но и любые другие структуры
данных, определенные программистом, например деревья.
39
5
Типы данных и модули
5.1
Пользовательские типы и структуры данных
При описании типов функций могут потребоваться пользовательские
синонимы уже существующих типов. Например, при громоздком определении
некоторого типа удобно назвать его одним именем. Не очень приятно каждый раз
писать что-нибудь наподобие [(Integer,[Double->(Char,Bool)])], удобнее дать этому
типу имя, например, MyType1. Такое определение имеет вид:
type MyType1 = [(Integer,[Double->(Char,Bool)])]
Далее можно использовать это имя при определении типов функций, либо
других типов и структур данных.
Такое определение типа не описывает новую структуру данных, а только дает
имя комбинации уже существующих типов.
Для того чтобы определить собственный тип данных используется следующая
запись:
data <имя> = <значение1> | <значение2> | ... |
<значениеN>
Таким образом, описывается структура данных с именем <имя>, которая
может принимать значение 1, значение 2, и так далее до значения N. Этими
значениями типа данных являются конструкторы данных. Каждый конструктор
должен иметь уникальное имя, начинающееся с прописной буквы. Имя
конструктора данных может совпадать с именем типа данных Например, описание
типа данных «цвет» может быть представлено так:
data Color = Red | Green | Blue
Кроме того конструктор может принимать некоторые данные как функция.
Например, можно дополнить тип данных «цвет», добавив представление цвета в
комбинации красного, зеленого и синего цветов:
data Color = Red | Green | Blue | RGB Double Double Double
Такая запись означает, что объекты типа Color могут принимать значения Red,
40
Green, Blue либо RGB r g b, где r g b — вещественные числа. Например, можно
определить функцию, возвращающую желтый цвет:
white :: Color
white = RGB 1.0 1.0 0.0
Задание типов данных может быть полиморфным, так же как задание
функций. То есть, можно задать такой тип данных, который содержит в себе
некоторый другой, но не конкретный тип. Например, стандартный тип Maybe может
быть описан следующим образом:
data Maybe a = Nothing | Just a
Это означает, что объект типа (Maybe a) может быть принимать значение
Nothing, либо Just x, где x – объект некоторого типа a. Для доступа к таким объектам
используется сопоставление с образцом. Например, можно реализовать функцию
unJust, которая вернет содержимое контейнерного класса Maybe если оно не равно
Nothing.
unJust (Just a) = a
Рассмотрим еще пример:
find :: (Eq a) => a -> [a] -> Maybe Int
find _ [] = Nothing
find a (x:xs)
| x == a = Just 0
| otherwise = case (find a xs) of
(Just i) -> Just (i+1)
Nothing -> Nothing
Эта функция возвращает индекс первого вхождения объекта, переданного
первым параметром, в списке, переданном вторым параметром, или Nothing в случае
его отсутствия. В определении функции используется класс Eq, ограничивающий
типы параметров функции сравнимыми типами. Классы будут рассмотрены в
следующей главе.
С помощью ключевого слова data можно описывать любые структуры данных.
41
Опишем в качестве еще одного примера бинарное дерево:
data Tree a = Nil | Node a (Tree a) (Tree a)
Это определение говорит о том, что дерево с элементами типа a может быть
либо пустым деревом, либо состоять из элемента типа a и еще из 2х деревьев,
которые определяются аналогично.
Опишем функцию добавления элемента в бинарное дерево поиска.
addtotree Nil x = Node x Nil Nil
addtotree (Node y left right) x
|x<y = Node y (addtotree left x) right
|x>y = Node y left (addtotree right x)
|otherwise = Node y left right
При добавлении в пустое дерево результатом является дерево с единственным
узлом, содержащим добавляемый элемент. А в том случае, если дерево не пустое, то
в зависимости от отношения добавляемого элемента с элементом в корне дерева,
выбирается последующая стратегия вычисления. Если добавляемый элемент
меньше, то его необходимо добавить в левое поддерево, следовательно, результатом
будет дерево с тем же ключом и правым поддеревом, но с новым левым поддеревом,
полученным при добавлении элемента в старое. Если добавляемый элемент больше
корневого, то он результатом будет дерево с измененным правым поддеревом. Если
же добавляемый элемент совпадает с корневым, то результат совпадет с переданным
в качестве аргумента деревом.
Организуем вывод дерева на экран в форме с уступами, определив функцию
преобразования его (дерева) в строку.
showtree tree = showtr tree 0 where
showtr (Nil) n = replicate n '\t' ++ "-\n"
showtr (Node x left right) n =
showtr right (n+1) ++
replicate n '\t' ++ show x ++ "\n" ++
showtr left (n+1)
Локальная функция showtr принимает два аргумента — дерево и уровень
42
глубины. Используемая функция show является стандартной функцией для перевода
в строковый вид практически любых объектов в Haskell.
Теперь в результате вычисления выражения
addtotree (addtotree (addtotree (addtotree
(addtotree (addtotree (addtotree Nil
5) 3) 8) 1) 4) 6) 9
которое означает последовательное добавление в дерево целых чисел 5, 3, 8, 1,
4, 6, 9, получим некоторый объект типа Tree. Передав его нашей функции showtree,
получим строковое представление этого дерева.
9
8
6
5
4
3
1
-
Существует еще один способ определения пользовательских данных —
ключевое слово newtype. Его использование практически аналогично data, за
исключением одного: типы данных, описанные с помощью newtype имеют только
один конструктор данных ровно с одним полем. Этот способ используется для
создания нового типа данных на основе уже имеющегося:
newtype MyInt = MyInt Int
43
Кроме того, есть возможность определения таких структур данных, как записи
с поименованными полями. Использование обыкновенных кортежей, либо структур
данных, при использовании объектов с большим количеством различных свойств не
всегда удобно. Приходится запоминать порядок их следования, и ориентироваться
только по нему, либо заводить соответствующую функцию для каждого из свойств.
Запись с поименованными полями позволяет автоматизировать этот процесс, и
предоставляет доступ к своим элементам через имена, что значительно упрощает
работу. Их описание производится следующим образом:
<имя записи> {
<имя поля 1> :: <тип поля 1>,
<имя поля 2> :: <тип поля 2>,
...,
<имя поля N> :: <тип поля N>}
Например:
data Human = Human {
name :: String,
height :: Double,
weight ::Double}
Для задания объекта данного типа можно записать:
mike = Human{name="Mike",height=173.4,weight=81.3}
Для изменения такого объекта, точнее для получения нового, но с
измененными некоторыми полями, можно писать так:
john = mike{name="John"}
5.2
Модули
Haskell поддерживает модульное программирование, то есть программа может
быть разделена на модули, и каждый модуль может быть использован в нескольких
программах.
Каждый модуль должен иметь вид:
[module <Имя> where]
44
<код>
Если необязательная часть опущена, то данный модуль не сможет быть
использован из других модулей. Имя модуля, также как и типы данных, должно
начинаться с прописной буквы, а также совпадать с именем файла.
Для получения доступа к определениям (функции, типы данных, классы) во
внешних модулях, необходимо в самом начале программы описать их следующим
образом:
[module <Имя> where]
import <модуль1>
...
import <модульN>
<остальной код>
После этого все определения из этих модулей становятся доступными в
текущем модуле. Но если в текущем модуле определены функции или типы данных,
совпадающие по имени с импортируемыми, либо если в разных подключаемых
модулях окажутся одноименные функции или типы данных, то возникнет ошибка.
Чтобы этого избежать, необходимо запретить использование таких определений из
соответствующих модулей. Это делается следующим образом:
import <модуль> hiding (<скрываемые
определения>)
Кроме того, при описании самого импортируемого модуля есть возможность
управлять доступом к определениям. Для этого нужно перечислить доступные
определения в скобках после имени модуля:
module <Имя>(<определения>) where <код>
Из
всех
описаний
для
внешних
модулей
будут
доступны
перечисленные в скобках.
В качестве примера можно привести программу из 3х модулей:
--Prog.hs
module Prog where
import Mod1
45
только
import Mod2 hiding(modfun)
--Mod1.hs
module Mod1(modfun) where
modfun = five * 2
five = 5
--Mod2.hs
module Mod2 where
modfun = 20
Из модуля Prog доступна функция modfun, определенная в модуле Mod1, но не
доступна функция five.
46
6
Классы и монады
6.1
Классы
В Haskell'е поддерживается объектно-ориентированная парадигма. Но она
немного отличается от привычной, принятой в других языках. Экземпляром класса
является структура данных. Каждый класс предполагает описание некоторых
функций для работы с типами данных, которые являются экземплярами этого
класса.
Для определения класса используется следующая запись:
сlass [(<ограничения>) =>] <имя>
<переменная типов> where <функции>
Переменная типов именует некий тип, который должен быть экземпляром
этого класса. После слова where распологаются описания типов функций, а также
выражение некоторых из этих функций через друг друга. С помощью этих
выражений интерпретатор сможет сам определить часть функций экземпляра
класса, если задана другая часть. Ограничения – это определение того, что
экземпляр нашего класса также должен быть экземпляром перечисленных здесь
классов. Это, своего рода, наследование. Рассмотрим пример:
class Eq a where
(==), (/=) :: a -> a -> Bool
x == y
= not (x/=y)
x /= y
= not (x==y)
Класс Eq является классом сравнимых типов. Для каждого экземпляра этого
класса должны быть определены функции равенства и неравенства. Вторая строка
означает, что функции (==) и (/=) должны принимать два аргумента типа a и
возвращать объект типа Bool, то есть True или False.
Следующие строчки в определении класса говорят о том, что если определена
функция (/=), то функция (==) определяется через нее соответствующим образом, и
наоборот. Благодаря этому программисту достаточно определить только функцию
47
сравнения на равенство (или неравенство), а другую функцию интерпретатор
определит сам.
Еще пример определения класса, но с наследованием:
class Eq a => MyClass a where
myFunc :: [a] -> a -> Int
Когда класс определен, можно объявить какой-либо тип данных экземпляром
этого класса:
instance MyClass Double where
myFunc [] _ = 0
myFunc (x:xs) z
|x==z = 1 + myFunc xs z
|otherwise = myFunc xs z
Таким образом мы объявляем стандартный тип Double экземпляром нашего
класса MyClass и определяем функцию myFunc как функцию, вычисляющую
количество элементов в первом аргументе, равных второму аргументу функции.
Определив тип как экземпляр класса, можно применять к объектам этого типа
описанные при этом функции.
test = myFunc x 2 where
x :: [Double]
x = [1,2,3,2,1]
Классы часто используются при описании функций с полиморфными
параметрами. Например, если нужно описать функцию, использующую операцию
сравнения, для многих типов данных, необходимо указать, что ее параметры,
которые участвуют в сравнении, должны быть типа класса Eq, который гарантирует
реализацию соответствующей функции.
Когда программист описывает свою структуру данных, она не принадлежит ни
к какому из классов. При необходимости программист должен реализовать
соответствующие функции для своей структуры и указать ее принадлежность к
48
классу. Например, ранее описанную структуру данных Tree можно объявить
экземпляром класса Show, для того что бы интерпретатор мог выводить ее на экран,
не прибегая к ручному вызову функции showtree. Для этого напишем:
instance Show a => Show (Tree a) where
show = showtree
После этого при получении интерпретатором результата типа Tree он сможет
вывести его на экран, а также любая другая функция сможет перевести объект типа
Tree в строковый вид с помощью функции show.
Кроме того, существует еще один способ объявления принадлежности типа к
классу. Он более прост, но не всегда может удовлетворить потребности
программиста. Он заключается в перечислении нужных классов сразу при
объявлении структуры:
data <имя> = <значение1> | <значение2> | ... |
<значениеN> deriving
(<класс1>,<класс2>,...,<классM>)
Здесь необходимые функции будут при возможности выведены автоматически.
Так, например, определим наш тип Tree экземплярам класса Eq, для того чтобы
деревья можно было сравнивать между собой.
data Tree a = Nil | Tree a (Tree a) (Tree a)
deriving (Eq)
Теперь возможно сравнение деревьев операцией
“==”, и
позволяет
использовать их в функциях, требующих это.
6.2
Ввод-вывод
Возможно написать побольше про монады
Как было сказано выше, Haskell — чистый функциональный язык
программирования, то есть функции в нем не могут иметь побочных эффектов, и
порядок вычисления функций не определен. Но в некоторых случаях без этого
49
обойтись нельзя. К таким случаям можно отнести работу с пользователем, с
файлами, с базами данных и так далее. В языке Haskell предусмотрено задание
таких функций с помощью монад — особых контейнерных классов. В изучении
Haskell'а монады считается самой трудной частью, поэтому начнем сразу с
примеров. Объяснение некоторых вещей будут опущены, чтобы не запутать
читателя. Рассмотрим следующий пример:
main = do
putStrLn "Hello world!"
putStrLn "Good bye world!"
В этом примере функция main обладает побочными эффектами — в результате
ее вычисления на экране отображается текст, и в ней определен порядок вычисления
— сначала на экран выводится первая строка — затем вторая.
При использовании нотации do программирование «нечистых» функций с
помощью
монад
сводится
практически
к
привычному
императивному
программированию.
Каждая следующая строчка должна быть функцией специального типа —
монадического типа. В случае работы с вводом-выводом это тип (IO a).
Для работы с командной строкой используются следующие функции:
putChar :: Char -> IO () -- вывод символа
putStr :: String -> IO () -- вывод строки
putStrLn :: String -> IO () -- вывод строки с
переходом на новую строку
getChar :: IO Char -- прочитать символ
getLine :: IO String -- прочитать строку
IO a — это монадический тип ввода-вывода, скрывающий побочные эффекты
в себе. Тип IO String означает, что функция вернет результат, содержащий строку, а
IO () означает, что функция возвращает результат, ничего не содержащий, смысл ее
вызова только в побочных эффектах (например, вывод). Пример:
main = do
putStrLn "What is you name?"
50
name <- getLine
putStrLn ("Hi, " ++ name ++ "!")
Здесь появилось «присваивание». По аналогии с императивными языками
можно сказать, что в строке
name <- getLine
произошло присваивание переменной name результата функции getLine. Но,
как мы знаем, в Haskell'е нет переменных и значит и присваиваний. В данном случае
произошло создание некоторого объекта с именем name, значение которого равно
результату вычисления функции getLine. То есть, если после этого написать еще раз
name <- getLine
то создастся новый объект, имя которого перекроет предыдущий.
Таким образом выполняется извлечение результатов монадических функций.
Для того чтобы задать подобным образом «переменные» значениями обыкновенных
функций используется упрощенная нотация let:
let name = "John"
Ветвление вычислительного процесса осуществляется с помощью тех же if
then else и case:
main = do
putStrLn "What is you name?"
name <- getLine
case name of
"GHC" -> putStrLn "No! I am GHC!"
_ -> putStrLn ("Hi, " ++ name ++ "!")
Если ветвление должно состоять из нескольких функций, то используется
ключевое слово do:
main = do
putStrLn "What is you name?"
name <- getLine
case name of
"GHC" -> do
51
putStrLn "No! I am GHC!"
main
_ -> putStrLn ("Hi, " ++ name ++ "!")
Циклы осуществляются с помощью рекурсии, как показано выше, или с
помощью специальных функций (реализованных также рекурсивно). К таким
функциям относится функция mapM_. Принцип ее работы аналогичен функции map
для списков — она применяет монадическую функцию ко всем элементам списка и
последовательно выполняет их. Пример:
writeDigit x = do
putStr " "
putStr (show x)
main = do
putStr "Digits:"
mapM_ writeDigit [0..9]
putStrLn ""
52
7
Примеры
Привести различные примеры (деревья поиска, AVL дерево, еще что-нибудь)
53
Заключение
Заключение
54
Список использованных источников
1 http://ru.wikibooks.org/wiki/Основы_функционального_программирования
2 http://www.rsdn.ru/article/funcprog/fp.xml
3 Душкин Р.В. Функциональное программирование на языке Haskell – ДМК Пресс,
2007 – 608 с.
4 http://en.wikibooks.org/wiki/Haskell/Pattern_matching
5 http://www.haskell.org/tutorial/
55
Download