Как отделить чистые функции от функций с побочными эффектами

В мире чистого функционального программирования большое пугало — это функции с побочными эффектами. Что именно мы подразумеваем под побочными эффектами? Чистая функция работает как функции в математике. Выход функции определяется исключительно ее входными аргументами.

Однако в программировании некоторые функции скрытны и изменяют какое-то внутреннее состояние или производят вывод, считывая какое-то изменяемое состояние. Мутирующее состояние просто означает изменение некоторых данных. Если это звучит абстрактно, позвольте мне привести простой пример: когда вы добавляете или удаляете значения из коллекции, такой как словарь или массив, вы изменяете состояние.

Функции, которые считывают или изменяют общее состояние, плохи, потому что они усложняют тестирование и отладку программ. Они также сильно усложняют параллельный запуск кода. Если две функции, работающие одновременно, попытаются изменить и прочитать из общего состояния одновременно, у нас, очевидно, возникнут проблемы.

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

Таким образом, я был заинтригован, когда прочитал, что Unison — это Haskell-подобный язык, который использует другой подход, называемый Алгебраическими эффектами, который должен значительно упростить написание кода с побочными эффектами. Возникает вопрос: выполняет ли Unison это обещание?

Вниз по кроличьей норе алгебраических эффектов

Не знаю, смогу ли я дать прямой ответ на этот вопрос. За последние несколько дней я спустился в кроличью нору, чтобы исследовать этот странный новый мир алгебраических эффектов в унисон. Я не уверен, действительно ли это трудно достать или я просто старею.

Что Unison обещает, так это упростить написание кода, как только вы поймете Способности. Способность — это концепция Unison для реализации Алгебраических эффектов. Основываясь на моем текущем опыте работы с Unison, это утверждение кажется мне разумным. Основные языки за последние несколько лет унаследовали функциональность стиля Monad для обработки ошибок и необязательных значений. Мой основной опыт связан с языком программирования Swift. Ваш пробег может отличаться, но, по моему опыту, он может стать довольно неуклюжим с переносом и развертыванием необязательных значений. Давайте сравним этот опыт с тем, как чтение и запись текста в консоль работает в Unison:

main : '{IO, Exception} ()
main _ =
    printLine "What is your name?"
    name = console.getLine ()
    printLine ("Hello " ++ name ++ "!")
    printLine "What is your age?"
    age = console.getLine ()
    printLine ("You are " ++ age ++ " years old!")

Код работает как обычный код Unison, несмотря на то, что ввод-вывод требует побочных эффектов. Мы можем сравнить с аналогичным примером в Haskell. Я просто взял несколько быстрых примеров из Haskell wiki on I/O для сравнения. Первая версия показанного кода выглядит следующим образом:

main = putStrLn "Hello, what is your name?"
      >> getLine
      >>= \name -> putStrLn ("Hello, " ++ name ++ "!")

Использование операторов >> и >>= делает этот код несколько загадочным. Ребята из Haskell упрощают код такого типа, используя ключевое слово do, которое выполняет некоторую магию, заставляя операторы >> и >>= каким-то образом испаряться.

main = do putStrLn "Hello, what is your name?"
          name <- getLine
          putStrLn ("Hello, " ++ name ++ "!")

По общему признанию, я забыл, как большая часть этого работает в деталях, и вам может быть не очевидно, как это работает, но это не имеет значения. Ключевым выводом является то, что вы можете заметить, что ввод-вывод в Haskell требует использования нескольких необычных операторов и написания кода необычным способом. Например, с ключевым словом do вы должны использовать <- для присвоения переменной вместо обычного символа равенства =.

Таким образом, Unison упрощает написание кода с побочными эффектами. Загвоздка в том, что понять механизм, лежащий в основе этой возможности, сложно, но выполнимо при условии практики и самоотверженности. На официальной веб-странице Unison уже есть введение в Способности, которое вы, возможно, захотите прочитать. Моя цель в этой статье — указать на камни преткновения, с которыми я столкнулся, в надежде, что она поможет вам разобраться в этой новой магии функционального программирования.

Оператор Handle-With

Одна из вещей, которая сбила меня с толку при чтении официального описания способностей, заключается в том, что роль выражения handle f with h была запрятана так глубоко. Это лежит в основе того, как вы справляетесь с побочными эффектами. Он связывает функцию f с побочными эффектами вместе с функцией-обработчиком h. Функция h обрабатывает каждый экземпляр побочных эффектов, создаваемых функцией f.

Мне нравится думать о f и h как о двух сопрограммах, которыми выражение handle f with h управляет посредством своего рода планирования сопрограмм (одновременно выполняется только одна функция. f приостанавливается, пока h обрабатывает запросы). f и h должны общаться друг с другом через какой-то общий интерфейс. Если вы посмотрите на две сопрограммы в Go или Julia, они обычно используют объект канала определенного типа. Тип канала говорит, какие объекты вы можете передавать из одной сопрограммы в другую. Угождение педантичным: да, Go использует не сопрограммы, а горутины, но разница не имеет отношения к этому примеру.

В Unison каналов нет. Вместо этого у вас есть тип Ability, который определяет типы запросов, которые могут быть отправлены из сопрограммы f в сопрограмму h, и какой тип объекта h может ответить.

ПРИМЕЧАНИЕ. Документы Unison не объясняют эту связь с точки зрения двух сопрограмм, поэтому это описание может быть неточным. Я просто думаю, что это помогает понять, что происходит.

Позвольте мне пояснить концепцию на примере. Далее идет возможность получения индивидуальных значений; либо натуральное число, либо символ.

structural ability Getter  where
  getChar : {Getter} Char
  getNum  : {Getter} Nat

Это глупый способ написания такого интерфейса, но я намеренно делаю его элементарным, чтобы уменьшить количество вещей, которые вы должны иметь в виду, следуя моему описанию.

getChar и getNum — операции способности Getter. Вы можете вызывать их как функции, не принимающие аргументов и возвращающие значения Char и Nat соответственно. Когда вы их вызываете, каждый из них создает отдельный объект запроса. Тип объекта запроса будет зависеть от того, что возвращает ваша функция f. Если ваша функция побочных эффектов f возвращает, скажем, массив натуральных чисел (тип [Nat]), то вызов getNum создаст объект запроса типа Request {Getter} [Nat], который будет отправлен обработчику h.

Обработчик выполняет сопоставление с образцом для полученного объекта запроса, чтобы выяснить, получает ли он запрос getChar или getNum. Когда функция f завершается, она возвращает значение, которое передается в качестве запроса обработчику h. Это завершает обмен между двумя функциями (сопрограммами). Ниже я проиллюстрировал обмен между функцией побочного эффекта doStuff и обработчиком handler. На этом рисунке обработчик отвечает номером 42, когда он получает запрос getNum.

Я немного солгал, когда сказал, что мы связываем функцию побочных эффектов f или doStuff с обработчиком h. Точнее, надо было написать handle e with h, где e и выражение. Вы можете думать о e как о коде в операторе try-catch в основном языке программирования, таком как Java. Предложение try позволяет вам написать некоторый код, который вы хотите оценить и который может вызвать исключение. Точно так же handle позволяет вам написать некоторый код, который может иметь побочные эффекты. Таким образом, моя иллюстрация общения между doStuff и handler приводится в движение утверждением:

handle !doStuff with handler

Что является сокращением для:

handle (doStuff ()) with handler

Другими словами, мы не просто передаем объект функции doStuff в handle. Вместо этого мы фактически выполняем функцию doStuff в контексте оператора handle-with.

Подумайте об этом так: всякий раз, когда вы хотите запустить функцию или код с побочными эффектами, это должно происходить в контексте оператора handle-with. Оператор handle-with будет оценивать результат вашей функции побочного эффекта doStuff.

Однако вы можете запускать код с побочными эффектами, если функция, выполняющая этот код, помечена как имеющая побочные эффекты. В Unison это делается путем присоединения одного или нескольких типов Ability к сигнатуре типа функции. Подумайте, как работает try-catch в Java. Метод в Java не может вызывать другой метод, выбрасывающий исключение, если окружающий метод также не помечен как выбрасывающий такое же исключение. Таким образом, требование сигнализировать о том, что вы выбрасываете исключение, поднимает стек вызовов. То же самое и с функциями побочных эффектов. Вы можете вызывать функцию с побочными эффектами только из другой функции, помеченной как имеющая побочные эффекты (функции с одной или несколькими возможностями в сигнатуре).

Итак, как остановить всю эту цепочку пузырей? В Java вы останавливаете это, заключая в функцию бросаемый код с помощью try-catch. Аналогичным образом, в Unison вы предотвращаете дальнейшее распространение требования к побочному эффекту, заключая код побочного эффекта в оператор handle-with.

На самом деле вы не увидите много операторов handle-with при написании кода Unison, потому что они были спрятаны в функциях более высокого уровня, обрабатывающих все детали. Как это работает, станет понятнее, если мы посмотрим, как мы на самом деле пишем функцию-обработчик для способности Getter.

Написание функции-обработчика

Мы собираемся написать обработчик для работы с простой функцией побочного эффекта, написанной следующим образом:

doStuff : () -> {Getter} [Nat]
doStuff _ = [getNum, getNum, getNum]

Здесь doStuff — это функция, не принимающая аргументов и возвращающая список натуральных чисел, полученный при трехкратном вызове getNum. Поскольку doStuff возвращает список чисел, наш обработчик также должен делать то же самое.

handler : Request {Getter} [Nat] -> [Nat]
    handler req = match req with
        { getNum -> resume } -> 
				handle   
					(resume 42)      -- send 42 back to doStuff
                with 
                    handler          -- resume has side-effects
        { result } -> 
                result               -- the list of numbers

Тип ввода reqRequest {Getter} [Nat], а возвращаемый тип handler[Nat], потому что это то, что возвращает doStuff. Предполагается, что обработчик в конечном итоге вернет результат для doStuff.

Обработчик получает как запрос, такой как getNum, так и продолжение с именем resume. Продолжение можно было назвать как угодно. resume - это просто общепринятое соглашение. Вы можете думать об этом как о передаче функции yield, которая при вызове возобновляет выполнение приостановленной сопрограммы, которая ее отправила. Текущая сопрограмма уступает (обработчику) и передает управление сопрограмме doStuff, передавая ей запрошенное значение; 42 в данном случае.

Одна вещь, которая сначала сбила меня с толку, заключалась в том, что функции-обработчики имеют тенденцию быть рекурсивными. Однако это неудивительно, учитывая, что у resume должны быть побочные эффекты. Имейте в виду, что resume 42 аналогичен запуску doStuff, функции с побочными эффектами. Помните, что после обработки первого getNum мы получим еще два getNum запроса.

По этой причине resume должно также быть заключено в оператор handle-with. Все эти рекурсивные вызовы в какой-то момент должны быть завершены, так как в конечном итоге функция doStuff завершает выполнение и возвращает свой результат. Это инициирует окончательный запрос к обработчику, но на этот раз продолжение не передается, потому что нет продолжения выполнения. Этот последний запрос представлен оператором {result} -> result, который завершает выполнение и возвращает result из обработчика.

Запуск кода побочных эффектов с обработчиком

Итак, у нас появился обработчик. Это означает, что мы, наконец, можем запустить нашу функцию doStuff.

> handle !doStuff with handler
  ⧩
  [42, 42, 42]

Этот код довольно бесполезен, потому что мы всегда возвращаем одно и то же значение 42. Нам нужен более общий обработчик, который может предоставлять нам разные числа.

Написание более универсального обработчика

Функциональное программирование часто связано с функциями высшего порядка: функциями, которые возвращают другие функции или принимают другие функции в качестве аргументов. Чтобы получить более общий обработчик, мы напишем функцию более высокого порядка, которая принимает в качестве входных данных функцию с побочными эффектами и значение, которое мы хотим предоставить этой функции. Назовем его runNumGetter:

runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff =
    handler : Request {Getter} [Nat] -> [Nat]
    handler = cases
        { getNum -> resume } -> handle (resume n) with handler
        { result } -> result  
    handle !doStuff with handler

Вот пример использования runNumGetter для предоставления числа 5 каждый раз, когда вызывается getNum:

> runNumGetter 5 doStuff
  ⧩
  [5, 5, 5]

Какие изменения мы внесли в предыдущий обработчик? Мы заменили resume 42 на resume n, где n — первый аргумент runNumberGetter. Посмотрим на сигнатуру функции:

runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff

Подпись говорит нам, что n имеет тип Nat, а doStuff имеет тип '{Getter} [Nat]. runNumGetter возвращает значение типа [Nat]. doStuff — это функция, не принимающая аргументов и возвращающая массив натуральных чисел с побочными эффектами. Формально это выражается как () ->{Getter} [Nat]. Однако использование одинарной кавычки дает нам сокращение '{Getter} [Nat]. Причина, по которой это сокращение полезно, заключается в том, что многие функции функционального программирования имеют дело с отложенными вычислениями. Если я напишу 42, оно будет оценено немедленно. Но если я оберну его функцией () -> 42, то это значение не будет оцениваться до тех пор, пока функция не будет вызвана.

Вы найдете концепцию отложенных вычислений во многих основных языках с функциональным вдохновением. Одним из примеров является Swift, который поддерживает ленивые свойства и где глобальные переменные всегда лениво инициализируются. В Swift можно пометить аргумент функции как автозакрытие. Это означает, что Swift автоматически перенесет аргумент на месте вызова в закрытие. Это упрощает реализацию таких вещей, как assert.

Котировки и отложенные вычисления

Отложенные вычисления дают нам способ обхода выражений с побочными эффектами до тех пор, пока их нельзя будет запустить в контексте оператора handle-with. Вы не можете оценить doStuff, где вызывается runNumGetter. Вместо этого вам нужно отложить оценку. Таким образом, вместо записи runNumGetter [getNum, getNum] handler вы пишете runNumGetter '[getNum, getNum] handler, потому что это задерживает вычисление выражения с побочными эффектами, помещая его внутрь функции без аргументов. Концептуально имеет смысл говорить об этом как о цитировании выражения или отсрочке и выражении.

Обработка запросов символов

Пример обработчика, который я привел, нереалистичен, потому что он не обрабатывает все возможные запросы. Например, он не обрабатывает getChar. Это потому, что это изменило бы возвращаемый тип обработчика. Вы должны каждый раз возвращать один тип. Позвольте мне привести пример обработчика getChar, чтобы пояснить:

runCharGetter : Char -> '{Getter} [Char] -> [Char]
runCharGetter ch doStuff =
    handler : Request {Getter} [Char] -> [Char]
    handler = cases
        { getChar -> resume} -> handle (resume ch) with handler
        { result } -> result  
    handle !doStuff with handler

Обратите внимание, что этот обработчик возвращает не список натуральных чисел, а список символов ([Char]). Вот пример его запуска:

> runCharGetter ?A '[getChar, getChar, getChar]
  ⧩
  [?A, ?A, ?A]

Обычно вы размещаете эти различные операции со способностями в отдельных типах способностей. Итак, почему я смешал их? В образовательных целях я хотел разъяснить вам, как определяется возвращаемый тип обработчика. Здесь вы можете видеть, что в зависимости от обрабатываемых вами значений вы возвращаете [Nat] или [Char]. Когда вы работаете с более общими обработчиками, становится сложнее отслеживать, что представляют собой все параметры типа. Позвольте мне пояснить, что я имею в виду под параметрами типа. Тип словаря Unison Map определяется как:

unique type Map k v

Здесь k и v — это идентификаторы, используемые для ссылки на параметры типа для типа ключа и значения. Таким образом, Map Char Int будет относиться к конкретному типу словаря с Char ключами и Int значениями. Чтобы сделать больше полезных возможностей, мы будем использовать параметры типа.

Общие обработчики, использующие параметры типа

Чтобы сделать способность Getter более полезной, она должна позволять получать любое значение, а не только значение Char или Nat. Обработчики также должны быть написаны для работы с любым из этих значений. В Unison уже определена такая способность:

structural ability Ask a where 
    ask : {Ask a} a

Здесь Ask использует параметр типа a для определения типа значения, которое должно быть возвращено при выполнении операции возможности ask.

Обработчик, созданный для работы с этой возможностью, под названием provide.handler также гораздо более общий, поскольку он позволяет вам запускать функции побочных эффектов с любым типом возвращаемого значения:

provide.handler : a -> Request {Ask a} r -> r
provide.handler a =
  h req = match req with
    { r }           -> r
    {ask -> resume} -> handle (resume a) with h
  h

Обратите внимание, что значение, которое вы получите, имеет тип a, что означает, что оно может быть любым. Предоставляя значение обработчику, вы также связываете параметр типа a, используемый с объектом Request. Это означает, что ваша функция doStuff должна запрашивать значения типа a. Тип возвращаемого значения r также параметризуется. Типы возврата [Nat] и [Char] больше не используются. Тип обработчика r будет соответствовать типу возвращаемого значения r функции doStuff. Тип Request связывает эти типы вместе. Поскольку doStuff будет производить запросы типа Request {Ask a} r, у нас есть способ зафиксировать тип возвращаемого значения doStuff в обработчике.

Это объясняет, почему Request должен содержать возвращаемый тип вызываемой функции побочного эффекта. Если бы это было не так, у нас не было бы возможности привязать этот тип к параметру типа r в обработчике. В нашем самом первом примере a соответствовал типу Nat, а тип возвращаемого значения r был типом [Nat].

Для использования обработчика Unison дает нам функцию provide (я упростил определение):

provide : a -> '{Ask a} r -> r
provide a asker = 
	handle !asker with (provide.handler a)

Функция provide позволяет нам запускать заключенное в кавычки выражение asker типа '{Ask a} r, которое имеет побочные эффекты. Это выражение возвращает значение типа r. В качестве альтернативы вы можете сформулировать это как запуск функции типа () ->{Ask a} r.

Таким образом, с provide у нас могут быть выражения со всеми типами возвращаемых значений:

> provide 12 '(ask + ask)
  ⧩
  24

> provide 42 '[ask, ask, ask]
  ⧩
  [42, 42, 42]

> provide 67 '(Char.fromNat ask)
  ⧩
  Some ?C

И, естественно, предоставленное значение не обязательно должно быть числом, как в приведенных выше примерах. Это может быть что угодно. Здесь я подаю provide строку и словарь.

> provide "Hello" '(ask ++ ask)
  ⧩
  "HelloHello"

> dict = Map.fromList [(1, "one"), (2, "two")]
> provide dict '(Map.lookup 2 ask)
  ⧩
  Some "two"

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

Более полезные обработчики

Способность Store немного полезнее, чем Ask, потому что вы можете вернуть ранее сохраненные значения. Он поддерживает две операции способности, put и get.

structural ability Store a where
  put : a ->{Store a} ()
  get : {Store a} a

Операция get не принимает аргументов, но возвращает значение типа a. put наоборот. Он принимает значение типа a и ничего не возвращает. Вы можете увидеть отличный пример того, как Store используется для создания стековой машины в Документации по стандартной библиотеке Unison. В примере они определяют три функции push, pop и add, каждая из которых имеет побочные эффекты:

stack.push : a ->{Store [a]} ()
stack.push n =
  use List +:
  Store.put (n +: Store.get)

pop до тех пор, пока push не примет аргумента, но вернет значение типа a

stack.pop : () -> {Abort, Store [a]} a
stack.pop _ =
  use Optional toAbort
  stack = Store.get
  Store.put (toAbort (List.tail stack))
  toAbort (List.head stack)

add не принимает аргументов и ничего не возвращает, так как он извлекает свои входные данные из стека и возвращает результат обратно в стек.

stack.add : () -> {Abort, Store [Int]} ()
stack.add _ =
  use stack pop
  x = !pop
  y = !pop
  use Int +
  stack.push (x + y)

Как и в случае с provide и runNumGetter, мы определяем функцию для запуска функции побочного эффекта, используя следующие операции:

runStack : '{Abort, Store [Int]} a -> Optional a
runStack p = 
	!(Abort.toOptional '(withInitialValue [] p))

Мы можем оценить выражение с помощью этих операций со способностями, которые вернут значение 130:

> runStack do
    use stack add push
    push +10
    push +20
    !add
    push +100
    !add
    !stack.pop

Я не буду подробно объяснять все в этом коде. Вместо этого я сосредоточусь на частях, которые озадачили меня при первом прочтении. Например, почему способность Abort указана как требование? Способность Abort позволяет прервать выполнение функции. Документация стандартной библиотеки использует в качестве примера пример деления на ноль:

divBy : Nat -> Nat ->{Abort} Nat
divBy a b =
  match b with
    0 -> abort
    n ->
      use Nat /
      a / b

Если мы делим на ноль, мы не можем дать результат и должны прерваться. Унисон позволяет нам конвертировать туда и обратно между необязательными значениями и способностью Abort. Функция stack.pop вводит побочный эффект Abort, позволяющий избежать работы с необязательными значениями. Когда мы извлекаем элемент из списка, может произойти сбой, потому что он пуст. Вот почему List.tail возвращает необязательное значение. Но мы бы не хотели иметь дело с этим необязательным значением, поэтому вместо этого мы превращаем его в Abort, когда обновляем состояние стека с помощью Store.put:

Store.put (toAbort (List.tail stack))

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

Способности в стандартной библиотеке

Если вы просмотрите стандартную библиотеку Unison, вы найдете много полезных возможностей. Одной из часто возникающих потребностей является потребность в случайном числе. Вы можете использовать способность Random, чтобы получить это. lcg предоставляет обработчик для запуска переданных ему выражений, использующих способность Random.

> lcg 42 '(Random.oneOf (range 1 100))
   ⧩
   63

Функция Random.ofOne выбирает случайное значение из предоставленного списка. Вы не можете запустить Random.oneOf напрямую, так как у него есть побочные эффекты. Вам нужна функция, такая как lcg, которая фиксирует эти побочные эффекты с помощью обработчика.

Кроме того, вы можете использовать возможность Поток для создания потоков объектов. Почему это полезно? Потому что вы можете представлять бесконечные списки объектов. Мы видим, что потоки часто используются для работы с символами из файлов.

Существует множество возможностей для обработки ошибок, таких как Throw, Exception и Abort. Обратите внимание, что одним из возможных источников путаницы является то, что Throw и Exception не связаны друг с другом. Вы используете Throw для создания текстовых сообщений об ошибках на случай, если что-то пойдет не так. С Exception вы предоставляете объект ошибки.

Использование обработки ошибок на практике

Одна из путаниц, возникшая у меня при чтении об обработке ошибок в Unison, заключается в том, что большинство примеров показывают, как вы превращаете Throw, Exception и Abort в помеченные объединения, такие как Необязательный и Любой. Разве это не возвращает нас обратно в страну Haskell Monad? Какой смысл, если вы просто получите помеченный союз в конце?

Подсказка содержится в примерах runStack стековой машины и main ввода-вывода. Они показывают, как ядро ​​вашего кода становится менее загроможденным способностями. Способности позволяют отсрочить превращение ошибок в помеченные объединения, чтобы большая часть вашего кода оставалась чистой.

Подумайте о том, как исключения могут помочь уменьшить количество кода ошибки, который у вас есть в функции, потому что вы можете просто позволить исключению подниматься до точки, где вы фактически обрабатываете исключение выше по стеку вызовов. В Unison вы можете определить такую ​​функцию, как divBy, которая может вызывать ошибки.

divBy : Nat -> Nat ->{Throw Text} Nat
divBy a b =
  match b with
    0 -> throw "divide by zero"
    n -> a / b

Если бы divBy вернул значение параметра, то функции, использующие divBy, должны были бы каким-то образом обрабатывать дополнительные значения. Благодаря способностям мы избегаем этого. Функция arithmetic выполняет несколько вычислений с участием divBy, но может полностью игнорировать тот факт, что divBy может дать сбой. Все, что нужно сделать, это отметить в сигнатуре функции, что требуется способность Throw.

arithmetic : Nat -> Nat ->{Throw Text} Nat
arithmetic a b =
  x = divBy a b
  y = divBy b (a - 3)
  x + y

Вы можете использовать ряд различных функций более высокого порядка, чтобы убрать способность Throw, когда вы не хотите продолжать всплывать Throw вверх по стеку вызовов. Одним из примеров является catchWith, который позволяет чистой функции вызывать функцию побочного эффекта, использующую способность Throw. Следующие оценки в ucm показывают превращение Throw в объединение с тегами Either и объединение с тегами Optional.

> catchWith (msg -> Left msg) '(Right (arithmetic 3 2))
  ⧩
  Left "divide by zero"

> catchWith (msg -> None) '(Some (arithmetic 3 2))
  ⧩
  None

Заворачивать

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

Эта история получилась настолько длинной, что я хочу подробно описать операции ввода-вывода, такие как чтение и запись в файлы, в отдельной статье. Следите за обновлениями!