Pull to refresh

Объяснение ввода-вывода в Haskell без монад

Reading time7 min
Views20K
Original author: Neil Mitchell
Эта статья объясняет, как выполнять ввод и вывод в Haskell, не пытаясь дать никакого понимания о монадах в целом. Мы начнём с простейшего примера, а затем постепенно перейдём к более сложным. Вы можете дочитать статью до конца, а можете остановиться после любого раздела: каждый последующий раздел позволит вам справиться с новыми задачами. Мы предполагаем знакомство с основами Haskell, в объёме глав с 1 по 6 книги «Programming in Haskell» Грэма Хаттона. [Прим. переводчика: главы «Введение», «Первые шаги», «Типы и классы», «Определение функций», «Выборки из списков», «Рекурсивные функции»]

Основные функции


В этом уроке мы будем использовать четыре стандартные функции ввода-вывода:

Простой ввод-вывод


Самая простая полезная форма ввода-вывода: прочитать файл, что-то сделать с его содержимым, а потом записать результаты в файл.
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. Четвертая строка записывает результат в файл. Это не даёт никакого полезного результата, поэтому мы игнорируем её, написав _ <-.

Упрощение ввода-вывода


Этот шаблон списка действий очень жёсткий, и люди обычно упрощают код с помощью следующих трёх правил:
  1. Вместо _ <- x можно писать просто x.
  2. Если на предпоследней строке нет связывающей стрелки (<-) и выражение имеет тип IO (), то последнюю строку с return () можно удалить.
  3. 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, попробуйте реализовать их, а потом использовать их при написании программ.
  • Реализуйте и используйте монаду состояния.
Tags:
Hubs:
Total votes 28: ↑23 and ↓5+18
Comments10

Articles