Изучай Haskell ради Добра! Ввод и вывод
Мы уже упоминали, что Хаскель - чисто функциональный язык. В то время как в императивных языках вы указываете компьютеру серию шагов для достижения некой цели, в функциональном программировании мы описываем чем является то или иное понятие. В Хаскеле функция не может изменить некоторое состояние, например поменять значение переменной (если функция изменяет состояние, мы говорим что функция имеет сторонние эффекты). Единственное что могут сделать функции в Хаскеле - вернуть нам некоторый результат основываясь на переданных параметрах. Если вызвать функцию дважды с одинаковыми параметрами, она вернет одинаковый результат. Если вы знакомы с императивными языками, может показаться что это ограничивает свободу наших действий, но мы видели, что на самом деле это дает весьма мощные возможности. В императивном языке у вас нет гарантии, что простая функция, которая всего-то должна обсчитать пару чисел, не сожгет ваш дом, не похитит собаку и не поцарапает машину во время вычислений. Например, когда мы создавали бинарное поисковое дерево, мы вставляли элемент в дерево не путем модификации дерева в точке вставки. Наша функция добавления нового элемента в дерево возвращала новое дерево, так как она не могла изменить старое.
Конечно, это хорошо что функции не могут изменять состояние, это помогает нам строить умозаключения о наших программах, но есть одна проблема. Если функция не может изменить ничего, как она сообщит нам о результатах вычислений? Для того чтобы вывести результат, функция должна изменить состояние устройства вывода (обычно это экран), который излучает фотоны, они путешествуют к нашему мозгу и изменяют состояние нашего сознания, вот так-то, чувак.
Но не надо отчаиваться, не все еще потеряно. Оказывается, в Хаскеле есть весьма умная система для работы с функциями со сторонними эффектами, которая четко разделяет чисто функциональную и "грязную" части нашей программы. "Грязная" часть программы выполняет всю грязную работу, например взаимодействие с клавиатурой и экраном. Разделив "чистую" и "грязную" части, мы можем так же свободно рассуждать о чисто функциональной части нашей программы и получать все преимущества функциональной чистоты, а именно - ленивость, гибкость, модульность, и при этом эффективно взаимодействовать с внешним миром.
До сих пор, для того чтобы потестировать наши функции, мы грузили их в 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 )
ОК! При некоторой удаче вы получите что-то похожее, и теперь вы можете запустить вашу программу вызвав ./helloworld.
Ну вот и наша первая программа, которая печатает что-то на терминале. Просто невероятно скучно!
Давайте изучим более подробно, что же мы написали. Сначала посмотрим на тип функции putStrLn.
ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello, world"
putStrLn "hello, world" :: IO ()
Мы можем прочесть тип putStrLn таким образом: putStrLn принимает строку и возвращает операцию ввода-вывода (I/O action) с результирующим типом () (это пустой кортеж, также известный как юнит). Операция ввода-вывода это нечто вызывающее побочные эффекты при выполнении (обычно это чтение входных данных или печать на экране), также операция возвращает некоторое значение. Печать строки на экране не имеет какого-либо значимого результата, поэтому возвращается значение ().
Пустой кортеж имеет значение (), его тип также ().
Когда будет выполнен оператор ввода-вывода? Вот для чего нужна main. Ввод-вывод выполнится если мы поместим операцию в функцию main и запустим нашу программу.
Возможность поместить всего один оператор ввода-вывода в программу выглядит не очень привлекательно. Но мы можем использовать do нотацию для того чтобы склеить несколько операторов ввода-вывода в один. Посмотрим на пример:
putStrLn "Hello, what's your name?"
О, новый синтаксис. И он похож на синтаксис императивных языков. Если откомпилировать и запустить эту программу, она будет работать так как вы и предполагаете. Обратите внимание, мы записали do и затем последовательность шагов, как мы бы делали в императивном языке. Каждый из этих шагов - оператор ввода-вывода. расположив их рядом с помощью do, мы склеили их в один оператор ввода-вывода. Получившийся оператор имеет тип IO(), это тип последнего оператора в цепочке.
По этой причине main всегда имеет сигнатуру main :: IO <что-то>, где <что-то> - некоторый конкретный тип. По общепринятому соглашению обычно не пишут декларацию типа для main.
В третьей строке можно видеть еще один ранее не встречавшийся нам элемент синтаксиса, name <- getLine. Выглядит, как будто считанная со стандартного входа строка сохраняется в переменной с именем name. Так ли это на самом деле? Давайте посмотрим на тип 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 это просто обычная строка, представляющая содержимое коробочки. Мы можем написать сложную функцию, которая, скажем, принимает ваше имя как параметр (обычная строка) и предсказывает вашу удачливость или будущее всей вашей жизни, основываясь на имени.
putStrLn "Hello, what's your name?"
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 действие заключает в себе результат. Вот почему наш предыдущий пример можно переписать так:
foo <- putStrLn "Hello, what's your name?"
Тем не менее foo всегда будет иметь значение (), так что большого смысла в этом нет. Обратите внимание, мы не связываем последний putStrLn с именем. Мы так делаем потому, что в блоке do последний оператор не может быть связан с именем, в отличие от предыдущих. Мы узнаем причины такого поведения немного позднее, когда мы познаем мир монад. До тех пор можно считать, что блок do автоматически получает результат последнего оператора и возвращает его в качестве собственного результата.
За исключением последней строчки, каждая строка в блоке do может быть использована для связывания. Например, putStrLn "BLAH" может быть записана как _ <- putStrLn "BLAH". Но в этом нет никакого смысла, так что мы опускаем <- для I/O действий, не возвращающих значимого результата.
Иногда начинающие думают что вызов
считает значение со стандартного входа и затем свяжет это значение с именем. На самом деле это не так. Такая запись даст getLine другое имя, в этом случае - name. Запомните, для того чтобы получить значение из I/O действия, вы должны выполнять его внутри другого I/O действия, и связывать его с именем при помощи <-.
Операция ввода-вывода будет выполнена только если ее имя main или если она помещена в составное действие с помощью блока do. Также мы можем использовать блок do для того чтобы склеить несколько I/O действий в одно. Затем мы можем использовать его в другом блоке do, и так далее. В любом случае действие будет выполнено только если оно каким-либо образом вызывается из main.
А, ну да, есть еще один способ выполнить действие по вводу-выводу. Если напечатать его в GHCI и нажать "энтер", действие выполнится.
Даже если мы просто наберем некоторое число или вызовем некоторую функцию в GHCI и нажмем Enter, GHCI вычислит значение, затем вызовет для него show чтобы получить строку, и напечатает строку на терминале используя putStrLn.
Помните связывания (bindings) let? Если нет, освежите свои знания в этой главе. Они должны быть такого вида: let <bindings> in expression, где <bindings> - это имена, даваемые выражениям, а expression - это выражение которое использует имена из <bindings>. Так же мы говорили, что в списковых выражениях, часть in не нужна. Так вот, в блоках do вы можете использовать let таким же образом как и в списковых выражениях. Смотрите:
putStrLn "What's your first name?"
putStrLn "What's your last name?"
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, который все равно надо выполнять с помощью <-.
Теперь мы напишем программу, которая будет считывать строки, переворачивать слова и распечатывать их. Выполнение программы прекратится когда мы введем пустую строку. Программа:
putStrLn $ reverseWords line
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 для того чтобы склеить несколько операторов в один. Эту часть можно было бы написать так:
putStrLn $ reverseWords line
Такая запись явно показывает, что блок 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, ничего подобного. Например, следующая программа успешно выполнится вся до последней строчки:
return "BLAH BLAH BLAH"
Все что делают операторы return - создают I/O действия, которые не делают ничего кроме как содержат значения, и все они отбрасываются, так как они не привязаны к именам. Мы можем использовать return вместе с <- для того чтобы связывать значения с именами.
putStrLn $ a ++ " " ++ b
Как вы можете видеть, return выполняет обратную операцию по отношению к <-. В то время как return принимает значение и заворачивает его в коробку, <- принимает (и исполняет) коробку, а затем привязывает полученное из нее значение к имени. Но все это выглядит лишним, так как в блоках do можно использовать let для привязки к именам, например так:
putStrLn $ a ++ " " ++ b
При работе с блоками do, мы чаще всего используем return либо для создания I/O действия, которое ничего не делает, либо для того чтобы do возвращал нужное нам значение, а не результат последнего I/O действия. Во втором случае мы используем return для того, чтобы создать I/O действие, которое будет всегда возвращать нужное нам значение, и этот return должен находиться в конце блока do.
В блок do можно поместить всего одно действие. Это то же самое, что записать это действие без do. Некоторые предпочитают писать then do return () если в else есть do.
Перед тем как мы перейдем к файлам, давайте посмотрим на некоторые полезные для ввода-вывода функции.
putStr похожа на putStrLn, она принимает строку как параметр и возвращает действие ввода/вывода, которое печатает строку на терминале. Единственное отличие - putStr не выполняет перевод на новую строку после печати, как это делает putStrLn.
main = do putStr "Hey, "
Сигнатура типа такова: putStr :: String -> IO (), результат инкапсулирован в результирующем I/O действии. Бесполезное значение, не имеет смысла привязывать его к имени.
putChar принимает символ и возвращает действие ввода/вывода, которое напечатает его на терминале.
main = do putChar 't'
На самом деле putStr задана рекурсивно с помощью putChar. Граничное условие для putStr это пустая строка. Если печатаемая строка пуста, мы вернем пустое действие ввода/вывода, return (). Если строка не пуста, мы печатаем первый символ этой строки вызывая putChar, а затем выводим остальные символы снова вызывая putStr.
putStr :: String -> IO ()
Как вы заметили, мы можем использовать рекурсию в I/O таким же образом как и в чистом коде. Таким же образом мы определяем граничные условия, а затем думаем что будет результатом. В результате мы получим действие, которое выведет первый символ, а затем остаток строки.
print принимает значение любого типа - экземпляра Show (что означает, что мы знаем, как представить этот тип в виде строки), вызывает show чтобы получить из него строку, и затем выводит эту строку на экран. В общем-то это putStrLn.show. Это выражение сначала вызывает show на переданном параметре, скармливает результат функции putStrLn, она которая возвращает действие ввода/вывода, которое и напечатает наше значение.
main = do print True
Как вы могли заметить, это очень полезная функция. Помните мы говорили о том, что действия ввода/вывода выполняются только из main, или когда мы выполняем их в GHCI? После того как мы напечатаем значение (например, 3 или [1, 2, 3]) и нажмем Enter, GHCI вызовет print с введенным значением для вывода на терминал!
Обычно мы хотим видеть строку на экране не заключенную в кавычки, поэтому для печати строк обычно используется putStrLn. Но для печати значений других типов обычно используется print.
getChar это оператор ввода-вывода, который считывает символ со стандартного входа. Таким образом, сигнатура его типа getChar :: IO Char, результат содержащийся в I/O действии - Char. Обратите внимание, что из-за буферизации чтение символа не завершится до тех пор, пока пользователь не нажмет Enter.
Похоже, что программа должна считывать символ и проверять его на равенство пробелу. Если это пробел - завершать выполнение, если это не пробел - печатать символ не экране, и начинать выполнение с самого начала. Так оно все и работает, но не совсем таким образом как вы можете ожидать. Проверим:
Вторая строка - наш ввод. Мы вводим hello sir и жмем Enter. Так как ввод буферизован, выполнение начнется только после того как мы нажмем Enter, не после каждого символа. После нажатия Enter обрабатывается все, что мы ввели. Поиграйтесь с программой, почувствуйте как она работает.
Функция when находится в модуле Control.Monad (для того чтобы получить доступ к нему, выполните import Monad). Она интересна, потому что выглядит как оператор управления ходом вычислений, но на самом деле это обычная функция. Она принимает булевское значение и действие I/O. Если булевское значение истинно, она возвращает второй параметр - действие I/O. Если первый параметр ложен, функция возвращает return (), пустое действие. Вот как мы можем переписать предыдущий пример с использованием when:
Таким образом это удобный способ инкапсулировать следующую логику: if <что-то> then do <действие I/o> else return ().
Функция sequence принимает список I/O действий и возвращает I/O действие выполняющее действия из списка одно за другим. Результат выполнения этого действия - список результатов вложенных действий. Сигнатура типа функции: sequence :: [IO a] -> IO [a]. Выполним:
То же самое, но с sequence:
rs <- sequence [getLine, getLine, getLine]
Таким образом sequence [getLine, getLine, getLine] создаст действие, которое выполнит getLine три раза. Если мы свяжем это действие с именем, результат будет списком результатов действий из списка, в нашем случае - то что пользователь введет с клавиатуры.
Часто sequence применяют совместно с map и функциями вывода наподобие print или putStrLn, если нам нужно распечатать все элементы списка. Вызов map print [1,2,3,4] не создаст одного I/O действия, будет создан список I/O действий, это все равно что написать [print 1, print 2, print 3, print 4]. Если нам нужно создать одно действие из списка, надо применить sequence.
ghci> sequence (map print [1,2,3,4,5])
А что за [(),(),(),(),()] в конце? Когда мы запускаем I/O действие в GHCI, оно выполняется и затем печатается его результат, если только это не (). () не печатается. Вот почему putStrLn "hehe" в GHCI просто печатает hehe (результат содержащийся в действии putStrLn "hehe" равен ()). Но если выполнить getLine в GHCI, его результат будет напечатан, так как тип функции getLine - IO String.
Так как такая последовательность действий - применять функцию к списку и вызывать sequence для результатов, требовалась очень часто, были написаны утилитарные функции mapM и mapM_. mapM принимает функцию и список, применяет функцию к элементам списка, объединяет элементы в одно действие. mapM_ работает так же, но отбрасывает результат. Она используется когда нам не важен результат комбинированного I/O действия.
ghci> mapM print [1,2,3]
ghci> mapM_ print [1,2,3]
forever принимает I/O действие - параметр и возвращает I/O действие - результат. Действие - результат будет повторять действие - параметр вечно. Эта функция входит в Control.Monad. Следующая программа будет бесконечно спрашивать у пользователя строку и возвращать ее В ВЕРХНЕМ РЕГИСТРЕ:
main = forever $ do
putStr "Give me some input: "
putStrLn $ map toUpper l
Функция forM (определена в Control.Monad) похожа на mapM, но параметры поменяны местами. Первый параметр - это список, второй - это функция, которую надо применить к списку и затем объединить действия из списка в одно действие. Для чего это придумано? Если творчески использовать лямбда-выражения и do нотацию, можно делать такие фокусы:
colors <- forM [1,2,3,4] (\a -> do
putStrLn $ "Which color do you associate with the number " ++ show a ++ "?"
putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
mapM putStrLn colors
(\a -> do . ) - это функция, которая принимает число и возвращает I/O действие. Нам пришлось поместить ее в скобки, иначе лямбда подумает что следующие два I/O действия принадлежат ей. Обратите внимание, что мы делаем return color внутри блока do. Мы это делаем для того, чтобы I/O действие, возвращаемое блоком do, содержало в себе цвет. На самом деле мы не обязаны этого делать, потому что getLine уже содержит цвет внутри себя. Выполняя color <- getLine и затем return color, мы распаковываем результат getLine и затем запаковываем его обратно, то есть это то же самое что просто вызвать getLine. Функция forM (вызываемая с двумя параметрами) создает I/O действие, чей результат мы связываем с colors. colors - это обычный список содержащий строки. В конце мы распечатываем все цвета вызывая mapM putStrLn colors.
Вы можете думать, что forM имеет следующий смысл - создай I/O действие для каждого элемента в списке. Что будет делать каждое I/O действие, может зависеть от элемента из которого создается действие. После создания списка действий, исполни их и привяжи их результаты к чему-нибудь. Но мы не обязаны связывать их, результаты можно просто отбросить.
Which color do you associate with the number 1?
Which color do you associate with the number 2?
Which color do you associate with the number 3?
Which color do you associate with the number 4?
The colors that you associate with 1, 2, 3 and 4 are:
На самом деле мы могли бы сделать это без forM, но так легче читается. Обычно мы используем forM когда нам нужно отобразить (map) и объединить (sequence) действия, которые мы тут же определяем в do. Таким образом, мы могли бы заменить последнюю строку на forM colors putStrLn.
В этом разделе мы изучили основы ввода-вывода. Также мы узнали, что такое I/O действия, как они позволяют выполнять ввод\вывод, в какой момент они выполняются. В качестве повторения, I/O действия это значения, такие же как любые другие в Хаскеле. Мы можем передать их в функции как параметры, функции могут возвращать I/O действия в качестве результата. Они отличаются тем, что если они попадут в функцию main (или их введут в GHCI), они будут выполнены. В этот момент они могут выводит на экран или играть Yakety Sax спикером. Каждое I/O действие может содержать результат общения с реальным миром.
Не думайте о функции, например, putStrLn как о функции, которая принимает строку и печатает ее на экране. Думайте о ней как о функции, которая принимает строку и возвращает I/O действие. Это действие, при выполнении, напечатает что-то действительно ценное на вашем терминале.
getChar это I/O действие, которое считывает символ с терминала. getLine это I/O действие которое считывает строку символов с терминала. Эти две элементарные функции и большинство языков программирования имеет аналогичные функции. Ну а теперь посмотрим на getContents. Это I/O действие, которое считывает что угодно со стандартного ввода, до тех пор пока не встретит символ конца файла (end-of-file character). Тип функции: getContents :: IO String. В getContents хорошо то, что она выполняет ленивое I/O. Когда мы делаем foo <- getContents, функция не считывает весь ввод, не сохраняет его в памяти, а затем привязывает его к имени foo. Нет, функция ленивая! Она говорит: "Да, да, я прочитаю ввод с терминала, но как-нибудь потом, когда он реально потребуется".
getContents полезна, когда нам надо подать вывод одной программы на вход другой. Если вы не знаете как работает перенаправление вывода в юникс-системах, вот краткий пример. Создадим текстовый файл, который содержит следующую хайку:
I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless
Да, хайку отстой, ну и что? Если кто-нибудь знает хороший учебник по хайку, дайте мне знать.
Далее, вспомните программу, которую мы написали для изучения функции forever. Она спрашивала у пользователя строку, возвращала ее В ВЕРХНЕМ РЕГИСТРЕ, и затем делала так снова и снова, бесконечно. Чтобы вам не пришлось скролить вверх, вот она еще раз:
main = forever $ do
putStr "Give me some input: "
putStrLn $ map toUpper l
Сохраним программу как capslocker.hs и скомпилируем ее. Теперь мы используем команду перенаправления вывода в Юникс (вертикальная черта, pipe), чтобы скормить текстовый файл прямо нашей программке. Воспользуемся программой GNU cat, которая выводит на экран файл, переданный ей в качестве аргумента. Смотри сюда, бояка!
$ ghc --make capslocker
[1 of 1] Compiling Main ( capslocker.hs, capslocker.o )
I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless
$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS
capslocker <stdin>: hGetLine: end of file
Как вы видите, перенаправление вывода одной программы (в нашем случае это cat) на вход другой (capslocker) делается с помощью символа |. То что мы сделали эквивалентно запуску capslocker, печатания хайку в терминале, и вводу символа конца файла (обычно это делается с помощью Ctrl-D). Или то же самое что запустить cat haiku.txt и сказать: "Погоди, не печатай это на терминале, передай это все программе capslocker".
Итак, мы использовали forever для того, чтобы получать ввод и трансформировать его в некоторый вывод. Вот почему мы можем использовать getContent, чтобы сделать нашу программу еще лучше и короче:
putStr (map toUpper contents)
Мы запускаем действие I/O getContents, и именуем возвращаемую им строку. Затем мы применяем toUpper к этой строке и печатаем ее на терминале. Обратите внимание, что так как строки это на самом деле списки, а списки ленивы, и getContents также ленива, она не будет пытаться считать весь вход за один раз, и сохранить его в памяти перед распечаткой результата. Вместо этого она будет печатать строки преобразованные в верхний регистр по мере считывания входа, так эта функция считывает вход только когда он нужен.
$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS
Отлично, работает. Что если запустить capslocker и вводить строки самому?
Мы выходим из программы нажав Ctrl-D. Неплохо! Как вы видите, программа печатает строки в верхнем регистре по мере ввода строк. Когда результат getContents связывается с contents, он не представляется в памяти в виде настоящей строки, но в виде обещания, что рано или поздно он вернет строку. Также есть обещание применить функцию к contents. Когда выполняется putStr, он говорит предыдущему обещанию: "Эй, мне нужна строка в верхнем регистре!". Так как никакой строки еще нет, то он говорит contents: "Алло, а не считать ли строку с терминала?". Вот когда getContents в самом деле считывает с терминала и передает строку коду, который ее запрашивал, чтобы сделать что-нибудь осязаемое. Этот код затем применяет toUpper к строке и отдает результат putStr, который его печатает. Затем putStr говорит, "Ау, мне нужна следующая строка, шевелись!", и так продолжается до тех пор, пока не закончатся строки на входе, что мы обозначаем символом конца файла.
Давайте напишем программу, которая будет принимать некоторый вход, и печатать только те строки, которые короче 10 символов. Смотрим:
putStr (shortLinesOnly contents)
shortLinesOnly :: String -> String
let allLines = lines input
shortLines = filter (\line -> length line < 10) allLines
result = unlines shortLines
Мы сделали часть нашей программы для ввода-вывода настолько короткой, насколько это возможно. Так как наша программа принимает некоторый вход и печатает некоторый выход, мы можем реализовать ее так: считываем вход, применяем к нему функцию, распечатываем результат работы функции.
Функция shortLinesOnly работает таким образом: она принимает строку, например "short\nlooooooooooooooong\nshort again". В этой строке можно выделить три других, две из которых короткие, а одна длинная. Ко входной строке применяется функция lines, которая преобразует ее в список ["short", "looooooooooooooong", "short again"], список мы связываем с именем allLines. Затем список строк фильтруется таким образом, что в нем остаются только строки, чья длина не превышает 10 символов. Результат - ["short", "short again"]. После чего функция unlines снова превращает список в строку, разделяя строки символами \n, что дает нам "short\nshort again". Попробуем еще раз:
i am a loooooooooong line.
yeah i'm long so what hahahaha.
$ ghc --make shortlinesonly
[1 of 1] Compiling Main ( shortlinesonly.hs, shortlinesonly.o )
$ cat shortlines.txt | ./shortlinesonly
Мы перенаправили содержимое shortlines.txt на вход shortlinesonly и на выходе получили только короткие строки.
Подобная последовательность действий - считывание строки со входа, преобразование ее функцией, и вывод результата настолько часто встречается, что существует функция, которая делает эту задачу еще легче, она называется interact. interact принимает функцию типа String -> String как параметр, и возвращает I/O действие, которое примет некоторый вход, запустит функция, и распечатает результат. Давайте изменим нашу программу чтобы воспользоваться этой функцией.
main = interact shortLinesOnly
shortLinesOnly :: String -> String
let allLines = lines input
shortLines = filter (\line -> length line < 10) allLines
result = unlines shortLines
Только для того чтобы показать что для данной задачи можно написать значительно более короткий код (хоть и менее читабельный), и для того чтобы продемонстрировать наше умение функциональной композиции, мы переработаем программу еще раз.
main = interact $ unlines . filter ((<10) . length) . lines
Ого, мы сократили код всего до одной строки, что довольно круто!
interact может быть использован для программ, в которые перенаправляют некоторый вход, и которые выводят некоторый результат, либо для программ которые спрашивают некоторые данные у пользователя, возвращая некоторый результат, и затем спрашивают еще один раз, и так далее. На самом деле между этими двумя типами нет никакой разницы, все зависит только от того, как пользователь собирается использовать программу.
Давайте напишем программу, которая постоянно считывает строку и затем говорит нам, является ли она палиндромом. Мы могли бы использовать getLine для считывания строки, говорить пользователю является ли она палиндромом, и снова запускать main. Но легче делать это с помощью interact. При использовании interact, подумайте как преобразовать некий вход в желаемый выход. В нашем случае мы хотим заменить строку на входе на "palindrome" или "not a palindrome" ("палиндром"\"не палиндром"). Итак, нам нужна функция, которая преобразует, например, "elephant\nABCBA\nwhatever" в "not a palindrome\npalindrome\nnot a palindrome". За дело!
respondPalindromes contents = unlines (map (\xs -> if isPalindrome xs then "palindrome" else "not a palindrome") (lines contents))
where isPalindrome xs = xs == reverse xs
Запишем то же самое в бесточечном стиле.
respondPalindromes = unlines . map (\xs -> if isPalindrome xs then "palindrome" else "not a palindrome") . lines
where isPalindrome xs = xs == reverse xs
Все довольно очевидно. В начале преобразуем строку, например, "elephant\nABCBA\nwhatever" в ["elephant", "ABCBA", "whatever"], затем применяем лямбду к списку, получаем ["not a palindrome", "palindrome", "not a palindrome"], затем соединяем список обратно в строку. Теперь мы можем сделать следующее:
main = interact respondPalindromes
not a palindrome
not a palindrome
Хоть мы и написали программу, которая преобразует одну большую составную строку в другую составную строку, она работает так, как будто мы обрабатываем строку за строкой. Это потому что Хаскел ленив, он хочет распечатать первую строку результата, но не может, потому что пока что не имеет первой строки ввода. Как только мы введем первую строку на вход, он напечатает первую строку на выходе. Мы выходим из программы по символу конца строки.
Так же мы можем запустить нашу программу, перенаправив в нее содержимое файла. Например, у нас есть такой файл:
Мы сохранили его с именем words.txt. Вот что мы получим если перенаправим его в нашу программу:
$ cat words.txt | runhaskell palindromes.hs
not a palindrome
Еще раз повторим, мы получаем такой же результат как если бы мы запускали программу и вводили слова вручную. Мы не видим входных строк, потому что вход берется из файла, а не со стандартного ввода.
К этому моменту, вероятно, вы знаете как работает ленивый ввод-вывод и как его можно использовать с пользой для себя. Вы можете думать в таких терминах - каким должен быть выход для данного входа и писать функцию для преобразования входа в выход. В ленивом вводе-выводе ничего не считывается со входа до тех пор пока это не станет абсолютно необходимым для того что мы собираемся напечатать.
До сих пор мы работали с вводом-выводом печатая на терминале и считывая с него. Ну а как читать и записывать файлы? В некотором смысле мы уже работали с файлами. Чтение с терминала можно представить как чтение из (специального) файла. То же верно для печати на терминале, это почти как запись в файл. Эти два файла - stdin и stdout, обозначающие, соответственно, стандартный ввод и вывод. Принимая это во внимание, мы увидим что запись и чтение из файлов очень похоже на запись в стандартный вывод и чтение со стандартного входа.
Для начала напишем очень простую программу, которая открывает файл с именем girlfriend.txt и печатает его на терминале. Файл содержит слова лучшего хита Avril Lavigne, Girlfriend. Вот содержимое файла girlfriend.txt:
Hey! Hey! You! You!
I don't like your girlfriend!
I think you need a new one!
handle <- openFile "girlfriend.txt" ReadMode
contents <- hGetContents handle
Запустив ее, получаем ожидаемый результат:
Hey! Hey! You! You!
I don't like your girlfriend!
I think you need a new one!
Посмотрим, что у нас тут, строка за строкой. Первая строчка это просто четыре восклицания, чтобы привлечь наше внимание. Во второй строчке Аврил сообщает нам, что ей не нравится ваш романтический партнер. Третья строчка подчеркивает, что неприятие это категорическое. Ну а четвертая строчка предписывает нам поискать новую подружку.
Ну, а теперь пройдемся по каждой строке программы. Наша программа - это несколько I/O действий, склеенных вместе с помощью блока do. В первой строке блока do мы использовали новую функцию, openFile. Вот ее сигнатура: openFile :: FilePath -> IOMode -> IO Handle. Если прочитать ее вслух, получится так: openFile принимает путь к файлу и режим открытия файла (IOMode) и возвращает I/O действие, которое откроет файл, получит дескриптор файла и заключит его в результат.
FilePath это просто синоним для String, он определен так:
type FilePath = String
Тип IOMode определен так:
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
Этот тип содержит перечисление режимов открытия файла, так же как наш тип содержал перечисление дней недели. Очень просто. Обратите внимание, что этот тип - IOMode, не путайте с IO Mode. IO Mode может быть типом I/O действия, который возвращает результат типа Mode, но IOMode - это просто перечисление.
В конце концов функция вернет I/O действие, которое откроет указанный файла в указанном режиме. Если мы привяжем это действие к имени, мы получим дескриптор файла (Handle). Значение типа Handle описывает, где находится наш файл. Мы будем использовать дескриптор для того чтобы знать, из какого файла читать. Было бы глупо открыть файл и не связать дескриптор файла с именем, потому что с ним потом ничего нельзя будет сделать. В нашем случае мы связали дескриптор с handle.
На следующей строчке мы видим функцию hGetContents. Она принимает Handle, таким образом она знает с каким файлом работать, и возвращает IO String - I/O действие, которое вернет содержимое файла в результате. Функция похожа на GetContents. Единственное отличие - GetContents читает со стандартного входа (т.е. с терминала), в то время как hGetContents принимает дескриптор файла, из которого будет происходить чтение. Во всех остальных смыслах они работают одинаково. Так же как GetContents, hGetContents не пытается прочитать весь файл целиком и сохранить его в памяти, но читает его по мере необходимости. Это очень удобно, потому что мы можем считать что contents хранит все содержимое файла, но на самом деле содержимого файла в памяти нет. Так что даже чтение из очень больших файлов не отожрет всю память, но будет считывать только то что нужно, и тогда когда нужно.
Обратите внимание на разницу между дескриптором, который используется для идентификации файла, и его содержимым. В нашей программе они привязываются к именам handle и contents. Дескриптор это нечто, с помощью чего мы знаем что есть наш файл. Если представить всю файловую систему в виде очень большой книги, а каждый файл в виде главы, то дескриптор будет чем-то вроде закладки, которая показывает нам, где мы в данный момент читаем (или пишем), в то время как contents будет содержать саму главу.
С помощью putStr contents мы распечатываем содержимое на стандартном выводе, и затем выполняем hClose, который принимает дескриптор и возвращает I/O действие, которое закрое файл. После открытия файла с помощью openFile вы должны закрывать файлы самостоятельно!
То что мы только что сделали можно сделать по-другому, с использованием функции withFile. Сигнатура этой функции: withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a. Она принимает путь к файлу, режим открытия файла, и некоторую функцию, принимающую дескриптор и возвращающую некоторое I/O действие. withFile вернет I/O действие, которое откроет файл, сделает с ним то что нам нужно и закроет файл. Результат, заключенный в результирующем I/O действии будет взят из результата переданной нами функции. Это может выглядеть сложным, но на самом деле все просто, особенно если использовать лямбды. Вот как можно переписать предыдущий пример с использованием withFile:
withFile "girlfriend.txt" ReadMode (\handle -> do
contents <- hGetContents handle
Как можно видеть, эта программа очень похожа на предыдущую. (\handle -> . ) - это функция, которая принимает дескриптор и возвращает I/O действие, обычно она реализуется в виде лямбды. Причина, по которой нужна функция возвращающая I/O действие вместо того чтобы просто принять I/O действие и затем закрыть файл - передаваемое I/O действие не будет знать с каким файлом работать. Таким образом, withFile открывает файл и передает дескриптор функции-параметру. Затем withFile получает I/O действие из функции, и создает новое I/O действие, оно вызывает I/O действие возвращенное из функции-параметра, и затем закрывает файл. Вот как мы можем написать свою собственную функцию withFile:
withFile' :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
withFile' path mode f = do
handle <- openFile path mode
result <- f handle
Мы знаем, что результат будет I/O действием, так что мы можем начать с do. Сначала мы открываем файл и получаем дескриптор. Затем мы передаем дескриптор нашей функции, и получаем обратно I/O действие, которое сделает всю работу. Мы связываем это действие с именем result, закрываем дескриптор, и возвращаем result. Возвращая тот же результат в I/O действии, что мы получили из функции f, мы делаем так, что наше I/O действие возвращает тот же результат, что мы получили из f handle. Например, если f handle возвращает действие, которое считывает некоторое количество строк со стандартного входа и записывает их в файл, и возвращает в качестве результата количество считанных строк, если мы используем такое действие в withFile', результирующее действие также будет содержать количество считанных строк.
Так же как hGetContents, которая работает так же как getContents , но с указанным файлом, существуют функции hGetLine, hPutStr, hPutStrLn, hGetChar и т.д. Они работают так же как их варианты без буквы h, но они принимают дескриптор как параметр и работают с файлом, а не со стандартным вводом-выводом. Пример: putStrLn это функция, которая принимает строку и возвращает I/O действие, которое напечатает строку на терминале, а затем выполнит перевод на новую строку. hPutStrLn принимает дескриптор файла и строку, и возвращает действие, которое запишет строку в файл и затем поместит в файл символ(ы) перехода на новую строку. Таким же образом, hGetLine принимает дескриптор и возвращает действие, которое считывает строку из файла.
Закрузка файлов и обработка их содержимого в виде строк настолько распространена, что есть три маленькие удобные функции, которые делают эту задачу еще легче.
Сигнатура функции readFile такова: readFile :: FilePath -> IO String. Мы помним, что FilePath это просто удобное обозначение для String. readFile принимает путь к файлу и возвращает I/O действие, которое прочитает файл (лениво, конечно же) и свяжет содержимое файла в виде строки с некоторым именем. Обычно это более удобно чем вызывать openFile и связывать дескриптор с именем, а затем вызывать hGetContents. Вот как мы могли бы переписать предыдущий пример с использованием readFile:
contents <- readFile "girlfriend.txt"
Так как мы не получаем дескриптор файла в качестве результата, мы не можем закрыть его сами. Если мы используем readFile, за нас это сделает Хаскелл.
Функция writeFile имеет тип writeFile :: FilePath -> String -> IO (). Она принимает путь к файлу и строку для записи в файл, и возвращает I/O действие, которое выполнит запись. Если такой файл уже существует, перед записью он будет обрезан до нулевой длины. Вот как получить версию girlfriend.txt в верхнем регистре и записать ее в girlfriendcaps.txt:
contents <- readFile "girlfriend.txt"
writeFile "girlfriendcaps.txt" (map toUpper contents)
HEY! HEY! YOU! YOU!
I DON'T LIKE YOUR GIRLFRIEND!
I THINK YOU NEED A NEW ONE!
appendFile имеет такую же сигнатуру как и writeFile, но appendFile не обрезает уже существующий файл до нулевой длины перед записью, а добавляет новое содержимое в конец файла.
Допустим, у нас есть файл todo.txt, в котором записаны задания, которые мы должны сделать, по одному заданию на строчку. Давайте напишем программу, которая спросит строчку со стандартного входа и добавит ее к нашему списку заданий.
Iron the dishes
Take salad out of the oven
Iron the dishes
Take salad out of the oven
Нам нужно добавлять "\n" в конце каждой строки, потому что getLine возвращает строку без символа перевода на новую строку в конце.
О, еще кое-что. Мы говорили что вызов contents <- hGetContents handle не приводит к считыванию всего содержимого файла в память. Ввод-вывод ленив, поэтому если мы сделаем так:
withFile "something.txt" ReadMode (\handle -> do
contents <- hGetContents handle
то на самом деле мы соединим трубой (pipe) файл с выводом. Так же как вы можете думать о списках как о потоках, также можно думать и о файлах. Наш код будет читать по одной строке за раз, и печатать на терминале по мере обработки. "Ну и каков "диаметр" трубы?", вы можете спросить. Как часто будут выполняться обращения к диску? Для текстовых файлов обычно используется буферизация по строкам. Это означает, что минимальная часть файла, которая может быть считана - одна строка. Вот почему в нашем случае программа считывает строку, печатает ее на экране, считывает следующую строку, печатает, и так далее. Для бинарных файлов обычно используется буферизация по блокам. Это означает, что файл читается кусочек за кусочком. Размер кусочка определяется операционной системой.
Вы можете управлять буферизацией используя функцию hSetBuffering. Она принимает дескриптор файла и режим буферизации (BufferMode), и возвращает I/O действие которое установит буферизацию. BufferMode это простой перечислимый тип, и его возможные значения таковы: NoBuffering, LineBuffering or BlockBuffering (Maybe Int). Maybe Int определяет размер буфера в байтах. Если это Nothing, размер буфера определяется операционной системой. NoBuffering означает, файл будет читаться по одному символу за раз. Обычно NoBuffering проигрывает любому буферизованному режиму, потому что обращается к диску слишком часто.
Вот наш последний пример, но он читает файл не строчка за строчкой, а использует буфер размером 2048 байтов.
withFile "something.txt" ReadMode (\handle -> do
hSetBuffering handle $ BlockBuffering (Just 2048)
contents <- hGetContents handle
Чтение файлов большими кусками может помочь, если мы хотим минимизировать количество обращений к диску или если файлы находятся на медленных сетевых ресурсах.
Также мы можем использовать функцию hFlush, которая принимает дескриптор и возвращает I/O действие, которое очистит буфер файла, ассоциированного с дескриптором. Если мы буферизуем по строкам, буфер очищается после каждой строки. Если мы буферизуем блоками, буфер очищается после чтения блока. Также буфер очищается после закрытия дескриптора. Это <очистка буфера> означает, что когда мы достигли символа новой строки <при буферизации по строкам>, механизм чтения (или записи) сообщает что получены все данные на текущий момент. Мы можем использовать hFlush чтобы досрочно инициировать такое сообщение. После очистки данные доступны другим программам, которые запущенны в это же время.
Можно провести такую аналогию с буферизацией блоками: туалетный бачок настроен таким образом, что он спускает воду после того как в нем наберется один галлон воды. Вы наливаете в него воду, и как только будет достигнута отметка в один галлон, вода автоматически спускается. Все данные (налитая вода), доступные на данный момент, "считаны". Но вы можете слить воду самостоятельно, нажав кнопку. Это заставит бачок спустить воду, и все накопленные "данные" станут доступны для обработки. Если вы еще до сих пор не поняли, спуск туалетного бачка - это метафора для hFlush. Не такая уж и хорошая метафора для концепции из мира программирования, но мне хотелось показать ее на предмете из реального мира, чтобы не слить все объяснение.
Мы уже написали программу которая добавляет новый элемент к списку заданий в todo.txt, теперь напишем программу для удаления элемента. Я приведу программу целиком, а затем мы обсудим ее детально, вы увидите что она очень проста. Мы будем использовать несколько новых функций из System.Directory и одну новую функцию из System.IO, все они будут объяснены позднее.
Итак, программа для удаления элемента из todo.txt:
handle <- openFile "todo.txt" ReadMode
(tempName, tempHandle) <- openTempFile "." "temp"
contents <- hGetContents handle
let todoTasks = lines contents
numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
putStrLn "These are your TO-DO items:"
putStr $ unlines numberedTasks
putStrLn "Which one do you want to delete?"
let number = read numberString
newTodoItems = delete (todoTasks !! number) todoTasks
hPutStr tempHandle $ unlines newTodoItems
renameFile tempName "todo.txt"
Сначала мы открываем todo.txt в режиме чтения и связываем его дескриптор с именем handle.
Далее, мы используем новую функцию из System.IO — openTempFile. Имя функции говорит само за себя (open temp file - открыть временный файл). Она принимает путь к временной директории и шаблон имени файла, а затем открывает временный файл. Мы использовали "." в качестве директории для временных файлов, так как . обозначает текущую директорию практически во всех ОС. Мы указали "temp" в качестве имени шаблона для временного файла, это означает что временный файл будет назван temp плюс несколько случайных символов. Функция возвращает I/O действие, которое создаст временный файл, результат действия - пара значений, имя временного файла и указатель. Мы могли бы открыть обычный файл, например, с именем todo2.txt, но использовать openTempFile - хорошая практика, в этом случае вы уверены что не перезапишете что-нибудь.
(Автор не использует getCurrentDirectory, этот абзац нужно пропустить при чтении - прим. пер.)Причина по которой мы используем getCurrentDirectory для получения текущей директории, вместо того чтобы передать "." в openTempFile, потому что . ссылается на текущую директорию в юникс-подобных системах и в Windows.
Далее, мы связываем содержимое файла todo.txt с contents. Затем разбиваем строку на список строк, каждая строчка из файла в отдельной строке. todoTasks будет содержать что-то вроде ["Iron the dishes", "Dust the dog", "Take salad out of the oven"]. Мы соединяем список чисел начинающихся с 0 и список строк, применяя к ним функцию, которая принимает число, например 3, и строку , например "hey" и возвращает "3 - hey", таким образом numberedTasks будет таким: ["0 - Iron the dishes", "1 - Dust the dog" . Затем мы объединяем список строк в одну строку с помощью unlines и печатаем ее на терминал. Обратите внимание, что вместо этого мы могли бы выполнить mapM putStrLn numberedTasks.
Мы спрашиваем пользователя, какую строку он хочет удалить, и ожидаем ввода числа. Скажем, пользователь хочет удалить номер 1, это "Dust the dog". numberString становится равным "1", но так как нам нужна не строка а число, мы вызываем read, и связываем результат с number.
Помните функции delete и !! из Data.List? !! возвращает элемент из списка по индексу, delete удаляет первое вхождение элемента в список, возвращая новый список без удаленного элемента. (todoTasks !! number), number это 1, возвращает "Dust the dog". Мы связываем todoTasks без первого встреченного "Dust the dog" с newTodoItems, затем преобразуем этот список в строку с помощью unlines, и записываем ее во временный файл. В этот момент исходный файл не изменен, а временный файл содержит все строки из исходного, за исключением удаленной строки.
После того как мы закроем исходный и временный файл, мы удалим исходный файл с помощью функции removeFile, которая, как вы можете видеть, принимает путь к файлу и удаляет его. После удаления старого todo.txt, мы используем renameFile чтобы переименовать временный файл в todo.txt. Обратите внимание, removeFile и renameFile (они обе определены в System.Directory) принимают пути к файлам как параметры, не дескрипторы.
Вот так. Мы могли бы записать то же самое меньшим количеством строк, но мы заботимся о том чтобы не перезаписать существующие файлы, а также вежливо спрашиваем систему куда нам поместить временный файл. Попробуем программу в работе.
These are your TO-DO items:
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
Which one do you want to delete?
Iron the dishes
Take salad out of the oven
These are your TO-DO items:
0 - Iron the dishes
1 - Take salad out of the oven
Which one do you want to delete?
Take salad out of the oven
Аргументы командной строки
Если вы пишите консольный скрипт или приложение, то вам наверняка понадобится работать с аргументами командной строки. К счастью, в стандартную библиотеку Хаскеля входят удобные функции для работы с ними.
В предыдущей главе мы написали программы для добавления и удаления элемента в список заданий. Но у нашего подхода есть две проблемы. Во-первых мы жестко задали имя файла со списком заданий в тексте программы. Мы решили, что файл будет называться todo.txt, и что пользователь никогда не захочет вести несколько списков.
Эту проблему можно решить спрашивая пользователя каждый раз, какой файл он хочет использовать как файл со списком заданий. Мы использовали такой подход когда спрашивали пользователя какой элемент он хочет удалить. Это работает, но не так уж хорошо, потому что пользователь должен запустить программу, подождать пока программа спросит что-нибудь, и затем дать ответ. Это называется интерактивной программой, и сложным моментом с таким видом программ является вот что - что если вам нужно автоматизировать выполнение этой программы, например с помощью скрипта? Гораздо сложнее написать скрипт, который будет взаимодействовать с программой, чем обычный скрипт, который просто вызовет программу один раз или несколько.
Вот почему иногда лучше сделать так, чтобы пользователь сообщал что он хочет при запуске программы, вместо того чтобы спрашивать его после запуска.И что может служить этой цели лучше командной строки!
В модуле System.Environment есть два полезных I/O действия. Первое - это getArgs, его тип - getArgs :: IO [String], оно получит аргументы с которыми была вызвана программа и вернет их в виде списка. Второе - getProgName, его тип getProgName :: IO String, это I/O действие, которое вернет имя программы.
Вот простенькая программа, которая показывает как работают эти два действия:
putStrLn "The arguments are:"
mapM putStrLn args
putStrLn "The program name is:"
Мы связываем getArgs и progName с именами args и progName. Мы выводим "The arguments are:" и затем для каждого аргумента в args мы выполняем putStrLn. После этого мы печатаем имя программы. Скомпилируем программу с именем arg-test.
$ ./arg-test first second w00t "multi word arg"
The arguments are:
The program name is:
Отлично. Вооружившись этим знанием, вы можете создавать крутые приложения командной строки. А давайте напишем какое-нибудь. В предыдущей главе мы написали раздельные программы для создания и удаления записи в списке задач. Давайте объединим их в одно приложение, а что ему делать будем указывать в командной строке. Также сделаем так, что программа сможет работать с разными файлами, не только todo.txt.
Назовем программу просто todo, и она сможет делать три разные вещи:
Отложим пока обработку ошибок ввода-вывода.
Наша программа будет устроена так, что если мы хотим добавить задачу "Найти волшебный меч власти" (Find the magic sword of power) к файлу todo.txt, то мы будем делать такой вызов: todo add todo.txt "Find the magic sword of power". Для просмотра списка задач вызов будет таким: todo view todo.txt, для удаления задачи с индексом 2 - todo remove todo.txt 2.
Начнем с создания управляющего ассоциативного списка. Это будет простой ассоциативный список, в котором аргументы из командной строки это ключи, а значения - функции, соответствующие ключам. Все функции будут типа [String] -> IO (). Они будут принимать список аргументов как параметр и возвращать I/O действие, которое будет выполнять просмотр, добавление, удаление.
dispatch :: [(String, [String] -> IO ())]
Нам еще предстоит определить функции main, add, view и remove, давайте начнем с main:
let (Just action) = lookup command dispatch
Сначала мы получаем аргументы и связываем их с (command:args). Если вы помните как работает сопоставление с образцом, это означает, что список аргументов переданный программе будет разбит на головной элемент и "хвост", "голова" будет связана с command, а "хвост" будет связан с args. Если мы вызовем нашу программу так: todo add todo.txt "Spank the monkey", command будет равно "add", а args будет равно ["todo.txt", "Spank the monkey"].
В следующей строке мы ищем нашу команду в управляющем списке. Так как "add" указывает на функцию add, мы получаем Just add в качестве результата. Мы используем сопоставление с образцом чтобы выделить функцию из Maybe. Что произойдет, если мы получим команду, которой нет в управляющем списке? В этом случае поиск вернет Nothing, но мы уже говорили что мы не будем уделять много внимания обработке ошибок, в этом случае сопоставление с образцом завершится неудачей и программа вывалится с ошибкой.
На последнем шаге мы вызываем функцию, которая выполняет выбранное действие, передавая ей остаток списка аргументов. Функция вернет I/O действие, которое добавит элемент, отобразит список заданий, или удалит элемент. Так как действие будет частью блока do в main, это действие будет выполнено. Если продолжить наш пример вызова, команда была add, она была вызвана с аргументами (["todo.txt", "Spank the monkey"]). Будет возвращено действие, которое добавит строку "Spank the monkey" к файлу "todo.txt".
Отлично! Все что нам осталось - реализовать add, view и remove. Начнем с add:
add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
Если мы вызовем нашу программу так: todo add todo.txt "Spank the monkey". "add" будет связан с command в первом сопоставлении с образцом в блоке main, а ["todo.txt", "Spank the monkey"] будут переданы функции, которую мы найдем в управляющем списке. Итак, пока мы не не заботимся о неправильном вводе, мы выполняем сопоставление с образцом с этими двумя элементами и возвращаем I/O действие, которое добавит строку к концу файла, добавив символ конца строки.
Далее, реализуем функциональность просмотра списка. Если мы хотим просмотреть элементы списка, мы вызываем программу так: todo view todo.txt. В первом сопоставлении с образцом, command будет "view", а args будет равно ["todo.txt"].
view :: [String] -> IO ()
view [fileName] = do
contents <- readFile fileName
let todoTasks = lines contents
numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
putStr $ unlines numberedTasks
Программа, которая удаляла задачу из списка, делала практически те же самые действия (мы отображали список задач, чтобы пользователь мог выбрать какую удалить). Но в этой функции мы просто отображаем список.
Ну и наконец, мы реализуем remove. Функция будет очень похожа на программу для удаления элемента, так что если вы не понимаете как работает функция удаление, прочитайте пояснения к программе. Основное отличие - мы не задаем жестко имя файла, а получаем его как аргумент. Также мы не спрашиваем у пользователя номер задачи для удаления, его мы также получаем в виде аргумента.
remove :: [String] -> IO ()
remove [fileName, numberString] = do
handle <- openFile fileName ReadMode
(tempName, tempHandle) <- openTempFile "." "temp"
contents <- hGetContents handle
let number = read numberString
todoTasks = lines contents
newTodoItems = delete (todoTasks !! number) todoTasks
hPutStr tempHandle $ unlines newTodoItems
renameFile tempName fileName
Мы открываем файл, полное имя которого задается в fileName, открываем временный файл, удаляем строку по индексу, записываем во временный файл, удаляем исходный файл, переименовываем временный файл в fileName.
Приведем полный листинг программы, во всей ее красе:
dispatch :: [(String, [String] -> IO ())]
let (Just action) = lookup command dispatch
add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
view :: [String] -> IO ()
view [fileName] = do
contents <- readFile fileName
let todoTasks = lines contents
numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
putStr $ unlines numberedTasks
remove :: [String] -> IO ()
remove [fileName, numberString] = do
handle <- openFile fileName ReadMode
(tempName, tempHandle) <- openTempFile "." "temp"
contents <- hGetContents handle
let number = read numberString
todoTasks = lines contents
newTodoItems = delete (todoTasks !! number) todoTasks
hPutStr tempHandle $ unlines newTodoItems
renameFile tempName fileName
Кратко резюмируем наше решение: мы создали ассоциативный список, который отображает команды в функции. Функции принимают аргументы из командной строки и возвращают I/O действие. Мы выделяем команду из аргументов командной строки, по ней находим соответствующую функцию в ассоциативном списке. Мы вызываем эту функцию, передавая ей все оставшиеся аргументы командной строки, получаем обратно I/O действие, которое сделает все необходимые действия, а затем мы просто выполняем действие!
В других языках мы бы реализовали то же самое с помощью оператора выбора (switch case) или похожего, но использование функций высшего порядка позволяет нам просто получить функцию из ассоциативного списка, передать ей параметры и получить в результате I/O действие.
Попробуем, как работает наша программа!
$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
$ ./todo add todo.txt "Pick up children from drycleaners"
$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
3 - Pick up children from drycleaners
$ ./todo remove todo.txt 2
$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Pick up children from drycleaners
Большой плюс такого подхода - легко добавлять новую функциональность. Добавить элемент в ассоциативный список, реализовать соответствующую функцию и готово! В качестве упражнения, можете реализовать функцию bump, которая примет файл, номер задачи, и вернет I/O действие, которое поднимет эту задачу на вершину списка задач.
Можно улучшить обработку корректности ввода, чтобы наша программа падала более изящно (например, если кто-то запустит ее, например, так: todo UP YOURS HAHAHAHA). Для этого создадим I/O действие, которое сообщает об ошибке (например, errorExit :: IO ()). Затем проверяем корректность ввода, и если обнаружена ошибка, выполняем это действие. Другой способ - использование исключений, с которыми мы скоро познакомимся.
Во многих случаях при программировании бывает нужно получить некоторые случайные данные. Возможно вы создаете игру, где нужно бросать игральные кости, или генерируете тестовые данные чтобы проверить вашу программу. Существует много применений таким данным. Ну, на самом деле, конечно, псевдо-случайным. Так как мы знаем, что единственным настоящим источником случайности можно считать пьяную обезьяну на одноколесном велосипеде, которая держится одной рукой за собственный зад, а в другой держит сыр. В этой главе мы узнаем как заставить Хаскелл генерировать вроде бы случайные данные (без сыра и велосипеда).
В большинстве языков программирования есть функции, которые возвращают некоторое случайное число. Каждый раз, когда вы вызываете такую функцию, вы (надеюсь) получите новое случайное число. Ну а как в Хаскеле? Как мы помним, Хаскелл - чистый функциональный язык. Это означает, что он обладает свойством ссылочной прозрачности. Это свойство означает, что если функции дважды передать некоторый аргумент, должна дважды вернуть один и тот же результат. На самом деле это удобно, поскольку облегчает наши размышления о программах, а также позволяет отложить вычисление до тех пор, пока оно на самом деле не понадобится. Если я вызываю функцию, я могу быть уверен, что она не делает каких-либо темных делишек на стороне, прежде чем вернуть мне результат. Все что надо принимать во внимание - это результат функции. Однако это делает получение случайных чисел не таким простым. Если у меня есть такая функция:
randomNumber :: (Num a) => a
Она не очень-то полезна в качестве источника случайных чисел, потому что она всегда возвращает 4, даже если я поклянусь что эта четверка абсолютно случайная, так как я использовал игральную кость для определения этого числа.
Как другие языки вычисляют псевдо-случайные числа? Они получают некоторую информацию от компьютера, например - текущее время, как часто и куда вы двигаете мышь, какие звуки вы издаете, когда сидите за компьютером, и основываясь на этом, выдают число, которое выглядит на самом деле случайным. Комбинация этих факторов (их случайность), вероятно, различаются в каждый данный момент времени, таким образом вы и получаете разные случайные числа.
Ага. Также и вы можете создавать случайные числа и в Хаскеле, если мы напишем функцию, которая принимает случайные величины как параметры, и основываясь на них возвращает некоторое число (или другой тип данных).
Посмотрим на модуль System.Random. В нем содержатся функции, которые удовлетворят все наши нужды в случайности. Давайте посмотрим на одну из экспортируемых функций, а именно random. Вот ее тип: random :: (RandomGen g, Random a) => g -> (a, g). Ага! В декларации мы видим несколько новых классов типов. Класс типов RandomGen предназначен для типов, которые могут служить источниками случайности. Класс типов Random предназначен для типов, которые могут принимать случайные значения. Булевские значения могут быть случайными, они могут быть True или False. Число может принимать огромное количество случайных значений. Может ли функция принимать случайное значение? Не думаю, скорее всего нет. Если мы попытаемся перевести объявление random на русский, получится как-то так: функция принимает генератор случайности (источник случайности), возвращает случайное значение и новый генератор случайности. Зачем она возвращает новый генератор вместе со случайным значением? Увидим через минуту.
Чтобы воспользоваться функцией random, нам нужно получить один из генераторов случайности. Модуль System.Random экспортирует полезный тип, StdGen, он является экземпляром класса типов RandomGen. Мы можем создать StdGen вручную, или попросить систему выдать нам генератор основывающийся на нескольких вроде как случайных вещах.
Для того чтобы создать генератор вручную, используйте функцию mkStdGen. Ее тип - mkStdGen :: Int -> StdGen. Он принимает целое число и основывается на нем, возвращая нам генератор. Давайте попробуем использовать random и mkStdGen для того чтобы получить (сомнительно что случайное) число.
ghci> random (mkStdGen 100)
Ambiguous type variable `a' in the constraint:
`Random a' arising from a use of `random' at <interactive>:1:0-20
Probable fix: add a type signature that fixes these type variable(s)
Чего это? А, да, функция random может возвращать значения любого типа, который входит в класс типов Random, так что мы должны указать Хаскелю какой тип мы желаем получить в результате. Также не будем забывать, что функция возвращает случайное значение и генератор в паре.
ghci> random (mkStdGen 100) :: (Int, StdGen)
Ну наконец-то! Число выглядит довольно случайным. Первый компонент кортежа это случайное число, второй элемент - текстовое представление нового генератора. Что случится, если мы вызовем random с тем же генератором снова?
ghci> random (mkStdGen 100) :: (Int, StdGen)
Как и следовало ожидать. Тот же результат для тех же параметров. Так что давайте-ка передадим другой генератор в параметре.
ghci> random (mkStdGen 949494) :: (Int, StdGen)
Отлично, получили другое число. Мы можем использовать аннотацию типа для того чтобы получать случайные значения разных типов.
ghci> random (mkStdGen 949488) :: (Float, StdGen)
ghci> random (mkStdGen 949488) :: (Bool, StdGen)
ghci> random (mkStdGen 949488) :: (Integer, StdGen)
Давайте напишем функцию, которая эмулирует трехкратное подбрасывание монеты. Если бы random не возвращал бы новый генератор вместе со случайным значением, нам пришлось бы передавать в функцию три случайных генератора в качестве параметров, и затем возвращать результат подбрасывания монеты для каждого из них. Но это выглядит не очень разумным, потому что если один генератор может создавать случайные значения типа Int (а он может принимать довольно много разных значений), его должно хватить и не троекратное подбрасывание монеты (что может дать нам в точности восемь комбинаций). В таких случаях оказывается очень полезным, что random возвращает новый генератор вместе со значением.
Будем представлять монету с помощью Bool. True это "орел", а False - "решка".
threeCoins :: StdGen -> (Bool, Bool, Bool)
let (firstCoin, newGen) = random gen
(secondCoin, newGen') = random newGen
(thirdCoin, newGen'') = random newGen'
in (firstCoin, secondCoin, thirdCoin)
Мы вызываем random с генератором, который нам передали в параметре, и получаем монету и новый генератор. Затем мы снова вызываем random, но на этот раз с новым генератором, чтобы получить вторую монету. Делаем то же самое с третьей монетой. Если бы мы вызывали random с одним генератором, все монеты имели бы одинаковое значение, и в результате мы могли бы получать только (False, False, False) или (True, True, True).
ghci> threeCoins (mkStdGen 21)
ghci> threeCoins (mkStdGen 22)
ghci> threeCoins (mkStdGen 943)
ghci> threeCoins (mkStdGen 944)
Обратите внимание, что нам не надо писать random gen :: (Bool, StdGen), потому что мы уже указали что мы желаем получить булевское значение в декларации типа функции. По декларации Хаскел может вычислить, что мы желаем получить булевское значение в этом случае.
А что если бы мы захотели подкинуть четыре монеты? Или пять? На этот случай есть функция randoms, которая принимает генератор и возвращает бесконечную последовательность значений, основываясь на переданном генераторе.
ghci> take 5 $ randoms (mkStdGen 11) :: [Int]
ghci> take 5 $ randoms (mkStdGen 11) :: [Bool]
ghci> take 5 $ randoms (mkStdGen 11) :: [Float]
Почему randoms не возвращает новый генератор вместе со списком? Мы легко могли бы реализовать функцию randoms так:
randoms' :: (RandomGen g, Random a) => g -> [a]
randoms' gen = let (value, newGen) = random gen in value:randoms' newGen
Рекурсивное определение. Мы получаем случайное значение и новый генератор из текущего генератора и затем создаем список, который помещает сгенерированное значение в голову списка, а значения сгенерированные по новому генератору - в хвост списка. Так как теоретически мы можем генерировать бесконечное количество чисел, мы не можем вернуть новый генератор.
Мы могли бы создать функцию, которая генерирует конечный поток чисел и новый генератор таким образом:
finiteRandoms :: (RandomGen g, Random a, Num n) => n -> g -> ([a], g)
finiteRandoms 0 gen = ([], gen)
finiteRandoms n gen =
let (value, newGen) = random gen
(restOfList, finalGen) = finiteRandoms (n-1) newGen
in (value:restOfList, finalGen)
Опять рекурсивное определение. Мы полагаем, что если нам нужно 0 чисел, мы возвращаем пустой список и исходный генератор. Для любого другого количества требуемых случайных значений, вначале мы получаем одно случайное число и новый генератор. Это будет голова списка. Затем мы говорим, что хвост будет состоять из n-1 чисел, сгенерированных новым генератором. Затем мы возвращаем объединенные голову и остаток списка, и финальный генератор, который мы получили после вычисления n-1 случайных чисел.
А что если мы захотим получить случайное число в некотором диапазоне? Все случайные числа до сих пор были чрезмерно большими или маленькими. Что если нам нужно подбросить игральную кость? Для этих целей мы используем функцию randomR. Она имеет тип randomR :: (RandomGen g, Random a) :: (a, a) -> g -> (a, g), что означает, что функция похожа на random, но получает в первом параметре пару значений, определяющих верхнюю и нижнюю границу диапазона, и возвращаемое значение будет в границах этого диапазона.
ghci> randomR (1,6) (mkStdGen 359353)
ghci> randomR (1,6) (mkStdGen 35935335)
Также существует функция randomRs, которая возвращает поток случайных значений в нами определенном диапазоне. Смотрим:
ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
Неплохо, выглядит как супер-секретный пароль или что-то в этом роде.
Вы, должно быть, спрашиваете себя, а какое отношение имеет этот параграф к I/O? До сих пор мы не сделали ничего, что имело бы отношение к вводу-выводу. До сих пор мы создавали генераторы случайных чисел вручную, основывая их на некотором целочисленном значении. Проблема в том, что если мы будем делать так в реальных программах, они всегда будут возвращать одинаковые последовательности случайных чисел, что не очень хорошо для нас. Вот почему System.Random содержит I/O действие getStdGen, тип которого - IO StdGen. При запуске программы, она запрашивает у системы хороший генератор случайных чисел, и сохраняет его в так называемом глобальном генераторе. getStdGen передает этот глобальный генератор вам, когда вы связываете ее с чем-нибудь.
Вот простая программа, генерирующая случайную строку.
putStr $ take 20 (randomRs ('a','z') gen)
Но будьте осторожны, если дважды вызвать getStdGen, то система два раза вернет один и тот же генератор. Если сделать так:
putStrLn $ take 20 (randomRs ('a','z') gen)
putStr $ take 20 (randomRs ('a','z') gen2)
вы получите дважды напечатанную одинаковую строку. Одним из способов получить две разные строки длиной в 20 символов - создать бесконечный поток, получить первые 20 символов и напечатать их в одной строке, получить следующий набор из 20 символов, и напечатать их в следующей строке. Для этого мы можем использовать функцию splitAt из Data.List, которая разбивает список в некоторой позиции и возвращает пару, содержащую первую часть списка в первом элементе пары и остаток списка во втором элементе пары.
let randomChars = randomRs ('a','z') gen
(first20, rest) = splitAt 20 randomChars
(second20, _) = splitAt 20 rest
Другой способ - использовать действие newStdGen, которое разбивает текущий глобальный генератор на два генератора. Действие замещает глобальный генератор одним из результирующих генераторов, и возвращает второй генератор в качестве результата.
putStrLn $ take 20 (randomRs ('a','z') gen)
Мы не только получаем новый генератор, когда связываем newStdGen с чем-нибудь, но и заменяем глобальный генератор, так что если мы воспользуемся getStdGen еще раз и свяжем его с чем-нибудь, мы получим генератор отличный от gen.
Вот маленькая программка, которая заставляет пользователя угадывать загаданное число.
askForNumber :: StdGen -> IO ()
askForNumber gen = do
let (randNumber, newGen) = randomR (1,10) gen :: (Int, StdGen)
putStr "Which number in the range from 1 to 10 am I thinking of? " --Я задумал число от 1 до 10. Какое?
when (not $ null numberString) $ do
let number = read numberString
if randNumber == number
then putStrLn "You are correct!"--Правильно!
else putStrLn $ "Sorry, it was " ++ show randNumber--Извините, но правильный ответ
Мы создаем функцию askForNumber, которая принимает генератор случайных чисел и возвращает I/O действие, которое спросит число у пользователя и сообщит ему, правильно ли он угадал. В этой функции сначала мы генерируем случайное число и новый генератор, основываясь на исходном генераторе, мы называем случайное число randNumber, новый генератор - newGen. Допустим, что было сгенерировано число 7. Затем мы предлагаем пользователю угадать, какое число мы задумали. Мы вызываем getLine и связываем ее результат с numberString. Если пользователь введет 7, numberString будет равно "7". Далее, мы используем when, для того чтобы проверить, не ввел ли пользователь пустую строку. Если ввел, выполняется пустое I/O действие return(), которое закончит выполнение программы. Если пользователь ввел не пустую строку, выполняется действие, состоящее из блока do. Мы вызываем read с numberString чтобы преобразовать его в число, number становится равным 7.
На минуточку! Если пользователь введет что-нибудь, что read не сможет прочесть (например, "haha"), наша программа упадет с ужасным сообщением об ошибке. Если вы не хотите, чтобы программа падала на некорректном вводе, используйте reads, она возвращает пустой список, если у функции не получилось считать строку. Если чтение прошло удачно, функция вернет список из одного элемента, содержащий пару, один компонент которой содержит желаемый элемент, второй элемент хранит остаток строки после считывания первого элемента.
Мы проверяем, равняется ли number случайно сгенерированному числу и выдаем пользователю соответствующее сообщение. Затем мы рекурсивно вызываем askForNumber, но на этот раз с вновь полученным генератором, что возвращает нам такое же I/O действие как мы только что выполнили, но основанное на новом генераторе. Затем это действие выполняется.
main состоит всего лишь из получения генератора случайных чисел от системы и вызова askForNumber с полученным генератором, для того чтобы получить первое действие.
Посмотрим на нашу программу в действии!
Я задумал число от 1 до 10. Какое? 4
Извините, но правильный ответ 3
Я задумал число от 1 до 10. Какое? 10
Я задумал число от 1 до 10. Какое? 2
Извините, но правильный ответ 4
Я задумал число от 1 до 10. Какое? 5
Извините, но правильный ответ 10
Я задумал число от 1 до 10. Какое?
Можно написать эту же программу по-другому:
let (randNumber, _) = randomR (1,10) gen :: (Int, StdGen)
putStr "Which number in the range from 1 to 10 am I thinking of?"--Я задумал число от 1 до 10. Какое?
when (not $ null numberString) $ do
let number = read numberString
if randNumber == number
then putStrLn "You are correct!"--Правильно!
else putStrLn $ "Sorry, it was " ++ show randNumber--Извините, но правильный ответ
Эта версия очень похожа на предыдущую, но вместо создания функции, которая принимает генератор и вызывает сама себя рекурсивно с вновь полученным генератором, мы делаем все действия внутри функции main. После того как мы скажем пользователю результат, угадал он или нет, мы обновим глобальный генератор и снова вызовем main. Оба подхода хороши, но мне больше нравится первый способ, так как он делает меньше действий в main и дает нам функцию, которую мы можем легко использовать повторно.
Bytestring - String, но быстрее
Список - полезная и удобная структура данных. Мы использовали списки почти что везде. Существует очень много функций работающих со списками, и ленивость Хаскелла позволяет нам заменить циклы for и while из других языков программирования на фильтрацию и отображение (mapping) списков, потому что вычисление произойдет только тогда, когда оно действительно понадобится. Так что такие вещи как бесконечные списки (и даже бесконечные списки бесконечных списков!) для нас не проблема. По той же причине списки могут быть использованы в качестве потоков, читаем ли мы со стандартного ввода или из файла. Мы можем открыть файл и считать его как строку, но на самом деле обращение к файлу будет происходить только по мере необходимости.
Тем не менее, обработка файлов как строк имеет один недостаток: она может оказаться медленной. Как вы знаете, String это просто синоним для [Char]. У символов нет фиксированного размера, так как для представления, скажем, Unicode символа может потребоваться несколько байтов. Более того, список - ленивая структура. Если у вас есть список, например, [1,2,3,4], он будет вычислен только когда это совершенно необходимо. На самом деле список, в некотором смысле, это обещание списка. Вспомним что [1,2,3,4] это всего лишь синтаксический сахар для записи 1:2:3:4:[]. Когда мы принуждаем вычисление первого элемент списка (например, выводим его на экран), остаток списка 2:3:4:[] также представляет собой "обещание списка", и так далее. Поэтому можно считать список всего лишь обещанием, что следующий элемент списка будет вычислен как только он действительно понадобится, и, вместе с элементом, будет создано обещание следующего элемента. Не нужно прилагать больших умственных усилий чтобы понять, что обработка простого списка чисел как серии обещаний - не самая эффективная вещь на свете.
Все эти накладные расходы связанные со списками обычно нас не волнуют, но при чтении и манипулировании большими файлами, это становится помехой. Вот почему в Хаскелле есть байтовые строки. Они похожи на списки, но каждый элемент имеет размер в один байт. Также списки и байтовые строки по-разному реализуют ленивость.
Байтовые строки бывают двух видов: строгие и ленивые. Строгие байтовые строки объявлены в Data.ByteString и они полностью не ленивые. Не используется никаких "обещаний", строгая строка байт представляет собой последовательность байтов в массиве. Строгая строка байтов не может быть бесконечной. Если вы вычисляете первый байт из строгой строки, вы должны вычислить ее целиком. Положительный момент - меньше накладных расходов, так как не используются thunk (технический термин для обещаний). Отрицательный момент - такие строки заполнят память быстрее, так как они считываются целиком.
Второй вид байтовых строк определен в Data.ByteString.Lazy. Они ленивы, но не настолько ленивы как списки. Как мы говорили ранее, в списке столько же thunk, сколько элементов. Вот почему это может сделать их медленными для некоторых целей. Ленивые строки байтов применяют другой подход - они хранятся блоками размером в 64Кб. Если вы вычисляете байт в ленивой байтовой строке (печатая, или другим способом), будут вычислены первые 64Кб. После этого будет возращено обещание вычислить остальные блоки. Ленивые байтовые строки похожи на список строгих байтовых строк размером в 64Кб. При обработке файла ленивыми байтовыми строками, файл будет считываться блок за блоком. Это удобно, потому что не вызывает резкого увеличения потребления памяти, и 64Кб, вероятно, влезет в L2 кэш вашего ЦП.
Если вы посмотрите документацию на Data.ByteString.Lazy, вы увидите множество функций с такими же именами как в Data.List, но в сигнатурах функций будет ByteString вместо [a], и Word8 вместо a. Функции в этом модуле работают с ByteString так же, как функции с теми же именами работают со списками. Так как имена совпадают, нам придется сделать уточненный импорт в скрипте, и затем загрузить этот скрипт в GHCI, для того чтобы поиграть с ByteString.
import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S
B содержит ленивые строки байт и функции, S - строгие. Главным образом мы будем использовать ленивую версию.
Функция pack имеет такую сигнатуру: pack :: [Word8] -> ByteString. Это означает, что она принимает список байтов типа Word8 и возвращает ByteString. Можно думать, будто функция принимает ленивый список и делает его менее ленивым, так что он ленив только кусочками по 64Кб.
Что за тип Word8? Он похож на Int, но имеет значительно меньший диапазон, а именно 0-255. Тип представляет восьми битовое число. Так же как и Int, он входит в класс типов Num. Например, мы знаем, что число 5 полиморфно, а значит оно может вести себя как любой числовой тип. В том числе, оно может принимать тип Word8.
ghci> B.pack [99,97,110]
ghci> B.pack [98..120]
Chunk "bcdefghijklmnopqrstuvwx" Empty
Как можно видеть, Word8 не доставляет много хлопот, потому что система типов определят, что числа должны быть преобразованы к нему. Если вы попытаетесь использовать большое число, например 336, в качестве Word8, число будет взято по модулю 256, т.е. сохранится 80.
Мы упаковали всего несколько значений в ByteString, они уместились в один блок. Empty это нечто вроде [] для списков.
unpack - функция, обратная pack. Она принимает строку байтов и возвращает список байтов.
Функция fromChunks принимает список строгих строк и преобразует их в ленивую строку. toChunks принимает ленивую строку байтов и преобразует ее в список строгих строк.
ghci> B.fromChunks [S.pack [40,41,42], S.pack [43,44,45], S.pack [46,47,48]]
Это полезно, если у вас есть множество маленьких строгих строк байтов, и вы хотите эффективно обработать их, не объединяя их в памяти в одну большую строгую строку.
Аналог : для строк байтов называется cons. Он принимает байт и строку байтов и помещает байт в начало строки. Оператор ленив, так что он создаст новый блок даже если исходный блок не заполнен. Вот почему лучше использовать строгую версию cons, cons', если вы собираетесь вставлять множество байтов в начало строки байтов.