Изучай Haskell ради Добра! Ввод и вывод

Miran Lipovača, “ Learn You a Haskell for Great Good! Input and Output”, public translation into Russian from English More about this translation.

Translate into another language.

Ввод и вывод

Мы уже упоминали, что Хаскель - чисто функциональный язык. В то время как в императивных языках вы указываете компьютеру серию шагов для достижения некой цели, в функциональном программировании мы описываем чем является то или иное понятие. В Хаскеле функция не может изменить некоторое состояние, например поменять значение переменной (если функция изменяет состояние, мы говорим что функция имеет сторонние эффекты). Единственное что могут сделать функции в Хаскеле - вернуть нам некоторый результат основываясь на переданных параметрах. Если вызвать функцию дважды с одинаковыми параметрами, она вернет одинаковый результат. Если вы знакомы с императивными языками, может показаться что это ограничивает свободу наших действий, но мы видели, что на самом деле это дает весьма мощные возможности. В императивном языке у вас нет гарантии, что простая функция, которая всего-то должна обсчитать пару чисел, не сожгет ваш дом, не похитит собаку и не поцарапает машину во время вычислений. Например, когда мы создавали бинарное поисковое дерево, мы вставляли элемент в дерево не путем модификации дерева в точке вставки. Наша функция добавления нового элемента в дерево возвращала новое дерево, так как она не могла изменить старое.

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

Но не надо отчаиваться, не все еще потеряно. Оказывается, в Хаскеле есть весьма умная система для работы с функциями со сторонними эффектами, которая четко разделяет чисто функциональную и "грязную" части нашей программы. "Грязная" часть программы выполняет всю грязную работу, например взаимодействие с клавиатурой и экраном. Разделив "чистую" и "грязную" части, мы можем так же свободно рассуждать о чисто функциональной части нашей программы и получать все преимущества функциональной чистоты, а именно - ленивость, гибкость, модульность, и при этом эффективно взаимодействовать с внешним миром.

Привет, мир!

До сих пор, для того чтобы потестировать наши функции, мы грузили их в GHCI. Там же мы изучали функции из стандартной библиотеки. Но теперь, после восьми глав, мы впервые собираемся написать нашу первую программу на Хаскеле! Ура! И конечно же, мы напишем старый добрый шедевр "Привет, мир".

Примечание. В этой главе я буду предполагать что вы используете юниксоидное окружение для изучения Хаскеля. Если вы работаете в Windows, я бы посоветовал вам загрузить Cygwin, это линуксоподобное окружение для Windows, насколько мне известно, это все что потребуется.

Итак, для начинающих, наберите следующее в вашем любимом текстовом редакторе:

main = putStrLn "hello, world"

Мы только что определили имя main, в нем мы вызываем функцию putStrLn с параметром "hello, world". Ничего необычного, как можно подумать, но это не так, мы убедимся в этом через несколько минут. Сохраните файл как helloworld.hs.

Сейчас мы собираемся сделать то, чего еще не пробовали делать. Мы собираемся скомпилировать нашу программу! Я взволнован! Откройте ваш терминал, перейдите в папку с сохраненным helloworld.hs и выполните следующую команду:

$ ghc --make helloworld

[1 of 1] Compiling Main ( helloworld.hs, helloworld.o )

Linking helloworld ...

ОК! При некоторой удаче вы получите что-то похожее, и теперь вы можете запустить вашу программу вызвав ./helloworld.

$ ./helloworld

hello, world

Ну вот и наша первая программа, которая печатает что-то на терминале. Просто невероятно скучно!

Давайте изучим более подробно, что же мы написали. Сначала посмотрим на тип функции putStrLn.

ghci> :t putStrLn

putStrLn :: String -> IO ()

ghci> :t putStrLn "hello, world"

putStrLn "hello, world" :: IO ()

Мы можем прочесть тип putStrLn таким образом: putStrLn принимает строку и возвращает операцию ввода-вывода (I/O action) с результирующим типом () (это пустой кортеж, также известный как юнит). Операция ввода-вывода это нечто вызывающее побочные эффекты при выполнении (обычно это чтение входных данных или печать на экране), также операция возвращает некоторое значение. Печать строки на экране не имеет какого-либо значимого результата, поэтому возвращается значение ().

Пустой кортеж имеет значение (), его тип также ().

Когда будет выполнен оператор ввода-вывода? Вот для чего нужна main. Ввод-вывод выполнится если мы поместим операцию в функцию main и запустим нашу программу.

Возможность поместить всего один оператор ввода-вывода в программу выглядит не очень привлекательно. Но мы можем использовать do нотацию для того чтобы склеить несколько операторов ввода-вывода в один. Посмотрим на пример:

main = do

putStrLn "Hello, what's your name?"

name <- getLine

putStrLn ("Hey " ++ name ++ ", you rock!")

О, новый синтаксис. И он похож на синтаксис императивных языков. Если откомпилировать и запустить эту программу, она будет работать так как вы и предполагаете. Обратите внимание, мы записали do и затем последовательность шагов, как мы бы делали в императивном языке. Каждый из этих шагов - оператор ввода-вывода. расположив их рядом с помощью do, мы склеили их в один оператор ввода-вывода. Получившийся оператор имеет тип IO(), это тип последнего оператора в цепочке.

По этой причине main всегда имеет сигнатуру main :: IO <что-то>, где <что-то> - некоторый конкретный тип. По общепринятому соглашению обычно не пишут декларацию типа для main.

В третьей строке можно видеть еще один ранее не встречавшийся нам элемент синтаксиса, name <- getLine. Выглядит, как будто считанная со стандартного входа строка сохраняется в переменной с именем name. Так ли это на самом деле? Давайте посмотрим на тип getLine.

ghci> :t getLine

getLine :: IO String

Ага. getLine это действие ввода-вывода, которое содержит результирующий тип - строку. Это понятно, потому что оператор ждет пока пользователь не введет что-нибудь с терминала, и затем это что-то будет представлено как строка. Что тогда делает name <- getLine? Можно прочитать это так: выполнить действие getLine и затем связать результат выполнения с именем name. getLine имеет тип IO String, поэтому name будет также иметь тип String. Можно думать об операции ввода-вывода как о коробочке с ножками, которая ходит в реальный мир и что-то в нем делает (рисует граффити на стене, например) и, может быть, принесет обратно какие-либо данные. Если коробочка что-то принесла, единственный способ открыть коробочку и извлечь данные - использовать конструкцию <-. Получить данные из операции ввода-вывода можно только внутри другой операции ввода-вывода. Таким образом Хаскель четко разделяет чистую и "грязную" части кода. getLine - не чистая функция, потому что ее результат может быть не одинаковым при последовательных вызовах. Вот почему она как бы запачкана конструктором типов I/O, и мы можем получить данные только внутри операций I/O. Так как I/O код также испачкан, любое вычисление зависящее от испачканных I/O данных, также будет давать "грязный" результат.

Если я говорю "испачканы", это не значит что мы не сможем использовать результат, содержащийся в I/O в чистом коде. Мы временно "очищаем" данные внутри действия, когда связываем их с именем. При выполнении name <- getLine, name это просто обычная строка, представляющая содержимое коробочки. Мы можем написать сложную функцию, которая, скажем, принимает ваше имя как параметр (обычная строка) и предсказывает вашу удачливость или будущее всей вашей жизни, основываясь на имени.

main = do

putStrLn "Hello, what's your name?"

name <- getLine

putStrLn $ "Read this carefully, because this is your future: " ++ tellFortune name

tellFortune (или любая другая функция которой мы передаем name) не должна знать ничего про I/O, это обычная функция String -> String.

Посмотрите на этот образец кода. Корректный ли он?

nameTag = "Hello, my name is " ++ getLine

Если вы ответили "нет", возьмите с полки пирожок. Если ответили "да", убейте себя об стену. Шучу, не надо! Это выражение не сработает потому что ++ требует, чтобы оба параметра были списками одинакового типа. Левый параметр имеет тип String (или [Char], если вам так угодно), в то время как getLine имеет тип IO String. Вы не сможете конкатенировать строку и операцию ввода-вывода. Для начала нам нужно извлечь результат из операции ввода-вывода, чтобы получить значение типа String, и единственный способ сделать это - выполнить что-то вроде name <- getLine внутри другого I/O действия. Если мы хотим работать с нечистыми данными, мы должны делать это в нечистом окружении. Итак, грязь от нечистоты распространяется как моровое поветрие, и в наших лучших интересах делать часть для ввода-вывода настолько маленькой, насколько это возможно.

Каждое выполненное I/O действие заключает в себе результат. Вот почему наш предыдущий пример можно переписать так:

main = do

foo <- putStrLn "Hello, what's your name?"

name <- getLine

putStrLn ("Hey " ++ name ++ ", you rock!")

Тем не менее foo всегда будет иметь значение (), так что большого смысла в этом нет. Обратите внимание, мы не связываем последний putStrLn с именем. Мы так делаем потому, что в блоке do последний оператор не может быть связан с именем, в отличие от предыдущих. Мы узнаем причины такого поведения немного позднее, когда мы познаем мир монад. До тех пор можно считать, что блок do автоматически получает результат последнего оператора и возвращает его в качестве собственного результата.

За исключением последней строчки, каждая строка в блоке do может быть использована для связывания. Например, putStrLn "BLAH" может быть записана как _ <- putStrLn "BLAH". Но в этом нет никакого смысла, так что мы опускаем <- для I/O действий, не возвращающих значимого результата.

Иногда начинающие думают что вызов

name = getLine

считает значение со стандартного входа и затем свяжет это значение с именем. На самом деле это не так. Такая запись даст getLine другое имя, в этом случае - name. Запомните, для того чтобы получить значение из I/O действия, вы должны выполнять его внутри другого I/O действия, и связывать его с именем при помощи <-.

Операция ввода-вывода будет выполнена только если ее имя main или если она помещена в составное действие с помощью блока do. Также мы можем использовать блок do для того чтобы склеить несколько I/O действий в одно. Затем мы можем использовать его в другом блоке do, и так далее. В любом случае действие будет выполнено только если оно каким-либо образом вызывается из main.

А, ну да, есть еще один способ выполнить действие по вводу-выводу. Если напечатать его в GHCI и нажать "энтер", действие выполнится.

ghci> putStrLn "HEEY"

HEEY

Даже если мы просто наберем некоторое число или вызовем некоторую функцию в GHCI и нажмем Enter, GHCI вычислит значение, затем вызовет для него show чтобы получить строку, и напечатает строку на терминале используя putStrLn.

Помните связывания (bindings) let? Если нет, освежите свои знания в этой главе. Они должны быть такого вида: let <bindings> in expression, где <bindings> - это имена, даваемые выражениям, а expression - это выражение которое использует имена из <bindings>. Так же мы говорили, что в списковых выражениях, часть in не нужна. Так вот, в блоках do вы можете использовать let таким же образом как и в списковых выражениях. Смотрите:

import Data.Char

main = do

putStrLn "What's your first name?"

firstName <- getLine

putStrLn "What's your last name?"

lastName <- getLine

let bigFirstName = map toUpper firstName

bigLastName = map toUpper lastName

putStrLn $ "hey " ++ bigFirstName ++ " " ++ bigLastName ++ ", how are you?"

Видите как выровнены операторы ввода-вывода в блоке do? Так же обратите внимание как выровнен let по отношению к I/O действиям, и как выровнены имена внутри let. Это хороший пример, потому что выравнивание текста важно в Хаскеле. Далее, мы записали map toUpper firstName, что превратит, например, "John" во много более крутую строку "JOHN". Мы связали эту строку в верхнем регистре с именем, которое использовали в дальнейшем при выводе не терминал.

Вам может быть не понятно, когда использовать <-, а когда let. Запомните, <- (в случае I/O) используется для выполнения I/O действий и связывания результатов с именами. map toUpper firstName не является I/O действием, это чистое выражение. Итого, используйте <- для связывания результатов I/O действий с именами, используйте let для связывания имен с чистыми выражениями. Если бы мы выполнили что-то вроде let firstName = getLine, мы бы просто создали синоним функции getLine, который все равно надо выполнять с помощью <-.

Теперь мы напишем программу, которая будет считывать строки, переворачивать слова и распечатывать их. Выполнение программы прекратится когда мы введем пустую строку. Программа:

main = do

line <- getLine

if null line

then return ()

else do

putStrLn $ reverseWords line

main

reverseWords :: String -> String

reverseWords = unwords . map reverse . words

Чтобы лучше понять как работает программа, запустите ее перед тем как мы рассмотрим код.

Подсказка: Для того чтобы запустить программу, вам надо либо откомпилировать ее и запустить получившийся выполнимый файл (ghc --make helloworld и затем ./helloworld), или вы можете использовать утилиту runhaskell таким образом: runhaskell helloworld.hs, ваша программа запустится сразу же.

Для начала посмотрим на функцию reverseWords. Это обычная функция, которая принимает строку, например "hey there man", вызывает функцию words чтобы получить список слов, ["hey","there","man"]. Затем мы применяем функцию reverse к списку, получаем ["yeh","ereht","nam"], затем мы помещаем результат обратно в строку используя unwords, конечным результатом будет "yeh ereht nam". Обратите внимание как мы использовали композицию функций. Без композиции нам бы пришлось писать что-то вроде reverseWords st = unwords (map reverse (words st)).

Теперь посмотрим на main. Сначала мы получаем строку с терминала с помощью getLine. Далее у нас условное выражение. Запомните, что в Хаскеле каждый if должен сопровождаться else, так как каждое выражение должно иметь некоторое значение. Наш оператор записан так, что если условие истинно (в нашем случае, когда введут пустую строку), мы выполним одну I/O операцию, если оно ложно - выполним I/O операцию из else. По этой же причине в do блоке операторы if должны иметь вид if условие then операция I/O else операция I/O.

Вначале посмотрим что делается в else. Так как мы можем поместить только одну I/O операцию после else, мы используем блок do для того чтобы склеить несколько операторов в один. Эту часть можно было бы написать так:

else (do

putStrLn $ reverseWords line

main)

Такая запись явно показывает, что блок do может рассматриваться как одно I/O действие, но и выглядит она не очень красиво. В любом случае внутри блока do мы можем вызвать reverseWords со строкой - результатом getLine и распечатать результат. После этого мы выполняем main. Получается, что main вызывается рекурсивно, и в этом нет ничего необычного, так как сама по себе main - тоже IO действие. Таким образом мы возвращаемся к началу программу в следующей рекурсивной итерации.

Ну а что случится если мы получим на вход пустую строку? В этом случае выполнится часть после then. То есть выполнится return (). Если вам приходилось писать на императивных языках вроде C, Java или на Python, вы можете думать что знаете как работает return, и, возможно, вы пропустите этот длинный параграф. Ну так вот, return в Хаскеле работает совершенно не так как в большинстве других языков. Он имеет такое же имя, что сбивает с толку, но на самом деле он довольно сильно отличается. В императивных языках обычно return прекращает выполнение метода или процедуры и возвращает некоторое значение вызывающему коду. В Хаскеле (и особенно в операциях I/O), он создает I/O действие из чистого значения. Если продолжать аналогию с коробочками, он берет значение и помещает его в коробочку. Получающееся в результате I/O действие на самом деле не выполняет никаких действий, оно просто инкапсулирует некоторое значение. Таким образом в контексте ввода-вывода, return "haha" будет иметь тип IO String. Какой смысл преобразовывать чистое значение в I/O действие, которое ничего не делает? Зачем "пачкать" нашу программу больше необходимого? Нам нужно некоторое I/O действие для второй части условного оператора, чтобы обработать случай пустой строки. Вот для чего мы создали фиктивное I/O действие, которое ничего не делает, записав return ().

Вызов return не прекращает выполнение блока do, ничего подобного. Например, следующая программа успешно выполнится вся до последней строчки:

main = do

return ()

return "HAHAHA"

line <- getLine

return "BLAH BLAH BLAH"

return 4

Pages: ← previous Ctrl next
1 2 3 4 5 6 7 8 9 10

© Miran Lipovača,

Original (English): Learn You a Haskell for Great Good! Input and Output

Translation: © asinitsyn, artobstrel95, JEuler, Jim-Trans-Hokkins, Yasir Arsanukaev, Kostafey, savask, neor .

License: creative commons attribution noncommercial blah blah blah ... license

translated.by crowd

Like this translation? Share it or bookmark!