Главная » Haskell » Ввод и вывод

0

Ни  один язык  программирования общего назначения не может  обойтись без работы с внешними устройствами. Однако должно быть вполне понятно, что ввод/вывод — это область программирования, где очень серьёзно встаёт вопрос о недетерминированности функций и наличии у них побочных эффектов. Встаёт очень сложная проблема, поскольку в чистых функциональных языках, каким является язык  Haskell, такие функции запрещены.  Более того, они просто запрещены теорией функционального программирования. Но отказ от реализации ввода/вывода не позволит языку стать языком общего назначения.

Решение было найдено при помощи выделения операций ввода/вывода в отдельный «подъязык», в рамках которого функции с определённым типом могли выполнять действия (вызывать побочный эффект изменения внешнего окружения — устройств вывода) и быть недетерминированными. Однако вне этого подъязыка язык Haskell остаётся чистым.

1.3.1.    Действия ввода/вывода

Честно говоря, нельзя думать о таких вещах, как вывод строки на экран или чтение строки с клавиатуры, как о функциях. Поэтому в языке Haskell используется понятие «действие» для описания таких специальных функций. Более того, эти функции должны иметь и специальный тип. Например, функция putStrLn, определённая в стандартном модуле Prelude и необходимая для того, чтобы вывести на экран строку, заканчивающуюся символом перевода строки, имеет следующий тип (подробно описана на стр. 273):

putStrLn :: String ->  IO ()

Таким же образом и тип функции getChar, которая считывает с клавиатуры один символ, выглядит так (подробно описана на стр. 136):

getChar  :: IO Char

Как  специальная функция, определённая в языке  Haskell,  каждое  действие ввода/вывода должно возвращать какое-то значение. Для того чтобы различать эти значения от базовых, типы  этих значений как бы обёрнуты контейнерным

типом IO. Поэтому любое действие ввода/вывода  будет иметь в сигнатуре своего типа символы IO, которые предваряют собой другие типы.

Необходимо отметить, что действия в отличие от обычных функций выполняются, а не вычисляются.  Как это сделано и чем выполнение действия отличается от вычисления значений функции, полностью  зависит от транслятора. Однако запустить действие на выполнение просто так нельзя, для этого необходимо использовать ключевое слово do либо специальные методы, определённые в классе Monad. Но  существует одно действие, которое  выполняется само. Это функция main, которая является, как и в языках программирования типа C, точкой входа в откомпилированные программы. Именно поэтому тип  функции main должен быть IO () — это действие, которое автоматически выполняется первым при запуске программы.

Тип IO () — это тип действия, которое  ничего не возвращает в  результате своей работы. Иные действия, имеющие некоторый  результат, который  можно получить в программе, должны возвращать другой тип. Так, к примеру, действие функции getChar заключается в  чтении символа с клавиатуры, причём далее этот считанный символ  возвращается  в качестве  результата. Именно поэтому тип этого действия  — IO Char.

Любые действия связываются в последовательности при помощи ключевого слова do. При помощи него можно связывать вызовы  функций (в том числе и использовать операторы ветвления if и case), получение значений в образцы (при помощи символа (<-))  и  множество  определений локальных переменных (ключевое слово let).

Например, так можно определить программу, которая читает символ с клавиатуры и тут же выводит его на экран:

main  :: IO  () main  =  do

c  < getChar putChar  c

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

и запустить, необходимо, чтобы функция main находилась в одноименном модуле Main. Хотя во многих трансляторах при  отсутствии явного указания имени модуля по умолчанию используется имя Main, и поэтому если не указывать имя модуля, всё откомпилируется в таких трансляторах и запустится замечательно, необходимо помнить о такой особенности.

Ещё один небольшой пример. Пусть имеется функция isReady, которая должна возвращать значение True,  если нажата  клавиша  «y»,  и  значение False в остальных случаях. Нельзя просто написать:

isReady  ::  IO  Bool isReady  =  do

c  < getChar c  ==  ’y’

В этом случае результатом выполнения операции сравнения будет значение типа Bool, а не IO Bool, так как результат и  соответственно его тип в списке do определяются по последнему действию. В этом случае необходимо воспользоваться методом return, который из простого типа данных делает контейнерный, в  котором  хранится значение исходного типа. То есть в предыдущем  примере последняя строка определения функции isReady должна  была выглядеть как return (c  == ’y’).

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

getString  :: IO  String getString  =  do

c  < getChar

if  (c  ==  ’\n’) then  return  "" else  do

cs  < getString return  (c:cs)

Необходимо отметить, что операторы множественного ветвления алгоритма можно вполне использовать внутри последовательности действий, которые определяются ключевым словом do. Однако имеется очень важный момент  — в частях then и else условного выражения и выражениях для альтернатив в операторе case необходимо также использовать последовательность действий, оформленную в виде списка do. Только в случае если в этих местах используется толь-

ко одно действие, ключевое слово do можно не использовать (это видно в части

then в предыдущем примере).

Такое положение дел связано с тем, что в таких местах необходимо указывать выражения, имеющие тот же тип, который возвращает само условное выражение. А раз такое условное выражение участвует в последовательности действий do, то оно должно иметь соответствующий тип IO.

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

todoList :: [IO  ()] todoList =  [putChar ’a’,

do  putChar  ’b putChar  ’c’,

do c  < getChar putChar  c]

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

sequence :: [IO  ()]  ->  IO () sequence []          = return () sequence  (a:as) = do

a

sequence as

Эта функция может использоваться для определения функции putString, которая выводит заданную строку на экран (её действие в чём-то противоположно действию функции getString, которая была определена чуть ранее):

putString :: String ->  IO ()

putString s  = sequence (map putChar  s)

На этом примере видно очень явное отличие системы  ввода/вывода языка Haskell от таких же систем императивных языков. Если бы в каком-нибудь императивном языке была бы определена функция, аналогичная функции map, то она бы в данном примере выполнила кучу действий. Однако вместо этого в языке Haskell просто создается список действий (одно для каждого символа строки), который потом обрабатывается функцией sequence для выполнения.

Источник: Душкин Р. В., Справочник по языку Haskell. М.: ДМК Пресс, 2008. 544 с., ил.

По теме:

  • Комментарии