В 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, также, как и в языках Си/Си++ название функции 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, в императивных языках, в которых есть функциональные подъязыки, нет чёткого разделения между обозначенными мирами.