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

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

Самая простая полезная форма ввода-вывода: прочитать файл, что-то сделать с его содержимым, а потом записать результаты в файл.

Эта программа читает file.in, выполняет функцию operate на его содержимом, а затем записывает результат в file.out. Функция main содержит все операции ввода-вывода, а функция operate — чистая. При написании operate не нужно понимать какие-либо подробности ввода-вывода. Первые два года программирования на Haskell я использовал только эту модель и её было вполне достаточно.

Список действий

Если шаблон, описанный в предыдущем разделе, недостаточен для ваших задач, то следующим шагом будет использование списка действий. Функцию main можно написать так:

Сначала идёт ключевое слово do , потом последовательность инструкций xI <- exprI , и заканчивается всё return () . В каждой инструкции слева от стрелки стоит образец (чаще всего просто переменная) некоторого типа t , а справа — выражение типа IO t . Переменные, связанные образцом, можно использовать в последующих инструкциях. Если вы хотите использовать выражение, тип которого отличен от IO t , то нужно написать xI <- return (exprI) . Функция return :: a -> IO a принимает любое значение и «оборачивает» его в тип IO.

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

operate — по-прежнему чистая функция. В первой строке после do с помощью сопоставления образцов извлекаются аргументы командной строки. Вторая строка читает файл, название которого указано в первом аргументе. Третья строка использует return для чистого значения operate src . Четвертая строка записывает результат в файл. Это не даёт никакого полезного результата, поэтому мы игнорируем её, написав _ <- .

Упрощение ввода-вывода
  1. Вместо _ <- x можно писать просто x .
  2. Если на предпоследней строке нет связывающей стрелки ( <- ) и выражение имеет тип IO () , то последнюю строку с return () можно удалить.
  3. x <- return y можно заменить на let x = y (если вы не используете имена переменных повторно).
Вложенный ввод-вывод

Пока что только функция main имела тип IO, но мы можем создавать новые функции этого типа, чтобы избежать повторения кода. Например, мы можем написать вспомогательную функцию для печати красивых заголовков:

Мы можем использовать эту функцию несколько раз внутри main :

Возвращение значений в IO

До сих пор все функции, которые мы написали, имели тип IO (), который позволяет нам производить действия ввода-вывода, но не позволяет выдавать интересные результаты. Чтобы выдать значение х , мы пишем в последней строке do -блока return x . В отличие от инструкции return в императивных языках, это return должно находиться на последней строке.

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

Теперь, если дано меньше двух аргументов, программа не упадёт, а использует имена файлов по умолчанию.

Выбор действий ввода-вывода

Пока что мы видели только статический список инструкций ввода-вывода, выполняемых по порядку. С помощью if мы можем выбрать, какие действия выполнять. Например, если пользователь не ввёл никаких аргументов, мы можем сообщить об этом:

Для выбора действий последней инструкцией в do -блоке нужно сделать if , и в каждой из его ветвей продолжить do . Единственный тонкий момент в том, что else должен иметь отступ хотя бы на один пробел больше, чем if . Это широко рассматривается как ошибка в определении Haskell, но на настоящий момент без этого дополнительного пробела не обойтись.

Передышка

Если вы начали читать, не зная ввода-вывода в Haskell, и добрались досюда, то я советую вам взять перерыв (выпейте чаю с пирожным; вы его заслужили). Описанная выше функциональность — это всё, что позволяют делать императивные языки, и она является полезной отправной точкой. Точно также, как функциональное программирование предоставляет гораздо более эффективные способы работы с функциями, рассматривая их как значения, оно позволяет рассматривать как значения и действия ввода-вывода, чем мы и займёмся в оставшейся части статьи.

Работа с IO значениями

До сих пор все инструкции выполнялись немедленно, но мы также можем создавать переменные типа IO. Используя вышеприведённую функцию title выше, мы можем написать:

Вместо выполнения действия через <- , мы поместили само значение IO в переменную x . x имеет тип IO () , так что теперь мы можем написать х в строке, чтобы выполнить записанное в ней действие. Написав x три раза, мы выполняем это действие три раза.

Передача действий в качестве аргументов

Мы можем также передавать IO -значения как аргументы функциям. В предыдущем примере мы выполнили действие title "Welcome" три раза, но как бы мы могли его выполнить пятьдесят раз? Мы можем написать функцию, которая принимает действие и число, и выполняет это действие соответствующее число раз:

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

Конечно, инструкция for в императивных языках позволяет делать то же, что и функция replicateM_ , но гибкость Haskell позволяет определять новые управляющие инструкции — очень мощное средство. Функция replicateM_ , определенная в Control.Monad, похожа на нашу, но более общая; так что можно её использовать вместо нашего варианта.

IO в структурах данных

Мы видели, как IO -значения передаются в качестве аргумента, поэтому неудивительно, что мы можем помещать их в структуры данных, например, списки и кортежи. Функция sequence_ принимает список действий и выполняет их по очереди:

Если в списке нет элементов, то sequence_ заканчивает работу с помощью return () . Если элементы в списке есть, то sequence_ выбирает первое действие с помощью head xs и выполняет его, а потом вызывает sequence_ на остальном списке tail xs . Как и replicateM_ , sequence_ уже присутствует в Control.Monad в более общем виде. Теперь легко можно переписать replicateM_ , используя sequence_ :

Сопоставление с образцом

В Haskell куда естественнее использовать сопоставление с образцом, чем null/head/tail . Если в do -блоке ровно одна инструкция, то слово do можно убрать. Например, в определении sequence_ это можно сделать после знака равенства и после then .

Теперь мы можем заменить if на сопоставление, как в любой аналогичной ситуации, не беспокоясь об IO :

Последний пример

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

Проектирование ввода-вывода в программе

Программа на Haskell обычно состоит из внешней оболочки действий, вызывающей чистые функции. В предыдущем примере main и operateFile являются частью оболочки, а operate и все функции, которые она использует — чистые. Как общий принцип проектирования, постарайтесь сделать слой действий как можно более тонким. Оболочка должно кратко выполнить необходимый ввод, а основную работу возложить на чистую часть. Использование явного ввода-вывода в Haskell необходимо, но должно быть сведено к минимуму — чистый Haskell куда красивее.

📎📎📎📎📎📎📎📎📎📎