Попробуем создать функцию получения одного символа из некоторого места. Мы не будем передавать его в саму функцию, хотя, очевидно, что так нельзя сделать, ибо у нас в Haskell нету ничего глобального (что уже является проблемой).
getchar :: Char
getchar = -- implementation defined --
get2chars :: [Char]
get2chars = [getchar, getchar]
Допустим, мы это смогли сделать. Что ещё проблемно?
- Поскольку функции по типам чистые, то модель вычисления может в теории лишь посчитать один раз получить
getchar
и переиспользовать это значение. - Даже если это (1) не так, никто не гарантирует, что наша последовательность вызовов для получения символов будет совпадать с ожидаемым.
Для начала решим первую проблему. Давайте введем для getchar
ещё один параметр-счётчик. Внутри getchar
мы не будем его использовать, однако вызов функции с разными числами уже даст гарантию на устранение проблемы с одинаковыми значениями.
Теперь решим вторую проблему. Пусть getchar
будет возвращать не Char
, а (Char, Int)
, где второе значение - это измененный переданный ему счётчик. Тогда, после возврата некого i'
мы переиспользуем его как номер вызова следующего getchar
.
getchar :: Int -> (Char, Int)
getchar = -- implementation defined --
get2chars :: Int -> [Char]
get2chars i = [a, b]
where
(a, i1) = getchar i
(b, i2) = getchar i1
Теперь всё точно работает. Но в чём ещё проблема? В расширяемости: если мы захотим создать функцию get4chars
, то нам придётся проделать те же махинации, только вызывать теперь get2chars
и потребовать, чтобы тот возвращал такую же пару.
Представим себе игрушечный тип IO a
в следующем виде: мы берём наш внешний мир, проводим с ним какие-то махинации и возвращаем в виде пары значения a
и нового, измененного состояния внешнего мира.
type IO a = RealWorld -> (a, RealWorld)
Заметим, что такая форма записи очень легко становится сильно общим случаем для вышеописанного паттерна в виде значения+указатель-на-изменения. По умолчанию, функцию входа в программу имеет сигнатуру в виде main :: IO ()
- если раскрыть по нашем типу main :: RealWorld -> ((), RealWorld)
, то есть, мы ничего не вернули, но мир вокруг нас поменялся.
Предположим, что мы смогли как-то реализовать RealWorld
и написали вот такую программу.
getChar :: RealWorld -> (Char, RealWorld)
getChar = -- implementation defined --
main :: RealWorld -> ((), RealWorld)
main w0 = do
let (a, w1) = getChar w0
(b, w2) = getChar w2
in ((), w2)
Правда ли, что компилятор может пропустить вызовы getChar
? Нет, так как мы явно пропихиваем в функции разные объекты. Правда ли, что компилятор может поменять местами вызовы getChar
? Нет, так как у нас следующее состояние мира зависит от предыдущего. Наконец, можем ли мы заиметь дублирующийся вызовы getChar
? Также нет, так как каждый раз мы используем разные RealWorld
объекты (банально, двигаемся с указателем).
Немного заглянем поглубже и посмотрим, как на самом деле выглядит тип IO
. В стандартной библиотеке вы сможете найти вот такой тип.
newtype IO a = IO { unIO :: State# RealWorld -> (State# RealWorld, a) }
Здесь сразу стоит отметить, что State#
- это не тот, который был про монады. Символ решётки в названии говорит о том, по декларации языка, что данный тип или класс используется в низкоуровневых вещах (например, он может быть связан с Си-шным кодом для вызова fopen
). Если взглянуть повнимательнее на функцию unIO
, то мы увидим, что это - почти в точности реализованный State
. Для IO
определён instance от монады.
instance Monad IO where
IO m >>= k = IO (\s ->
case m s of
(newS, a) -> unIO (k a) newS)
return x = IO (\s -> (s, x))
-- стандартно заворачиваем в состояние, которое мы никак не меняем,
-- также не меняем и все другие состояния
Из монад мы помним про оператор (>>)
, который, как мы знаем, имел некоторый эффект - его результат нам не был важен, но он мог повлиять. Для удобства мы ограничимся только этим вариантом оператора.
(>>) :: IO a -> IO b -> IO b
Для чего нужна do
-нотация? По большей части она нужна для удобства написания а-ля императивного кода на Haskell: когда мы прописываем действия один за другим. В этом нам всё ещё может помочь оператор (>>)
, поскольку он для IO
гарантирует порядок исполнения через вставки в виде let
-in
. Грубо говоря, вот такой кусочек кода: (action1 >> action2) world0
полностью эквивалентен:
let (_, world1) = action1 world0
(b, world2) = action2 world1 -- одно зависит от другого, поэтому
in (b, world2) -- пропусков или swap'ов быть не должно
В этом нам также может помочь и do
-нотация.
main :: IO ()
main = do putStrLn "What is your name?"
putStrLn "How old are you?"
putStrLn "Nice day!"
-- полностью эквивалентно --
main :: IO ()
main = putStrLn "What is your name?" >>
putStrLn "How old are you?" >>
putStrLn "Nice day!"
Представим, что у нас есть список действий, которые мы бы хотели исполнить, причем не вызывая каждую из них по отдельности. Для таких случаев была придумана функция sequence_
, которая берет список IO a
и возвращает IO ()
, исполняя каждую из действий в том порядке, в которой была задана последовательность.
sequence_ :: [IO a] -> IO ()
sequence_ [] = return ()
sequence_ (x:xs) = x >> sequence_ xs
Также вспомним и про bind оператор. Если рассматривать его специализацию в виде IO a
, то мы также можем гарантировать, что все действия будут выполнены по порядку из-за развёртки и в let
-in
выражения. Грубо говоря, такая строчка - (action1 >>= action2) world0
- будет развернута в
let (a, world1) = action1 world0
(b, world2) = action2 a world1
in (b, world2)
Теперь, представим себе функцию из стандартной библиотеки getLine :: IO String
, она считывает с стандартного ввода в виде строки и возвращает, внимание, IO-объект. Очевидно, мы не сможем написать вот такое: let s = getLine
, для таких случаев был создан новый оператор вида (<-)
, который мы можем также использовать, чтобы код казался менее изощренным.
main :: IO ()
main = do
s <- getLine
putStrLn s
Как и let
-in
после s <- getLine
нам ниже по коду доступен данный индификатор как переменная. На самом деле, это также является синтаксическим сахаром. Данный оператор можно раскрыть в bind, который далее прокидывает свой аргумент дальше, в данном случае, - это putStrLn
.
main :: IO ()
main = do
getLine >>= \s -> putStrLn s
Как уже было сказано ранее, поскольку IO
является монадическим типом, то return
будет заворачивать нам некоторый объект в IO
контекст. Допустим, мы захотим функцию, которая бы считывала бы с консоли и возвращала строчку как развернутую. Приведем пример оной и использование.
getReversedLine :: IO String
getReversedLine = do
s <- getLine
-- прочитали с консоли, у индификатора `s` тип `String` (!)
return (reverse s)
-- развернули строку `reverse s` получили объект типа `String` (!)
-- но мы возвращаем `IO` (контекст), а значит завернём его, используя `return`
main :: IO ()
main = do
s <- getReversedLine
putStrLn s
Важно: не следует путать return
здесь, с любым другим таким же оператором в любом другом языке программирования - мы не прерываем действия контекста функции, мы запаковываем нечто в IO
-контекст.
Представим себе небольшую программу, которая бы читала из файла foo.txt
, дописывала в начало символ 'a'
, записывала бы обновленный контент в bar.txt
, а затем мы бы прочитали bar.txt
и вывели на экран.
main :: IO ()
main = do
fileContent <- readFile "foo.txt"
writeFile "bar.txt" ('a':fileContent)
readFile "bar.txt" >>= putStrLn
По модулю присутствия файлов foo.txt
и bar.txt
и прав пользовательского кода на открытие этих файлов, данный код абсолютно безопасен и ничем не примечателен. Но теперь сделаем небольшое изменение: зачем нам писать в какой-то другой файл, если мы можем написать в тот же файл?
main :: IO ()
main = do
fileContent <- readFile "foo.txt"
writeFile "foo.txt" ('a':fileContent)
readFile "foo.txt" >>= putStrLn
Внезапно, но такой код выдаст исключение: ресурс, а именно файловый дескриптор foo.txt
, занят другим потоком. Вспоминаем про Haskell: он настолько ленив, что даже работа с критическими дескрипторами может быть ленива и опасна. И действительно: readFile
по документации - это LazyIO
, в данном случае fileContent
- это просто привязка и не более, данный индификатор не владеет ресурсами. С другой стороны, если бы мы хоть что-нибудь сделали с fileContent
(если вывести, например), то дальнейшая перезапись файла будет безопасным.
Поскольку язык Haskell компилируемый и компилятор, вообще говоря, GCC, то, неудивительно, что мы можем прикрутить к нашей программе вызовы C-кода. Для этого используется специальная прагма ForeignFunctionInterface
, затем мы объявляем, что функция с таким-то названием (напоминание: в языке Си все названия функций являются уникальными и в ассемблер-коде записываются так, как они называются) и такими-то типами есть и компилируем, подав на вход объектный файл.
/* clang -c simple.c -o simple.o */
int example(int a, int b)
{
return a + b;
}
-- ghc simple.o simple_ffi.hs -o simple_ffi --
{-# LANGUAGE ForeignFunctionInterface #-}
import Foreign.C.Types
foreign import ccall safe "example"
example :: CInt -> CInt -> CInt
main = print (example 42 27)
Представим себе волшебную переменную varA
, которая умеет изменяться, не создавая при этом никаких копий вокруг себя. У нас есть волшебные функции для работы с ней и, внимание, она и её функции чистые.
main :: IO()
main = do let a0 = readVariable varA
let _ = writeVariable varA 1
let a1 = readVariable varA
print (a0, a1)
Проблемы? Ровно те же, которые обсуждали про getchar
: компилятор не гарантирует порядок вычислений, а также - let _ = writeVariable varA 1
- исполнения некоторых строчек.
На этот случай в IO
было придумано IORef a
- это IO'шная обёртка надо объектом типа a
, которая умеет изменяться с помощью схоже выше функций. А поскольку это - IO-монада - то все действия с ними нечисты, а значит, гарантировано верны и в правильном порядке.
import Data.IORef (newIORef, readIORef, writeIORef)
main :: IO ()
main = do
varA <- newIORef 0
-- создали новую переменную IORef Int (будем считать для простоты, что это - Int)
a0 <- readIORef varA
-- прочитали значение `0`
writeIORef varA 1
-- записали в `varA` значение `1`, теперь там новое значение
a1 <- readIORef varA
-- прочитали значение `1`
print (a0, a1)
-- вывели "(0,1)"
Помимо стандартных переменных существуют также и изменяемые по индексу массивы. Вполне однозначно, что происходит ниже.
import Data.Array.IO (IOArray, newArray, readArray, writeArray)
main :: IO ()
main = do
arr <- newArray (1,10) 37 :: IO (IOArray Int Int)
-- создали новый массив, с индексами [1..10] и заданными значениями 37 :: Int
a <- readArray arr 1
-- прочитали по индексу `1` значение `37`
writeArray arr 1 64
-- записали по индексу `1` значение `64`
b <- readArray arr 1
-- прочитали по индексу `1` значение `64`
print (a, b)
-- вывели "(37,64)"
Никто Data.Array.IO
не пользуется из-за скорости. Вместо него рекомендуется использовать пакет vector
с изменяемыми/неизменяемыми массивами.
Начнем с функции бросания исключения - throwIO
- в качестве параметра он принимает Exception e
, где Exception
- это type class, и возвращает IO a
. Почему именно так? Потому что эта функция расходящийся, тип a
- какой угодно тип вплоть до _|_
(то есть, лжи), а поскольку мы работаем всё время в обёртке IO
, то данный тип находится в IO
.
throwIO :: Exception e => e -> IO a
Приведем небольшой пример использования данной функции. Здесь мы считываем (считаем, что readLn
считывает и возвращает Int
) два числа, проверяем второе число на равенство нулю: если это не так, то выводим деление первого на второе, иначе - бросим исключение DivideByZero
.
import Control.Exception (ArithException (..), catch, throwIO)
import Control.Monad (when)
readAndDivide :: IO Int
readAndDivide = do
x <- readLn
y <- readLn
when (y == 0) (throwIO DivideByZero)
return (x `div` y)
Пример использования в GHCi.
ghci> readAndDivide
7
3
2
-- всё ок, 3 != 0, поэтому мы спокойно делим одно на другое
ghci> readAndDivide
3
0
*** Exception: divide by zero
-- всё ок, 0 == 0, поэтому мы спокойно бросили исключение
Там, где мы что-то бросаем, хочется ещё и ловить. Haskell предоставляет нам функцию catch
, которая, как и во многих языках, представляет из себя настройку обработчика исключений.
catch :: Exception e => IO a -> (e -> IO a) -> IO a
Первым аргументом мы подаем нечто IO'шное, что мы хотим обработать на исключения, вторым подаётся непосредственно функция обработки - в данном случае нам необходимо соблюдать сходство типов a
, что может быть не всегда удобно. Обратим внимание на e -> IO a
- мы принимаем исключение, а затем что-то с ней делаем, - а что если мы настроили для DivideByZero
, а нам прилетает какая-то другая ошибка? Будет Haskell-ошибка по отсутствию pattern-matching'а. Мы можем это исправить, ловя вообще всё, что угодно, но об этом будет позже.
Приведем пример более безопасной версии функции вышеописанной readAndDivide
.
safeReadAndDivide :: IO Int
safeReadAndDivide = readAndDivide `catch` \DivideByZero -> return (-1)
Пример использования в GHCi.
ghci> safeReadAndDivide
7
3
2
-- исключения не случилось: вернули `7 / 3`
ghci> safeReadAndDivide
3
0
-1
-- исключение случилось: вернули `-1`
Подобно Java мы умеем и можем создавать свои типизированные исключения в Haskell. Важный момент: стандартные исключения в Haskell нетипизируемы, а значит, мы не можем как в Java, какие именно прилетят исключения из блока IO
. Для создания своего исключения нам нужно сделать derive от непосредственного исключения Exception
(с подключенной прагмой, так как Exception
- это type class) и для наглядности Show
, Typeable
.
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveDataTypeable #-}
import Control.Exception (Exception)
import Data.Typeable (Typeable)
data MyException = DummyException
deriving (Show, Typeable, Exception)
В данном случае, у нас только один конструктор. Давайте им воспользуемся для примера.
ghci> throwIO DummyException
*** Exception: DummyException
-- бросили и не поймали исключения
ghci> :{
ghci| throwIO DummyException `catch` \DummyException ->
ghci| putStrLn "Dummy exception is thrown"
ghci| :}
Dummy exception is thrown
-- бросили и поймали - смогли сойтись по конструктору `DummyException`
Что же такое на самом деле Exception
? Как уже говорилось ранее, это type class, который, во первых, Show
, так как мы должны уметь показать саму ошибку, а, во вторых, это некий Typeable
- это такой тип, который позволяет на этапе runtime производить какие-то махинации с типами, в том числе, и определять, кто-есть-кто - чем-то схоже по поведению с reflection-системой в Java. Казалось бы, нам ничего более и не нужно от исключения как от type class, но, на самом деле, там есть ещё две странные функции.
class (Typeable e, Show e) => Exception e where
displayException :: e -> String
fromException :: SomeException -> Maybe e
toException :: e -> SomeException
Итак, на самом деле, несмотря на явное отсутствие ООП в Haskell, здесь мы видим некоторое подобие наследования от SomeException
. Действительно, любое исключение - это какое-то из них, а значит, нам нужна некоторая коробочка, в которую мы можем заложить наше исключение и попытаться достать оттуда - если мы сошлись по типам, то возвращаем Just
, иначе - Nothing
(подобное поведение есть в std::any
).
data SomeException = forall e. Exception e => SomeException
Это, так называемый, existential type. Про него будет информация дальше, но если вкратце: то это обёртка (или: коробка), которая хранит внутри себя некоторое/какое-то исключение.
Тогда, мы можем попробовать переписать наш пример с бросанием и ловлей DivideByZero
-исключением на некоторый общий вариант, если программа вдруг не знает про какой-то тип исключения.
tryReadAndDivide :: IO (Either String Int)
tryReadAndDivide = readAndDivide `catch` \(e :: SomeException) ->
case fromException e of
(Just dbze :: Maybe DivideByZero) -> return $ Left $ displayException dbze
_ -> return $ Left $ "Something else happened"
Но, погодите, почему мы ловим SomeException
, если мы точно кидаем DivideByZero
и мы точно не имеем ничего, что связано с наследованием и неявным приведением? На самом деле, это уже магия, которую не понять, так специально было сделано, чтобы программист мог ловить и обрабатывать любое исключение.
Существует достаточно много полезных функций, которые обеспечивают те же гарантии и действия, что и стандартные конструкции в других языках программирования.
try :: Exception e => IO a -> IO (Either e a)
-- принимаем IO-действие и пытаемся его выполнить
-- если прилетело исключение, то мы заворачиваем его как `Left` в обёртке `IO`
-- в ином случае, мы заворачиваем результат вычисления как `Right` (успех)
tryJust :: Exception e => (e -> Maybe b) -> IO a -> (Either b a)
-- делает ровно тоже самое, что и вышеописанный `try`, только
-- при вылете исключения мы, с помощью `e -> Maybe b`, фильтруем его
-- во что-то нужное,
-- например, мы хотим извлечь оттуда нечто и передать как ошибку
finally :: IO a -> IO b -> IO a
-- принимаем IO действие (`IO a`) и пытаемся его выполнить
-- при любом раскладе, даже если вылетело исключение из `IO a`, мы выполняем `IO b`
onException :: IO a -> IO b -> IO a
-- принимаем IO действие (`IO a`) и пытаемся его выполнить
-- если прилетело исключение, то мы пытаемся выполнить другое IO действие (`IO b`)
-- в ином случае, не пытаемся
bracket :: IO a -- (1) -> отвечает за выделение ресурсов
-> (a -> IO b) -- (2) -> отвечает за высвобождение выделенных ресурсов
-> (a -> IO c) -- (3) -> отвечает за основные IO действия,
-- результат которого это (4)
-> IO c -- (4) -> отвечает за возвращаемое значение
-- подобное RAII в Java, мы сначала пытаемся выделить память/открыть файлы
-- и так далее (1)
-- затем, мы производим наши основные действия как IO-действия,
-- получая некий результат типа `c` (3)
-- после, мы должны высвободить ресурс и освободить занятую память, например,
-- с помощью (2)
-- наконец, возвращаем результат основного IO-действия (4)
В модуле IO
есть такой интересный модуль, называемый как Unsafe
, в нём есть основная функция, ради чего и создавалось, именуемая как unsafePerformIO
. По её сигнатуре можно догадаться, что она убирает IO
контекст и превращает любое нечистое в чистое (по модулю проблем).
unsafePerformIO :: IO a -> a
Попробуем вспомнить, для чего нам нужен был контекст для работы с объектами: для того, чтобы не позволить компилятору что-то удумать и переставить/проигнорировать какие-то действия в коде.
import System.IO.Unsafe
foo :: ()
foo = unsafePerformIO (putStrLn "foo")
-- производим вывод строки и получаем `IO`-объект, от которого
-- мы поспешно избавляемся, развернув контекст и вернув `Unit`
bar :: String
bar = unsafePerformIO (do
putStrLn "bar"
-- вывели строку "bar", вернулся `IO`-объект
return "baz"
-- возвращаем `IO String`
)
-- выводим из `IO String` саму строку из контекста
main :: IO ()
main = do
let f = foo
-- пытаемся "запустить функцию", но, поскольку, это - константа, то ничего не
-- произойдет, более того, мы не увидим выведенного "foo", так как мы
-- убрали контекст `IO`, значит, компилятор вправе ничего не делать
putStrLn bar
-- здесь мы увидим "bar" и "baz", так как `IO`-контекст у "bar" не было убран,
-- а возвращаемая строка по итогу попадает в `IO` контекст
-- компилятор не вправе проигнорировать какое-то из действий
Попробуем разобраться немного в другом примере использования unsafe
операций.
import System.IO.Unsafe
helper i = print i >> return i
main = do
one <- helper 1
let two = unsafePerformIO (helper 2)
print (one + two)
Казалось бы, мы частично определили порядок для one
: он точно должен был пойти первым при вызове, а уже потом two
, так как print
явно зависит от двух выше операций. Внезапно, это совсем не так, вернее, компилятор может сгенерировать так код, что 2
высветится раньше, чем 1
. В чем же дело? А дело всё в том, что в не-unsafe
мы используем, что и позволяет гарантировать последовательность операций, какое-то одно глобальное состояния, которое далее прокидывается во все остальные IO
действия в коде. Казалось бы, мы могли бы прокидывать то же состояния и в unsafe
, но этого не происходит, так как нам попросту могут подать не-IO
действие. Но, как это возможно?
unsafePerformIO (IO f) =
case f fakeStateToken of
-- это возможно благодаря некоторому не настоящему состоянию внешнего мира,
-- до которого невозможно достучаться, но который используется в этом коде
(ignoredStateToken, result) -> result
Таким образом, в выше описанном коде в строке let two = ...
мы не обновляем состояние мира, а значит, компилятор вправе переставить его, куда захочет (совсем убрать не получится, так как его результат используется ниже).
Но кое-где такая штука всё-таки применяется, например, в полезной для хоть какого-то дебага в Haskell: Debug.Trace
. Там есть функции, которые вычисляют выражения, выводят их сразу на консоль, и возвращают вычисленное - например, trace
.
trace :: String -> a -> a
trace str expr = unsafePerformIO (do
traceIO str
return expr
)
Как уже было сказано ранее, тип String
- это просто список символов, буквально, type String = [Char]
, что довольно медленно, из-за абстракции как такового списка. Тем более, String
поддерживает только ASCII, из-за не wide-char (char с двумя байтами), поэтому придется что-то использовать другое.
Для работы со строками есть смысл использовать Data.Text
. Во первых, он быстрый. Во вторых, он поддерживает unicode таблицу. И наконец, все его методы пересекаются с методами String
- буквально те же названия и гарантии. Но причем тут unsafe
? Дело в том, что сам по себе Data.Text
- это, на самом деле, Си'шный указатель, а работа с Си'шными типами производится исключительно через IO
. Благо, создатели библиотеки постарались и убрали необходимость везде протаскивать IO
и мы имеем снаружи как бы чистые функции и типы данных.
ByteString
, как можно догадаться по названию, обеспечивает максимальную скорость работы с байтами как одно машинное слово (8 бит). Он также использует Си'шный указатель и функции, и также всё это обернуто в unsafePerformIO
, чтобы пользователям библиотеки не пришлось протаскивать IO
.