Skip to content
This repository has been archived by the owner on Jun 21, 2024. It is now read-only.

Latest commit

 

History

History
532 lines (394 loc) · 35.4 KB

L6-RealWorld.md

File metadata and controls

532 lines (394 loc) · 35.4 KB

RealWorld

getchar

Попробуем создать функцию получения одного символа из некоторого места. Мы не будем передавать его в саму функцию, хотя, очевидно, что так нельзя сделать, ибо у нас в Haskell нету ничего глобального (что уже является проблемой).

getchar :: Char
getchar = -- implementation defined --

get2chars :: [Char]
get2chars = [getchar, getchar]

Допустим, мы это смогли сделать. Что ещё проблемно?

  1. Поскольку функции по типам чистые, то модель вычисления может в теории лишь посчитать один раз получить getchar и переиспользовать это значение.
  2. Даже если это (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 и потребовать, чтобы тот возвращал такую же пару.

RealWorld и реальный мир

Представим себе игрушечный тип 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))
  -- стандартно заворачиваем в состояние, которое мы никак не меняем,
  -- также не меняем и все другие состояния

do-нотация

Из монад мы помним про оператор (>>), который, как мы знаем, имел некоторый эффект - его результат нам не был важен, но он мог повлиять. Для удобства мы ограничимся только этим вариантом оператора.

(>>) :: 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

return

Как уже было сказано ранее, поскольку 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-контекст.

Ленивое 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 (если вывести, например), то дальнейшая перезапись файла будет безопасным.

FFI

Поскольку язык 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

Начнем с функции бросания исключения - 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, поэтому мы спокойно бросили исключение

catch

Там, где мы что-то бросаем, хочется ещё и ловить. 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`

MyException

Подобно 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

Что же такое на самом деле 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) 

Unsafe

В модуле 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
  )

Text и ByteString

Как уже было сказано ранее, тип String - это просто список символов, буквально, type String = [Char], что довольно медленно, из-за абстракции как такового списка. Тем более, String поддерживает только ASCII, из-за не wide-char (char с двумя байтами), поэтому придется что-то использовать другое.

Для работы со строками есть смысл использовать Data.Text. Во первых, он быстрый. Во вторых, он поддерживает unicode таблицу. И наконец, все его методы пересекаются с методами String - буквально те же названия и гарантии. Но причем тут unsafe? Дело в том, что сам по себе Data.Text - это, на самом деле, Си'шный указатель, а работа с Си'шными типами производится исключительно через IO. Благо, создатели библиотеки постарались и убрали необходимость везде протаскивать IO и мы имеем снаружи как бы чистые функции и типы данных.

ByteString, как можно догадаться по названию, обеспечивает максимальную скорость работы с байтами как одно машинное слово (8 бит). Он также использует Си'шный указатель и функции, и также всё это обернуто в unsafePerformIO, чтобы пользователям библиотеки не пришлось протаскивать IO.