各類編程語言對尾遞歸的支持

  版權申明:本文爲博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須註明原文網址

  http://www.cnblogs.com/Colin-Cai/p/11774213.html 

  做者:窗戶

  QQ/微信:6679072

  E-mail:6679072@qq.com

  尾遞歸javascript

 

  這篇文章,咱們講尾遞歸。在遞歸中,若是該函數的遞歸形式表如今函數返回的時候,則稱之爲尾遞歸。html

  舉個簡單的例子,用僞碼以下:java

  function Add(a, b)golang

  if a = 0sql

    return b編程

  return Add(a-1, b+1)瀏覽器

  endruby

  

  上面這個函數其實是兩個數的加法,簡單起見,只考慮非負整數,後面敘述具體語言老是會以這個函數爲例子。全部的return部分都是再也不依賴於遞歸,或者是返回Add函數,其參數的計算再也不依賴於遞歸,典型的尾遞歸。微信

  上述代碼很容易用循環表示:編程語言

  

  function Add(a, b)

  while True

    if a = 0

      return b

    end

    a <= a-1

    b <= b+1

  end

  end

 

  全部的尾遞歸均可以用循環表示,只須要把傳入的參數當成是狀態,運算的過程當成是狀態的轉換。

  好比Add(3,0)的計算就通過

  3,0

  2,1

  1,2

  0,3

  這樣的狀態轉換。

  

  函數的計算會維護一個棧,每當遇到函數調用會記錄當前運行的狀態,如此在函數返回的時候能夠恢復上下文。

  好比,對於Fibonacci數列,僞碼以下:

 

  function fib(n)

  if n < 3

    return 1

  end

  return fib(n-1)+fib(n-2)

  end

  

  咱們計算fib(4),棧大體以下:

  fib(4)

  =>

  fib(4)

  fib(3)

  =>

  fib(4)

  fib(3)

  fib(2)

  =>

  fib(4)

  fib(3)

  fib(2) 1

  =>

  f(4)

  f(3) 1+

  =>

  f(4)

  f(3) 1+

  f(1)

  =>

  f(4)

  f(3) 1+

  f(1) 1

  =>

  f(4)

  f(3) 2

  =>

  f(4) 2+

  =>

  f(4) 2+

  f(2)

  =>

  f(4) 2+

  f(2) 1

  =>

  f(4) 3

  =>

  3

  

  而做爲尾遞歸,咱們計算Add(3,0),棧多是以下過程:

  Add(3,0)

  =>

  Add(3,0)

  Add(2,1)

  =>

  Add(3,0)

  Add(2,1)

  Add(1,2)

  =>

  Add(3,0)

  Add(2,1)

  Add(1,2)

  Add(0,3)

  =>

  Add(3,0)

  Add(2,1)

  Add(1,2)

  Add(0,3) 3

  =>

  Add(3,0)

  Add(2,1)

  Add(1,2) 3

  =>

  Add(3,0)

  Add(2,1) 3

  =>

  Add(3,0) 3

  =>

  3

 

  對於Add函數,以上棧的長度與計算量成正比。如此,意味着計算量越大所須要的棧越大,甚至致使超過最大限制而沒法運算。

  同時咱們發現,簡單的轉爲循環表示的Add則沒有這個問題。

  這裏,能夠採用一個編譯技術,就是尾遞歸優化,其通常狀況是,若是一個函數的計算中遇到了徹底轉化成另外一個函數調用的狀況,那麼棧的當前函數部分的信息能夠徹底抹去,而替換爲新的函數。如此處理下,此狀況棧不會增加。

  Add(3,0)的棧的過程以下:

  Add(3,0)

  =>

  Add(2,1)

  =>

  Add(1,2)

  =>

  Add(0,3)

  =>

  3

 

  尾遞歸優化給了咱們一種迭代的方式,之因此研究它,在於函數式編程會用到它。

  注:遞歸論區分遞歸和迭代(迭置),和計算機上定義有一點區別,在此不深刻。

 

C/C++

 

  咱們從底層的語言開始,首先仍是上面的加法實現。爲了讓範圍更大一點,便於觀察,咱們使用unsigned long long類型。

/*add.c*/
unsigned long long add(unsigned long long a, unsigned long long b)
{
        if(a==0ULL)
                return b;
        return add(a-1ULL,b+1ULL);
}

  再寫一個main來測試它,用命令行參數去得到傳入add的兩個參數

#include <stdio.h>
unsigned long long add(unsigned long long a, unsigned long long b);
int main(int argc, char **argv)
{
    unsigned long long a, b;
    sscanf(argv[1], "%llu", &a);
    sscanf(argv[2], "%llu", &b);
    printf("%llu\n", add(a,b));
    return 0;
}

  用gcc編譯,

  gcc add.c main.c -o a.out

  運行一下,

  ./a.out 10000000 100000000

  立刻發生短錯誤,直接崩潰。看來C語言做爲底層語言不必支持這個啊?

  因而咱們開啓優化,

  gcc -O2 add.c main.c -o a.out

  而後運行一下

  ./a.out 10000000000000000 10000000000000000

  當即獲得咱們想要的值而沒有發生崩棧

  20000000000000000

  看來……不對,1億億次迭代瞬間完成?

  objdump反彙編一把,

00000000004006b0 <add>:
  4006b0:       48 8d 04 37             lea    (%rdi,%rsi,1),%rax
  4006b4:       c3                      retq   

  ……原來全被優化了,gcc如今還真強大,直接猜出語義,clang測一把也是如此。

  這個並不是咱們想要的,咱們得用其餘手段去驗證(其實咱們能夠抽出部分優化選項來,但此處講的是驗證思路)。

  此處藉助我在《相互遞歸》中講的奇偶判斷,分三個函數,實現以下,

/*sub1.c*/
unsigned long long sub1(unsigned long long x)
{
        return x - 1ULL;
}
/*is_odd.c*/
unsigned long long sub1(unsigned long long x);
int is_even(unsigned long long x);
int is_odd(unsigned long long x)
{
        if(x == 0ULL)
                return 0;
        return is_even(sub1(x));
}
/*is_even.c*/
unsigned long long sub1(unsigned long long x);
int is_odd(unsigned long long x);
int is_even(unsigned long long x)
{
        if(x == 0ULL)
                return 1;
        return is_odd(sub1(x));
}

  上述函數是單獨編寫,甚至,減1的操做也單獨用一個文件來實現。如此測試的緣由,就在於,咱們要排除掉總體優化的可能。

  還須要寫一個main函數來驗證,

/*main.c*/
#include <stdio.h>
int is_odd(unsigned long long x);
int main(int argc, char **argv)
{
    unsigned long long x;
    sscanf(argv[1], "%llu", &x);
    printf("%llu is %s\n", x, is_odd(x)?"odd":"even");
    return 0;
}

  以上四個文件單獨編譯,開啓-O2優化選項(固然,其實main無所謂)

  for i in sub1.c is_odd.c is_even.c main.c; do gcc -O2 -c $i; done

  而後連接,

  gcc sub1.o is_odd.o is_even.o main.o -o a.out

  而後咱們對一個很大的數來進行測試,

  ./a.out 10000000000

  一下子以後,程序打印出

  10000000000 is even

  以上能夠證實,gcc/clang對於尾遞歸優化支持的挺好。實際上,很早以前大部分C語言編譯器就支持了這點,由於從技術上來看,並非很複雜的事情。而C++也同理。

 

Python

 

  Python實現add以下

def add(a, b):
    if a==0:
        return b
    return add(a-1, b+1)

  計算add(1000,0)就崩棧了,顯然Python的發行是不支持尾遞歸優化的。

  不過這裏棧彷佛小了點,能夠用sys.setrlimit來修改棧的大小,這其實是UNIX-like的系統調用。

  有人用捕捉異常的方式讓其強行支持尾遞歸,效率固然是損失不少的,不過這個想法卻是很好。想起之前RISC大多不支持奇邊界存取值,好比ARM,因而在內核中用中斷處理強行支持奇邊界錯誤,雖然效率低了不少,但邏輯上是經過的。殊途同歸,的確也是一條路,不過我仍是更加指望Python在將來支持尾遞歸優化吧。  

 

JavaScript

 

  依然是用add測試,編寫如下網頁

<input type="text" id="in1" />
<input type="text" id="in2" />
<input type="button" id="bt1" onclick="test()" value="測試"/>
<script type="text/javascript">
function add(a, b)
{
    if (a==0) { 
        return b;
    }
    return add(a-1, b+1);
}

function test()
{
    a = parseInt(document.getElementById("in1").value);
    b = parseInt(document.getElementById("in2").value);
    try {
        alert(add(a,b));
    }
    catch(err) {
        alert('Error');
    }
}
</script>

 

  

  就用1000000和0來測試,沒看到哪一個瀏覽器不跳出Error的……聽說v8引擎作好了,但是人家就不給你用……

 

 

Scheme

 

  而後咱們來看Scheme,按照Scheme的標準一貫強行規定Scheme支持尾遞歸優化。

  咱們實現add函數以下

(define (add a b)
 (if (zero? a) b (add (- a 1) (+ b 1))))

  實現更爲複雜的奇偶判斷

(define (is-odd x)
    (if (zero? x) #f (is_even (- x 1))))
(define (is-even x)
    (if (zero? x) #t (is_odd (- x 1))))

  使用Chez Scheme、Racket、guile測試,使用很大的數來運算,

  而後使用top來觀測程序的內存使用狀況,咱們發現,雖然CPU佔用率多是100%,但內存的使用並不增長。就連guile這樣的一個小的實現都是如此,從而它們都是符合標準而對尾遞歸進行優化的。

 

Common Lisp

 

  測完Scheme,再來測Scheme的本家兄弟,另一種Lisp——Common Lisp

  先用Common Lisp實現add,由於Common Lisp將數據和過程用不一樣的命名空間,致使代碼有點奇怪(彷佛很不數學)

(defun add(a b)
 (if (zerop a) b (funcall #'add (- a 1) (+ b 1))))

  使用clisp來運行

  (add 10000 10000)

  結果就

*** - Program stack overflow. RESET

  由於沒有尾遞歸優化的規定,因此對於那種無限循環,Common Lisp只能選擇迭代才能保證不崩棧,好比使用do。使用do從新實現add以下

(defun add(a b)
 (do
  ((x a (- x 1))
   (y b (+ y 1)))
  ((zerop x) 
y)))

  如此,終於不崩棧了。可是彷佛也改變了Lisp的味道,do顯然此處只能在設計編譯器、解釋器的時候就得單獨實現,雖然按理Lisp下這些都應該是宏,可是不管用宏如何將函數式編程映射爲顯示的迭代,由於尾clisp遞歸優化不支持,則沒法和系統提供的do同樣。

  sbcl是Common Lisp的另一個實現,在這個實現中,咱們使用第一個add函數的版本,沒有發生崩棧。咱們再來實現一下奇偶判斷

(defun is-odd(x) 
 (if (zerop x) '() (funcall #'is-even (- x 1))))
(defun is-even(x)
 (if (zerop x)  t (funcall #'is-odd (- x 1))))

  計算

 (is-even 1000000000)

  過了幾秒,返回告終果t,證實了sbcl對尾遞歸作了優化。也終於給了咱們一個更爲靠譜的Common Lisp的實現。

 

AWK

 

  選擇一種腳本語言來測試這個問題,使用GNU awk來實現add

awk '
function add(a,b)
{
   if(a==0)
       return b
    return add(a-1, b+1)
}
{print add($1, $2)}'

  運行後,用top來觀測內存佔用

  

   

 

  輸入

  100000000 1

  讓其作加法

  

 

 

  內存使用瞬間爆發,直到進程被系統KO。

  話說,awk沒有對尾遞歸優化也屬正常,並且對於內存的使用還真不節制,超過了個人想象。不過這也與語言的目的有關,awk本就沒打算作這類事情。

 

Haskell

 

  直接上以下Haskell程序來描述奇偶判斷

is_even x = if x==0 then True else is_odd (x-1)
is_odd x = if x==0 then False else is_even (x-1)
main = print (is_even 1000000000)

  用ghc編譯運行,輸出True,用時33秒。

  Haskell不虧是號稱純函數式編程,尾遞歸優化無條件支持。

 

Prolog

 

  本不想測prolog,由於首先它並無所謂的函數,靠的是謂詞演化來計算,推理上的優化是其基本需求。尾遞歸本不屬於Prolog的支持範疇,固然能夠構造相似尾遞歸的東西,並且Prolog固然能夠完成,不會有懸念。

  好比咱們實現奇偶判斷以下:

is_even(0, 1).
is_even(X, T) :- M is X-1, is_odd(M, T).
is_odd(0, 0).
is_odd(X, T) :- M is X-1, is_even(M, T).

  查詢

  ?- is_even(100000000,S),write(S),!.

  獲得

  1

 

Erlang

 

  先寫一個model包含add/even/odd三個函數,

-module(mytest).
-export([add/2,even/1,odd/1]).
add(A,B)->if A==0->B;true->add(A-1,B+1) end.
even(X)->if X==0->true;true->odd(X-1) end.
odd(X)->if X==0->false;true->even(X-1) end.

  加載模板,並測試以下

1> c(mytest).
{ok,mytest}
2> mytest:add(1000000000,1000000000).
2000000000
3> mytest:even(1000000000).          
true
4> mytest:odd(1000000000).
false

  顯然,Erlang對尾遞歸支持很好。

 

golang

 

  編寫add的實現以下

package main
import "fmt"
func add(a int, b int) int {
    if (a==0) {
        return b;
    }
    return add(a-1,b+1);
}

func main() {
    fmt.Println(add(100000000, 0))
}

  

  運行

  go run add.go

  立刻崩潰

 

Lua

 

  Lua的做者和JS的做者同樣是Lisp的粉絲,Lua的後期設計(從Lua4)聽說參考了Scheme。

function odd(x)
    if (x==0) then
        return false
    end
    return even(x-1)
end
function even(x)
    if (x==0) then
        return true
    end
    return odd(x-1)
end
print(odd(io.read()))

 

  運行

  echo 1000000000 | lua5.3 x.lua

  過程當中,觀察內存沒有明顯變化,以後打印出了false

  看來,至少參考了Scheme的尾遞歸優化。

 

Ruby

 

  Ruby的做者松本行弘也是Lisp的粉絲,固然,我想大多數編程語言的做者都會是Lisp的粉絲,由於它會給人不少啓發。

  實現奇偶判斷以下:

#!/usr/bin/ruby
 
def odd(x)
    if x == 0
        return 0
    end
    return even(x-1)
end

def even(x)
    if x == 0
        return 1
    end
    return odd(x-1)
end

puts even gets.to_i

 

  然而,數字大一點點,就崩棧了。Ruby並不支持尾遞歸優化。

 

尾聲

 

  測了這些語言以及相應的工具,其實仍是在於函數式編程裏,尾遞歸實現的迭代是咱們常用的手段,編譯器/解釋器的支持就會顯得很重要了。再深一步,咱們會去想一想,編譯器/解釋器此處該如何作,是否能夠對現有的設計進行修改呢?或者,對該語言/工具的將來懷着什麼樣的期待呢?再或者,若是咱們本身也設計一種編程語言,會如何設計這種編程語言呢?……

相關文章
相關標籤/搜索