Функциональное программирование. Конспект лекций
.pdf
D:\MyHaskell> ghc helloWorld.hs
Однако при разработке и отладке программ гораздо удобнее пользоваться интерпретатором или GHCi (рис. 1.1), или, если вы работаете под Windows, WinGHCi (рис. 1.2). Для исполнения программы интерпретатором ее надо загрузить и далее ввести имя исполняемой функции с заданными аргументами. Загрузка выполняется с помощью пункта меню File –> Load. Если код программы не содержит ошибок, то загрузка проходит нормально. Если же присутствуют ошибки, то интерпретатор выдает сообщение на ошибку, указывает номер строки и столбца в программе, а также тип ошибки. Далее следует ввести имя функции и, если надо, значения аргументов.
Рис. 1.1. Интерпретатор GHCi
Рис. 1.2. Интерпретатор WinGHCi
– 11 –
Приведенная выше программа содержит только одну функцию – main без аргументов. Для ее запуска введем main и получим результат выполнения функции:
Prelude Main> main
Hello, World!!!
Порядок записи функций не существенен. Поэтому в один файл можно записывать совершенно разные функции, но вызывать на выполнение ту, которая требуется. После обновления файла (внесения исправлений или записи новой функции) следует его перезагрузить в интерпретатор с помощью пункта меню Actions –> Reload (или кнопки Reload files).
1.3. Элементы языка Haskell
Haskell – строго типизированный язык. Любая конструкция в языке, задающая некоторый объект, имеет определенный тип, который можно «вычислить» еще до начала выполнения программы, т.е. статически. Основу системы типов в Haskell составляют элементарные встроенные типы данных:
целые (Integer и Int), вещественные (Float и Double), логические (Bool = True | False),
символьные (Char).
Все идентификаторы в Haskell чувствительны к регистру букв. Существует два вида идентификаторов:
идентификаторы типа или класса (начинаются с заглавной буквы), идентификаторы простых и сложных типов, функций (начина-
ются со строчной буквы или символа подчеркивания). Идентификаторы строятся из букв, подчеркиваний и цифр, а
также символа «’» («апостроф»). Первым символом идентификатора должна быть буква или подчеркивание. Обычно апостроф используется в именах функций, вспомогательных по отношению к функции, заданной тем же именем, только без апострофов.
Тип Integer определяет потенциально бесконечный набор целых чисел произвольной длины. В операциях над целыми типа Integer никогда не происходит «переполнения». Для таких «ограниченных» целых используется идентификатор типа Int. Типы Float и
– 12 –
Double определяют вещественные числа, такие как 3.14, -2.71828 или 0.12е-10. Символьный тип Char имеет литеральные вида ‘a’.
Логический тип представлен двумя значениями – True и False (истина и ложь).
В программе можно вводить свои собственные идентификаторы заданного типа:
var::Int var = 777
Через двойное двоеточие (::) указывается, что константа var имеет тип Int, а значение равно 777. Следует отметить, что var – это не переменная, это константа, синоним значения 777, которую можно использовать в дальнейшем, но нельзя изменять.
Значения произвольных типов можно объединять в кортежи, например:
pair :: (Int, Char) pair = (2, 'a')
В Haskell имеется большое количество библиотек, в которых реализовано множество полезных функций. Если выбрать пункт меню Help –> GHC Documentation –> Libraries, то можно увидеть большое количество иерархически реализованных библиотек
(рис. 1.3).
Рис. 1.3. Библиотеки Haskell
– 13 –
Для поиска функций в библиотеке удобно пользоваться индексом. Работа с функциями имеет определенную специфику. При вызове функций аргументы отделяются от идентификатора пробелом. Эта операция имеет наивысший приоритет и называется применением функции. Операторы, задающие арифметические операции, могут рассматриваться как обычные двуместные функции. Для превращения знака бинарной операции в двуместную функцию надо лишь заключить знак операции в скобки, так что запись x + y полностью эквивалентна записи (+) x y. Возможен и обратный синтаксис. Для того чтобы превратить обычный идентификатор функции двух аргументов в бинарный оператор, достаточно заключить идентификатор функции в обратные апострофы (клавиша на клавиатуре в левом верхнем углу с буквой Е). Если имеется функция func двух аргументов (т.е. бинарная операция), то данную функцию можно применить двумя способами:
func x y (традиционный способ применения)
и
x ` func` y (с помощью обратных апострофов).
Каждая функция в Haskell – это некоторое значение, для которого определен тип. В типе функции указывают типы ее аргументов и тип значения функции, при этом типы аргументов и значения функции отделяются друг от друга символом '->', так, например, тип функции f1 одного вещественного аргумента с вещественным результатом записывается как
f1::Double -> Double,
а тип функции f2 с двумя целыми аргументами и одним логическим результатом записывается в виде
f2::Int -> Int -> Bool.
Определение функции записывается в виде уравнения, в котором определяется вычисление при задании значений аргументов. Например, функция удвоения, которая удваивает значение своего вещественного аргумента и выдает получившееся значение, может быть описана как
twice :: Double -> Double twice x = 2 * x
– 14 –
где twice – идентификатор функции, которая принимает аргумент типа Double и возвращает значение типа Double. В определении функции используется функция-оператор умножения.
В уравнении, определяющем поведение функции twice, имеется левая часть, задающую форму вызова функции, и правая часть, в которой записывается выражение, заменяющее вызов функции в тот момент, когда будет задано значение ее аргумента. Вычисление выражения twice 7.5 можно представить следующим образом. Сначала происходит сопоставление фактического значения аргумента 7.5 с формальным аргументом функции, заданном в уравнении, определяющем поведение функции. Потом вызов функции заменяется правой частью уравнения, в которой вместо формального аргумента используется сопоставленное с ним значение фактического аргумента. Таким образом, после сопоставления и замены вместо выражения twice 7.5 получаем выражение 2 * 7.5, которое после применения операции умножения дает значение 15.
Рассмотренное выше преобразование выражений называют также редукциями. Можно сказать, что вычисление выражений в Haskell (исполнение программы) осуществляется с помощью последовательных редукций исходного выражения.
1.4. Ленивые вычисления в Haskell
В Haskell используется стратегия отложенных или ленивых вычислений, смысл которой состоит в том, что вычисление выражения производится только тогда, когда результат вычисления действительно необходим. Рассмотрим функцию логического «или», уравнение которой может выглядеть так:
a `cor` b = if a then True else b.
Вычислим результат вызова этой функции, передав ей в качестве аргументов два логических выражения:
(x = = 0) `cor` (y / x < 5).
Если x=2 и y=6, то вычисление происходит одинаково, независимо от способа передачи аргументов в функцию cor. Первый аргумент при вычислении в данном контексте выдаст значение False, а второй аргумент – значение True. Результатом вычисления всего выражения будет, соответственно, True.
– 15 –
Однако при x=0 и y=1 вычисление второго аргумента приведет к ошибке, несмотря на то, что первый аргумент будет иметь значение True, и ветвь вычислений else, в которой происходит обращение ко второму аргументу b, вообще не будет выполняться, так что от значения второго аргумента результат работы функции не зависит. Но если аргументы будут передаваться в функцию по значению, то вычисление значений аргументов будет произведено еще до начала работы функции, так что в данном контексте программа закончится аварийно. Если же аргументы будут передаваться по необходимости, то до обращения ко второму аргументу дело не дойдет, поскольку функция после вычисления значения первого аргумента сразу же выдаст значение True.
В языке Haskell по умолчанию принята схема передачи аргументов по необходимости. Поскольку никакие выражения в Haskell, вообще говоря, не могут обладать побочным эффектом (вычисление или невычисление выражения никак не влияет на глобальный контекст вычислений), то применение ленивой схемы вычислений не может изменить результат работы программы по сравнению с энергичной схемой вычислений.
– 16 –
Лекция 2. Синтаксис и структура программ
Описываются синтаксис и структура программ на языке Haskell. Дано понятие модуля, особенности импорта и экспорта модулей и функций. Рассмотрены типы данных, способы конструирования пользовательских типов данных. Рассматриваются класс типов данных и экземпляры класса типов. Рассмотрен двумерный синтаксис программ на языке Haskell.
Ключевые слова: тип данных; класс типов; модуль; реализация класса типов.
Введение. Программы на Haskell бывают двух видов [2]: приложения (executable) и библиотеки (library). Приложения представляют собой исполняемые файлы, которые решают некоторую задачу. Библиотеки содержат отдельные значения, функции, которые можно использовать в других программах на Haskell.
Программа состоит из модулей (module). В одном файле должен находиться только один модуль – один файл. Имя модуля совпадает с именем файла. Имя модуля начинается с большой буквы, файлы имеют расширение .hs. Например FirstModule.hs. Типичный модуль в Haskell имеет вид:
module ModuleName (определение1, определение2,..., определе-
ниеN) where
import Module1 (...) import Module2 (...)
...
-- определения определение1 определение2
...
Каждый модуль содержит набор определений. Определения де-
лятся на экспортируемые и внутренние. Экспортируемые опреде-
ления могут быть использованы за пределами модуля, а внутренние – только внутри модуля, и обычно они используются в выражениях для экспортируемых определений.
Модуль состоит из двух частей – заголовка и определений.
– 17 –
В заголовке после директивы module объявляется имя модуля, за которым в скобках следует список экспортируемых определений; после скобок стоит слово where. Затем идет список импортируемых модулей, определения которых будут использованы.
Как после имени модуля, так и в директиве import скобки с определениями можно не писать, так как в этом случае считается, что экспортируются/импортируются все определения. Можно явно указать импортируемые функции, конструкторы типов, методы класса типов, например:
module ModuleName where
import Prelude(Bool(..), Show(..), Eq(..))
Две точки в скобках означают «все конструкторы» (в случае типа) и «все методы» (в случае класса типа).
Можно скрыть определение некоторых функций из модуля, например:
import Prelude hiding (id, (>>), pred, (*>), (+>), sequence)
Здесь производится скрытие методов id, (>>), pred, (*>), (+>), sequence, определенных в модуле Prelude, так как эти методы будут переопределены разработчиком.
При разработке любой программы по умолчанию загружается модуль Prelude, который содержит большинство необходимых определений типов, классов типов, функций и т.п.
Комментарии в Haskell начинаются с двух дефисов «--» и продолжаются до конца строки. Комбинациями символов {- … -} ограничиваются многострочные комментарии.
Haskell определения могут описывать четыре вида сущностей: типы; значения; классы типов;
экземпляры классов типов.
2.1. Типы
Типы представляют собой каркас программы. Они описывают все возможные значения. Например:
– 18 –
not :: Bool -> Bool
Выражение var :: T означает, что значение v имеет тип T. Стрелка a -> b означает функцию, т.е. из типа a получается тип b.
В данном примере функция not из Bool в Bool представляет собой логическую операцию «не». Маленькие буквы a или b в описании типа функции (f::a->b) являются параметрами типа, т.е. на место параметра может быть поставлен любой тип.
Программа представляет собой описание некоторого явления или процесса. Типы определяют основные слова или термины и способы их комбинирования. А значения представляют собой комбинации базовых слов. Значения комбинируются не произвольным образом, а на основе определенных правил, которые задаются типами.
Помимо встроенных типов разработчик может задавать собственные типы с помощью ключевого слова data. Например, можно задать типы данных для календаря:
-- Дата
data Date = Date Year Month Day -- Год
data Year = Year Int -- Int это целые числа
-- Месяц
data Month = January | February | March | April | May | June | July | August | September | October | No-
vember | December data Day = Day Int -- Неделя
data Week = Monday | Tuesday | Wednesday
| Thursday | Friday | Sat- |
|
urday |
|
| Sunday |
|
-- Время |
|
data Time = Time Hour Minute Second |
|
data Hour = Hour Int |
-- Час |
data Minute = Minute Int |
-- Минута |
data Second = Second Int |
–- Секунда |
– 19 –
При определении типа data Month = January | February использу-
ется символ «|», который обозначает возможные значения типа
Month, т.е. тип Month может быть или словом January, или February и т.д. Перечисленные альтернативы January, February, представляют из себя конструкторы создания выражений типа Month.
Выражение data Date = Date Year Month Day означает, что тип
Date создается (конструируется) с помощью конструктора Date, которому в качестве параметров передаются типы Year Month Day. В свою очередь, тип Year описан в выражении
data Year = Year Int
и создается с помощью конструктора Year, которому в качестве параметра передается тип Int. Имя типа Year и имя конструктора Year могут быть разными, так как они относятся к разным доменам имен.
С помощью ключевого слова type определяется синоним типа. При этом не вводится новый тип, а лишь определяется для него псевдоним. Например, String является синонимом для списка значений типа Char (тип Char представляет символы):
type String = [Char].
Типы делят выполнение программы на две стадии: компиляцию (compile-time) и вычисление (run-time). На этапе компиляции происходит проверка типов. Программа, которая не прошла проверку типов, считается бессмысленной и не вычисляется.
2.1.1. Рекурсивные типы
Типы могут быть рекурсивными, т.е. одним из подтипов в определении типа может быть сам определяемый тип. Например:
data Nat = Zero | Succ Nat.
Значение типа Nat может быть или Zero, или формироваться с помощью конструктора Succ с аргументом типа Nat. Таким простым и коротким определением описываются все положительные числа. Рекурсивные определения типов приводят к рекурсивным функциям. Сложение и умножение выглядят как
(+)a Zero = a
(+)a (Succ b) = Succ (a + b)
– 20 –
