【问题标题】:Idiomatic exceptions for exiting loops in OCamlOCaml 中退出循环的惯用异常
【发布时间】:2015-09-05 08:48:14
【问题描述】:

在 OCaml 中,可以通过引发异常提前退出命令式循环。

虽然在 OCaml 中使用命令式循环并不是惯用的本身,但我想知道用早期退出来模拟命令式循环的最惯用方法是什么(考虑到诸如性能,如果可能的话)。

例如,an old OCaml FAQ 提到异常 Exit

Exit:用于跳出循环或函数。

它仍然是最新的吗? standard library 只是将其称为通用异常:

Exit 异常不会由任何库函数引发。它是为在您的程序中使用而提供的。

this answer 相关的另一个问题提到使用预先计算的let exit = Exit 异常来避免循环内的分配。还需要吗?

此外,有时人们想以特定值退出循环,例如raise (Leave 42)。是否有惯用的例外或命名约定来执行此操作?在这种情况下我应该使用引用(例如let res = ref -1 in ... <loop body> ... res := 42; raise Exit)吗?

最后,在嵌套循环中使用Exit 可以防止出现一些想要退出多个循环的情况,例如Java 中的break <label>。这将需要定义具有不同名称的异常,或者至少使用一个整数来指示应该退出多少范围(例如,Leave 2 表示应该退出 2 个级别)。同样,这里是否有一种惯用的方法/异常命名?

【问题讨论】:

  • 这并不能回答您的问题,但无论您最终做什么,从 OCaml 4.02 开始,您都应该使用 raise_notrace 处理控制流异常,以确保即使在调试时也不会创建回溯打开:caml.inria.fr/pub/docs/manual-ocaml/libref/Pervasives.html。供您参考,我使用延续来解决您描述的大部分问题。
  • 为了清楚起见,像 Java 标签一样获得提前退出效果的“惯用”方法是创建故障延续,这将导致“返回”到程序的某些部分。然后您传递失败延续,其他代码可以使用值调用它们以立即“退出”到任何这些点。它比退出循环更通用,因为您可以从任何获得这些延续之一的事物中“退出”。它也是类型安全的,因为您必须在退出点向延续传递上下文预期的值类型。不过,我不确定这是否是您要查找的内容。
  • @antron 我几乎明白你的意思。你能用代码作为答案做一个最小的例子吗?我会投票;)
  • 是的,给我一点时间来挖掘我的代码 :)

标签: loops ocaml idioms imperative-programming


【解决方案1】:

Exit 没问题(我不确定我是否可以说它是惯用的)。但是,请确保您使用的是raise_notrace,如果您使用的是最新的编译器(自 4.02 起)。

更好的解决方案是使用来自OCaml Core librarywith_return。它不会有任何范围问题,因为它会为每个嵌套创建一个全新的异常类型。

当然,你可以达到同样的效果,或者只是获取Core的实现的源代码。

更惯用的方法是不要使用异常来缩短迭代,并考虑使用现有算法(findfind_mapexists 等)或者只写一个递归函数,如果没有算法适合你。

【讨论】:

    【解决方案2】:

    正如最初在 cmets 中发布的那样,在 OCaml 中提前退出的惯用方法是使用延续。在您希望提前返回的地方,您创建一个延续,并将其传递给可能提前返回的代码。这比循环的标签更通用,因为您几乎可以从任何可以访问延续的东西中退出。

    此外,正如 cmets 中所发布的,请注意 raise_notrace 用于您永远不希望运行时生成其跟踪的异常。

    “天真的”第一次尝试:

    module Continuation :
    sig
      (* This is the flaw with this approach: there is no good choice for
         the result type. *)
      type 'a cont = 'a -> unit
    
      (* with_early_exit f passes a function "k" to f. If f calls k,
         execution resumes as if with_early_exit completed
         immediately. *)
      val with_early_exit : ('a cont -> 'a) -> 'a
    end =
    
    struct
      type 'a cont = 'a -> unit
    
      (* Early return is implemented by throwing an exception. The ref
         cell is used to store the value with which the continuation is
         called - this is a way to avoid having to generate an exception
         type that can store 'a for each 'a this module is used with. The
         integer is supposed to be a unique identifier for distinguishing
         returns to different nested contexts. *)
      type 'a context = 'a option ref * int64
      exception Unwind of int64
    
      let make_cont ((cell, id) : 'a context) =
        fun result -> cell := Some result; raise_notrace (Unwind id)
    
      let generate_id =
        let last_id = ref 0L in
        fun () -> last_id := Int64.add !last_id 1L; !last_id
    
      let with_early_exit f =
        let id = generate_id () in
        let cell = ref None in
        let cont : 'a cont = make_cont (cell, id) in
        try
          f cont
        with Unwind i when i = id ->
          match !cell with
          | Some result -> result
            (* This should never happen... *)
          | None        -> failwith "with_early_exit"
    end
    
    
    
    let _ =
      let nested_function i k = k 15; i in
    
      Continuation.with_early_exit (nested_function 42)
      |> string_of_int
      |> print_endline
    

    如您所见,上面通过隐藏异常实现了提前退出。延续实际上是一个部分应用的函数,它知道创建它的上下文的唯一 id,并且有一个引用单元格来存储结果值,同时将异常抛出到该上下文。上面的代码打印出 15。您可以将延续 k 传递到任意深度。您还可以在传递给with_early_exit 的位置立即定义函数f,产生类似于在循环上添加标签的效果。我经常使用这个。

    上面的问题是'a cont的结果类型,我随意设置为unit。实际上,'a cont 类型的函数永远不会返回,因此我们希望它的行为类似于 raise——可用于任何类型的预期。但是,这不会立即起作用。如果您执行type ('a, 'b) cont = 'a -> 'b 之类的操作,并将其传递给嵌套函数,则类型检查器将在一个上下文中推断'b 的类型,然后强制您仅在具有相同类型的上下文中调用延续,即您将无法做类似的事情

    (if ... then 3 else k 15)
    ...
    (if ... then "s" else k 16)
    

    因为第一个表达式强制'bint,但第二个表达式要求'bstring

    为了解决这个问题,我们需要提供一个类似于raise 的函数来提前返回,即

    (if ... then 3 else throw k 15)
    ...
    (if ... then "s" else throw k 16)
    

    这意味着远离纯粹的延续。我们必须取消部分应用上面的make_cont(我将其重命名为throw),并传递裸上下文:

    module BetterContinuation :
    sig
      type 'a context
    
      val throw : 'a context -> 'a -> _
      val with_early_exit : ('a context -> 'a) -> 'a
    end =
    
    struct
      type 'a context = 'a option ref * int64
      exception Unwind of int64
    
      let throw ((cell, id) : 'a context) =
        fun result -> cell := Some result; raise_notrace (Unwind id)
    
      let generate_id = (* Same *)
    
      let with_early_exit f =
        let id = generate_id () in
        let cell = ref None in
        let context = (cell, id) in
        try
          f context
        with Unwind i when i = id ->
          match !cell with
          | Some result -> result
          | None        -> failwith "with_early_exit"
    end
    
    
    
    let _ =
      let nested_function i k = ignore (BetterContinuation.throw k 15); i in
    
      BetterContinuation.with_early_exit (nested_function 42)
      |> string_of_int
      |> print_endline
    

    表达式throw k v 可用于需要不同类型的上下文中。

    我在我从事的一些大型应用程序中普遍使用这种方法。我什至更喜欢它而不是常规例外。我有一个更复杂的变体,with_early_exit 的签名大致如下:

    val with_early_exit : ('a context -> 'b) -> ('a -> 'b) -> 'b
    

    第一个函数表示尝试做某事,第二个函数表示可能导致的'a 类型错误的处理程序。与变体和多态变体一起,这为异常处理提供了更明确的类型。它对多态变体尤其强大,因为编译器可以推断出错误变体集。

    Jane Street 方法的效果与此处描述的相同,事实上,我之前有一个使用一流模块生成异常类型的实现。我不知道为什么我最终选择了这个——可能会有细微的差别:)

    【讨论】:

    • 顺便说一下,上面需要传递裸上下文,以及 Jane Street 使用记录都是由于 OCaml 类型系统中的缺陷,即缺少“底部”类型. “BetterContinuation”和 Jane Street 记录的目标是提供可以在使用它们的每个上下文中推断出新结果类型的函数。
    【解决方案3】:

    只是为了回答我的问题中其他答案中没有提到的特定部分:

    ... 使用预先计算的 let exit = Exit 异常来避免循环内的分配。还需要吗?

    我在4.02.1+fp 上使用Core_bench 做了一些微基准测试,结果表明没有显着差异:当比较两个相同的循环时,一个包含在循环之前声明的本地exit,另一个没有它,时间差别很小。

    此示例中raise Exitraise_notrace Exit 之间的差异也很小,在某些运行中约为 2%,在其他运行中高达 7%,但很可能在如此短的实验的误差范围内。

    总体而言,我无法衡量任何明显的差异,因此除非有人举出 Exit/exit 显着影响性能的示例,否则我更喜欢前者,因为它更清晰并且避免创建几乎无用的变量。

    最后,我还比较了两个习惯用法的区别:在退出循环之前使用对值的引用,或者创建包含返回值的特定异常类型。

    参考结果值+Exit:

     let res = ref 0 in
     let r =
       try
         for i = 0 to n-1 do
           if a.(i) = v then
            (res := v; raise_notrace Exit)
         done;
         assert false
       with Exit -> !res
     in ...
    

    具有特定的异常类型:

     exception Res of int
     let r =
       try
         for i = 0 to n-1 do
           if a.(i) = v then
             raise_notrace (Res v)
         done;
         assert false
       with Res v -> v
     in ...
    

    再一次,差异很小,并且在运行之间变化很大。总体而言,第一个版本(参考 + Exit)似乎有一点优势(快 0% 到 10%),但差异不足以推荐一个版本而不是另一个版本。

    由于前者需要定义一个初始值(可能不存在)或者使用选项类型来初始化引用,而后者需要为循环返回的每种类型的值定义一个新的异常,所以这里没有理想的解决方案.

    【讨论】:

      【解决方案4】:

      关于重点

      使用预先计算的 let exit = Exit 异常来避免分配 循环内。还需要吗?

      答案是,使用最新版本的 OCaml。以下是 OCaml 4.02.0 变更日志的相关摘录。

      • PR#6203:常量异常构造函数不再分配 (Alain Frisch)

      这里是 PR6203:http://caml.inria.fr/mantis/view.php?id=6203

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多