Spec-Zone.ru › OCaml
☰Язык программирования OCaml
  • Язык программирования OCaml
  • Расширения языка

Глава 12 Расширения языка

24 Обработчики эффектов

  • 24.1 Основы
  • 24.2 Конкурентность
  • 24.3 Потоки пользовательского уровня
  • 24.4 Инверсия управления
  • 24.5 Семантика
  • 24.6 Поверхностные обработчики

(Введено в версии 5.0)

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

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

24.1 Основы

Для понимания основ, давайте определим эффект (то есть операцию), который принимает целое число в качестве аргумента и возвращает целое число в качестве результата. Мы назовём этот эффект Xchg.

open Effect
open Effect.Deep

type _ Effect.t += Xchg: int -> int t
let comp1 () = perform (Xchg 0) + perform (Xchg 1)

Мы объявляем эффект обмена Xchg, расширяя предварительно определённый расширяемый вариант типа Effect.t с новым конструктором Xchg: int -> int t. Объявление можно интуитивно прочитать как «эффект Xchg принимает целое число в качестве параметра, и при выполнении этого эффекта он возвращает целое число». Вычисление comp1 дважды выполняет эффект с помощью примитива perform и возвращает их сумму.

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

try_with comp1 ()
{ effc = fun (type a) (eff: a t) ->
    match eff with
    | Xchg n -> Some (fun (k: (a, _) continuation) ->
        continue k (n+1))
    | _ -> None }

- : int = 3

try_with выполняет вычисление comp1 () под обработчиком эффектов, который обрабатывает эффект Xchg. Как упоминалось ранее, обработчики эффектов — это обобщение обработчиков исключений. Подобно обработчикам исключений, когда вычисление выполняет эффект Xchg, управление переходит к соответствующему обработчику. Однако, в отличие от обработчиков исключений, обработчик также получает ограниченное продолжение k, которое представляет собой приостановленное вычисление между точкой perform и этим обработчиком.

Обработчик использует примитив continue для возобновления приостановленного вычисления с преемником предлагаемого значения. В этом примере вычисление comp1 выполняет Xchg 0 и Xchg 1 и получает значения 1 и 2 от обработчика соответственно. Таким образом, всё выражение вычисляется до 3.

Полезно отметить, что мы должны использовать локально абстрактный тип (type a) в обработчике эффектов. Тип Effect.t — это GADT, и объявления эффектов могут иметь различные параметры типа для разных эффектов. Параметр типа a в типе a Effect.t представляет тип значения, возвращаемого при выполнении эффекта. Из того факта, что eff имеет тип a Effect.t и из того факта, что Xchg n имеет тип int Effect.t, проверяющий тип выводит, что a должно быть int, поэтому нам разрешено передавать целое число n+1 в качестве аргумента в continue k.

Ещё один момент, на который следует обратить внимание, — это тот, что случай «| _ -> None» необходим при обработке эффектов. Этот случай можно интуитивно прочитать как «передать необработанные эффекты внешнему обработчику».

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

Ограничения

Эффекты OCaml являются синхронными: невозможно выполнить эффект асинхронно из обработчика сигнала, финализатора, обратного вызова memprof или сигнала GC, и поймать его из основной части кода. Вместо этого это приведёт к исключению Effect.Unhandled (12.24.5).

Аналогично, эффекты несовместимы с использованием обратных вызовов из C в OCaml (раздел ‍22.7). Невозможно для эффекта пересечь вызов caml_callback, вместо этого это приведёт к исключению Effect.Unhandled. В частности, необходимо быть внимательным при смешивании библиотек, использующих обратные вызовы из C в OCaml, и библиотек, использующих эффекты.

24.2 Конкурентность

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

Задача либо находится в приостановленном состоянии, либо завершена. Мы представляем состояние задачи следующим образом:

type 'a status =
  Complete of 'a
| Suspended of {msg: int; cont: (int, 'a status) continuation}

Задача либо завершена, с результатом типа 'a, или приостановлена с сообщением msg для отправки и продолжением cont. Тип (int,'a status) continuation говорит о том, что приостановленное вычисление ожидает значение int для возобновления и возвращает значение типа 'a status при возобновлении.

Далее мы определим функцию step, которая выполняет один шаг вычисления до тех пор, пока оно не завершится или не приостановится:

let step (f : unit -> 'a) () : 'a status =
  match_with f ()
  { retc = (fun v -> Complete v);
    exnc = raise;
    effc = fun (type a) (eff: a t) ->
      match eff with
      | Xchg msg -> Some (fun (cont: (a, _) continuation) ->
          Suspended {msg; cont})
      | _ -> None }

Аргументом функции step, f, является вычисление, которое может выполнить эффект Xchg и возвращает результат типа 'a. Функция step сама возвращает значение типа 'a status.

В функции step мы используем примитив match_with. Подобно try_with, примитив match_with устанавливает обработчик эффектов. Однако, в отличие от try_with, где предоставляется только случай эффекта effc, match_with ожидает обработчики для случаев возврата значения (retc) и исключений (exnc). Фактически, try_with можно определить с помощью match_with следующим образом: let try_with f v {effc} = match_with f v {retc = Fun.id; exnc = raise; effc}.

В функции step,

  • Случай retc: Если вычисление возвращает значение v, мы возвращаем Complete v.
  • Случай exnc: Если вычисление вызывает исключение, обработчик вызывает то же исключение.
  • Случай effc: Если вычисление выполняет действие Xchg msg с продолжением cont, тогда мы возвращаем Suspended{msg;cont}. Таким образом, в этом случае продолжение cont не вызывается обработчиком немедленно; вместо этого оно сохраняется в структуре данных для последующего использования.

Поскольку функция step обрабатывает действие Xchg, step f — это вычисление, которое не выполняет действие Xchg. Однако оно может выполнять другие действия. Кроме того, поскольку мы используем глубокие обработчики, продолжение cont, сохранённое в состоянии, не выполняет действие Xchg.

Теперь мы можем написать простой планировщик, который выполняет пару задач до завершения:

let rec run_both a b =
  match a (), b () with
  | Complete va, Complete vb -> (va, vb)
  | Suspended {msg = m1; cont = k1},
    Suspended {msg = m2; cont = k2} ->
      run_both (fun () -> continue k1 m2)
               (fun () -> continue k2 m1)
  | _ -> failwith "Improper synchronization"

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

Теперь мы можем определить второе вычисление, которое также обменивает два сообщения:

let comp2 () = perform (Xchg 21) * perform (Xchg 21)

Наконец, мы можем запустить две вычисления вместе:

run_both (step comp1) (step comp2)

- : int * int = (42, 0)

Вычисление comp1 предлагает значения 0 и 1, а в обмен получает значения 21 и 21, которые оно складывает, получая 42. Вычисление comp2 предлагает значения 21 и 21 и в обмен получает значения 0 и 1, которые оно перемножает, получая 0. Связь между двумя вычислениями запрограммирована полностью внутри run_both. Действительно, определения comp1 и comp2 сами по себе не придают никакого смысла действию Xchg.

24.3 Потоки пользовательского уровня

Давайте расширим предыдущий пример для произвольного числа задач. Многие языки, такие как GHC Haskell и Go, предоставляют потоки пользовательского уровня как примитивную функцию, реализованную в системе выполнения. С помощью обработчиков эффектов потоки пользовательского уровня и их планировщики могут быть реализованы в самом OCaml. Обычно системы многопоточных вычислений пользовательского уровня предоставляют примитив fork для создания новой конкуретной задачи и примитив yield для уступки управления другой задаче. Соответственно, мы объявим два эффекта следующим образом:

type _ Effect.t += Fork : (unit -> unit) -> unit t
                 | Yield : unit t

Эффект Fork принимает лямбду (подвешенное вычисление, представленное как функция типа unit -> unit) и возвращает единицу исполнителю. Эффект Yield непараметризован и возвращает единицу при выполнении. Рассмотрим, что задача, выполняющая действие Xchg, может совпасть с любой другой задачей, также предлагающей обмен значением.

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

let fork f = perform (Fork f)
let yield () = perform Yield
let xchg v = perform (Xchg v)

Функция верхнего уровня run определяет планировщик:

(* A concurrent round-robin scheduler *)
let run (main : unit -> unit) : unit =
  let exchanger = ref None in (* waiting exchanger *)
  let run_q = Queue.create () in (* scheduler queue *)
  let enqueue k v =
    let task () = continue k v in
    Queue.push task run_q
  in
  let dequeue () =
    if Queue.is_empty run_q then () (* done *)
    else begin
      let task = Queue.pop run_q in
      task ()
    end
  in
  let rec spawn (f : unit -> unit) : unit =
    match_with f () {
      retc = dequeue;
      exnc = (fun e ->
        print_endline (Printexc.to_string e);
        dequeue ());
      effc = fun (type a) (eff : a t) ->
        match eff with
        | Yield -> Some (fun (k : (a, unit) continuation) ->
            enqueue k (); dequeue ())
        | Fork f -> Some (fun (k : (a, unit) continuation) ->
            enqueue k (); spawn f)
        | Xchg n -> Some (fun (k : (int, unit) continuation) ->
            begin match !exchanger with
            | Some (n', k') ->
                exchanger := None; enqueue k' n; continue k n'
            | None -> exchanger := Some (n, k); dequeue ()
            end)
        | _ -> None
    }
  in
  spawn main

Мы используем изменяемый очередь run_q для хранения очереди планировщика. Очередь FIFO позволяет циклическое планирование задач в планировщике. enqueue добавляет задачи в очередь, а dequeue извлекает задачи из очереди и выполняет их. Ячейка ссылок exchanger хранит (подвешенную) задачу, предлагающую обмен значением. В любой момент времени существует ноль или одна приостановленная задача, которая предлагает обмен.

Большая часть работы выполняется функцией spawn. Функция spawn выполняет данное вычисление f в обработчике эффектов. Если f возвращает значение (случай retc), мы извлекаем и выполняем следующую задачу из очереди планировщика. Если вычисление f вызывает исключение (случай exnc), мы выводим исключение и выполняем следующую задачу из очереди.

Вычисление f также может выполнять действия. Если f выполняет действие Yield, текущая задача приостанавливается (добавляется в очередь готовых задач), и выполняется следующая задача из очереди планировщика. Если эффект — Fork f, тогда текущая задача приостанавливается, и новая задача f выполняется немедленно через вызов хвоста spawn f. Обратите внимание, что этот выбор выполнить новую задачу сначала является произвольным. Мы могли бы очень хорошо выбрать вместо этого добавить задачу для f в очередь готовых задач и возобновить k немедленно.

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

Обратите внимание, что этот код планировщика не идеален — он может утечь ресурсы. Мы объясним и исправим это в следующей секции ‍12.24.3.

Теперь мы можем написать конкурентную программу, которая использует только что определенные операции:

open Printf

let _ = run (fun _ ->
  fork (fun _ ->
    printf "[t1] Sending 0\n";
    let v = xchg 0 in
    printf "[t1] received %d\n" v);
  fork (fun _ ->
    printf "[t2] Sending 1\n";
    let v = xchg 1 in
    printf "[t2] received %d\n" v))

[t1] Sending 0
[t2] Sending 1
[t2] received 0
[t1] received 1

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

Возобновление с исключением

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

Например, рассмотрим операцию dequeue в предыдущем примере, воспроизведенную ниже:

…
let dequeue () =
  if Queue.is_empty run_q then () (* done *)
  else (Queue.pop run_q) ()

Если очередь планировщика пуста, dequeue считает, что планировщик закончил работу, и возвращается к вызывающему коду. Однако может быть задача, ожидающая обмена значением (сохраненная в ячейке ссылок exchanger), которая остается заблокированной навсегда! Если заблокированная задача удерживает ресурсы, эти ресурсы утекут. Например, рассмотрим следующую задачу:

let leaky_task () =
  fork (fun _ ->
    let oc = open_out "secret.txt" in
    Fun.protect ~finally:(fun _ -> close_out oc) (fun _ ->
      output_value oc (xchg 0)))

Задача записывает полученное сообщение в файл secret.txt. Она использует Fun.protect для обеспечения закрытия канала вывода oc как при нормальном, так и при исключительном возвращении. К сожалению, этого недостаточно. Если эффект обмена xchg 0 не может быть сопоставлен с эффектом обмена, выполненным какой-либо другой нитью, то эта задача остается заблокированной навсегда. Таким образом, канал вывода oc никогда не закрывается.

Чтобы избежать этой проблемы, необходимо придерживаться простого правила: каждое продолжение должно быть в конечном итоге либо продолжено, либо прервано. Здесь мы используем discontinue для обеспечения того, что заблокированная задача не остается заблокированной вечно. Прервав эту задачу, мы принудительно завершаем ее (с исключением):

exception Improper_synchronization

let dequeue () =
  if Queue.is_empty run_q then begin
    match !exchanger with
    | None -> () (* done *)
    | Some (n, k) ->
        exchanger := None;
        discontinue k Improper_synchronization
  end else (Queue.pop run_q) ()

Когда очередь планировщика пуста, а поток обмена заблокирован, функция dequeue прерывает заблокированный поток с исключением Improper_synchronization. Это исключение возникает при вызове заблокированной функции xchg, что приводит к выполнению блока finally и закрытию канала вывода oc. С точки зрения пользователя, кажется, что вызов функции xchg 0 вызывает исключение Improper_synchronization.

24.4 Инверсия управления

При обработке обхода данных структуры существуют два основных подхода, зависящих от того, производитель или потребитель управляет обходом. Например, в List.iter f l производитель List.iter имеет контроль и передает элемент потребителю f для обработки. С другой стороны, модуль Seq предоставляет механизм, аналогичный отложенным спискам, где потребитель управляет обходом. Например, Seq.forever Random.bool возвращает бесконечную последовательность случайных бит, где каждый бит производится (по требованию) при запросе потребителя.

Естественно, производители, такие как List.iter, легче реализуются в первом стиле. Второй стиль более удобен для потребителя, так как предпочтительнее и естественнее иметь контроль над обходом. Чтобы получить лучшее из обоих миров, мы хотели бы написать производителя в первом стиле и автоматически преобразовать его во второй стиль. Благодаря обработчикам эффектов, преобразование можно написать один раз и навсегда как функцию библиотеки. Назовём эту функцию invert. Сначала рассмотрим использование функции invert, а затем её реализацию. Тип этой функции представлен ниже:

val invert : iter:(('a -> unit) -> unit) -> 'a Seq.t

Функция invert принимает функцию iter (производитель, передающий элементы потребителю) и возвращает последовательность (где контроль имеет потребитель). Например,

let lst_iter = Fun.flip List.iter [1;2;3]

val lst_iter : (int -> unit) -> unit = 

является функцией iter с типом (int -> unit) -> unit. Выражение lst_iter f передаёт элементы 1, 2 и 3 потребителю f. Например,

lst_iter (fun i -> Printf.printf "%d\n" i)

1
2
3
- : unit = ()

Выражение invert lst_iter возвращает последовательность, позволяющую потребителю обрабатывать список по требованию. Например,

let s = invert ~iter:lst_iter
let next = Seq.to_dispenser s;;

val s : int Seq.t = 
val next : unit -> int option = 
next();;

- : int option = Some 1
next();;

- : int option = Some 2
next();;

- : int option = Some 3
next();;

- : int option = None

Мы можем использовать функцию invert с любой функцией iter. Например,

let s = invert ~iter:(Fun.flip String.iter "OCaml")
let next = Seq.to_dispenser s;;

val s : char Seq.t = 
val next : unit -> char option = 
next();;

- : char option = Some 'O'
next();;

- : char option = Some 'C'
next();;

- : char option = Some 'a'
next();;

- : char option = Some 'm'
next();;

- : char option = Some 'l'
next();;

- : char option = None

Реализация инверсии управления

Реализация функции invert приведена ниже:

let invert (type a) ~(iter : (a -> unit) -> unit) : a Seq.t =
  let module M = struct
    type _ Effect.t += Yield : a -> unit t
  end in
  let yield v = perform (M.Yield v) in
  fun () -> match_with iter yield
  { retc = (fun _ -> Seq.Nil);
    exnc = raise;
    effc = fun (type b) (eff : b Effect.t) ->
      match eff with
      | M.Yield v -> Some (fun (k: (b,_) continuation) ->
          Seq.Cons (v, continue k))
      | _ -> None }

Функция invert объявляет эффект Yield, который принимает элемент для выдачи в качестве параметра. Функция yield выполняет эффект Yield. Лямбда-выражение fun () -> ... откладывает все действия до первого запроса элемента последовательности. После этого вычисление iter yield выполняется в обработчике эффектов. Каждый раз, когда функция iter передает элемент функции yield, вычисление прерывается эффектом Yield. Эффект Yield обрабатывается возвратом значения Seq.Cons(v,continue k) потребителю. Потребитель получает элемент v и приостановленное вычисление, которое для потребителя является просто хвостом последовательности.

Когда потребитель запрашивает следующий элемент из последовательности (применяя его к ()), возобновляется продолжение k. Это позволяет вычислению iter yield прогрессировать, пока оно не выдаст ещё один элемент или не завершится нормально. В последнем случае возвращается значение Seq.Nil, указывающее потребителю, что итерация завершена.

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

24.5 Семантика

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

Вложенные обработчики

Как и обработчики исключений, обработчики эффектов могут быть вложены.

type _ Effect.t += E : int t
                 | F : string t

let foo () = perform F

let bar () =
  try_with foo ()
  { effc = fun (type a) (eff: a t) ->
      match eff with
      | E -> Some (fun (k: (a,_) continuation) ->
          failwith "impossible")
      | _ -> None }

let baz () =
  try_with bar ()
  { effc = fun (type a) (eff: a t) ->
      match eff with
      | F -> Some (fun (k: (a,_) continuation) ->
          continue k "Hello, world!")
      | _ -> None }

В этом примере вычисление foo выполняет F, внутренний обработчик обрабатывает только E, а внешний обработчик обрабатывает F. Вызов baz возвращает Hello, world!.

baz ()

- : string = "Hello, world!"

Волокна

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

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

В точке perform в foo в предыдущем примере стек программы выглядит следующим образом:

+-----+   +-----+   +-----+
|     |   |     |   |     |
| baz |<--| bar |<--| foo |
|     |   |     |   |     |
|     |   |     |   |     |
+-----+   +-----+   +-----+ <- stack_pointer

Две ссылки соответствуют двум обработчикам эффектов в программе. Когда эффект F обрабатывается в baz, состояние программы выглядит следующим образом:

+-----+                   +-----+   +-----+
|     |                   |     |   |     |   +-+
| baz |                   | bar |<--| foo |<--|k|
|     |                   |     |   |     |   +-+
+-----+ <- stack_pointer  +-----+   +-----+

Ограниченное продолжение k — это объект в куче, который ссылается на участок стека, соответствующий приостановленному вычислению. Захват продолжения не подразумевает копирование кадров стека. Когда продолжение возобновляется, стек восстанавливается в предыдущее состояние, связывая участок стека, на который указывает k, с текущим стеком. Поскольку ни захват продолжения, ни возобновление не требуют копирования кадров стека, приостановление выполнения с помощью perform и возобновление с помощью continue или discontinue являются быстрыми операциями.

Необработанные эффекты

В отличие от языков, таких как Eff и Koka, обработчики эффектов в OCaml не обеспечивают безопасность эффектов; компилятор не гарантирует статически, что все эффекты, выполняемые программой, будут обработаны. Если эффект не имеет соответствующего обработчика, то исключение Effect.Unhandled возникает в точке соответствующего perform. Например, в предыдущем примере bar не обрабатывает эффект F. Поэтому при запуске bar мы получим исключение Effect.Unhandled F.

try bar () with Effect.Unhandled F -> "Saw Effect.Unhandled exception"

- : string = "Saw Effect.Unhandled exception"

Линейные продолжения

Как обсуждалось ранее ‍12.24.3, ограниченные продолжения в OCaml должны использоваться линейно — каждое захваченное продолжение должно быть возобновлено либо с помощью continue, либо discontinue ровно один раз. Попытка использовать продолжение более одного раза вызывает исключение Continuation_already_resumed. Например:

try_with perform (Xchg 0)
{ effc = fun (type a) (eff : a t) ->
    match eff with
    | Xchg n -> Some (fun (k: (a, _) continuation) ->
        continue k 21 + continue k 21)
    | _ -> None }

Exception: Stdlib.Effect.Continuation_already_resumed.

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

Хотя свойство «возобновление не более одного раза» для продолжений гарантируется динамической проверкой, нет проверки, гарантирующей, что продолжения возобновляются «хотя бы один раз». Пользователь должен гарантировать, что захваченные продолжения возобновляются хотя бы один раз. Невозобновление продолжений приведёт к утечке памяти, выделенной для волокна, а также любых ресурсов, которые может удерживать приостановленное вычисление.

Можно установить финализатор на захваченное продолжение, чтобы гарантировать освобождение ресурсов:

exception Unwind
Gc.finalise (fun k ->
  try ignore (discontinue k Unwind) with _ -> ()) k

В этом случае, если k станет недоступным, финализатор гарантирует, что стек продолжений будет размотан путём прерывания с исключением Unwind, позволяя вычислению освободить ресурсы. Однако временные затраты финализаторов намного больше, чем стоимость захвата продолжения. Поэтому рекомендуется, чтобы пользователь позаботился о возобновлении продолжения ровно один раз, а не полагался на финализатор.

24.6 Поверхностные обработчики

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

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

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

type _ Effect.t += Send : int -> unit Effect.t
                 | Recv : int Effect.t

Предположим, что мы хотим навязать протокол, допускающий только чередующиеся последовательности эффектов Send и Recv, соответствующие регулярному выражению (Send;Recv)*;Send?. Таким образом, последовательности эффектов [] (пустая последовательность), [Send], [Send;Recv], [Send;Recv;Send] и т.д. разрешены, но не [Recv], [Send;Send], [Send;Recv;Recv] и т.д. Ключевое наблюдение здесь заключается в том, что множество обрабатываемых эффектов изменяется со временем. Мы можем довольно естественно реализовать этот протокол с помощью поверхностных обработчиков, как показано ниже:

open Effect.Shallow

let run (comp: unit -> unit) : unit =
  let rec loop_send : type a. (a,unit) continuation -> a -> unit = fun k v ->
    continue_with k v
      { retc = Fun.id;
        exnc = raise;
        effc = fun (type b) (eff : b Effect.t) ->
          match eff with
          | Send n -> Some (fun (k: (b,_) continuation) ->
              loop_recv n k ())
          | Recv -> failwith "protocol violation"
          | _ -> None }
  and loop_recv : type a. int -> (a,unit) continuation -> a -> unit = fun n k v ->
    continue_with k v
      { retc = Fun.id;
        exnc = raise;
        effc = fun (type b) (eff : b Effect.t) ->
          match eff with
          | Recv -> Some (fun (k: (b,_) continuation) ->
              loop_send k n)
          | Send v -> failwith "protocol violation"
          | _ -> None }
  in
  loop_send (fiber comp) ()

Функция run выполняет вычисление comp, гарантируя, что оно может выполнять только чередующиеся последовательности эффектов Send и Recv. Поверхностный обработчик использует набор примитивов, отличающийся от глубокого обработчика. Примитив fiber (в последней строке) принимает функцию 'a -> 'b и возвращает ('a,'b) Effect.Shallow.continuation. Выражение continue_with k v h возобновляет продолжение k со значением v под обработчиком h.

Взаимно рекурсивные функции loop_send и loop_recv возобновляют данное продолжение k со значением v под разными обработчиками. Функция loop_send обрабатывает эффект Send и вызывает функцию loop_recv по хвостовой ссылке. Если вычисление выполняет эффект Recv, то loop_send прерывает вычисление, вызвав исключение. Аналогично, функция loop_recv обрабатывает эффект Recv и вызывает функцию loop_send по хвостовой ссылке. Если вычисление выполняет эффект Send, то loop_recv прерывает вычисление. Учитывая, что продолжение, захваченное в поверхностном обработчике, не включает обработчик, в динамической области видимости вычисления comp всегда установлен только один обработчик.

Вычисление изначально выполняется функцией loop_send (см. последнюю строку кода выше), которая гарантирует, что первым эффектом, который вычисление может выполнить, является эффект Send. Обратите внимание, что вычисление свободно выполнять эффекты, отличные от Send и Recv, которые могут быть обработаны внешним обработчиком.

Мы можем видеть, что функция run позволит вычислению, следующему протоколу:

run (fun () ->
  printf "Send 42\n";
  perform (Send 42);
  printf "Recv: %d\n" (perform Recv);
  printf "Send 43\n";
  perform (Send 43);
  printf "Recv: %d\n" (perform Recv))

Send 42
Recv: 42
Send 43
Recv: 43
- : unit = ()

и прервёт те, которые этого не делают:

run (fun () ->
  Printf.printf "Send 0\n";
  perform (Send 0);
  Printf.printf "Send 1\n";
  perform (Send 1) (* protocol violation *))

Send 0
Send 1
Exception: Failure "protocol violation".

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

« Операторы связыванияЯзыковые расширения
Copyright © 2024 Institut National de Recherche en Informatique et en Automatique

© 1995-2024 INRIA.
https://ocaml.org/manual/5.2/effects.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API