两种递归函数的区别

问题描述 投票:0回答:3

在 OCaml 中,我见过有两种编写

map
函数的方法

let rec map f xs =
  match xs with
  | [] -> []
  | x::rest -> f x :: map f rest

let map f xs =
  let rec go xs = 
    match xs with
    | [] -> []
    | x::rest -> f x :: go rest
  in go xs

第二个看起来更优化,因为它类似于循环不变消除,但在函数式编程中它可能涉及分配一个新的闭包。谁能解释两种递归函数风格之间的区别,特别是在性能方面?感谢您的帮助!

我在SO中找不到类似的问题,我希望有一个像“递归不变消除”这样的术语来描述从第一个程序到第二个程序的转换。

performance recursion ocaml
3个回答
4
投票

我一直想知道完全相同的事情:编译器是否优化递归函数中的不变参数? 既然你的问题促使我对其进行基准测试,那么让我在这里分享我的结果。

协议

我还没有尝试过

map
,因为它需要大列表,这会导致 stack_overflow。我可以用
rev_map
尝试一下,但我看不到分配巨大列表的意义,而在整数上测试等效行为更容易(而且我担心分配。最终会触发一轮 GC,这会弄乱我的时间测量)。

以下代码使用带有不变参数的虚拟递归函数重现您的用例,如

map
中所示:

let rec g f x = if x = 0 then 0 else g f (f x)

let g2 f x =
  let rec aux x = if x = 0 then 0 else aux (f x) in
  aux x

let time title f x =
  let t = Sys.time () in
  let fx = f x in
  Printf.printf "%s: %fs\n%!" title (Sys.time () -. t) ;
  fx

let main =
  let nb = int_of_string Sys.argv.(1) in
  ignore (time "normal" (g pred) nb) ;
  ignore (time "invariant elimination" (g2 pred) nb)

您可以编译它(例如

ocamlopt a.ml
)并通过执行以下操作来运行它
./a.out 10000000000
。显然,您可以更改整数参数来调整递归调用的次数。

结果

在我的电脑上,输入数字10000000000,输出:

normal: 11.813643s
invariant elimination: 11.646377s

关于更大的值:

20000000000

normal: 23.353022s
invariant elimination: 22.977813s

30000000000

normal: 35.586871s
invariant elimination: 35.421313s

我懒得去更高的地方。 在我看来,这似乎表明两个版本是等效的,也许编译器确实优化了递归函数中的不变参数,但它只是不可测量,也许不是。

字节码比较

我还尝试查看生成的字节码是否相同(ocamlc -dinstr a.ml),并且它确实略有不同,正如您在下面的代码片段中看到的那样

正常

编译一个仅包含以下内容的文件:

let g f x =
  let rec aux f x = if x = 0 then 0 else aux f (f x) in
  aux f x

给予

    branch L2
    restart
L3: grab 1
    acc 1
    push
    const 0
    eqint
    branchifnot L4
    const 0
    return 2
L4: acc 1
    push
    acc 1
    apply 1
    push
    acc 1
    push
    offsetclosure 0
    appterm 2, 4
    restart
L1: grab 1
    closurerec 3, 0
    acc 2
    push
    acc 2
    push
    acc 2
    appterm 2, 5
L2: closure L1, 0
    push
    acc 0
    makeblock 1, 0
    pop 1
    setglobal E!

不变消除

编译一个仅包含以下内容的文件:

let g2 f x =
  let rec aux x = if x = 0 then 0 else aux (f x) in
  aux x

给出:

    branch L2
L3: acc 0
    push
    const 0
    eqint
    branchifnot L4
    const 0
    return 1
L4: acc 0
    push
    envacc 1
    apply 1
    push
    offsetclosure 0
    appterm 1, 2
    restart
L1: grab 1
    acc 0
    closurerec 3, 1
    acc 2
    push
    acc 1
    appterm 1, 4
L2: closure L1, 0
    push
    acc 0
    makeblock 1, 0
    pop 1
    setglobal E2!

但是我不够专业,无法得出任何结论,因为我不会说字节码。 也正是在这里,我决定现在答案并不那么重要,无论如何,下次我见到他时问@gasche会更容易。


1
投票

使用

go
表明有 Haskell 背景。 OCaml 和 Haskell 都是函数式编程语言,但存在实质性差异,人们对 Haskell 的了解不应该用来对 OCaml 做出假设。

我认为没有特别的理由以第二种方式写

map
。如果您使用 OCaml 4.14.0 或更高版本,您可能需要使用
tail_mod_cons
使
map
尾递归,而不需要显式累加器,如 Stef 的评论中所示。

let[@tail_mod_cons] rec map f = 
  function 
  | [] -> [] 
  | x::xs -> f x :: map f xs

当然,真正的解决方案是:

let map = List.map

List.map
实现 与上面非常简单的实现类似,但进行了一些调整以确保评估顺序并使
tail_mod_cons
更高效。

let[@tail_mod_cons] rec map f = function
    [] -> []
  | [a1] ->
      let r1 = f a1 in
      [r1]
  | a1::a2::l ->
      let r1 = f a1 in
      let r2 = f a2 in
      r1::r2::map f l

0
投票

和其他人一样,我从未见过第二种形式。而且我很难想象它能提供什么样的优化。然而我知道的是(正如 @Stef 和 @Chris 指出的)这个函数可以用尾递归的方式编写。所以只是为了完整起见:

let map f xs =
  let rec go xs ys =
    match xs with
    | [] -> ys
    | x::rest -> go rest ((f x)::ys)
  in List.rev (go xs [])

此版本比您帖子中的两种形式更加优化,因为每个下一个递归调用都可以重用相同的堆栈帧,从而消除不必要的分配,节省空间和执行时间。

© www.soinside.com 2019 - 2024. All rights reserved.