Основы функционального программирования/Haskell/Модули и монады
Основы функционального программирования
- Вводная лекция
- Структуры данных и базисные операции:
Языки программирования не обходятся без понятия модуля, разве что за исключением языков самого низкого уровня, да и те последнее время обретают некоторые высокоуровневые свойства. Модули появились, когда программирование только зарождалось и возникла надобность разбивать программы на логические части, каждую из которых создавал бы отдельный разработчик или коллектив.
В этой лекции освещается, как в Haskell работают модули и более интересное новичкам явление монады.
Модули
правитьВ Haskell у модулей двоякое назначение: с одной стороны, — контроль за пространством имён (как и во всех других языках), с другой, — создание абстрактных типов данных.
Определить модуль просто: имя состоит из любых символов, лишь начинаться оно должно с заглавной буквы. Имя модуля никак не связано с файловой системой (как, например, в Паскале или Java): файл, содержащий описание модуля, может называться отлично от самого модуля; также, он может содержать несколько модулей, ибо модуль есть лишь декларация самого высокого уровня.
На верхнем уровне модуля в Haskell может быть множество деклараций (описаний и определений): типы, классы, данные, функции. Один вид деклараций, если он вообще используется, должен стоять в модуле на первом месте: это включение в модуль других модулей, что делается словом import
. Другие определения могут появляться в любой последовательности.
Определение модуля должно начинаться со служебного слова module
. Ниже приведено определение модуля Tree
:
module Tree (Tree (Leaf, Branch), fringe) where
data Tree a = Leaf a
| Branch (Tree a) (Tree a)
fringe :: Tree a -> [a]
fringe (Leaf x) = [x]
fringe (Branch left right) = fringe left ++ fringe right
Описан один тип: Tree
(не беда, что имя типа совпадает с названием модуля: здесь они в различных пространствах имён) и одна функция: fringe
. Здесь модуль Tree
явно экспортирует тип Tree
(вместе со своими подтипами Leaf
и Branch
) и функцию fringe
, для этого имена типа и функции указаны в скобках после имени модуля. Если наименование какого-либо объекта не указывать в скобках, то он не будет экспортироваться, то есть этот объект не будет виден извне текущего модуля.
Использование модуля в другом модуле выглядит ещё проще:
module Main where
import Tree (Tree(Leaf, Branch), fringe)
main = print (fringe (Branch (Leaf 1) (Leaf 2)))
В приведенном примере видно, что модуль Main
импортирует модуль Tree
, причём в декларации import
явно описано, какие именно объекты импортируются из модуля Tree
. Если это описание опустить, то импортироваться будут все объекты, которые модуль экспортирует, то есть в данном случае можно было просто написать: import Tree
.
В обычной практике один модуль импортирует несколько других, но при этом в импортируемых модулях есть объекты с одинаковым именем. Естественно, что в этом случае возникает конфликт имён. Чтобы этого избежать, в Haskell существует специальное служебное слово qualified
, при помощи которого определяются те импортируемые модули, имена объектов в которых приобретают вид: <Имя Модуля>.<Имя Объекта>
, то есть для того, чтобы обратиться к объекту из квалифицированного модуля, перед его именем необходимо написать имя модуля:
module Main where
import qualified Tree
main = print (Tree.fringe (Tree.Leaf 'a'))
Использование такого синтаксиса полностью лежит на совести программиста. Некоторым нравится полная определённость, которую привносят квалифицированные имена, и они используют их в ущерб размеру программ. Другим нравится использовать короткие мнемонические имена, и они используют квалификаторы (имена модулей) только по необходимости.
Абстрактные типы
правитьВ Haskell модуль является единственным способом создать так называемые абстрактные типы данных, то есть такие, в которых скрыто представление типа, но открыты только специфические операции над созданным типом, набор которых вполне достаточен для работы с типом. Например, хотя тип Tree
является достаточно простым, его всё-таки лучше сделать абстрактным типом, то есть скрыть то, что Tree
состоит из Leaf
и Branch
. Это делается следующим образом:
module TreeADT (Tree, leaf, branch, cell, left, right, isLeaf) where
data Tree a = Leaf a
| Branch (Tree a) (Tree a)
leaf = Leaf
branch = Branch
cell (Leaf a) = a
left (Branch l r) = l
right (Branch l r) = r
isLeaf (Leaf _) = True
isLeaf _ = False
Видно, что к внутренностям типа Tree
внешний пользователь (программист) может пробраться, только используя определённые функции. Впоследствии, когда создатель этого модуля захочет изменить представление типа (например, оптимизировать его), ему необходимо будет изменить и функции, которые оперируют полями типа Tree
. В свою очередь, программист, использовавший в своей программе тип Tree
, ничего менять не будет, ибо его программа всё так же останется работоспособной.
Другие аспекты использования модулей
правитьДалее приводятся дополнительные аспекты системы модулей в Haskell:
- В декларации импорта (
import
) можно выборочно спрятать некоторые из экспортируемых объектов служебным словомhiding
. Это бывает полезным для явного исключения определений некоторых объектов из импортируемого модуля. - При импорте можно определить псевдоним модуля для квалификации имён, экспортируемых из него объектов. Для этого используется служебное слово
as
. Это может быть полезным для укорачивания имен модулей. - Все программы неявно импортируют модуль
Prelude
. Если сделать явный импорт этого модуля, то в его декларации возможно скрыть некоторые объекты, чтобы впоследствии их переопределить. - Все декларации
instance
неявно экспортируются и импортируются всеми модулями. - Методы классов могут быть так же как и подтипы данных перечислены в скобках после имени соответствующего класса во время декларации экспорта/импорта.
Монады
правитьМногие новички в функциональном программировании бывают часто озадачены понятием монады в Haskell. Но монады очень часто встречаются в языке: так, например, система ввода-вывода основана именно на понятии монады, а стандартные библиотеки содержат целые модули, посвящённые монадам. Необходимо отметить, что понятие монады в Haskell основано на теории категорий, но чтобы не вдаваться в абстрактную математику, далее будет представлено интуитивное понимание монад.
Монады суть типы, представляющие собой экземпляры одного из следующих монадических классов: Functor
, Monad
и MonadPlus
. Ни один из этих классов не может быть предком для другого класса, то есть монадические классы ненаследуемы. В модуле Prelude
определены три монады: IO
, []
и Maybe
, то есть список также является монадой.
Математически монада определяется через набор правил, которые связывают операции, производимые над монадой. Эти правила дают интуитивное понимание того, как должна использоваться монада и какова её внутренняя структура. Для конкретизации далее рассматривается класс Monad
, в котором определены две базовые операции и одна функция:
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
m >> k = m >>= \_ -> k
Две операции (>>=)
и (>>)
— это операции связывания. Они комбинируют два монадических значения, тогда как функция return
преобразует переданное значение некоторого типа a
в монадическое значения типа m a
. Сигнатура операции (>>)
помогает понять операцию связывания: выражение m a >>= \v -> m b
комбинирует монадическое значение m a
, содержащее объект типа a
, с функцией, которая оперирует значением v
типа a
и возвращает результат типа m b
. Результатом же комбинации будет монадическое значение типа m b
. Операция (>>)
используется тогда, когда функция не использует значения, полученного первым монадическим операндом.
Точное значение операции связывания конечно же зависит от конкретной реализации монады. Так, например, тип IO
определяет операцию (>>=)
как последовательное выполнение двух её операндов, а результат выполнения первого операнда последовательно передаётся во второй. Для двух других встроенных монадических типов (списки и Maybe
) эта операция определена как передача нуля или более параметров из одного вычислительного процесса в следующий.
В Haskell определено служебное слово, на уровне языка поддерживающее использование монад, — слово do
, понимание которого можно увидеть в следующих правилах его применения:
do e1 ; e2 = e1 >> e2
do p <- e1 ; e2 = e1 >>= \p -> e2
Первое выражение выполняется всегда (переноса значений из первого операнда во второй не производится). Во втором выражении может произойти ошибка, в этом случае производится вызов функции fail
, которая также определена в классе Monad
. Поэтому более точное определение служебного слова do
для второго случая будет выглядеть так:
do p <- e1 ; e2 = e1 >>= (\v -> case v of
p -> e2
_ -> fail "s")
где s
— это строка, которая может определять местоположение оператора do
в программе, а может просто нести некоторую семантическую нагрузку. Например, в монаде IO
действие ('a' -> getChar)
вызовет функцию fail
в том случае, если считанный символ не является символом 'a'
. Это действие прерывает исполнение программы, так как определённая в монаде IO
функция fail
в свою очередь вызывает системную функцию error
.
Класс MonadPlus
используется для тех монад, в которых есть нулевой элемент и операция +
. Определение этого класса выглядит следующим образом:
class (Monad m) => MonadPlus m where
mzero :: m a
mplus :: m a -> m a -> m a
Нулевой элемент в этом классе монад подчиняется следующим правилам:
m >>= \x -> mzero = mzero
mzero >>= m = mzero
Например, для списков нулевым элементом является пустой список []
, а операцией +
— конкатенация списков. Поэтому монада списков является экземпляром класса MonadPlus
. С другой стороны, монада IO
не имеет нулевого элемента, поэтому является экземпляром только класса Monad
.
Встроенные монады
правитьДля конкретизации полученных сведений необходимо рассмотреть что-нибудь более приземлённое. Так как списки являются монадами и в то же время списки достаточно скрупулёзно изучены, они являются отличным материалом, на котором можно рассмотреть практическое применение механизма монад.
Для списков операция связывания обретает смысл в соединении вместе набора операций, производимых над каждым элементом списка. При использовании со списками сигнатура операции (>>=)
обретает следующий вид:
(>>=) :: [a] -> (a -> [b]) -> [b]
Это обозначает, что дан список значений типа a
и функция, которая проецирует значение типа a
на список значений типа b
. Связывание применяет функцию к каждому элементу данного списка значений типа a
и возвращает полученный список значений типа b
. Эта операция уже известна: определители списков работают именно таким образом. То значит, что следующие три выражения абсолютно одинаковы:
Выражение 1
[(x, y) | x <- [1, 2, 3], y <- [1, 2, 3], x /= y]
Выражение 2
do x <- [1, 2, 3]
y <- [1, 2, 3]
True <- return (x /= y)
return (x, y)
Выражение 3
[1, 2, 3] >>= (\x -> [1, 2, 3] >>= (\y -> return (x /= y) >>=
(\r -> case r of
True -> return (x, y)
_ -> fail "")))
Какое выражение использовать в написании программ — выбирать программисту.
Упражнения
править- Как можно интуитивно понять явление монады?
- Какой практический смысл у монад в функциональном программировании?
Ответы для самопроверки
править1. Первое, что приходит в голову, — это то, что монада — это контейнерный тип. Ведь действительно, список — это контейнерный тип, так как внутри списка содержатся элементы другого типа. Именно это и показано в определении монадического типа:
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
Запись m a
как бы показывает, что тип a
(необходимо чётко помнить, что при определении классов и других типов данных символы типа a
, b
и так далее обозначают переменные типов) обрамлён монадическим типом m
. Однако в реальности физическое обрамление доступно только для монадического типа «список», так как его обозначение в виде квадратных скобок пошло традиционно. В строгой нотации Haskell нужно было бы писать что-нибудь вроде: List (1 2 3 4 5)
— это список [1, 2, 3, 4, 5]
.
2. Применение монад в функциональных языках — это по существу возвращение к императивности. Ведь операции связывания (>>=)
и (>>)
предполагают последовательное выполнение связанных выражений с передачей или без результатов вычисления. То есть монады — это императивное ядро внутри функциональных языков. С одной стороны, это идёт в разрез с теорией функционального програмирования, где отрицается понятие императивности, но, с другой стороны, некоторые задачи решаются только при помощи императивных принципов. И опять же, Haskell предоставляет удивительную возможность по генерации списков, но это только благодаря тому, что сам тип «список» выполнен в виде монады.