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