Pratical Cljr – loop/recur
Programming Clojure这块写的过于简单, Pratical Clojure写的还不错, 这本书在某些章节写的深度不错, 讨论why, 而不是仅仅how, 故摘录
首先, Clojure是不提供直接的looping语法的!!! 这个理解很重要
因为looping是imperative的概念, 对于FP, 需要改变思维的方式, 用递归来解决同样的问题, 所以下面也说对于从imperative 转来的程序员, 习惯从递归(recursive)的角度思考问题, 是很大的挑战.
It will probably come as a minor shock to users of imperative programming languages that Clojure provides no direct looping syntax.
Instead, like other functional languages, it uses recursion in scenarios where it is necessary to execute the same code multiple times.
Thinking recursively is one of the largest challenges coming from imperative to functional languages, but it is surprisingly powerful and elegant, and you will soon learn how to easily express any repeated computation using recursion.
对于递归, 首先需要通过参数来不断的传递计算的中间结果, 而不是通过某些全局变量, 并且需要有一个base case作为结束条件, 每次迭代都需要修改base condition并check.
For effective recursion in Clojure (or any other functional language, for that matter), you only need to keep these guidelines in mind:
• Use a recursive function’s arguments to store and modify the progress of a computation. In imperative programming languages, loops usually work by repeatedly modifying a single variable.
• Make sure the recursion has a base case or base condition.
• With every iteration, the recursion must make at least some progress towards the base condition.
下面举两个例子递归的例子, 开根号, 幂乘
Uses Newton’s algorithm to recursively calculate the square root of any number.
很有意思, 一直都很想当然的使用开根号, 从没想过它是怎么实现的... 不断的试错, 逼近, 当误差小于0.001的时候返回
(defn abs "Calculates the absolute value of a number" [n] (if (< n 0) (* -1 n) n)) (defn avg "returns the average of two arguments" [a b] (/ (+ a b) 2)) (defn good-enough? "Tests if a guess is close enough to the real square root" [number guess] (let [diff (- (* guess guess) number)] (if (< (abs diff) 0.001) true false))) (defn sqrt "returns the square root of the supplied number" ([number] (sqrt number 1.0)) ;Clojure实现默认参数真是比较麻烦 ([number guess] (if (good-enough? number guess) guess (sqrt number (avg guess (/ number guess))))))
uses recursion to calculate exponents, 幂操作
(defn power "Calculates a number to the power of a provided exponent." [number exponent] (if (zero? exponent) 1 (* number (power number (- exponent 1)))))
递归其实有两种, 自顶向下, 自底向上
自底向上, 这种递归在imperative语言中, 都是可以简单的用for循环替代的, 设定初值, 然后不断迭代直到达到条件. 这种思路其实用递归来表达反而不很清晰, 用循环来写反而更容易理解, 但对于FP只能用递归来实现.
自顶向下, Xn = function(Xn-1), 如power就是 Xn = X*(Xn-1), 典型的递归思路, 比如迷宫, 二叉树遍历问题. 其实用自顶向下的思路取思考和code更清晰, 也更适合于使用FP.
递归是FP理想的思维和编程方式, 但是理想和现实总是有差距的...
个人觉得, 因为当前的FP都是运行在Turing机上, 而其实Turing机是专为imperative设计的, 所以并无法完全发挥出FP的威力.
递归有个问题是太耗stack空间, 尤其当递归的层数大的时候, 会成为很大的issue, 所以FP在实现递归的时候需要解决这个问题
而对于自顶向下思路, 必须依赖于stack, 无法避免
所以其实只能用自底向上的思路进行设计, 因为对于这种思路其实就是循环, 所以编译器很容易就可以经行优化, 从而不使用stack…
这个其实是很无奈的妥协,
我觉得使用自顶向下的思路, 才是纯正的FP的思路, 而实际上你不能这么写, 其实还是要以循环的思路来写
自底向上优化的方法叫Tail-call optimization, 当递归出现在tail position时, 编译器会将他优化成不耗费stack的iteration, 即for循环.
其实, 我个人觉得这个rule有些难理解, 我的理解不是这样的, 我上面说了递归有两种,
其实也符合这个规则,
对于sqrt, 自底向上思路, 就是循环, 递归点一般都在tail position
而对于自顶向下的思路, 都是基于这样的公式, Xn = function(Xn-1), 如power就是 Xn = X*(Xn-1), 所以递归不可能在tail position
One practical problem with recursion is that, due to the hardware limitations of physical computers, there is a limit on the number of nested functions (the size of the stack).
There is a strict limit on the number of times a function can recur. For small functions, this rarely matters. But if recursion is a generic and complete replacement for loops, it becomes an issue. There are many situations in which it is necessary to iterate or recur indefinitely.
Historically, functional languages resolve this issue through tail-call optimization. Tail-call optimization means that, if certain conditions are met, the compiler can optimize the recursive calls in such a way that they do not consume stack. Under the covers, they’re implemented as iterations in the compiled machine code.
The only requirement for a recursive call to be optimized in most functional languages is that the call occurs in tail position. There are several formal definitions of tail position, but the easiest to remember, and the most important, is that it is the last thing a function does before returning.
Clojure’s recur
对于大部分FP, tail call optimization是由编译器自动完成的, 程序员不必关心这个问题, 所以对于大部分FP是完全没有looping语法, 所有都通过递归来实现.
但是Clojure没有这么做, 这也遭到很多人炮轰, Clojure需要使用recur form(用recur简单的替换函数名)显式来标注tail call optimization.
从某种意义上来说, recur完全没有存在的价值和必要, 编译器完全应该自动完成tail call optimization, Clojure没有这样做, 主要是由于JVM的限制, 很难实现自动优化.
并且这种tail call optimization只能发生在tail position, 如果程序员用错地方, 编译器会complain……
In some functional languages, such as Scheme, tail call optimization happens automatically whenever a recursive call is in tail position.
Clojure does not do this. In order to have tail recursion in Clojure, it is necessary to indicate it explicitly using the recur form.
To use recur, just call it instead of the function name whenever you want to make a recursive call. It will automatically call the containing function with tail-call optimization enabled.
Clojure has come under fire from some quarters for not doing tail-call optimization by default, whenever possible, without the need for the recur special form.
Although the invention of recur was spurred by the limitations of the JVM that make it difficult to do automatic tail optimization, many members of the Clojure community find that having explicit tail recursion is much clearer and more convenient than having it implicitly assumed. With Clojure, you can tell at a glance if a function is tail recursive or not, and it’s impossible to make a mistake.
If something uses recur, it’s guaranteed never to run out of stack space due to recursion. And if you try to use recur somewhere other than in correct tail position, the compiler will complain. You are never left wondering whether a call is actually in tail position or not.
例子, add-up,
(defn add-up
"adds all the numbers below a given limit"
([limit] (add-up limit 0 0 ))
([limit current sum]
(if (< limit current)
sum
(add-up limit (+ 1 current) (+ current sum)))))
最初版本, 使用纯的FP思路, 用递归解决问题...问题出现了, 当递归次数大时(下面例子, 5000次), 会发生栈溢出
user=> (add-up 3) 6 user=> (add-up 500) 125250 user=> (add-up 5000) java.lang.StackOverflowError
Clojure本身不会做任何优化, 你必须自己使用recur替换函数名, 问题解决了, 因为此时不会真正的递归, 而只是iteration
(defn add-up "adds all the numbers up to a limit" ([limit] (add-up limit 0 0 )) ([limit current sum] (if (< limit current) sum (recur limit (+ 1 current) (+ current sum))))) user=> (add-up 5000) 12502500
Using loop
上面说了recur是个没有必要的设计, 或是一种不得已的妥协, 对于loop更是这样, 妥协后的继续妥协
既然只能使用自底向上的思路, 来写FP程序, 而其实这种思路用递归实现, 我觉得是很别扭的, 也许别人也这有觉得, 并且其实编译器最终也会将其优化成循环
所以何必装比, 既然无法用纯FP的思路, 干脆用循环的思路来写还方便些, 所以造出loop…recur, 让大家使用的更方便些, 挺好.
对于loop的好处, 虽然写了一大堆, 两点
更简洁, 不用定义function, 更重要的是简化之前初始条件的初始化的工作
第二就是上面说的, 既然实际是循环, 我们就当循环来写
To define a loop construct, use the loop form. It in turn takes two forms: first, a vector of initial argument bindings (in name/value pairs) and an expression for the body.
(defn add-up "adds all the numbers up to a limit" [limit] (loop [current 0 sum 0] (if (< limit current) sum (recur (+ 1 current) (+ current sum))))) user=> (add-up 5000) 12502500