Теперь, когда вы понимаете идеи, лежащие в основе ввода-вывода в языке Haskell, можно приступать к интересным штукам. В этой главе мы будем обрабатывать файлы, генерировать случайные числа, читать аргументы командной строки и много чего ещё. Будьте готовы!
Файлы и потоки
Вооружившись знанием того, как работают действия ввода-вывода, можно перейти к чтению и записи файлов. Но прежде давайте посмотрим, как Haskell умеет работать с потоками данных. Потоком называется последовательность фрагментов данных, которые поступают на вход программы и выводятся в результате её работы. Например, когда вы вводите в программу символы, печатая их на клавиатуре, последовательность этих символов может рассматриваться как поток.
Перенаправление ввода
Многие интерактивные программы получают пользовательский ввод с клавиатуры. Однако зачастую гораздо удобнее «скормить» программе содержимое текстового файла. Такой способ подачи входных данных называется перенаправлением ввода.
Посмотрим, как перенаправление ввода работает с программой на языке Haskell. Для начала создадим текстовый файл, содержащий небольшое хайку, и сохраним его под именем haiku.txt:
Я маленький чайник
Ох уж этот обед в самолёте
Он столь мал и невкусен
Ну да, хайку, прямо скажем, не шедевр – и что? Если кто в курсе, где найти хороший учебник по хайку, дайте знать.
Теперь напишем маленькую программу, которая непрерывно читает строку ввода и выводит её в верхнем регистре:
import Control.Monad
import Data.Char
main = forever $ do
l <- getLine
putStrLn $ map toUpper l
Сохраните эту программу в файле capslocker.hs и скомпилируйте её.
Вместо того чтобы вводить строки с клавиатуры, мы перенаправим на вход программы содержимое файла haiku.txt. Чтобы сделать это, нужно добавить символ < после имени программы и затем указать имя файла, в котором хранятся исходные данные. Посмотрите:
$ ghc capslocker
[1 of 1] Compiling Main ( capslocker.hs, capslocker.o )
Linking capslocker ...
$ ./capslocker < haiku.txt
Я МАЛЕНЬКИЙ ЧАЙНИК
ОХ УЖ ЭТОТ ОБЕД В САМОЛЁТЕ
ОН СТОЛЬ МАЛ И НЕВКУСЕН
capslocker:
То, что мы проделали, практически эквивалентно запуску программы capslocker, вводу нашего хайку с клавиатуры и передаче символа конца файла (обычно это делается нажатием клавиш Ctrl+D). С тем же успехом можно было бы запустить capslocker и сказать: «Погоди, не читай ничего с клавиатуры, возьми содержимое этого файла!».
Получение строк из входного потока
Давайте посмотрим на действие ввода-вывода getContents, упрощающее обработку входного потока за счёт того, что оно позволяет рассматривать весь поток как обычную строку. Действие getContents читает всё содержимое стандартного потока ввода вплоть до обнаружения символа конца файла. Его тип: getContents :: IO String. Самое приятное в этом действии то, что ввод-вывод в его исполнении является ленивым. Это означает, что выполнение foo <- getContents не приводит к загрузке в память всего содержимого потока и связыванию его с именем foo. Нет, действие getContents для этого слишком лениво. Оно скажет: «Да, да, я прочту входные данные с терминала как-нибудь потом, когда это действительно понадобится!».
В примере capslocker.hs для чтения ввода строка за строкой и печати их в верхнем регистре использовалась функция forever. Если мы перейдём на getContents, то она возьмёт на себя все заботы о деталях ввода-вывода – о том, когда и какую часть входных данных нужно прочитать. Поскольку наша программа просто берёт входные данные, преобразует их и выводит результат, пользуясь getContents, её можно написать короче:
import Data.Char
main = do
contents <- getContents
putStr $ map toUpper contents
Мы выполняем действие getContents и даём имя contents строке, которую она прочтёт. Затем проходим функцией toUpper по всем символам этой строки и выводим результат на терминал. Имейте в виду: поскольку строки являются списками, а списки ленивы, как и действие getContents, программа не будет пытаться прочесть и сохранить в памяти всё содержимое входного потока. Вместо этого она будет читать данные порциями, переводить каждую порцию в верхний регистр и печатать результат.
Давайте проверим:
$ ./capslocker < haiku.txt
Я МАЛЕНЬКИЙ ЧАЙНИК
ОХ УЖ ЭТОТ ОБЕД В САМОЛЁТЕ
ОН СТОЛЬ МАЛ И НЕВКУСЕН
Работает. А что если мы просто запустим capslocker и будем печатать строки вручную (для выхода из программы нужно нажать Ctrl+D)?
$ ./capslocker
хей хо
ХЕЙ ХО
идём
ИДЁМ
Чудесно! Как видите, программа печатает строки в верхнем регистре по мере ввода строк. Когда результат действия getContents связывается с идентификатором сontents, он представляется в памяти не в виде настоящей строки, но в виде обещания, что рано или поздно он вернёт строку. Также есть обещание применить функцию toUpper ко всем символам строки сontents. Когда выполняется функция putStr, она говорит предыдущему обещанию: «Эй, мне нужна строка в верхнем регистре!». Поскольку никакой строки ещё нет, она говорит идентификатору сontents: «Аллё, а не считать ли строку с терминала?». Вот тогда функция getContents в самом деле считывает с терминала и передаёт строку коду, который её запрашивал, чтобы сделать что-нибудь осязаемое. Затем этот код применяет функцию toUpper к символам строки и отдаёт результат в функцию putStr, которая его печатает. После чего функция putStr говорит, «Ау, мне нужна следующая строка, шевелись!» – и так продолжается до тех пор, пока не закончатся строки на входе, что мы обозначаем символом конца файла.
Теперь давайте напишем программу, которая будет принимать некоторый вход и печатать только те строки, длина которых меньше 15 символов. Смотрим:
main = do
contents <- getContents
putStr $ shortLinesOnly contents
shortLinesOnly :: String -> String
shortLinesOnly = unlines . filter (\line -> length line < 15) . lines
Фрагмент программы, ответственный за ввод-вывод, сделан настолько малым, насколько это вообще возможно. Так как предполагается, что наша программа печатает результат, основываясь на входных данных, её можно реализовать согласно следующей логике: читаем содержимое входного потока, запускаем на этом содержимом некоторую функцию, печатаем результат работы этой функции.
Функция shortLinesOnly принимает строку – например, такую: "коротко\nдлииииииииииинно\nкоротко". В этом примере в строке на самом деле три строки входных данных: две короткие и одна (посередине) длинная. В результате применения функции lines получаем список ["коротко", "длииииииииииинно", "коротко"]. Затем список строк фильтруется, и остаются только строки, длина которых меньше 15 символов: ["коротко", "коротко"]. Наконец, функция unlines соединяет элементы списка в одну строку, разделяя их символом перевода строки: "коротко\nкоротко".
Попробуем проверить, что получилось. Сохраните этот текст в файле shortlines.txt:
Я короткая
И я
А я длиииииииинная!!!
А уж я-то какая длиннющая!!!!!!!
Коротенькая
Длиииииииииииииииииииииинная
Короткая
Сохраните программу в файле shortlinesonly.hs и скомпилируйте её:
$ ghc shortlinesonly.hs
[1 of 1] Compiling Main ( shortlinesonly.hs, shortlinesonly.o )
Linking shortlinesonly ...
Чтобы её протестировать, перенаправим содержимое файла shortlines.txt на её поток ввода:
$ ./shortlinesonly < shortlines.txt
Я короткая
И я
Коротенькая
Короткая
Видно, что на терминал выведены только короткие строки.
Преобразование входного потока
Подобная последовательность действий – считывание строки из потока ввода, преобразование её функцией и вывод результата – настолько часто встречается, что существует функция, которая делает эту задачу ещё легче; она называется interact. Функция interact принимает функцию типа String –> String как параметр и возвращает действие ввода-вывода, которое примет некоторый вход, запустит заданную функцию и распечатает результат. Давайте изменим нашу программу так, чтобы воспользоваться этой функцией:
main = interact shortLinesOnly
shortLinesOnly :: String -> String
shortLinesOnly = unlines . filter (\line -> length line < 15) . lines
Этой программой можно пользоваться, либо перенаправляя файл в поток ввода, либо вводя данные непосредственно с клавиатуры, строка за строкой. Результат будет одинаковым, однако при вводе с клавиатуры входные данные будут чередоваться с выходными.
Давайте напишем программу, которая постоянно считывает строку и затем говорит нам, является ли введённая строка палиндромом. Можно было бы использовать функцию getLine, чтобы она считывала строку, затем говорить пользователю, является ли она палиндромом, и снова запускать функцию main. Но легче делать это с помощью функции interact. Когда вы её используете, всегда думайте, как преобразовать некий вход в желаемый выход. В нашем случае мы хотим заменить строку на входе на "палиндром" или "не палиндром".
respondPalindromes :: String -> String
respondPalindromes =
unlines .
map (\xs -> if isPal xs then "палиндром" else "не палиндром") .
lines
isPal xs = xs == reverse xs
Всё вполне очевидно. Вначале преобразуем строку, например
"слон\nпотоп\nчто-нибудь"
в список строк
["слон", "потоп", "что-нибудь"]
Затем применяем анонимную функцию к элементам списка и получаем:
["не палиндром", "палиндром", "не палиндром"]
Соединяем список обратно в строку функцией unlines. Теперь мы можем определить главное действие ввода-вывода:
main = interact respondPalindromes
Протестируем:
$ ./palindromes
ха-ха
не палиндром
арозаупаланалапуазора
палиндром
печенька
не палиндром
Хоть мы и написали программу, которая преобразует одну большую составную строку в другую составную строку, она работает так, как будто мы обрабатываем строку за строкой. Это потому что язык Haskell ленив – он хочет распечатать первую строку результата, но не может, поскольку пока не имеет первой строки ввода. Как только мы введём первую строку на вход, он напечатает первую строку на выходе. Мы выходим из программы по символу конца файла.
Также можно запустить нашу программу, перенаправив в неё содержимое файла. Например, у нас есть файл words.txt:
кенгуру
радар
ротор
мадам
Вот что мы получим, если перенаправим его на вход нашей программы:
$ ./palindromes < words.txt
не палиндром
палиндром
палиндром
палиндром
Ещё раз: результат аналогичен тому, как если бы мы запускали программу и вводили слова вручную. Здесь мы не видим входных строк, потому что вход берётся из файла, а не со стандартного ввода.
К этому моменту, вероятно, вы уже усвоили, как работает ленивый ввод-вывод и как его можно использовать с пользой для себя. Вы можете рассуждать о том, каким должен быть выход для данного входа, и писать функцию для преобразования входа в выход. В ленивом вводе-выводе ничего не считывается со входа до тех пор, пока это не станет абсолютно необходимым для того, что мы собираемся напечатать.
Чтение и запись файлов
До сих пор мы работали с вводом-выводом, печатая на терминале и считывая с него. Ну а как читать и записывать файлы? В некотором смысле мы уже работали с файлами. Чтение с терминала можно представить как чтение из специального файла. То же верно и для печати на терминале – это почти что запись в файл. Два файла – stdin и stdout – обозначают, соответственно, стандартный ввод и вывод. Принимая это во внимание, мы увидим, что запись и чтение из файлов очень похожи на запись в стандартный вывод и чтение со стандартного входа.
Для начала напишем очень простую программу, которая открывает файл с именем girlfriend.txt и печатает его на терминале. В этом файле записаны слова лучшего хита Авриль Лавин, «Girlfriend». Вот содержимое girlfriend.txt:
Эй! Ты! Эй! Ты!
Мне не нравится твоя подружка!
Однозначно! Однозначно!
Думаю, тебе нужна другая!
Программа:
import System.IO
main = do
handle <– openFile "girlfriend.txt" ReadMode
contents <– hGetContents handle
putStr contents
hClose handle
Скомпилировав и запустив её, получаем ожидаемый результат:
Эй! Ты! Эй! Ты!
Мне не нравится твоя подружка!
Однозначно! Однозначно!
Думаю, тебе нужна другая!
Посмотрим, что у нас тут? Первая строка – это просто четыре восклицания: они привлекают наше внимание. Во второй строке Авриль сообщает вам, что ей не нравится ваша подружка. Третья строка подчёркивает, что неприятие это категорическое. Ну а четвёртая предписывает подружиться с кем-нибудь получше.
А теперь пройдёмся по каждой строке кода. Наша программа – это несколько действий ввода-вывода, «склеенных» с помощью блока do. В первой строке блока do мы использовали новую функцию, openFile. Вот её сигнатура: openFile :: FilePath –> IOMode –> IO Handle. Если попробовать это прочитать, получится следующее: «Функция openFile принимает путь к файлу и режим открытия файла (IOMode) и возвращает действие ввода-вывода, которое откроет файл, получит дескриптор файла и заключит его в результат».
Тип FilePath – это просто синоним для типа String; он определён так:
type FilePath = String
Тип IOMode определён так:
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
Этот тип содержит перечисление режимов открытия файла, так же как наш тип содержал перечисление дней недели. Очень просто! Обратите внимание, что этот тип – IOMode; не путайте его с IO Mode. Тип IO Mode может быть типом действия ввода-вывода, которое возвращает результат типа Mode, но тип IOMode – это просто перечисление.
В конце концов функция вернёт действие ввода-вывода, которое откроет указанный файл в указанном режиме. Если мы привяжем это действие к имени, то получим дескриптор файла (Handle). Значение типа Handle описывает, где находится наш файл. Мы будем использовать дескриптор для того, чтобы знать, из какого файла читать. Было бы глупо открыть файл и не связать дескриптор файла с именем, потому что с ним потом ничего нельзя будет сделать! В нашем случае мы связали дескриптор с идентификатором handle.
На следующей строке мы видим функцию hGetContents. Она принимает значение типа Handle; таким образом, она знает, с каким файлом работать, и возвращает значение типа IO String – действие ввода-вывода, которое вернёт содержимое файла в результате. Функция похожа на функцию getContents. Единственное отличие – функция getContents читает со стандартного входа (то есть с терминала), в то время как функция hGetContents принимает дескриптор файла, из которого будет происходить чтение. Во всех остальных смыслах они работают одинаково. Так же как и getContents, наша функция hGetContents не пытается прочитать весь файл целиком и сохранить его в памяти, но читает его по мере необходимости. Это очень удобно, поскольку мы можем считать, что идентификатор contents хранит всё содержимое файла, но на самом деле содержимого файла в памяти нет. Так что даже чтение из очень больших файлов не отожрёт всю память, но будет считывать только то, что нужно, и тогда, когда нужно.
Обратите внимание на разницу между дескриптором, который используется для идентификации файла, и его содержимым. В нашей программе они привязываются к именам handle и contents. Дескриптор – это нечто, с помощью чего мы знаем, что есть наш файл. Если представить всю файловую систему в виде очень большой книги, а каждый файл в виде главы, то дескриптор будет чем-то вроде закладки, которая показывает нам, где мы в данный момент читаем (или пишем), в то время как идентификатор contents будет содержать саму главу.
С помощью вызова putStr contents мы распечатываем содержимое на стандартном выводе, а затем выполняем функцию hClose, которая принимает дескриптор и возвращает действие ввода-вывода, закрывающее файл. После открытия файла с помощью функции openFile вы должны закрывать файлы самостоятельно!
Использование функции withFile
То, что мы только что сделали, можно сделать и по-другому – с использованием функции withFile. Сигнатура этой функции:
withFile :: FilePath –> IOMode –> (Handle –> IO a) –> IO a
Она принимает путь к файлу, режим открытия файла и некоторую функцию, принимающую дескриптор и возвращающую некое действие ввода-вывода. Функция withFile вернёт действие ввода-вывода, которое откроет файл, сделает с ним то, что нам нужно, и закроет его. Результат, помещённый в заключительном действии ввода-вывода, будет взят из результата переданной нами функции. С виду это может показаться сложным, но на самом деле всё просто, особенно если использовать анонимные функции. Вот как можно переписать предыдущий пример с использованием функции withFile:
import System.IO
main = do
withFile "girlfriend.txt" ReadMode (\handle –> do
contents <– hGetContents handle
putStr contents)
Функция (\handle -> …) принимает дескриптор файла и возвращает действие ввода-вывода. Обычно пишут именно так, пользуясь анонимной функцией. Нам действительно нужна функция, возвращающая действие ввода-вывода, а не просто выполнение некоторого действия и последующее закрытие файла, поскольку действие, переданное функции withFile, не знало бы, с каким файлом ему необходимо работать. Сейчас же функция withFile открывает файл, а затем передаёт его дескриптор функции, которую мы ей передали. Функция возвращает действие ввода-вывода, на основе которого withFile создаёт новое действие, работающее почти так же, как и исходное, но с добавлением гарантированного закрытия файла даже в тех случаях, когда что-то пошло не так.
Время заключать в скобки
Обычно, если какой-нибудь фрагмент кода вызывает функцию error (например, когда мы пытаемся вызвать функцию head для пустого списка) или случается что-то плохое при вводе-выводе, наша программа завершается с сообщением об ошибке. В таких обстоятельствах говорят, что произошло исключение. Функция withFile гарантирует, что независимо от того, возникнет исключение или нет, файл будет закрыт.
Подобные сценарии встречаются довольно часто. Мы получаем в распоряжение некоторый ресурс (например, файловый дескриптор), хотим с ним что-нибудь сделать, но кроме того хотим, чтобы он был освобождён (файл закрыт). Как раз для таких случаев в модуле Control.Exception имеется функция bracket. Вот её сигнатура:
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
Первым параметром является действие, получающее ресурс (дескриптор файла). Второй параметр – функция, освобождающая ресурс. Эта функция будет вызвана даже в случае возникновения исключения. Третий параметр – это функция, которая также принимает на вход ресурс и что-то с ним делает. Именно в третьем параметре и происходит всё самое важное, а именно: чтение файла или его запись.
Поскольку функция bracket – это и есть всё необходимое для получения ресурса, работы с ним и гарантированного освобождения, с её помощью можно получить простую реализацию функции withFile:
withFile :: FilePath –> IOMode –> (Handle –> IO a) –> IO a
withFile name mode f = bracket (openFile name mode)
(\handle -> hClose handle)
(\handle -> f handle)
Первый параметр, который мы передали функции bracket, открывает файл; результатом является дескриптор. Второй параметр принимает дескриптор и закрывает его. Функция bracket даёт гарантию, что это произойдёт, даже если возникнет исключение. Наконец, третий параметр функции bracket принимает дескриптор и применяет к нему функцию f, которая по заданному дескриптору делает с файлом всё необходимое, будь то его чтение или запись.
Хватай дескрипторы!
Подобно тому как функция hGetContents работает по аналогии с функцией getContents, но с указанным файлом, существуют функции hGetLine, hPutStr, hPutStrLn, hGetChar и т. д., ведущие себя так же, как их варианты без буквы h, но принимающие дескриптор как параметр и работающие с файлом, а не со стандартным вводом-выводом. Пример: putStrLn – это функция, принимающая строку и возвращающая действие ввода-вывода, которое напечатает строку на терминале, а затем выполнит перевод на новую строку. Функция hPutStrLn принимает дескриптор файла и строку и возвращает действие, которое запишет строку в файл и затем поместит в файл символ(ы) перехода на новую строку. Функция hGetLine принимает дескриптор и возвращает действие, которое считывает строку из файла.
Загрузка файлов и обработка их содержимого в виде строк настолько распространена, что есть три маленькие удобные функции, которые делают эту задачу ещё легче.
Сигнатура функции readFile такова:
readFile :: FilePath –> IO String
Мы помним, что тип FilePath – это просто удобное обозначение для String. Функция readFile принимает путь к файлу и возвращает действие ввода-вывода, которое прочитает файл (лениво, конечно же) и свяжет содержимое файла в виде строки с некоторым именем. Обычно это более удобно, чем вызывать функцию openFile и связывать дескриптор с именем, а затем вызывать функцию hGetContents. Вот как мы могли бы переписать предыдущий пример с использованием readFile:
import System.IO
main = do
contents <– readFile "girlfriend.txt"
putStr contents
Так как мы не получаем дескриптор файла в качестве результата, то не можем закрыть его сами. Если мы используем функцию readFile, за нас это сделает язык Haskell.
Функция writeFile имеет тип
writeFile :: FilePath –> String –> IO ()
Она принимает путь к файлу и строку для записи в файл и возвращает действие ввода-вывода, которое выполнит запись. Если такой файл уже существует, перед записью он будет обрезан до нулевой длины. Вот как получить версию файла girlfriend.txt в верхнем регистре и записать её в файл girlfriendcaps.txt:
import System.IO
import Data.Char
main = do
contents <– readFile "girlfriend.txt"
writeFile "girlfriendcaps.txt" (map toUpper contents)
Функция appendFile имеет ту же сигнатуру, что и writeFile, и действует почти так же. Она только не обрезает уже существующий файл до нулевой длины перед записью, а добавляет новое содержимое в конец файла.
Список дел
Воспользуемся функцией appendFile на примере написания программы, которая добавляет в текстовый файл, содержащий список наших дел, новое задание. Допустим, у нас уже есть такой файл с названием todo.txt, и каждая его строка соответствует одному заданию.
Наша программа будет читать из стандартного потока ввода одну строку и добавлять её в конец файла todo.txt:
import System.IO
main = do
todoItem <– getLine
appendFile "todo.txt" (todoItem ++ "\n")
Обратите внимание на добавление символа конца строки вручную, функция getLine возвращает строку без него.
Сохраните этот файл с именем appendtodo.hs, скомпилируйте его и несколько раз запустите.
$ ./appendtodo
Погладить посуду
$ ./appendtodo
Помыть собаку
$ ./appendtodo
Вынуть салат из печи
$ cat todo.txt
Погладить посуду
Помыть собаку
Вынуть салат из печи
ПРИМЕЧАНИЕ. Программа cat в Unix-подобных системах используется для вывода содержимого текстового файла на терминал. В Windows можно воспользоваться командой type или посмотреть содержимое файла в любом текстовом редакторе.
Удаление заданий
Мы уже написали программу, которая добавляет новый элемент к списку заданий в файл todo.txt; теперь напишем программу для удаления элемента. Мы применим несколько новых функций из модуля System.Directory и одну новую функцию из модуля System.IO; их работа будет объяснена позднее.
import System.IO
import System.Directory
import Data.List
main = do
contents <– readFile "todo.txt"
let todoTasks = lines contents
numberedTasks = zipWith (\n line –> show n ++ " – " ++ line)
[0..] todoTasks
putStrLn "Ваши задания:"
mapM_ putStrLn numberedTasks
putStrLn "Что вы хотите удалить?"
numberString <– getLine
let number = read numberString
newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
(tempName, tempHandle) <– openTempFile "." "temp"
hPutStr tempHandle newTodoItems
hClose tempHandle
removeFile "todo.txt"
renameFile tempName "todo.txt"
Сначала мы читаем содержимое файла todo.txt и связываем его с именем contents. Затем разбиваем всё содержимое на список строк. Список todoTasks выглядит примерно так:
["Погладить посуду", "Помыть собаку", "Вынуть салат из печи"]
Далее соединяем числа, начиная с 0, и элементы списка дел с помощью функции, которая берёт число (скажем, 3) и строку (например, "привет") и возвращает новую строку ("3 – привет"). Вот примерный вид списка numberedTasks:
["0 - Погладить посуду", "1 - Помыть собаку", "2 - Вынуть салат из печи"]
Затем с помощью вызова mapM_ putStrLn numberedTasks мы печатаем каждое задание на отдельной строке, после чего спрашиваем пользователя, что он хочет удалить, и ждём его ответа. Например, он хочет удалить задание 1 (Помыть собаку), так что мы получим число 1. Значением переменной numberString будет "1", и, поскольку вместо строки нам необходимо число, мы применяем функцию read и связываем результат с именем number.
Помните функции delete и !! из модуля Data.List? Оператор !! возвращает элемент из списка по индексу, функция delete удаляет первое вхождение элемента в список, возвращая новый список без удалённого элемента. Выражение (todoTasks !! number), где number – это 1, возвращает строку "Помыть собаку". Мы удаляем первое вхождение этой строки из списка todoTasks, собираем всё оставшееся в одну строку функцией unlines и даём результату имя newTodoItems.
Далее используем новую функцию из модуля System.IO – openTempFile. Имя функции говорит само за себя: open temp file – «открыть временный файл». Она принимает путь к временному каталогу и шаблон имени файла и открывает временный файл. Мы использовали символ . в качестве каталога для временных файлов, так как . обозначает текущий каталог практически во всех операционных системах. Строку "temp" мы указали в качестве шаблона имени для временного файла; это означает, что временный файл будет назван temp плюс несколько случайных символов. Функция возвращает действие ввода-вывода, которое создаст временный файл; результат действия – пара значений, имя временного файла и дескриптор. Мы могли бы открыть обычный файл, например с именем todo2.txt, но использовать openTempFile – хорошая практика: в этом случае не приходится опасаться, что вы случайно что-нибудь перезапишете.
Теперь, когда временный файл открыт, запишем туда строку newTodoItems. В этот момент исходный файл не изменён, а временный содержит все строки из исходного, за исключением удалённой.
Затем мы закрываем временный файл и удаляем исходный с помощью функции removeFile, которая принимает путь к файлу и удаляет его. После удаления старого файла todo.txt мы используем функцию renameFile, чтобы переименовать временный файл в todo.txt. Обратите внимание: функции removeFile и renameFile (обе они определены в модуле System.Directory) принимают в качестве параметров не дескрипторы, а пути к файлам.
Сохраните программу в файле с именем deletetodo.hs, скомпилируйте её и проверьте:
$ ./deletetodo
Ваши задания:
0 – Погладить посуду
1 – Помыть собаку
2 – Вынуть салат из печи
Что вы хотите удалить?
1
Смотрим, что осталось:
$ cat todo.txt
Погладить посуду
Вынуть салат из печи
Круто! Удалим ещё что-нибудь:
$ ./deletetodo
Ваши задания:
0 – Погладить посуду
1 – Вынуть салат из печи
Что вы хотите удалить?
0
Проверяя файл с заданиями, убеждаемся, что осталось только одно:
$ cat todo.txt
Вынуть салат из печи
Итак, всё работает. Осталась только одна вещь, которую мы в этой программе не учли. Если после открытия временного файла что-то произойдёт и программа неожиданно завершится, то временный файл не будет удалён. Давайте это исправим.
Уборка
Чтобы гарантировать удаление временного файла, воспользуемся функцией bracketOnError из модуля Control.Exception. Она очень похожа на bracket, но если последняя получает ресурс и гарантирует, что освобождение ресурса будет выполнено всегда, то функция bracketOnError выполнит завершающие действия только в случае возникновения исключения. Вот исправленный код:
import System.IO
import System.Directory
import Data.List
import Control.Exception
main = do
contents <– readFile "todo.txt"
let todoTasks = lines contents
numberedTasks = zipWith (\n line –> show n ++ " – " ++ line)
[0..] todoTasks
putStrLn "Ваши задания:"
mapM_ putStrLn numberedTasks
putStrLn "Что вы хотите удалить?"
numberString <– getLine
let number = read numberString
newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
bracketOnError (openTempFile "." "temp")
(\(tempName, tempHandle) –> do
hClose tempHandle
removeFile tempName)
(\(tempName, tempHandle) –> do
hPutStr tempHandle newTodoItems
hClose tempHandle
removeFile "todo.txt"
renameFile tempName "todo.txt")
Вместо обычного использования функции openTempFile мы заключаем её в bracketOnError. Затем пишем, что должно произойти при возникновении исключения: мы хотим закрыть и удалить временный файл. Если же всё нормально, пишем новый список заданий во временный файл; все эти строки остались без изменения. Мы выводим новые задания, удаляем исходный файл и переименовываем временный.
Аргументы командной строки
Если вы пишете консольный скрипт или приложение, то вам наверняка понадобится работать с аргументами командной строки. К счастью, в стандартную библиотеку языка Haskell входят удобные функции для работы с ними.
В предыдущей главе мы написали программы для добавления и удаления элемента в список заданий. Но у нашего подхода есть две проблемы. Во-первых, мы жёстко задали имя файла со списком заданий в тексте программы. Мы решили, что файл будет называться todo.txt, и что пользователь никогда не захочет вести несколько списков.
Эту проблему можно решить, спрашивая пользователя каждый раз, какой файл он хочет использовать как файл со списком заданий. Мы использовали такой подход, когда спрашивали пользователя, какой элемент он хочет удалить. Это, конечно, работает, но не идеально, поскольку пользователь должен запустить программу, подождать, пока она спросит что-нибудь, и затем дать ответ. Такая программа называется интерактивной, и сложность здесь заключается вот в чём: вдруг вам понадобится автоматизировать выполнение этой программы, например, с помощью скрипта? Гораздо сложнее написать скрипт, который будет взаимодействовать с программой, чем обычный скрипт, который просто вызовет её один или несколько раз!
Вот почему иногда лучше сделать так, чтобы пользователь сообщал, чего он хочет, при запуске программы, вместо того чтобы она сама спрашивала его после запуска. И что может послужить этой цели лучше командной строки!..
В модуле System.Environment есть два полезных действия ввода-вывода. Первое – это функция getArgs; её тип – getArgs :: IO [String]. Она получает аргументы, с которыми была вызвана программа, и возвращает их в виде списка. Второе – функция getProgName, тип которой – getProgName :: IO String. Это действие ввода-вывода, возвращающее имя программы.
Вот простенькая программа, которая показывает, как работают эти два действия:
import System.Environment
import Data.List
main = do
args <– getArgs
progName <– getProgName
putStrLn "Аргументы командной строки:"
mapM putStrLn args
putStrLn "Имя программы:"
putStrLn progName
Мы связываем значения, возвращаемые функциями getArgs и progName, с именами args и progName. Выводим строку "Аргументы командной строки:" и затем для каждого аргумента из списка args выполняем функцию putStrLn. После этого печатаем имя программы. Скомпилируем программу с именем arg-test и проверим, как она работает:
$ ./arg-test first second w00t "multi word arg"
Аргументы командной строки:
first
second
w00t
multi word arg
Имя программы:
arg-test
Ещё больше шалостей со списком дел
В предыдущих примерах мы писали отдельные программы для добавления и удаления заданий в списке дел. Теперь мы собираемся объединить их в новое приложение, а что ему делать, будем указывать в командной строке. Кроме того, позаботимся о том, чтобы программа смогла работать с разными файлами – не только todo.txt.
Назовём программу просто todo, она сможет делать три разные вещи:
• просматривать задания;
• добавлять задания;
• удалять задания.
Для добавления нового задания в список дел в файле todo.txt мы будем писать:
$ ./todo add todo.txt "Найти магический меч силы"
Просмотреть текущие задания можно будет командой view:
$ ./todo view todo.txt
Для удаления задания потребуется дополнительно указать его индекс:
$ ./todo remove todo.txt 2
Многозадачный список задач
Начнём с реализации функции, которая принимает команду в виде строки (например, "add" или "view") и возвращает функцию, которая в свою очередь принимает список аргументов и возвращает действие ввода-вывода, выполняющее в точности то, что необходимо:
import System.Environment
import System.Directory
import System.IO
import Data.List
import Control.Exception
dispatch :: String -> [String] –> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove
Функция main будет выглядеть так:
main = do
(command:argList) <- getArgs
dispatch command argList
Первым делом мы получаем аргументы и связываем их со списком (command:argsList). Таким образом, первый аргумент будет связан с именем command, а все остальные – со списком argList. В следующей строке к переменной commands применяется функция dispatch, результатом которой может быть одна из функций add, view или remove. Затем результирующая функция применяется к списку аргументов argList.
Предположим, программа запущена со следующими параметрами:
$ ./todo add todo.txt "Найти магический меч силы"
Тогда значением command будет "add", а значением argList – список ["todo.txt", "Найти магический меч силы"]. Поэтому сработает первый вариант определения функции dispatch и будет возвращена функция add. Применяем её к argList, результатом оказывается действие ввода-вывода, добавляющее новое задание в список.
Теперь давайте реализуем функции add, view и remove. Начнём с первой из них:
add :: [String] –> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
При вызове
$ ./todo add todo.txt "Найти магический меч силы"
функции add будет передан список ["todo.txt", "Найти магический меч силы"]. Поскольку пока мы не обрабатываем некорректный ввод, достаточно будет сопоставить аргумент функции add с двухэлементным списком. Результатом функции будет действие ввода-вывода, добавляющее строку вместе с символом конца строки в конец файла.
Далее реализуем функциональность просмотра списка. Если мы хотим просмотреть элементы списка, то вызываем программу так: todo view todo.txt. В первом сопоставлении с образцом идентификатор command будет связан со строкой view, а идентификатор argList будет равен ["todo.txt"].
Вот код функции view:
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
contents <- readFile fileName
let todoTasks = lines contents
number = read numberString
newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
bracketOnError (openTempFile "." "temp")
(\(tempName, tempHandle) –> do
hClose tempHandle
removeFile tempName)
(\(tempName, tempHandle) –> do
hPutStr tempHandle newTodoItems
hClose tempHandle
removeFile fileName
renameFile tempName fileName)
Мы открываем файл, полное имя которого задаётся в идентификаторе fileName, открываем временный файл, удаляем строку по индексу, записываем во временный файл, удаляем исходный файл и переименовываем временный в fileName. Приведём полный листинг программы во всей её красе:
import System.Environment
import System.Directory
import System.IO
import Control.Exception
import Data.List
dispatch :: String -> [String] -> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove
main = do
(command:argList) <- getArgs
dispatch command argList
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
contents <- readFile fileName
let todoTasks = lines contents
number = read numberString
newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
bracketOnError (openTempFile "." "temp")
(\(tempName, tempHandle) -> do
hClose tempHandle
removeFile tempName)
(\(tempName, tempHandle) -> do
hPutStr tempHandle newTodoItems
hClose tempHandle
removeFile fileName
renameFile tempName fileName)
Резюмируем наше решение. Мы написали функцию dispatch, отображающую команды на функции, которые принимают аргументы командной строки в виде списка и возвращают соответствующее действие ввода-вывода. Основываясь на значении первого аргумента, функция dispatch даёт нам необходимую функцию. В результате вызова этой функции мы получаем требуемое действие и выполняем его.
Давайте проверим, как наша программа работает:
$ ./todo view todo.txt
0 – Погладить посуду
1 – Помыть собаку
2 – Вынуть салат из печи
$ ./todo add todo.txt "Забрать детей из химчистки"
$ ./todo view todo.txt
0 – Погладить посуду
1 – Помыть собаку
2 – Вынуть салат из печи
3 – Забрать детей из химчистки
$ ./todo remove todo.txt 2
$ ./todo view todo.txt
0 – Погладить посуду
1 – Помыть собаку
2 – Забрать детей из химчистки
Большой плюс такого подхода – легко добавлять новую функциональность. Добавить вариант определения функции dispatch, реализовать соответствующую функцию – и готово! В качестве упражнения можете реализовать функцию bump, которая примет файл и номер задачи и вернёт действие ввода-вывода, которое поднимет указанную задачу на вершину списка задач.
Работаем с некорректным вводом
Можно было бы дописать эту программу, улучшив сообщения об ошибках, возникающих при некорректных исходных данных. Начать можно с добавления варианта функции dispatch, который срабатывает при любой несуществующей команде:
dispatch :: String -> [String] -> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove
dispatch command = doesntExist command
doesntExist :: String -> [String] -> IO ()
doesntExist command _ =
putStrLn $ "Команда " ++ command ++ " не определена"
Также можно добавить варианты определения функций add, view и remove для случаев, когда программе передано неправильное количество аргументов. Например:
add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
add _ = putStrLn "Команда add принимает в точности два аргумента"
Если функция add будет применена к списку, содержащему не два элемента, первый образец не сработает, поэтому пользователю будет выведено сообщение об ошибке. Аналогично дописываются функции view и remove.
Заметьте, что мы не обрабатываем все возможные случаи некорректного ввода. К примеру, программа «упадёт», если мы запустим её так:
./todo
Мы также не проверяем, существует ли файл, с которым идёт работа. Добавить обработку всех этих событий несложно, хотя и несколько утомительно, поэтому оставляем реализацию «защиты от дурака» в качестве упражнения для читателя.
Случайность
Зачастую при программировании бывает необходимо получить некоторые случайные данные. Возможно, вы создаёте игру, где нужно бросать игральные кости, или генерируете тестовые данные, чтобы проверить вашу программу. Существует много применений случайным данным. На самом деле они, конечно, псевдослучайны – ведь мы-то с вами знаем, что настоящим примером случайности можно считать разве что пьяную обезьяну на одноколесном велосипеде, которая одной лапой хватается за собственный зад, а в другой держит сыр. В этой главе мы узнаем, как заставить язык Haskell генерировать вроде бы случайные данные (без сыра и велосипеда).
В большинстве языков программирования есть функции, которые возвращают некоторое случайное число. Каждый раз, когда вы вызываете такую функцию, вы (надеюсь) получаете новое случайное число. Ну а как в языке Haskell? Как мы помним, Haskell – чистый функциональный язык. Это означает, что он обладает свойством детерминированности. Выражается оно в том, что если функции дважды передать один и тот же аргумент, она должна дважды вернуть один и тот же результат. На самом деле это удобно, поскольку облегчает наши размышления о программах, а также позволяет отложить вычисление до тех пор, пока оно на самом деле не пригодится. Если я вызываю функцию, то могу быть уверен, что она не делает каких-либо темных делишек на стороне, прежде чем вернуть мне результат. Однако из-за этого получать случайные числа не так-то просто. Допустим, у меня есть такая функция:
randomNumber :: Int
randomNumber = 4
Она не очень-то полезна в качестве источника случайных чисел, потому что всегда возвращает 4, даже если я поклянусь, что эта четвёрка абсолютно случайная, так как я использовал игральную кость для определения этого числа!
Как другие языки вычисляют псевдослучайные числа? Они получают некую информацию от компьютера, например: текущее время, как часто и в каком направлении вы перемещаете мышь, какие звуки вы издаёте, когда сидите за компьютером, и, основываясь на этом, выдают число, которое на самом деле выглядит случайным. Комбинации этих факторов (их случайность), вероятно, различаются в каждый конкретный момент времени; таким образом, вы и получаете разные случайные числа.
Ага!.. Так же вы можете создавать случайные числа и в языке Haskell, если напишете функцию, которая принимает случайные величины как параметры и, основываясь на них, возвращает некоторое число (или другой тип данных).
Посмотрим на модуль 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
Probable fix: add a type signature that fixes these type variable(s)
Что это?… Ах, да, функция random может возвращать значения любого типа, который входит в класс типов Random, так что мы должны указать языку Haskell, какой тип мы желаем получить в результате. Также не будем забывать, что функция возвращает случайное значение и генератор в паре.
ghci> random (mkStdGen 100) :: (Int, StdGen)
(–1352021624,651872571 1655838864)
Ну наконец-то! Число выглядит довольно-таки случайным. Первый компонент кортежа – это случайное число, второй элемент – текстовое представление нового генератора. Что случится, если мы вызовем функцию random с тем же генератором снова?
ghci> random (mkStdGen 100) :: (Int, StdGen)
(–1352021624,651872571 1655838864)
Как и следовало ожидать! Тот же результат для тех же параметров. Так что давайте-ка передадим другой генератор в пара метре.
ghci> random (mkStdGen 949494) :: (Int, StdGen)
(539963926,466647808 1655838864)
Отлично, получили другое число. Мы можем использовать аннотацию типа для того, чтобы получать случайные значения разных типов.
ghci> random (mkStdGen 949488) :: (Float, StdGen)
(0.8938442,1597344447 1655838864)
ghci> random (mkStdGen 949488) :: (Bool, StdGen)
(False,1485632275 40692)
ghci> random (mkStdGen 949488) :: (Integer, StdGen)
(1691547873,1597344447 1655838864)
Подбрасывание монет
Давайте напишем функцию, которая эмулирует трёхкратное подбрасывание монеты. Если бы функция random не возвращала новый генератор вместе со случайным значением, нам пришлось бы передавать в функцию три случайных генератора в качестве параметров и затем возвращать результат подбрасывания монеты для каждого из них. Но это выглядит не очень разумным, потому что если один генератор может создавать случайные значения типа Int (а он может принимать довольно много разных значений), его должно хватить и на троекратное подбрасывание монеты (что даёт нам в точности восемь комбинаций). В таких случаях оказывается очень полезно, что функция random возвращает новый генератор вместе со значением.
Будем представлять монету с помощью Bool. True – это «орёл», а False –«решка».
threeCoins :: StdGen –> (Bool, Bool, Bool)
threeCoins gen =
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)
(True,True,True)
ghci> threeCoins (mkStdGen 22)
(True,False,True)
ghci> threeCoins (mkStdGen 943)
(True,False,True)
ghci> threeCoins (mkStdGen 944)
(True,True,True)
Обратите внимание, что нам не надо писать random gen :: (Bool, StdGen): ведь мы уже указали, что мы желаем получить булевское значение, в декларации типа функции. По декларации язык Haskell может вычислить, что нам в данном случае нужно получить булевское значение.
Ещё немного функций, работающих со случайностью
А что если бы мы захотели подкинуть четыре монеты? Или пять? На этот случай есть функция randoms, которая принимает генератор и возвращает бесконечную последовательность значений, основываясь на переданном генераторе.
ghci> take 5 $ randoms (mkStdGen 11) :: [Int]
[–1807975507,545074951,–1015194702,–1622477312,–502893664]
ghci> take 5 $ randoms (mkStdGen 11) :: [Bool]
[True,True,True,True,False]
ghci> take 5 $ randoms (mkStdGen 11) :: [Float]
[7.904789e–2,0.62691015,0.26363158,0.12223756,0.38291094]
Почему функция 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)
(6,1494289578 40692)
ghci> randomR (1,6) (mkStdGen 35935335)
(3,1250031057 40692)
Также существует функция randomRs, которая возвращает поток случайных значений в заданном нами диапазоне. Смотрим:
ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
"ndkxbvmomg"
Неплохо, выглядит как сверхсекретный пароль или что-то в этом духе!
Случайность и ввод-вывод
Вы, должно быть, спрашиваете себя: а какое отношение имеет эта часть главы к системе ввода-вывода? Пока ещё мы не сделали ничего, что имело бы отношение к вводу-выводу! До сих пор мы создавали генераторы случайных чисел вручную, основывая их на некотором целочисленном значении. Проблема в том, что если делать так в реальных программах, они всегда будут возвращать одинаковые последовательности случайных чисел, а это нас не вполне устраивает. Вот почему модуль System.Random содержит действие ввода-вывода getStdGen, тип которого – IO StdGen. При запуске программа запрашивает у системы хороший генератор случайных чисел и сохраняет его в так называемом глобальном генераторе. Функция getStdGen передаёт этот глобальный генератор вам, когда вы связываете её с чем-либо.
Вот простая программа, генерирующая случайную строку.
import System.Random
main = do
gen <– getStdGen
putStrLn $ take 20 (randomRs ('a','z') gen)
Теперь проверим:
$ ./random_string
pybphhzzhuepknbykxhe
$ ./random_string
eiqgcxykivpudlsvvjpg
$ ./random_string
nzdceoconysdgcyqjruo
$ ./random_string
bakzhnnuzrkgvesqplrx
Но будьте осторожны: если дважды вызвать функцию getStdGen, система два раза вернёт один и тот же генератор. Если сделать так:
import System.Random
main = do
gen <– getStdGen
putStrLn $ take 20 (randomRs ('a','z') gen)
gen2 <– getStdGen
putStr $ take 20 (randomRs ('a','z') gen2)
вы получите дважды напечатанную одинаковую строку.
Лучший способ получить две различные строки – использовать действие ввода-вывода newStdGen, которое разбивает текущий глобальный генератор на два генератора. Действие замещает глобальный генератор одним из результирующих генераторов и возвращает второй генератор в качестве результата.
import System.Random
main = do
gen <– getStdGen
putStrLn $ take 20 (randomRs ('a','z') gen)
gen' <– newStdGen
putStr $ take 20 (randomRs ('a','z') gen')
Мы не только получаем новый генератор, когда связываем с чем-либо значение, возвращённое функцией newStdGen, но и заменяем глобальный генератор; так что если мы воспользуемся функцией getStdGen ещё раз и свяжем его с чем-нибудь, мы получим генератор, отличный от gen.
Вот маленькая программка, которая заставляет пользователя угадывать загаданное число.
import System.Random
import Control.Monad(when)
main = do
gen <- getStdGen
askForNumber gen
askForNumber :: StdGen -> IO ()
askForNumber gen = do
let (randNumber, newGen) = randomR (1,10) gen :: (Int, StdGen)
putStr "Я задумал число от 1 до 10. Какое? "
numberString <- getLine
when (not $ null numberString) $ do
let number = read numberString
if randNumber == number
then putStrLn "Правильно!"
else putStrLn $ "Извините, но правильный ответ "
++ show randNumber
askForNumber newGen
Здесь мы создаём функцию askForNumber, принимающую генератор случайных чисел и возвращающую действие ввода-вывода, которое спросит число у пользователя и сообщит ему, угадал ли он. В этой функции мы сначала генерируем случайное число и новый генератор, основываясь на исходном генераторе; случайное число мы называем randNumber, а новый генератор – newGen. Допустим, что было сгенерировано число 7. Затем мы предлагаем пользователю угадать, какое число мы задумали. Вызываем функцию getLine и связываем её результат с идентификатором numberString. Если пользователь введёт 7, numberString будет равно 7. Далее мы используем функцию when для того, чтобы проверить, не ввёл ли пользователь пустую строку. Если ввёл, выполняется пустое действие ввода-вывода return(), которое закончит выполнение программы. Если пользователь ввёл не пустую строку, выполняется действие, состоящее из блока do. Мы вызываем функцию read со значением numberString в качестве параметра, чтобы преобразовать его в число; образец number становится равным 7.
ПРИМЕЧАНИЕ. На минуточку!.. Если пользователь введёт что-нибудь, чего функция read не сможет прочесть (например, "ха-ха" ), наша программа «упадёт» с ужасным сообщением об ошибке. Если вы не хотите, чтобы программа «падала» на некорректном вводе, используйте функцию reads: она возвращает пустой список, если у функции не получилось считать строку. Если чтение прошло удачно, функция вернёт список из одного элемента, содержащий пару, один компонент которой содержит желаемый элемент; второй компонент хранит остаток строки после считывания первого.
Мы проверяем, равняется ли number случайно сгенерированному числу, и выдаём пользователю соответствующее сообщение. Затем рекурсивно вызываем нашу функцию askForNumber, но на сей раз с вновь полученным генератором; это возвращает нам такое же действие ввода-вывода, как мы только что выполнили, но основанное на новом генераторе. Затем это действие выполняется.
Функция main состоит всего лишь из получения генератора случайных чисел от системы и вызова функции askForNumber с этим генератором для того, чтобы получить первое действие.
Посмотрим, как работает наша программа!
$ ./guess_the_number
Я задумал число от 1 до 10. Какое?
4
Извините, но правильный ответ 3
Я задумал число от 1 до 10. Какое?
10
Правильно!
Я задумал число от 1 до 10. Какое?
2
Извините, но правильный ответ 4
Я задумал число от 1 до 10. Какое?
5
Извините, но правильный ответ 10
Я задумал число от 1 до 10. Какое?
Можно написать эту же программу по-другому:
import System.Random
import Control.Monad (when)
main = do
gen <- getStdGen
let (randNumber, _) = randomR (1,10) gen :: (Int, StdGen)
putStr "Я задумал число от 1 до 10. Какое? "
numberString <- getLine
when (not $ null numberString) $ do
let number = read numberString
if randNumber == number
then putStrLn "Правильно!"
else putStrLn $ "Извините, но правильный ответ "
++ show randNumber
newStdGen
main
Эта версия очень похожа на предыдущую, но вместо создания функции, которая принимает генератор и вызывает сама себя рекурсивно с вновь полученным генератором, мы производим все действия внутри функции main. После того как пользователь получит ответ, угадал ли он число, мы обновим глобальный генератор и снова вызовем функцию main. Оба подхода хороши, но мне больше нравится первый способ, так как он предусматривает меньше действий в функции main и даёт нам функцию, которую мы можем легко использовать повторно.
Bytestring: тот же String, но быстрее
Список – полезная и удобная структура данных. Мы использовали списки почти что везде. Существует очень много функций, работающих со списками, и ленивость языка Haskell позволяет нам заменить циклы типа for и while из других языков программирования на фильтрацию и отображение списков, потому что вычисление произойдёт только тогда, когда оно действительно понадобится. Вот почему такие вещи, как бесконечные списки (и даже бесконечные списки бесконечных списков!) для нас не проблема. По той же причине списки могут быть использованы в качестве потоков, читаем ли мы со стандартного ввода или из файла. Мы можем открыть файл и считать его как строку, но на самом деле обращение к файлу будет происходить только по мере необходимости.
Тем не менее обработка файлов как строк имеет один недостаток: она может оказаться медленной. Как вы знаете, тип String – это просто синоним для типа [Char]. У символов нет фиксированного размера, так как для представления, скажем, символа в кодировке Unicode может потребоваться несколько байтов. Более того, список – ленивая структура. Если у вас есть, например, список [1,2,3,4], он будет вычислен только тогда, когда это необходимо. На самом деле список, в некотором смысле, – это обещание списка. Вспомним, что [1,2,3,4] – это всего лишь синтаксический сахар для записи 1:2:3:4:[]. Когда мы принудительно выполняем вычисление первого элемента списка (например, выводим его на экран), остаток списка 2:3:4:[] также представляет собой «обещание списка», и т. д. Список всего лишь обещает, что следующий элемент будет вычислен, как только он действительно понадобится, причём вместе с элементом будет создано обещание следующего элемента. Не нужно прилагать больших умственных усилий, чтобы понять, что обработка простого списка чисел как серии обещаний – не самая эффективная вещь на свете!
Все эти накладные расходы, связанные со списками, обычно нас не волнуют, но при чтении больших файлов и манипулировании ими это становится помехой. Вот почему в языке Haskell есть байтовые строки. Они похожи на списки, но каждый элемент имеет размер один байт. Также списки и байтовые строки по-разному реализуют ленивость.
Строгие и ленивые
Байтовые строки бывают двух видов: строгие и ленивые. Строгие байтовые строки объявлены в модуле Data.ByteString, и они полностью не ленивые. Не используется никаких «обещаний», строгая строка байтов представляет собой последовательность байтов в массиве. Подобная строка не может быть бесконечной. Если вы вычисляете первый байт из строгой строки, вы должны вычислить её целиком. Положительный момент – меньше накладных расходов, поскольку не используются «обещания». Отрицательный момент – такие строки заполнят память быстрее, так как они считываются целиком.
Второй вид байтовых строк определён в модуле Data.ByteString. Lazy. Они ленивы – но не настолько, как списки. Как мы говорили ранее, в списке столько же «обещаний», сколько элементов. Вот почему это может сделать его медленным для некоторых целей. Ленивые строки байтов применяют другой подход: они хранятся блоками размером 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]
Chunk "can" Empty
ghci> B.pack [98..120]
Chunk "bcdefghijklmnopqrstuvwx" Empty
Как можно видеть, Word8 не доставляет много хлопот, поскольку система типов определяет, что числа должны быть преобразованы к нему. Если вы попытаетесь использовать большое число, например 336, в качестве значения типа Word8, число будет взято по модулю 256, то есть сохранится 80.
Мы упаковали всего несколько значений в тип ByteString; они уместились в один блок. Значение Empty – это нечто вроде [] для списков.
Если нужно просмотреть байтовую строку байт за байтом, её нужно распаковать. Функция unpack обратна функции pack. Она принимает строку байтов и возвращает список байтов. Вот пример:
ghci> let by = B.pack [98,111,114,116]
ghci> by
Chunk "bort" Empty
ghci> B.unpack by
[98,111,114,116]
Вы также можете преобразовывать байтовые строки из строгих в ленивые и наоборот. Функция fromChunks принимает список строгих строк и преобразует их в ленивую строку. Соответственно, функция toChunks принимает ленивую строку байтов и преобразует её в список строгих строк.
ghci> B.fromChunks [S.pack [40,41,42], S.pack [43,44,45], S.pack [46,47,48]]
Chunk "()*" (Chunk "+,–" (Chunk "./0" Empty))
Это полезно, если у вас есть множество маленьких строгих строк байтов и вы хотите эффективно обработать их, не объединяя их в памяти в одну большую строгую строку.
Аналог конструктора : для строк байтов называется cons. Он принимает байт и строку байтов и помещает байт в начало строки.
ghci> B.cons 85 $ B.pack [80,81,82,84]
Chunk "U" (Chunk "PQRT" Empty)
Модули для работы со строками байтов содержат большое количество функций, аналогичных функциям в модуле Data.List, включая следующие (но не ограничиваясь ими): head, tail, init, null, length, map, reverse, foldl, foldr, concat, takeWhile, filter и др.
Есть и функции, имя которых совпадает с именем функций из модуля System.IO, и работают они аналогично, только строки заменены значениями типа ByteString. Например, функция readFile в модуле System.IO имеет тип
readFile :: FilePath –> IO String
а функция readFile из модулей для строк байтов имеет тип
readFile :: FilePath –> IO ByteString
ПРИМЕЧАНИЕ. Обратите внимание, что если вы используете строгие строки и выполняете чтение файла, он будет считан в память целиком! При использовании ленивых байтовых строк файл будет читаться аккуратными порциями.
Копирование файлов при помощи Bytestring
Давайте напишем простую программу, которая принимает два имени файла в командной строке и копирует первый файл во второй. Обратите внимание, что модуль System.Directory уже содержит функцию copyFile, но мы собираемся создать нашу собственную реализацию.
import System.Environment
import qualified Data.ByteString.Lazy as B
main = do
(fileName1:fileName2:_) <– getArgs
copy fileName1 fileName2
copy :: FilePath –> FilePath –> IO ()
copy source dest = do
contents <– B.readFile source
bracketOnError
(openTemplFile "." "temp")
(\(tempName, tempHandle) -> do
hClose templHandle
removeFile tempName)
(\(tempName, tempHandle) -> do
B.hPutStr tempHandle contents
hClose tempHandle
renameFile tempName dest)
В функции main мы получаем аргументы командной строки и вызываем функцию copy, в которой всё волшебство и происходит. Вообще говоря, можно было бы просто прочитать содержимое одного файла и записать его в другой. Однако если бы что-то пошло не так (например, закончилось бы место на диске), у нас в каталоге остался бы файл с некорректным содержимым. Поэтому мы пишем во временный файл, который в случае возникновения ошибки просто удаляется.
Сначала для чтения содержимого входного файла мы используем функцию B.readFile. Затем с помощью bracketOnError организуем обработку ошибок. Мы получаем ресурс посредством вызова openTemplFile "." "temp", который возвращает пару из имени временного файла и его дескриптора. После этого указываем, что должно произойти при возникновении исключения. В этом случае мы закроем дескриптор и удалим временный файл. Наконец, выполняется собственно копирование. Для записи содержимого во временный файл используется функция B.hPutStr. Временный файл закрывается, и ему даётся имя, которое он должен иметь в итоге.
Заметьте, что мы использовали B.readFile и B.hPutStr вместо их обычных версий. Для открытия, закрытия и переименования файлов специальные функции не требуются. Они нужны только для чтения и записи.
Проверим программу:
$ ./bytestringcopy bart.txt bort.txt
Обратите внимание, что программа, не использующая строки байтов, могла бы выглядеть точно так же. Единственное отличие – то, что мы используем B.readFile и B.hPutStr вместо readFile и hPutStr. Во многих случаях вы можете «переориентировать» программу, использующую обычные строки, на использование строк байтов, просто импортировав нужные модули и проставив имя модуля перед некоторыми функциями. В ряде случаев вам придётся конвертировать свои собственные функции для использования строк байтов, но это несложно.
Если вы хотите улучшить производительность программы, которая считывает много данных в строки, попробуйте использовать строки байтов; скорее всего, вы добьётесь значительного улучшения производительности, затратив совсем немного усилий. Обычно я пишу программы, используя обычные строки, а затем переделываю их на использование строк байтов, если производительность меня не устраивает.
Исключения
[11]
В любой программе может встретиться фрагмент, который может отработать неправильно. Разные языки предлагают различные способы обработки подобных ошибок. В языке С мы обычно используем некоторое заведомо неправильное возвращаемое значение (например, –1 или пустой указатель), чтобы указать, что результат функции не должен рассматриваться как правильное значение. Языки Java и С#, с другой стороны, предлагают использовать для обработки ошибок механизм исключений. Когда возникает исключительная ситуация, выполнение программы передаётся некоему определённому нами участку кода, который выполняет ряд действий по восстановлению и, возможно, снова вызывает исключение, чтобы другой код для обработки ошибок мог выполниться и позаботиться о каких-либо других вещах.
В языке Haskell очень хорошая система типов. Алгебраические типы данных позволяют объявить такие типы данных, как Maybe и Either; мы можем использовать значения этих типов для представления результатов, которые могут отсутствовать. В языке C выбор, скажем, –1 для сигнала об ошибке – это просто предварительная договорённость. Эта константа имеет значение только для человека. Если мы не очень аккуратны, то можем трактовать подобные специальные значения как допустимые, и затем они могут привести к упадку и разорению вашего кода. Система типов языка Haskell даёт нам столь желанную безопасность в этом аспекте. Функция a –> Maybe b явно указывает, что результатом может быть значение типа b, завёрнутое в конструктор Just, или значение Nothing. Тип функции отличается от простого a –> b, и если мы попытаемся использовать один тип вместо другого, компилятор будет «жаловаться» на нас.
Кроме алгебраических типов, хорошо представляющих вычисления, которые могут закончиться неудачей, язык Haskell имеет поддержку исключительных ситуаций, так как они приобретают особое значение в контексте ввода-вывода. Всё может пойти вкривь и вкось, если вы работаете с внешним миром, который столь ненадёжен! Например, при открытии файла может случиться всякое. Он может быть заблокирован, его может не оказаться по заданному пути, или не будет такого диска, или ещё что-нибудь…
При возникновении исключительной ситуации хорошо бы иметь возможность перейти на некоторый код обработки ошибки. Хорошо, код для ввода-вывода (то есть «грязный» код) может вызывать исключения. Имеет смысл. Ну а как насчёт чистого кода? Он тоже может вызывать исключения! Вспомним функции div и head. Их типы – (Integral a) => a –> a –> a и [a] –> a соответственно. Никаких значений типа Maybe или Either в возвращаемом типе, и тем не менее они могут вызвать ошибку! Функция div взорвётся у вас в руках, если вы попытаетесь разделить на нуль, а функция head выпадет в осадок, если передать ей пустой список.
ghci> 4 `div` 0
*** Exception: divide by zero
ghci> head []
*** Exception: Prelude.head: empty list
Чистый код может выбрасывать исключения, но они могут быть перехвачены только в части кода, работающей с системой ввода-вывода (когда мы внутри блока do в функции main). Причина в том, что вы не знаете, когда что-то будет (если вообще будет!) вычислено в чистом коде, так как он ленив и не имеет жёстко определённого порядка выполнения, в то время как код для ввода-вывода такой порядок имеет.
Раньше мы говорили, что нам желательно проводить как можно меньше времени в части нашей программы, посвящённой вводу-выводу. Логика программы должна располагаться главным образом в чистых функциях, поскольку их результат зависит только от параметров, с которыми функции были вызваны. При работе с чистыми функциями вы должны думать только о том, что функции возвращают, так как они не могут сделать чего-либо другого. Это облегчит вам жизнь!.. Даже несмотря на то, что некоторая логика в коде для ввода-вывода необходима (например, открытие файлов и т. п.), она должна быть сведена к минимуму. Чистые функции по умолчанию ленивы; следовательно, мы не знаем, когда они будут вычислены – это не должно иметь значения. Но как только чистые функции начинают вызывать исключения, становится важным момент их выполнения. Вот почему мы можем перехватывать исключения из чистых функций в части кода, посвящённой вводу-выводу. И это плохо: ведь мы стремимся оставить такую часть настолько маленькой, насколько возможно!… Однако если мы не перехватываем исключения, наша программа «падает». Решение? Не надо мешать исключения и чистый код! Пользуйтесь преимуществами системы типов языка Haskell и используйте типы вроде Either и Maybe для представления результатов, при вычислении которых может произойти ошибка.
Обработка исключений, возникших в чистом коде
В стандарте языка Haskell 98 года присутствует механизм обработки исключений ввода-вывода, который в настоящее время считается устаревшим. Согласно современному подходу все исключения, возникшие как при выполнении чистого кода, так и при осуществлении ввода-вывода, должны обрабатываться единообразно. Этой цели служит единая иерархия типов исключений из модуля Control.Exception, в которую легко можно включать собственные типы исключений. Любой тип исключения должен реализовывать экземпляр класса типов Exception. В модуле Control.Exception объявлено несколько конкретных типов исключений, среди которых IOException (исключения ввода-вывода), ArithException (арифметические ошибки, например, деление на ноль), ErrorCall (вызов функции error), PatternMatchFail (не удалось выбрать подходящий образец в определении функции) и другие.
Простейший способ выполнить действие, которое потенциально может вызвать исключение,– воспользоваться функцией try:
try :: Exception e => IO a -> IO (Either e a)
Функция try пытается выполнить переданное ей действие ввода-вывода и возвращает либо Right <результат действия> либо Left <исключение> , например:
ghci> try (print $ 5 `div` 2) :: IO (Either ArithException ())
2
Right ()
ghci> try (print $ 5 `div` 0) :: IO (Either ArithException ())
Left divide by zero
Обратите внимание, что в данном случае потребовалось явно указать тип выражения, поскольку для вывода типа информации недостаточно. Помимо прочего, указание типа исключения позволяет обрабатывать не все исключения, а только некоторые. В следующем примере исключение функцией try обнаружено не будет:
> try (print $ 5 `div` 0) :: IO (Either IOException ())
*** Exception: divide by zero
Указание типа SomeException позволяет обнаружить любое исключение:
ghci> try (print $ 5 `div` 0) :: IO (Either SomeException ())
Left divide by zero
Попробуем написать программу, которая принимает два числа в виде параметров командной строки, делит первое число на второе и наоборот и выводит результаты. Нашей первой целью будет корректная обработка ошибки деления на ноль.
import Control.Exception
import System.Environment
printQuotients :: Integer -> Integer -> IO ()
printQuotients a b = do
print $ a `div` b
print $ b `div` a
params :: [String] -> (Integer, Integer)
params [a,b] = (read a, read b)
main = do
args <- getArgs
let (a, b) = params args
res <- try (printQuotients a b) :: IO (Either ArithException ())
case res of
Left e -> putStrLn "Деление на 0!"
Right () -> putStrLn "OK"
putStrLn "Конец программы"
Погоняем программу на различных значениях:
$ ./quotients 20 7
2
0
OK
Конец программы
$ ./quotients 0 7
0
Деление на 0!
Конец программы
$ ./quotients 7 0
Деление на 0!
Конец программы
Понятно, что пока эта программа неустойчива к другим видам ошибок. В частности, мы можем «забыть» передать параметры командной строки или передать их не в том количестве:
$ ./quotients
quotients: quotients.hs:10:1-31: Non-exhaustive patterns in function params
$ ./quotients 2 3 4
quotients: quotients.hs:10:1-31: Non-exhaustive patterns in function params
Это исключение генерируется при вызове функции params, если переданный ей список оказывается не двухэлементным. Можно также указать нечисловые параметры:
$ ./quotients a b
quotients: Prelude.read: no parse
Исключение здесь генерируется функцией read, которая не в состоянии преобразовать переданный ей параметр к числовому типу.
Чтобы справиться с любыми возможными исключениями, выделим тело программы в отдельную функцию, оставив в функции main получение параметров командной строки и обработку исключений:
mainAction :: [String] -> IO ()
mainAction args = do
let (a, b) = params args
printQuotients a b
main = do
args <- getArgs
res <- try (mainAction args) :: IO (Either SomeException ())
case res of
Left e -> putStrLn "Ошибка"
Right () -> putStrLn "OK"
putStrLn "Конец программы"
Мы были вынуждены заменить тип исключения на SomeException и сделать сообщение об ошибке менее информативным, поскольку теперь неизвестно, исключение какого вида в данном случае произошло.
$ ./quotients a b
Ошибка
Конец программы
$ ./quotients
Ошибка
Конец программы
Понятно, что в общем случае обработка исключения должна зависеть от её типа. Предположим, что у нас имеется несколько обработчиков для исключений разных типов:
handleArith :: ArithException -> IO ()
handleArith _ = putStrLn "Деление на 0!"
handleArgs :: PatternMatchFail -> IO ()
handleArgs _ = putStrLn "Неверное число параметров командной строки!"
handleOthers :: SomeException -> IO ()
handleOthers e = putStrLn $ "Неизвестное исключение: " ++ show e
К сожалению, чтобы увидеть исключение от функции read, нужно воспользоваться наиболее общим типом SomeException.
Вместо того чтобы вручную вызывать функцию обработчика при анализе результата try, можно применить функцию catch, вот её тип:
ghci> :t catch
catch :: Exception e => IO a -> (e -> IO a) -> IO a
ПРИМЕЧАНИЕ. Модуль Prelude экспортирует старую версию функции catch , которая способна обрабатывать только исключения ввода-вывода. Чтобы использовать новый вариант её определения, необходимо использовать скрывающий импорт: import Prelude hiding (catch) .
Функция catch принимает в качестве параметров действие и обработчик исключения: если при выполнении действия генерируется исключение, то вызывается его обработчик. Тип обработчика определяет, какие именно исключения будут обработаны. Рассмотрим примеры, в которых функция mainAction вызывается непосредственно в GHCi:
ghci> mainAction ["2","0"]
*** Exception: divide by zero
ghci> mainAction ["0","2"] `catch` handleArith
0
Деление на 0!
ghci> mainAction ["2","0"] `catch` handleArgs
*** Exception: divide by zero
ghci> mainAction ["2","0"] `catch` handleOthers
Неизвестное исключение: divide by zero
ghci> mainAction ["a", "b"] `catch` handleArgs
*** Exception: Prelude.read: no parse
ghci> mainAction ["a", "b"] `catch` handleOthers
Неизвестное исключение: Prelude.read: no parse
Если строка, выводимая GHCi, начинается с ***, то соответствующее исключение не было обработано. Обратите внимание на обычный для функции catch инфиксный способ вызова. Заметьте также, что обработчик handleOthers способен обработать любое исключение.
Вернёмся к основной программе. Нам хочется, чтобы возникшее исключение было обработано наиболее подходящим образом: если произошло деление на ноль, то следует выполнить handleArith, при неверном числе параметров командной строки – handleArgs, в остальных случаях – handleOthers. В этом нам поможет функция catches, посмотрим на её тип:
> :t catches
catches :: IO a -> [Handler a] -> IO a
Функция catches принимает в качестве параметров действие и список обработчиков (функций, которые упакованы конструктором данных Handler) и возвращает результат действия. Если в процессе выполнения происходит исключение, то вызывается первый из подходящих по типу исключения обработчиков (поэтому, в частности, обработчик handleOthers должен быть последним). Перепишем функцию main так, чтобы корректно обрабатывались все возможные исключительные ситуации:
main = do
args <- getArgs
mainAction args `catches`
[Handler handleArith,
Handler handleArgs,
Handler handleOthers]
putStrLn "Конец программы"
Посмотрим, как она теперь работает:
$ ./quotients 20 10
2
0
Конец программы
$ ./quotients
Неверное число параметров командной строки!
Конец программы
$ ./quotients 2 0
Деление на 0!
Конец программы
$ ./quotients a b
Неизвестное исключение: Prelude.read: no parse
Конец программы
В этом разделе мы разобрались с работой функций try, catch и catches, позволяющих обработать исключение, в том числе и возникшее в чистом коде. Заметьте ещё раз, что вся обработка выполнялась в рамках действий ввода-вывода. Посмотрим теперь, как работать с исключениями, которые возникают при выполнении операций ввода-вывода.
Обработка исключений ввода-вывода
Исключения ввода-вывода происходят, когда что-то пошло не так при взаимодействии с внешним миром в действии ввода-вывода, являющемся частью функции main. Например, мы пытаемся открыть файл, и тут оказывается, что он был удалён, или ещё что-нибудь в этом духе. Посмотрите на программу, открывающую файл, имя которого передаётся в командной строке, и говорящую нам, сколько строк содержится в файле:
import System.Environment
import System.IO
main = do
(fileName:_) <– getArgs
contents <– readFile fileName
putStrLn $ "В этом файле " ++ show (length (lines contents)) ++
" строк!"
Очень простая программа. Мы выполняем действие ввода-вывода getArgs и связываем первую строку в возвращённом списке с идентификатором fileName. Затем связываем имя contents с содержимым файла. Применяем функцию lines к contents, чтобы получить список строк, считаем их количество и передаём его функции show, чтобы получить строковое представление числа. Это работает – но что получится, если передать программе имя несуществующего файла?
$ ./linecount dont_exist.txt
linecount: dont_exist.txt: openFile: does not exist (No such file or directory)
Ага, получили ошибку от GHC с сообщением, что файла не существует! Наша программа «упала». Но лучше бы она печатала красивое сообщение, если файл не найден. Как этого добиться? Можно проверять существование файла, прежде чем попытаться его открыть, используя функцию doesFileExist из модуля System.Directory.
import System.Environment
import System.IO
import System.Directory
main = do
(fileName:_) <– getArgs
fileExists <– doesFileExist fileName
if fileExists
then do
contents <– readFile fileName
putStrLn $ "В этом файле " ++
show (length (lines contents)) ++
" строк!"
else putStrLn "Файл не существует!"
Мы делаем вызов fileExists <– doesFileExist fileName, потому что функция doesFileExist имеет тип doesFileExist :: FilePath –> IO Bool; это означает, что она возвращает действие ввода-вывода, содержащее булевское значение, которое говорит нам, существует ли файл. Мы не можем напрямую использовать функцию doesFileExist в условном выражении.
Другим решением было бы использовать исключения. В этом контексте они совершенно уместны. Ошибка при отсутствии файла происходит в момент выполнения действия ввода-вывода, так что его перехват в секции ввода-вывода лёгок и приятен. К тому же, обработка исключений позволяет сделать этот код менее громоздким:
import Prelude hiding (catch)
import Control.Exception
import System.Environment
countLines :: String -> IO ()
countLines fileName = do
contents <- readFile fileName
putStrLn $ "В этом файле " ++ show (length (lines contents)) ++
" строк!"
handler :: IOException -> IO ()
handler e = putStrLn "У нас проблемы!"
main = do
(fileName:_) <- getArgs
countLines fileName `catch` handler
Здесь мы определяем обработчик handler для всех исключений ввода-вывода и пользуемся функцией catch для перехвата исключения, возникающего в функции countLines.
Попробуем:
$ ./linecount linecount.hs
В этом файле 17 строк!
$ ./linecount dont_exist.txt
У нас проблемы!
Исключение ввода-вывода может быть вызвано целым рядом причин, среди которых, помимо отсутствия файла, может быть также отсутствие права на чтение файла или вообще отказ жёсткого диска. В обработчике мы не проверяли, какой вид исключения IOException получили. Мы просто возвращаем строку "У нас проблемы", что бы ни произошло.
Простой перехват всех типов исключений в одном обработчике – плохая практика в языке Haskell, так же как и в большинстве других языков. Что если произошло какое-либо другое исключение, которое мы не хотели бы перехватывать, например прерывание программы? Вот почему мы будем делать то же, что делается в других языках: проверять, какой вид исключения произошёл. Если это тот вид, который мы ожидали перехватить, вызовем обработчик. Если это нечто другое, мы не мешаем исключению распространяться далее. Давайте изменим нашу программу так, чтобы она перехватывала только исключение, вызываемое отсутствием файла:
import Prelude hiding (catch)
import Control.Exception
import System.Environment
import System.IO.Error (isDoesNotExistError)
countLines :: String -> IO () countLines fileName = do
contents <- readFile fileName
putStrLn $ "В этом файле " ++ show (length (lines contents)) ++
" строк!"
handler :: IOException -> IO ()
handler e
| isDoesNotExistError e = putStrLn "Файл не существует!"
| otherwise = ioError e
main = do
(fileName:_) <- getArgs
countLines fileName `catch` handler
Программа осталась той же самой, но поменялся обработчик, который мы изменили таким образом, что он реагирует только на одну группу исключений ввода-вывода. С этой целью мы воспользовались предикатом isDoesNotExistError из модуля System.IO.Error. Мы применяем его к исключению, переданному в обработчик, чтобы определить, было ли исключение вызвано отсутствием файла. В данном случае мы используем охранные выражения, но могли бы использовать и условное выражение if–then–else. Если исключение вызвано другими причинами, перевызываем исключение с помощью функции ioError.
ПРИМЕЧАНИЕ. Функции try , catch , ioError и некоторые другие объявлены одновременно в модулях System.IO.Error (устаревший вариант) и Control.Exception (современный вариант), поэтому подключение обоих модулей (например, для использования предикатов исключений ввода-вывода) требует скрывающего или квалифицированного импорта либо же, как в предыдущем примере, явного указания импортируемых функций.
Итак, исключение, произошедшее в действии ввода-вывода countLines, но не по причине отсутствия файла, будет перехвачено и перевызвано в обработчике:
$ ./linecount dont_exist.txt
Файл не существует!
$ ./linecount norights.txt
linecount: noaccess.txt: openFile: permission denied (Permission denied)
Существует несколько предикатов, предназначенных для определения вида исключения ввода-вывода:
• isAlreadyExistsError (файл уже существует);
• isDoesNotExistError (файл не существует);
• isAlreadyInUseError (файл уже используется);
• isFullError (не хватает места на диске);
• isEOFError (достигнут конец файла);
• isIllegalOperation (выполнена недопустимая операция);
• isPermissionError (недостаточно прав доступа).
Пользуясь этими предикатами, можно написать примерно такой обработчик:
handler :: IOException -> IO ()
handler e
| isDoesNotExistError e = putStrLn "Файл не существует!"
| isPermissionError e = putStrLn "Не хватает прав доступа!"
| isFullError e = putStrLn "Освободите место на диске!"
| isIllegalOperation e = putStrLn "Караул! Спасите!"
| otherwise = ioError e
Убедитесь, что вы перевызываете исключение, если оно не подходит под ваши критерии; в противном случае ваша программа иногда будет «падать» молча, что крайне нежелательно.
Модуль System.IO.Error также экспортирует функции, которые позволяют нам получать атрибуты исключения, например дескриптор файла, вызвавшего исключение, или имя файла. Все эти функции начинаются с префикса ioe; их полный список вы можете найти в документации. Скажем, мы хотим напечатать имя файла в сообщении об ошибке. Значение fileName, полученное при помощи функции getArgs, напечатать нельзя, потому что в обработчик передаётся только значение типа IOException и он не знает ни о чём другом. Функция зависит только от своих параметров. Но мы можем вызвать функцию ioeGetFileName, которая по переданному ей исключению возвращает Maybe FilePath. Функция пытается получить из значения исключения имя файла, если такое возможно. Давайте изменим обработчик так, чтобы он печатал полное имя файла, из-за которого возникло исключение (не забудьте включить функцию ioeGetFileName в список импорта для модуля System.IO.Error):
handler :: IOException -> IO ()
handler e
| isDoesNotExistError e =
case ioeGetFileName e of
Just fileName -> putStrLn $ "Файл " ++ fileName ++
" не существует!"
Nothing -> putStrLn "Файл не существует!"
| otherwise = ioError e
where fileName = ioeGetFileName e
В охранном выражении, если предикат isDoesNotExistError вернёт значение True, мы использовали выражение case, чтобы вызвать функцию ioeGetFileName с параметром e; затем сделали сопоставление с образцом по возвращённому значению с типом Maybe. Выражение case часто используется в случаях, когда вам надо сделать сопоставление с образцом, не создавая новую функцию. Посмотрим, как это сработает:
$ ./linecount dont_exist.txt
Файл dont_exists.txt не существует!
Вы не обязаны использовать один обработчик для перехвата всех исключений в части кода, работающей с системой ввода-вывода. Вы можете перекрыть только отдельные части кода с помощью функции catch или перекрывать разные участки кода разными обработчиками, например так:
main = do
action1 `catch` handler1
action2 `catch` handler2
launchRockets
Функция action1 использует функцию handler1 в качестве обработчика, а функция action2 использует handler2. Функция launchRockets не является параметром функции catch, так что любое сгенерированное в ней исключение обрушит нашу программу, если только эта функция не использует try или catch внутри себя для обработки собственных ошибок. Конечно же, action1, action2 и launchRockets – это действия ввода-вывода, которые «склеены» друг с другом блоком do и, вероятно, определены где-то в другом месте. Это похоже на блоки try–catch в других языках: вы можете поместить всю вашу программу в один блок try–catch или защищать отдельные участки программы и перехватывать различные исключения для разных участков.
Вспомогательные функции для работы с исключениями
Ранее в этой главе мы уже познакомились с функциями bracket и bracketOnError, которые реализуют наиболее часто используемый сценарий обработки исключений, когда работа с ресурсом состоит из трёх стадий:
• получение ресурса;
• использование ресурса;
• освобождение ресурса.
В наших примерах на первой стадии открывался файл, на второй шла работа с его содержимым, а на третьей файл закрывался. Функция bracket гарантировала выполнение всех трёх действий, даже если в процессе генерировалось исключение, а функция bracketOnError запускала третье действие только в случае возникновения исключения.
Обратите внимание, что программист, использующий такого рода функции, не работает непосредственно с исключениями – ему лишь достаточно понимать логику и порядок вызова конкретных действий.
Модуль Control.Exception содержит ещё несколько подобных функций. Функция finally обеспечивает гарантированное выполнение некоторого действия по завершении другого действия. Это всего навсего упрощённый вариант функции bracket. Вот её сигнатура:
finally :: IO a -> IO b -> IO a
В следующем примере текст "Готово!" печатается в каждом из двух случаев, несмотря на возникновение исключения во втором:
ghci> print (20 `div` 10) `finally` putStrLn "Готово!"
2
Готово!
ghci> print (2 `div` 0) `finally` putStrLn "Готово!"
Готово!
*** Exception: divide by zero
Функция onException позволяет выполнить заключительное действие только в случае возникновения исключения:
ghci> print (20 `div` 10) `onException` putStrLn "Ошибка!"
2
ghci> print (2 `div` 0) `finally` putStrLn "Ошибка!"
Ошибка!
*** Exception: divide by zero
Заметьте, что обе эти функции, в отличие от try или catch, не обрабатывают исключения – они лишь гарантируют выполнение указанных действий. Все эти функции нетрудно реализовать вручную, пользуясь лишь try или catch. Фактически они устанавливают свой обработчик, перехватывают исключение, выполняют заданные действия, а после этого повторно генерируют то же самое исключение. Тем не менее, если ваша задача соответствует одному из приведённых сценариев, стоит воспользоваться уже существующей функцией.