Clojure - 循环变量 - 不可变性

3

我正在尝试学习Clojure中的函数式编程。许多函数式编程教程都从不可变性的好处开始讲起,其中一个常见的例子是命令式语言中的循环变量。在这方面,Clojure的loop-recur与它们有何不同?例如:

(defn factorial [n]
  (loop [curr-n n curr-f 1]
    (if (= curr-n 1)
      curr-f
      (recur (dec curr-n) (* curr-f curr-n)))))

在命令式语言中,curr-ncurr-f这两个可变值与循环变量类似,不是吗?


1
就心智模型而言,将recur视为将名称重新绑定到新值上——这些值仍然是不可变的,如果你将其中一个值存储在某个地方(比如原子中),它不会因为recur恰好重新绑定了原始值而改变。 - Charles Duffy
2个回答

4
正如Thumbnail指出的那样,在clojure中使用loop-recur与经典递归函数调用具有相同的形式和效果。它存在的唯一原因是比纯递归更有效率。
由于recur只能出现在尾位置,所以可以保证loop变量再也不会被需要。因此,不需要在堆栈上保留它们,因此不使用堆栈(不像嵌套函数调用,无论是否递归)。最终结果是它看起来和其他语言中的命令式循环非常相似。
与Java风格的for循环相比,改进之处在于所有“变量”仅限于在loop表达式中初始化和在recur表达式中更新时“更改”。循环体内或其他地方(例如嵌入式函数调用可能会在Java中使循环变量发生变化)都不能更改变量。由于这些限制了“循环变量”可被突变/更新的位置,因此减少了意外更改它们的错误机会。这些限制的代价是,循环不像传统的Java风格循环那样灵活。
与任何东西一样,由您决定这种成本-收益权衡何时比其他成本-收益权衡更好。如果您想要一个纯Java风格的循环,可以使用clojure atom来模拟Java变量。
; Let clojure figure out the list of numbers & accumulate the result
(defn fact-range [n]
  (apply * (range 1 (inc n))))
(spyx (fact-range 4))

; Classical recursion uses the stack to figure out the list of
; numbers & accumulate the intermediate results
(defn fact-recur [n]
  (if (< 1 n)
    (* n (fact-recur (dec n)))
    1))
(spyx (fact-recur 4))

; Let clojure figure out the list of numbers; we accumulate the result
(defn fact-doseq [n]
  (let [result (atom 1) ]
    (doseq [i (range 1 (inc n)) ]
      (swap! result * i))
    @result ))
(spyx (fact-doseq 4))

; We figure out the list of numbers & accumulate the result
(defn fact-mutable [n]
  (let [result (atom 1)
        cnt    (atom 1) ]
    (while (<= @cnt n)
      (swap! result * @cnt)
      (swap! cnt inc))
    @result))
(spyx (fact-mutable 4))

(fact-range 4) => 24
(fact-recur 4) => 24
(fact-doseq 4) => 24
(fact-mutable 4) => 24

即使在我们使用原子来模拟Java中的可变变量的最后一种情况下,每次我们改变某些东西时都会用swap!函数明显标记,这使得“意外”变异更难被忽视。
附注:如果您希望使用spyx,它可以在Tupelo库中找到。

2

curr-n和curr-f是否类似于命令式语言中的循环变量,是可变值?

不是。你总是可以将loop-recur重写为递归函数调用。例如,您的factorial函数可以重写为...

(defn factorial [n]
  ((fn whatever [curr-n curr-f]
     (if (= curr-n 1)
       curr-f
       (whatever (dec curr-n) (* curr-f curr-n))))
   n 1))

这种方法速度较慢,当数字过大时容易出现堆栈溢出的情况。


在转换调用的时候,recur 会覆盖唯一的堆栈帧而不是分配一个新的。这仅在调用者的堆栈帧之后从未被引用 - 我们称之为尾部位置 - 才能正常工作。

loop 只是语法糖。我怀疑它是否为宏,但它可能是。只不过之前的绑定应该对后面的绑定可用,就像 let 一样,虽然我认为这个问题目前已经不重要了。


1
如果我没记错的话,它们实际上在生成的字节码中被改变了,因此编译成了一个真正的循环。 - Arthur Ulfeldt
如果你把那个粘贴到你的答案里,我肯定会投票支持它。 - Arthur Ulfeldt

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接