Основы функционального программирования/Операции ввода/вывода в Haskell'е

Лекции по функциональному программированию
1. Вводная лекция
2. Структуры данных и базисные операции
3. Структуры данных и базисные операции — 2
4. Основы языка Haskell
5. Служебные слова и синтаксис Haskell'а
6. Модули и монады в Haskell'е
7. Операции ввода/вывода в Haskell'е
8. Конструирование функций
9. Доказательство свойств функций
10. Формализация ФП на основе λ-исчисления
11. Трансформация программ

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

Уже говорилось, что все операции ввода/вывода построены при помощи такого понятия языка Haskell, как монада (лекция 6). В то же время для понимания системы операций ввода/вывода в Haskell’е нет особой необходимости понимать теоретические основы понятия монада. Монады можно рассматривать как концептуальные рамки, в которых содержится система ввода/вывода. Можно сказать, что понимание теории категорий так же необходимо для использования системы операций ввода/вывода в Haskell’е, как и понимание теории групп для выполнения арифметических операций.

Операции ввода/вывода в любом языке основаны на понятии действия. При возбуждении действия, оно выполняется. Однако в Haskell’е действия не возбуждаются, а скорее просто декларируются. В свою очередь действия могут быть атомарными или составленными из последовательности других действий. Монада IO содержит операции, которые позволяют создавать сложные действия из атомарных. Т.е. монаду в данном случае можно рассматривать как клей, который связывает действия в программе.

Базовые операции ввода/выводаПравить

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

getChar	:: IO Char

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

putChar	:: Char -> IO ()

Друг с другом действия связываются при помощи оператора связывания. Т.е. символы >>= выстраивают последовательность действий. Как известно, вместо этой функции можно использовать служебное слово do. Оно использует такой же двумерный синтексис, как и слова let и where, поэтому можно не использовать для разделения вызова функций символ « ; ». При помощи слова do можно связывать вызовы функций, определение данных (при помощи символов « -> ») и множество определений локальных переменных (служебное слово let).

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

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

В этом примере не случайно для имени функции выбрано слово main. В Haskell’е, также, как и в языке C/C++ название функции main используется для обозначения точки входа в программу. Кроме того, в Haskell’е тип функции main должен быть типом монады IO (обычно используется IO ()). Ко всему прочему, точка входа в виде функции main должна быть определена в модуле с именем Main. Пусть имеется функций ready, которая должна возвращать True, если нажата клавиша «y», и False в остальных случаях. Нельзя просто написать:

ready :: IO Bool
ready = do c <- getChar
           c == ’y’

Потому что в этом случае результатом выполнения операции сравнения будет значение типа Bool, а не IO Bool. Для того, чтобы возвращать монадические значения, существует специальная функция return, которая из простого типа данных делает монадический. Т.е. в предыдущем примере последняя строка определения функции ready должна была выглядеть как «return (c == ‘y’)». В следующем примере показана более сложная функция, которая считывает строку символов с клавиатуры:

Пример 16. Функция getLine.

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

Необходимо помнить, что в тот момент, когда программист перешёл в мир действий (использовал систему операций ввода/вывода), назад пути нет. Т.е. если функция не использует монадический тип IO, то она не может заниматься вводом/выводом, и наоборот, если функция возвращает монадический тип IO, то она должна подчиняться парадигме действий в Haskell’е.

Программирование при помощи действийПравить

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

todoList :: [IO ()]
todoList = [putChar ’a’,
            do putChar ’b’
               putChar ’c’,
            do c <- getChar
               putChar c]

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

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

Эта функция может быть полезна для написания функции putStr, которая выводит строку на экран:

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

На этом примере видно явное отличие системы операций ввода/вывода языка Haskell от систем императивных языков. Если бы в каком-нибудь императивном языке была бы функция map, она бы выполнила кучу действий. Вместо этого в Haskell’е просто создается список действий (одно для каждого символа строки), который потом обрабатывается функцией sequence_ для выполнения.

Обработка исключенийПравить

Что делать, если в процессе операций ввода/вывода возникла неординарная ситуация? Например, функция getChar обнаружила конец файла. В этом случае произойдет ошибка. Как и любой продвинутый язык программирования Haskell предлагает для этих целей механизм обработки исключений. Для этого не используется какой-то специальный синтаксис, но есть специальный тип IOError, который содержит описания всех возникаемых в процессе ввода/вывода ошибок.

Обработчик исключений имеет тип (IOError -> IO a), при этом функция catch ассоциирует (связывает) обработчик исключений с набором действий:

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

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

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

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

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

В этой программе видно, что можно использовать вложенные друг в друга обработчики ошибок. В функции getChar’ отлавливается ошибка, которая возникает при обнаружении символа конца файла. Если ошибка другая, то при помощи функции ioError она отправляется дальше и ловится обработчиком, который «сидит» в функции getLine’. Для определённости в Haskell’е предусмотрен обработчик исключений по умолчанию, который находится на самом верхнем уровне вложенности. Если ошибка не поймана ни одним обработчиком, который написан в программе, то её ловит обработчик по умолчанию, который выводит на экран сообщение об ошибке и останавливает программу.

Файлы, каналы и обработчикиПравить

Для работы с файлами Haskell предоставляет все возможности, что и другие языки программирования. Однако большинство этих возможностей определены в модуле IO, а не в Prelude, поэтому для работы с файлами необходимо явно импортировать модуль IO. Открытие файла порождает обработчик (он имеет тип Handle). Закрытие обработчика инициирует закрытие соответствующего файла. Обработчики могут быть также ассоциированы с каналами, т.е. портами взаимодействия, которые не связаны напрямую с файлами. В Haskell’е предопределены три таких канала — stdin (стандартный канал ввода), stdout (стандартный канал вывода) и stderr (стандартный канал вывода сообщений об ошибках).

Таким образом, для использования файлов можно пользоваться следующими вещами:

type FilePath = String
openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

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

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

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

Здесь использована одна интересная и важная функция — hGetContents, которая берёт содержимое переданного ей в качестве аргумента файла и возвращает его в качестве одной длинной строки.

Окончательные замечанияПравить

Получается так, что в Haskell’е заново изобретено императивное программирование...

В некотором смысле — да. Монада IO встраивает в Haskell маленький императивный подъязык, при помощи которого можно осуществлять операции ввода/вывода. И написание программ на этом подъязыке выглядит обычно с точки зрения императивных языков. Но есть существенное различие: в Haskell’е нет специального синтаксиса для ввода в программный код императивных функций, все осуществляется на уровне функциональной парадигмы. В то же время опытные программисты могут минимизировать императивный код, используя монаду IO только на верхних уровнях своих программ, т.к. в Haskell’е императивный и функциональный миры чётко разделены между собой. В отличие от Haskell’а в императивных языках, в которых есть функциональные подъязыки, нет чёткого разделения между обозначенными мирами.