Эта статья объясняет, как выполнять ввод и вывод в Haskell, не пытаясь дать никакого понимания о монадах в целом. Мы начнём с простейшего примера, а затем постепенно перейдём к более сложным. Вы можете дочитать статью до конца, а можете остановиться после любого раздела: каждый последующий раздел позволит вам справиться с новыми задачами. Мы предполагаем знакомство с основами Haskell, в объёме глав с 1 по 6 книги «Programming in Haskell» Грэма Хаттона. [Прим. переводчика: главы «Введение», «Первые шаги», «Типы и классы», «Определение функций», «Выборки из списков», «Рекурсивные функции»]
В этом уроке мы будем использовать четыре стандартные функции ввода-вывода:
Самая простая полезная форма ввода-вывода: прочитать файл, что-то сделать с его содержимым, а потом записать результаты в файл.
Эта программа читает file.in, выполняет функцию operate на его содержимом, а затем записывает результат в file.out. Функция main содержит все операции ввода-вывода, а функция operate — чистая. При написании operate не нужно понимать какие-либо подробности ввода-вывода. Первые два года программирования на Haskell я использовал только эту модель и её было вполне достаточно.
Если шаблон, описанный в предыдущем разделе, недостаточен для ваших задач, то следующим шагом будет использование списка действий. Функцию main можно написать так:
Сначала идёт ключевое слово
В качестве простого примера мы можем написать программу, которая получает аргументы командной строки, читает файл, заданный первым аргументом, работает с его содержимым, а затем записывает в файл, заданный вторым аргументом:
Этот шаблон списка действий очень жёсткий, и люди обычно упрощают код с помощью следующих трёх правил:
Используя эти правила, мы можем переписать наш пример:
Пока что только функция
Мы можем использовать эту функцию несколько раз внутри
До сих пор все функции, которые мы написали, имели тип IO (), который позволяет нам производить действия ввода-вывода, но не позволяет выдавать интересные результаты. Чтобы выдать значение
Эта функция возвращает первые два аргумента командной строки, или значения по умолчанию в том случае, если в командной строке было меньше двух аргументов. Теперь мы можем её использовать в нашей программе:
Теперь, если дано меньше двух аргументов, программа не упадёт, а использует имена файлов по умолчанию.
Пока что мы видели только статический список инструкций ввода-вывода, выполняемых по порядку. С помощью
Для выбора действий последней инструкцией в
Если вы начали читать, не зная ввода-вывода в Haskell, и добрались досюда, то я советую вам взять перерыв (выпейте чаю с пирожным; вы его заслужили). Описанная выше функциональность — это всё, что позволяют делать императивные языки, и она является полезной отправной точкой. Точно также, как функциональное программирование предоставляет гораздо более эффективные способы работы с функциями, рассматривая их как значения, оно позволяет рассматривать как значения и действия ввода-вывода, чем мы и займёмся в оставшейся части статьи.
До сих пор все инструкции выполнялись немедленно, но мы также можем создавать переменные типа IO. Используя вышеприведённую функцию
Вместо выполнения действия через
Мы можем также передавать
Здесь мы использовали выбор действий, чтобы решить, когда следует остановиться, и рекурсию, чтобы продолжить выполнение. Теперь мы можем переписать предыдущий пример таким образом:
Конечно, инструкция
Мы видели, как
Если в списке нет элементов, то
В Haskell куда естественнее использовать сопоставление с образцом, чем
Теперь мы можем заменить
В качестве последнего примера, представьте, что мы хотим выполнить некоторые операции с каждым файлом, заданным в командной строке. Используя то, чему мы научились, мы можем написать:
Программа на Haskell обычно состоит из внешней оболочки действий, вызывающей чистые функции. В предыдущем примере
Теперь вы готовы сделать любой ввод-вывод, который понадобится вашей программе. Чтобы закрепить навыки, я советую сделать что-нибудь из следующего списка:
Основные функции
В этом уроке мы будем использовать четыре стандартные функции ввода-вывода:
readFile :: FilePath -> IO String
— чтение файлаwriteFile :: FilePath -> String -> IO ()
— запись в файлgetArgs :: IO [String]
— получение аргументов командной строки (из модуляSystem.Environment
)putStrLn :: String -> IO ()
— вывод строки, и переноса строки после неё, на консоль
Простой ввод-вывод
Самая простая полезная форма ввода-вывода: прочитать файл, что-то сделать с его содержимым, а потом записать результаты в файл.
main :: IO () main = do src <- readFile "file.in" writeFile "file.out" (operate src) operate :: String -> String operate = ... -- ваша функция
Эта программа читает file.in, выполняет функцию operate на его содержимом, а затем записывает результат в file.out. Функция main содержит все операции ввода-вывода, а функция operate — чистая. При написании operate не нужно понимать какие-либо подробности ввода-вывода. Первые два года программирования на Haskell я использовал только эту модель и её было вполне достаточно.
Список действий
Если шаблон, описанный в предыдущем разделе, недостаточен для ваших задач, то следующим шагом будет использование списка действий. Функцию main можно написать так:
main :: IO () main = do x1 <- expr1 x2 <- expr2 ... xN <- exprN return ()
Сначала идёт ключевое слово
do
, потом последовательность инструкций xI <- exprI
, и заканчивается всё return ()
. В каждой инструкции слева от стрелки стоит образец (чаще всего просто переменная) некоторого типа t
, а справа — выражение типа IO t
. Переменные, связанные образцом, можно использовать в последующих инструкциях. Если вы хотите использовать выражение, тип которого отличен от IO t
, то нужно написать xI <- return (exprI)
. Функция return :: a -> IO a
принимает любое значение и «оборачивает» его в тип IO.В качестве простого примера мы можем написать программу, которая получает аргументы командной строки, читает файл, заданный первым аргументом, работает с его содержимым, а затем записывает в файл, заданный вторым аргументом:
main :: IO () main = do [arg1,arg2] <- getArgs src <- readFile arg1 res <- return (operate src) _ <- writeFile arg2 res return ()
operate
— по-прежнему чистая функция. В первой строке после do
с помощью сопоставления образцов извлекаются аргументы командной строки. Вторая строка читает файл, название которого указано в первом аргументе. Третья строка использует return
для чистого значения operate src
. Четвертая строка записывает результат в файл. Это не даёт никакого полезного результата, поэтому мы игнорируем её, написав _ <-
.Упрощение ввода-вывода
Этот шаблон списка действий очень жёсткий, и люди обычно упрощают код с помощью следующих трёх правил:
- Вместо
_ <- x
можно писать простоx
. - Если на предпоследней строке нет связывающей стрелки (
<-
) и выражение имеет типIO ()
, то последнюю строку сreturn ()
можно удалить. x <- return y
можно заменить наlet x = y
(если вы не используете имена переменных повторно).
Используя эти правила, мы можем переписать наш пример:
main :: IO () main = do [arg1,arg2] <- getArgs src <- readFile arg1 let res = operate src writeFile arg2 res
Вложенный ввод-вывод
Пока что только функция
main
имела тип IO, но мы можем создавать новые функции этого типа, чтобы избежать повторения кода. Например, мы можем написать вспомогательную функцию для печати красивых заголовков:title :: String -> IO () title str = do putStrLn str putStrLn (replicate (length str) '-') putStrLn ""
Мы можем использовать эту функцию несколько раз внутри
main
:main :: IO () main = do title "Hello" title "Goodbye"
Возвращение значений в IO
До сих пор все функции, которые мы написали, имели тип IO (), который позволяет нам производить действия ввода-вывода, но не позволяет выдавать интересные результаты. Чтобы выдать значение
х
, мы пишем в последней строке do
-блока return x
. В отличие от инструкции return
в императивных языках, это return
должно находиться на последней строке.readArgs :: IO (String,String) readArgs = do xs <- getArgs let x1 = if length xs > 0 then xs !! 0 else "file.in" let x2 = if length xs > 1 then xs !! 1 else "file.out" return (x1,x2)
Эта функция возвращает первые два аргумента командной строки, или значения по умолчанию в том случае, если в командной строке было меньше двух аргументов. Теперь мы можем её использовать в нашей программе:
main :: IO () main = do (arg1,arg2) <- readArgs src <- readFile arg1 let res = operate src writeFile arg2 res
Теперь, если дано меньше двух аргументов, программа не упадёт, а использует имена файлов по умолчанию.
Выбор действий ввода-вывода
Пока что мы видели только статический список инструкций ввода-вывода, выполняемых по порядку. С помощью
if
мы можем выбрать, какие действия выполнять. Например, если пользователь не ввёл никаких аргументов, мы можем сообщить об этом:main :: IO () main = do xs <- getArgs if null xs then do putStrLn "You entered no arguments" else do putStrLn ("You entered " ++ show xs)
Для выбора действий последней инструкцией в
do
-блоке нужно сделать if
, и в каждой из его ветвей продолжить do
. Единственный тонкий момент в том, что else
должен иметь отступ хотя бы на один пробел больше, чем if
. Это широко рассматривается как ошибка в определении Haskell, но на настоящий момент без этого дополнительного пробела не обойтись.Передышка
Если вы начали читать, не зная ввода-вывода в Haskell, и добрались досюда, то я советую вам взять перерыв (выпейте чаю с пирожным; вы его заслужили). Описанная выше функциональность — это всё, что позволяют делать императивные языки, и она является полезной отправной точкой. Точно также, как функциональное программирование предоставляет гораздо более эффективные способы работы с функциями, рассматривая их как значения, оно позволяет рассматривать как значения и действия ввода-вывода, чем мы и займёмся в оставшейся части статьи.
Работа с IO значениями
До сих пор все инструкции выполнялись немедленно, но мы также можем создавать переменные типа IO. Используя вышеприведённую функцию
title
выше, мы можем написать:main :: IO () main = do let x = title "Welcome" x x x
Вместо выполнения действия через
<-
, мы поместили само значение IO
в переменную x
. x
имеет тип IO ()
, так что теперь мы можем написать х
в строке, чтобы выполнить записанное в ней действие. Написав x
три раза, мы выполняем это действие три раза.Передача действий в качестве аргументов
Мы можем также передавать
IO
-значения как аргументы функциям. В предыдущем примере мы выполнили действие title "Welcome"
три раза, но как бы мы могли его выполнить пятьдесят раз? Мы можем написать функцию, которая принимает действие и число, и выполняет это действие соответствующее число раз:replicateM_ :: Int -> IO () -> IO () replicateM_ n act = do if n == 0 then do return () else do act replicateM_ (n-1) act
Здесь мы использовали выбор действий, чтобы решить, когда следует остановиться, и рекурсию, чтобы продолжить выполнение. Теперь мы можем переписать предыдущий пример таким образом:
main :: IO () main = do let x = title "Welcome" replicateM_ 3 x
Конечно, инструкция
for
в императивных языках позволяет делать то же, что и функция replicateM_
, но гибкость Haskell позволяет определять новые управляющие инструкции — очень мощное средство. Функция replicateM_
, определенная в Control.Monad, похожа на нашу, но более общая; так что можно её использовать вместо нашего варианта.IO в структурах данных
Мы видели, как
IO
-значения передаются в качестве аргумента, поэтому неудивительно, что мы можем помещать их в структуры данных, например, списки и кортежи. Функция sequence_
принимает список действий и выполняет их по очереди:sequence_ :: [IO ()] -> IO () sequence_ xs = do if null xs then do return () else do head xs sequence_ (tail xs)
Если в списке нет элементов, то
sequence_
заканчивает работу с помощью return ()
. Если элементы в списке есть, то sequence_
выбирает первое действие с помощью head xs
и выполняет его, а потом вызывает sequence_
на остальном списке tail xs
. Как и replicateM_
, sequence_
уже присутствует в Control.Monad в более общем виде. Теперь легко можно переписать replicateM_
, используя sequence_
:replicateM_ :: Int -> IO () -> IO () replicateM_ n act = sequence_ (replicate n act)
Сопоставление с образцом
В Haskell куда естественнее использовать сопоставление с образцом, чем
null/head/tail
. Если в do
-блоке ровно одна инструкция, то слово do
можно убрать. Например, в определении sequence_
это можно сделать после знака равенства и после then
.sequence_ :: [IO ()] -> IO () sequence_ xs = if null xs then return () else do head xs sequence_ (tail xs)
Теперь мы можем заменить
if
на сопоставление, как в любой аналогичной ситуации, не беспокоясь об IO
:sequence_ :: [IO ()] -> IO () sequence_ [] = return () sequence_ (x:xs) = do x sequence_ xs
Последний пример
В качестве последнего примера, представьте, что мы хотим выполнить некоторые операции с каждым файлом, заданным в командной строке. Используя то, чему мы научились, мы можем написать:
main :: IO () main = do xs <- getArgs sequence_ (map operateFile xs) operateFile :: FilePath -> IO () operateFile x = do src <- readFile x writeFile (x ++ ".out") (operate src) operate :: String -> String operate = ...
Проектирование ввода-вывода в программе
Программа на Haskell обычно состоит из внешней оболочки действий, вызывающей чистые функции. В предыдущем примере
main
и operateFile
являются частью оболочки, а operate
и все функции, которые она использует — чистые. Как общий принцип проектирования, постарайтесь сделать слой действий как можно более тонким. Оболочка должно кратко выполнить необходимый ввод, а основную работу возложить на чистую часть. Использование явного ввода-вывода в Haskell необходимо, но должно быть сведено к минимуму — чистый Haskell куда красивее.Что дальше
Теперь вы готовы сделать любой ввод-вывод, который понадобится вашей программе. Чтобы закрепить навыки, я советую сделать что-нибудь из следующего списка:
- Пишите много кода на Haskell.
- Прочитайте главы 8 и 9 «Programming in Haskell». Рассчитывайте на то, чтобы потратить около 6 часов, думая над разделами с 8.1 до 8.4 (хорошо бы попасть в больницу с лёгкой травмой).
- Прочитайте Монады как контейнеры, отличное введение в монады.
- Посмотрите на документацию по законам монад, и найдите, где я использовал их в этой статье.
- Прочитайте документацию всех функций в Control.Monad, попробуйте реализовать их, а потом использовать их при написании программ.
- Реализуйте и используйте монаду состояния.