07. Модули. Ввод-вывод

Модули

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

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

Для импорта модуля используется синтаксис import <module>. Импорт модулей производится до любых определений (типов данных, функций, классов).

Модуль Data.List предоставляет функции для работы над списками, среди которых есть функция nub, удаляющая дубликаты из списка. Воспользуясь этой функцией, реализуем функцию countUnique, подсчитывающую количество уникальных элементов в списке:

import Data.List

countUnique :: Eq a => [a] -> Int
countUnique = length . nub

Здесь строчка import Data.List отвечает за импорт всех доступных функций из модуля Data.List. Если же необходимо импортировать только несколько функций, то это можно сделать, указав соответствующий список функций:

import Data.List (nub, sort)

Аналогичным образом можно указать список функций, которые не нужно импортировать:

import Data.List hiding (nub)

Иногда в одном модуле необходимо использовать функции из двух различных модулей, которые называются одинаково. Например, модуль Data.Map, в котором определяются функции для работы со структурой, позволяющей искать значения по ключам, экспортирует несколько функций, которые называются точно так же, как функции из Prelude. Например, filter или null. Таким образом, если мы импортируем модуль и попытаемся использовать функцию filter, компилятор не поймет, какую именно функцию мы хотим применить (заметьте, что функция filter не является методом класса типов и поэтому не является полиморфной: если бы это было так, компилятор смог бы определить нужную функцию из контекста). Проблема решается следующим образом:

import qualified Data.Map

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

import qualified Data.Map as Map

Теперь достаточно написать Map.filter. В принципе, можно было бы оставить одну букву и обращаться к функции как M.filter. Но на практике код получается более приспособленным для чтения, если из контекста понятно, о каком модуле идет речь. Кроме того, для структур данных часто используется следующая комбинация двух импортов:

import Data.Map (Map)
import qualified Data.Map as Map

Теперь при использовании структуры данных при записи типа не нужно указывать Map.Map, достаточно написать просто Map. При этом функции для работы с этой структурой обязаны иметь имя модуля в качестве префикса, например: Map.filter или Map.lookup.

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

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

module Logger ( Log, tell ) where

В данном случае модуль экспортирует конструктор типа Log и функцию tell. Вот пример реализации модуля:

data Log a = Log a String

instance Functor Log where
    fmap f (Log x sx) = Log (f x) sx

instance Monad Log where
    return x = Log x “”
    (Log x sx) >>= f = Log y (sx ++ sy)
        where Log y sy = f x

tell :: String -> Log ()
tell = Log ()

Заметьте, что модуль экспортирует только конструктор типа Log, но не конструктор значения! Поэтому, импортируя этот модуль, пользователь сможет полагаться только на интерфейс — классы типов Functor и Monad, а также на функцию tell. Если бы мы хотели также экспортировать конструкторы значений, можно было бы написать так:

module Logger (Log(Log), tell) where

или так:

module Logger (Log(..), tell) where

Во втором случае не приходится перечислять все конструкторы структуры данных.

Ввод-вывод в Haskell

Операции ввода-вывода всегда подразумевают некоторые побочные эффекты. Например, в виде печати сообщения на экране. Но Haskell — чистый язык, каким образом можно добавить возможность ввода-вывода не нарушая чистоту? Очень просто — мы можем инкапсулировать побочные эффекты ввода-вывода в специальную монаду! Эта монада называется IO. Самая простая программа с использованием ввода-вывода может выглядеть так:

main :: IO ()
main = putStrLn "Hello, world!"

Как и ожидается, эта программа печатает сообщение Hello, world! на экране. Функция putStrLn имеет тип String -> IO () и просто печатает строчку (с переводом строки). Функция main — как и в других языках — представляет собой саму программу.

Поскольку мы уже познакомились с монадами, предположить, как устроен ввод-вывод в Haskell не составляет труда. Действительно, простая реализация монады IO может быть такой:

data IO a = IO (World -> (a, World))

Здесь World — это структура данных, представляющая внешний мир. Сразу становится понятным смысл типа IO a — это вычисление, которое взаимодействует со внешним миром и возвращает значение типа a. На самом деле, мы уже встречали подобную структуру данных:

data State s a = State (s -> (a, s))

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

Стандартный модуль Prelude экспортирует большинство необходимых функций для работы с IO. Вот некоторые из них:

Функции вывода

-- вывод символа в стандартный поток вывода
putChar :: Char -> IO ()

-- вывод строки в стандартный поток вывода
putStr :: String -> IO ()

-- то же, что и putStr, но добавляет перевод строки в конце
putStrLn :: String -> IO ()

-- печатает значение любого печатаемого типа.
-- Обычно функция print реализована просто как композиция show и putStrLn:
print :: Show a => a -> IO ()
print = putStrLn . show

Функции ввода

-- чтение символа из стандартного потока ввода.
getChar :: IO Char

-- чтение строки из стандартного потока ввода.
getLine :: IO String

-- чтение всего стандартного потока ввода.
-- Чтение осуществляется ленивым образом:
-- очередная порция потока считывается только при необходимости.
getContents :: IO String

-- вызов interact f передает весь входной поток ввода в качестве
-- параметра функции f и выводит в стандартный поток вывода результат работы функции.
interact :: (String -> String) -> IO ()

Работа с файлами

-- синоним типа для путей файлов и директорий.
-- Конкретная интерпретация строки целиком лежит на операционной системе.
type FilePath = String

-- чтение содержимого файла.
-- Чтение осуществляется лениво, как в случае getContents.
readFile :: FilePath -> IO String

-- запись содержимого в файл
writeFile :: FilePath -> String -> IO ()

-- добавление содержимого в файл (приписывание в конец).
appendFile :: FilePath -> String -> IO ()

-- то же, что и read, но в случае ошибки разбора,
-- вызывается исключение в монаде IO, а не завершение программы.
readIO :: Read a => String -> IO a

-- комбинация getLine и readIO:
readLn :: Read a => IO a
readLn = getLine >>= readIO