Bash 中的遞歸函數

作爲 Linux/Unix 系統上內核與用戶之間的接口,shell 由於使用方便、可交互能力強、具有強大的編程能力等特性而受到廣泛的應用。bash(Bourne Again shell)是對 Bourne shell 的擴展,並且綜合了很多 csh 和 Korn Shell 中的優點,使得 bash 具有非常靈活且強大的編程接口,同時又有很友好的用戶界面。bash 所提供的諸如命令補齊、通配符、命令歷史記錄、別名之類的新特性,使其迅速成爲很多用戶的首選。

然而,作爲一種解釋性語言,bash 在編程能力方面提供的支持並不像其他編譯性的語言(例如 C 語言)那樣完善,執行效率也會低很多,這些缺點在編寫函數(尤其是遞歸函數)時都展現的一覽無餘。本文將從經典的 fork 炸彈入手,逐一介紹在 bash 中編寫遞歸函數時需要注意問題,並探討各種問題的解決方案。

儘管本文是以 bash 爲例介紹相關概念,但是類似的思想基本上也適用於其他 shell。

遞歸經典:fork 炸彈

函數在程序設計中是一個非常重要的概念,它可以將程序劃分成一個個功能相對獨立的代碼塊,使代碼的模塊化更好,結構更加清晰,並可以有效地減少程序的代碼量。遞歸函數更是充分提現了這些優點,通過在函數定義中調用自身,可以將複雜的計算問題變成一個簡單的迭代算法,當回溯到邊界條件時,再逐層返回上一層函數。有很多數學問題都非常適合於採用遞歸的思想來設計程序求解,例如階乘、漢諾(hanoi)塔等。

可能很多人都曾經聽說過 fork 炸彈,它實際上只是一個非常簡單的遞歸程序,程序所做的事情只有一樣:不斷 fork 一個新進程。由於程序是遞歸的,如果沒有任何限制,這會導致這個簡單的程序迅速耗盡系統裏面的所有資源。

在 bash 中設計這樣一個 fork 炸彈非常簡單,Jaromil 在 2002 年設計了最爲精簡的一個 fork炸彈的實現,整個程序從函數定義到調用僅僅包含 13 個字符,如清單 1 所示。


清單1. bash 中的 fork 炸彈
                
.(){ .|.& };.

這串字符乍看上去根本就看不出個所以然來,下面讓我們逐一解釋一下它究竟在幹些什麼。爲了解釋方便,我們對清單1中的內容重新設置一下格式,並在前面加上了行號,如清單 2 所示。


清單2. bash 中的 fork 炸彈的解釋
                
  1 .()
  2 {
  3  .|.& 
  4 }
  5 ;
  6 .

  • 第 1 行說明下面要定義一個函數,函數名爲小數點,沒有可選參數。
  • 第 2 行表示函數體開始。
  • 第 3 行是函數體真正要做的事情,首先它遞歸調用本函數,然後利用管道調用一個新進程(它要做的事情也是遞歸調用本函數),並將其放到後臺執行。
  • 第 4 行表示函數體結束。
  • 第 5 行並不會執行什麼操作,在命令行中用來分隔兩個命令用。從總體來看,它表明這段程序包含兩個部分,首先定義了一個函數,然後調用這個函數。
  • 第 6 行表示調用本函數。

對於函數名,大家可能會有所疑惑,小數點也能做函數名使用嗎?畢竟小數點是 shell 的一個內嵌命令,用來在當前 shell 環境中讀取指定文件,並運行其中的命令。實際上的確可以,這取決於 bash 對命令的解釋順序。默認情況下,bash 處於非 POSIX 模式,此時對命令的解釋順序如下:

  • 關鍵字,例如 if、for 等。
  • 別名。別名不能與關鍵字相同,但是可以爲關鍵字定義別名,例如 end=fi。
  • 特殊內嵌命令,例如 break、continue 等。POSIX 定義的特殊內嵌命令包括:.(小數點)、:(冒號)、break、continue、eval、exec、exit、export、readonly、return、set、shift、times、trap 和 unset。bash 又增加了一個特殊的內嵌命令 source。
  • 函數。如果處於非 POSIX 模式,bash 會優先匹配函數,然後再匹配內嵌命令。
  • 非特殊內嵌命令,例如 cd、test 等。
  • 腳本和可執行程序。在 PATH 環境變量指定的目錄中進行搜索,返回第一個匹配項。

由於默認情況下,bash 處於非 POSIX 模式,因此 fork 炸彈中的小數點會優先當成一個函數進行匹配。(實際上,Jaromil 最初的設計並沒有使用小數點,而是使用的冒號,也能起到完全相同的效果。)要使用 POSIX 模式來運行 bash 腳本,可以使用以下三種方法:

  • 使用 --posix 選項啓動 bash。
  • 在運行 bash 之後,執行 set -o posix 命令。
  • 使用 /bin/sh 。

最後一種方法比較有趣,儘管 sh 在大部分系統上是一個指向 bash 的符號鏈接,但是它所啓用的卻是 POSIX 模式,所有的行爲都完全遵守 POSIX 規範。在清單 3 給出的例子中,我們可以發現,小數點在默認 bash 中被解釋成一個函數,能夠正常執行;但是在 sh 中,小數點卻被當作一個內嵌命令,因此調用函數時會被認爲存在語法錯誤,無法正常執行。


清單3. bash 與 sh 對命令匹配順序的區別
                
[root@localhost ~]# ls -l /bin/bash /bin/sh
-rwxr-xr-x 1 root root 735144 2007-08-31 22:20 /bin/bash
lrwxrwxrwx 1 root root      4 2007-12-18 13:26 /bin/sh -> bash
[root@localhost ~]# echo $SHELL
/bin/bash
[root@localhost ~]# .() { echo hello; } ; .
hello
[root@localhost ~]# sh
sh-3.2# echo $SHELL
/bin/bash
sh-3.2# .() { echo hello; } ; .
sh: `.': not a valid identifier
sh: .: filename argument required
.: usage: . filename [arguments]
sh-3.2#

一旦運行清單 1 給出的 fork 炸彈,會以2的指數次冪的速度不斷產生新進程,這會導致系統資源會被迅速耗光,最終除非重新啓動機器,否則基本上就毫無辦法了。爲了防止這會造成太大的損害,我們可以使用 ulimit 限制每個用戶能夠創建的進程數,如清單 4 所示。


清單4. 限制用戶可以創建的進程數
                
[root@localhost ~]# ulimit -u 128
[root@localhost ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
max nice                        (-e) 20
file size               (blocks, -f) unlimited
pending signals                 (-i) unlimited
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) unlimited
max rt priority                 (-r) unlimited
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 128
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited
[root@localhost ~]# .() { .|.& } ; .
[1] 6152
[root@localhost ~]# bash: fork: Resource temporarily unavailable
bash: fork: Resource temporarily unavailable
bash: fork: Resource temporarily unavailable
...

在清單 4 中,我們將用戶可以創建的最大進程數限制爲 128,執行 fork 炸彈會迅速 fork 出大量進程,此後會由於資源不足而無法繼續執行。

fork 炸彈讓我們認識到了遞歸函數的強大功能,同時也意識到一旦使用不當,遞歸函數所造成的破壞將是巨大的。實際上,fork 炸彈只是一個非常簡單的遞歸函數,它並不涉及參數傳遞、返回值等問題,而這些問題在使用 bash 編程時是否有完善的支持呢?下面讓我們通過幾個例子來逐一介紹在 bash 中編寫遞歸函數時應該注意的相關問題。

返回值問題

有一些經典的數學問題,使用遞歸函數來解決都非常方便。階乘就是這樣一個典型的問題,清單 5 給出了一個實現階乘計算的 bash 腳本(當然,除了使用遞歸函數之外,簡單地利用一個循環也可以實現計算階乘的目的,不過本文以此爲例來介紹遞歸函數的相關問題)。


清單5. 階乘函數的 bash 實現
                
[root@localhost shell]# cat -n factorial1.sh
     1  #!/bin/bash
     2
     3  factorial()
     4  {
     5    i=$1
     6
     7    if [ $i -eq 0 ]
     8    then
     9      return 1;
    10    else
    11      factorial `expr $i - 1`
    12      return `expr $i /* $? `
    13    fi
    14  }
    15
    16  if [ -z $1 ]
    17  then
    18    echo "Need one parameter."
    19    exit 1
    20  fi
    21
    22  factorial $1
    23
    24  echo $?
[root@localhost shell]# ./factorial1.sh 5
0

這個腳本看上去並沒有什麼問題:遞歸函數的參數傳遞和普通函數沒什麼不同,返回值是通過獲取 $? 的值實現的,這是利用了執行命令的退出碼。然而,最終的結果卻顯然是錯誤的。調試一下就會發現,當遞歸回溯到盡頭時,變量 i 的值被修改爲 0;而退出上次函數調用之後,變量 i 的新值也被帶了回來,詳細信息如清單 6 所示(請注意黑體部分)。


清單6. 調試 factorial1.sh 的問題
                
[root@localhost shell]# export PS4='+[$FUNCNAME: $LINENO] '
[root@localhost shell]# sh -x factorial1.sh 5
+[: 16] '[' -z 5 ']'
+[: 22] factorial 5
+[factorial: 5] i=5
+[factorial: 7] '[' 5 -eq 0 ']'
++[factorial: 11] expr 5 - 1
+[factorial: 11] factorial 4
+[factorial: 5] i=4
+[factorial: 7] '[' 4 -eq 0 ']'
++[factorial: 11] expr 4 - 1
+[factorial: 11] factorial 3
+[factorial: 5] i=3
+[factorial: 7] '[' 3 -eq 0 ']'
++[factorial: 11] expr 3 - 1
+[factorial: 11] factorial 2
+[factorial: 5] i=2
+[factorial: 7] '[' 2 -eq 0 ']'
++[factorial: 11] expr 2 - 1
+[factorial: 11] factorial 1
+[factorial: 5] i=1
+[factorial: 7] '[' 1 -eq 0 ']'
++[factorial: 11] expr 1 - 1
+[factorial: 11] factorial 0
+[factorial: 5] i=0
+[factorial: 7] '[' 0 -eq 0 ']'
+[factorial: 9] return 1
++[factorial: 12] expr 0 '*' 1
+[factorial: 12] return 0
++[factorial: 12] expr 0 '*' 0
+[factorial: 12] return 0
++[factorial: 12] expr 0 '*' 0
+[factorial: 12] return 0
++[factorial: 12] expr 0 '*' 0
+[factorial: 12] return 0
++[factorial: 12] expr 0 '*' 0
+[factorial: 12] return 0
+[: 24] echo 0
0

這段腳本問題的根源在於變量的作用域:在 shell 腳本中,不管是否在函數中定義,變量默認就是全局的,一旦定義之後,對於此後執行的命令全部可見。bash 也支持局部變量,不過需要使用 local 關鍵字進行顯式地聲明。local 是bash 中的一個內嵌命令,其作用是將變量的作用域設定爲只有對本函數及其子進程可見。局部變量只能在變量聲明的代碼塊中可見,這也就意味着在函數內聲明的局部變量只能在函數代碼塊中才能被訪問,它們並不會污染同名全局變量。因此爲了解決上面這個程序的問題,我們應該使用 local 關鍵字將 i 聲明爲局部變量。修改後的腳本如清單 7 所示。


清單7. 遞歸函數中使用 local 關鍵字聲明局部變量
                
[root@localhost shell]# cat -n factorial2.sh 
     1  #!/bin/bash
     2
     3  factorial()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 0 ]
     8    then
     9      return 1;
    10    else
    11      factorial `expr $i - 1`
    12      return `expr $i /* $? `
    13    fi
    14  }
    15
    16  if [ -z $1 ]
    17  then
    18    echo "Need one parameter."
    19    exit 1
    20  fi
    21
    22  factorial $1
    23
    24  echo $?
[root@localhost shell]# ./factorial2.sh 5
120
[root@localhost shell]# ./factorial2.sh 6
                208
            

這下 5 的階乘計算對了,但是稍微大一點的數字都會出錯,比如 6 的階乘計算出來是錯誤的 208。這個問題的原因在於腳本中傳遞函數返回值的方式存在缺陷,$? 所能傳遞的最大值是 255,超過該值就沒有辦法利用這種方式來傳遞返回值了。解決這個問題的方法有兩種,一種是利用全局變量,另外一種則是利用其他方式進行週轉(例如標準輸入輸出設備)。清單 8 和清單 9 分別給出了這兩種方法的參考實現。


清單8. 使用全局變量傳遞返回值
                
[root@localhost shell]# cat -n factorial3.sh 
     1  #!/bin/bash
     2
     3  factorial()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 0 ]
     8    then
     9      rtn=1
    10    else
    11      factorial `expr $i - 1`
    12      rtn=`expr $i /* $rtn `
    13    fi
    14
    15    return $rtn
    16  }
    17
    18  if [ -z $1 ]
    19  then
    20    echo "Need one parameter."
    21    exit 1
    22  fi
    23
    24  factorial $1
    25
    26  echo $rtn
[root@localhost shell]# ./factorial3.sh 6
720


清單9. 利用標準輸入輸出設備傳遞返回值
                
[root@localhost shell]# cat -n factorial4.sh 
     1  #!/bin/bash
     2
     3  factorial()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 0 ]
     8    then
     9      echo 1
    10    else
    11      local j=`expr $i - 1`
    12      local k=`factorial $j` 
    13      echo `expr $i /* $k `
    14    fi
    15  }
    16
    17  if [ -z $1 ]
    18  then
    19    echo "Need one parameter."
    20    exit 1
    21  fi
    22
    23  rtn=`factorial $1`
    24  echo $rtn
[root@localhost shell]# ./factorial4.sh 6
720

儘管利用全局變量或標準輸入輸出設備都可以解決如何正確傳遞返回值的問題,但是它們卻各有缺點:如果利用全局變量,由於全局變量對此後的程序全部可見,一旦被其他程序修改,就會出錯,所以編寫代碼時需要格外小心,特別是在編寫複雜的遞歸程序的時候;如果利用標準輸入輸出設備,那麼遞歸函數中就存在諸多限制,例如任何地方都不能再向標準輸出設備中打印內容,否則就可能被上一層調用當作正常輸出結果讀走了,另外速度方面也可能存在嚴重問題。

參數傳遞問題

在設計函數時,除了返回值之外,我們可能還希望所調用的函數還能夠返回其他一些信息。例如,在上面的階乘遞歸函數中,我們除了希望計算最後的結果之外,還希望瞭解這個函數一共被調用了多少次。熟悉 c 語言之類的讀者都會清楚,這可以通過傳遞一個指針類型的參數實現。然而,在 bash 中並不支持指針,它提供了另外一種在解釋性語言中常見的設計:間接變量引用(indirect variable reference)。讓我們看一下下面這個例子:

var2=$var3
var1=$var2

其中變量 var2 的存在實際上就是爲了讓 var1 能夠訪問 var3,實際上也可以通過 var1 直接引用 var3 的值,方法是 var1=/$$var3(請注意轉義字符是必須的,否則 $$ 符號會被解釋爲當前進程的進程 ID 號),這種方式就稱爲間接變量引用。從 bash2 開始,對間接變量引入了一種更爲清晰的語法,方法是 var1=${!var3}。

清單 10 中給出了使用間接變量引用來統計階乘函數被調用次數的實現。


清單10. 利用間接變量引用統計遞歸函數的調用次數
                
[root@localhost shell]# cat -n depth.sh 
     1  #!/bin/bash
     2
     3  factorial()
     4  {
     5    local i=$1
     6    local l=$2
     7
     8    if [ $i -eq 0 ]
     9    then
    10      eval ${l}=1
    11      rtn=1
    12    else
    13      factorial `expr $i - 1` ${l}
    14      rtn=`expr $i /* $rtn `
    15      
    16      local k=${!l}
    17      eval ${l}=`expr ${k} + 1`
    18    fi
    19
    20    return $rtn
    21  }
    22
    23  if [ -z $1 ]
    24  then
    25    echo "Need one parameter."
    26    exit 1
    27  fi
    28
    29  level=0
    30  factorial $1 level
    31
    32  echo "The factorial of $1 is : $rtn"
    33  echo " the function of factorial is invoked $level times."
[root@localhost shell]# ./depth.sh 6
The factorial of 6 is : 720
 the function of factorial is invoked 7 times.

在上面我們曾經介紹過,爲了解決變量作用域和函數返回值的問題,在遞歸函數中我們使用 local 聲明局部變量,並採用全局變量來傳遞返回值。但是隨着調用關係變得更加複雜,全局變量的值有可能在其他地方被錯誤地修改。實際上,使用局部變量也存在一個問題,下面讓我們來看一下清單 11 中給出的例子。


清單11. 查找字符串在文件中是否存在,並計算所在行數和出現次數
                
[root@localhost shell]# cat -n getline1.sh 
     1  #!/bin/bash
     2
     3  GetLine()
     4  {
     5    string=$1
     6    file=$2
     7
     8    line=`grep -n $string $file`
     9    if [ $? -eq 0 ]
    10    then
    11      printf "$string is found as the %drd line in $file /n" `echo $line /
                         | cut -f1 -d:`
    12      num=`grep $string $file | wc -l`
    13      rtn=0
    14    else
    15      printf "$string is not found in $file /n"
    16      num=0
    17      rtn=1
    18    fi
    19
    20    return $rtn;
    21  }
    22
    23  if [ ! -f testfile.$$ ]
    24  then
    25    cat >> testfile.$$ <<EOF
    26  first line .
    27  second line ..
    28  third line ...
    29  EOF
    30  fi
    31
    32  num=0
    33  rtn=0
    34  for i in "second" "six" "line"
    35  do
    36    echo
    37    GetLine $i testfile.$$
    38    echo "return value: $rtn"
    39
    40    if [ $num -gt 0 ]
    41    then
    42      echo "$num occurences found totally."
    43    fi
    44  done
[root@localhost shell]# ./getline1.sh 

second is found as the 2rd line in testfile.4280 
return value: 0
1 occurences found totally.

six is not found in testfile.4280 
return value: 1

line is found as the 1rd line in testfile.4280 
return value: 0
3 occurences found totally.
[root@localhost shell]#

這段程序的目的是查找某個字符串在指定文件中是否存在,如果存在,就計算第一次出現的行數和總共出現的次數。爲了說明局部變量和後面提到的子函數的問題,我們故意將對出現次數的打印也放到了 GetLine 函數之外進行處理。清單 11 中全部使用全局變量,並沒有出現什麼問題。下面讓我們來看一下將 GetLine 中使用的局部變量改用 local 聲明後會出現什麼問題,修改後的代碼和執行結果如清單 12 所示。


清單12. 使用 local 聲明局部變量需要注意的問題
                
[root@localhost shell]# cat -n getline2.sh 
     1  #!/bin/bash
     2
     3  GetLine()
     4  {
     5    local string=$1
     6    local file=$2
     7
     8    local line=`grep -n $string $file`
     9    if [ $? -eq 0 ]
    10    then
    11      printf "$string is found as the %drd line in $file /n" `echo $line /
                         | cut -f1 -d:`
    12      num=`grep $string $file | wc -l`
    13      rtn=0
    14    else
    15      printf "$string is not found in $file /n"
    16      num=0
    17      rtn=1
    18    fi
    19
    20    return $rtn;
    21  }
    22
    23  if [ ! -f testfile.$$ ]
    24  then
    25    cat >> testfile.$$ <<EOF
    26  first line .
    27  second line ..
    28  third line ...
    29  EOF
    30  fi
    31
    32  num=0
    33  rtn=0
    34  for i in "second" "six" "line"
    35  do
    36    echo
    37    GetLine $i testfile.$$
    38    echo "return value: $rtn"
    39
    40    if [ $num -gt 0 ]
    41    then
    42      echo "$num occurences found totally."
    43    fi
    44  done
[root@localhost shell]# ./getline2.sh 

second is found as the 2rd line in testfile.4300 
return value: 0
1 occurences found totally.

six is found as the 0rd line in testfile.4300 
                return value: 0

line is found as the 1rd line in testfile.4300 
return value: 0
3 occurences found totally.

清單 12 的運行結果顯示,在文件中搜索 six 關鍵字時的結果是錯誤的,調試會發現,問題的原因在於:第 8 行使用 local 將 line 聲明爲局部變量,並將 grep 命令的執行結果賦值給 line 變量。然而不論 grep 是否成功在文件中找到匹配項(grep 程序找到匹配項返回值爲 0,否則返回值爲 1),第 9 行中 $? 的值總是 0。實際上,第 8 行相當於執行了兩條語句:第一條語句使用 grep 在文件中查找匹配項,第二條語句將 grep 命令的結果賦值給變量 line,並設定其作用域只對於本函數及其子進程可見。因此第 9 行命令中 $? 的值實際上是執行 local 命令的返回值,不管 grep 命令的結果如何,它總是 0。

要解決這個問題,可以將第 8 行的命令拆分開,首先使用單獨一行將變量 line 聲明爲 local的,然後再執行這條 grep 命令,並將結果賦值給變量 line(此時前面不能加上 local)。

解決變量作用域的另外一種方法是使用子 shell。所謂子 shell 是在當前 shell 環境中啓動一個子 shell 來執行所調用的命令或函數,這個函數中所聲明的所有變量都是局部變量,它們不會污染原有 shell 的名字空間。清單 13 給出了使用子 shell 修改後的例子。


清單13. 利用子 shell 實現局部變量
                
[root@localhost shell]# cat -n getline3.sh 
     1  #!/bin/bash
     2
     3  GetLine()
     4  {
     5    string=$1
     6    file=$2
     7
     8    line=`grep -n $string $file`
     9    if [ $? -eq 0 ]
    10    then
    11      printf "$string is found as the %drd line in $file /n" `echo $line  /
                     | cut -f1 -d:`
    12      num=`grep $string $file | wc -l`
    13      rtn=0
    14    else
    15      printf "$string is not found in $file /n"
    16      num=0
    17      rtn=1
    18    fi
    19
    20    return $rtn;
    21  }
    22
    23  if [ ! -f testfile.$$ ]
    24  then
    25    cat >> testfile.$$ <<EOF
    26  first line .
    27  second line ..
    28  third line ...
    29  EOF
    30  fi
    31
    32  num=0
    33  rtn=0
    34  for i in "second" "six" "line"
    35  do
    36    echo
    37    (GetLine $i testfile.$$)
    38    echo "return value: $? (rtn = $rtn)"
    39
    40    if [ $num -gt 0 ]
    41    then
    42      echo "$num occurences found totally."
    43    fi
    44  done
[root@localhost shell]# ./getline3.sh 

second is found as the 2rd line in testfile.4534 
return value: 0 (rtn = 0)

six is not found in testfile.4534 
return value: 1 (rtn = 0)

line is found as the 1rd line in testfile.4534 
return value: 0 (rtn = 0)

在清單 13 中,GetLine 函數並不需要任何變化,變量定義和程序調用都沿用正常方式。唯一的區別在於調用該函數時,要將其作爲一個子 shell 來調用(請注意第 37 行兩邊的圓括號)。另外一個問題是在子 shell 中修改的所有變量對於原有 shell 來說都是不可見的,這也就是爲什麼在第 38 行要通過 $? 來檢查返回值,而 rtn 變量的值卻是錯誤的。另外由於 num 在 GetLine 函數中也被當作是局部變量,同樣無法將修改後的值傳出來,因此也並沒有打印所匹配到的 line 的數目是 3 行的信息。

解決上面這個問題就只能使用前面提到的利用標準輸入輸出設備的方法了,否則即使使用間接變量引用也無法正常工作。清單 14 給出了一個使用間接變量引用的例子,儘管我們使用不同的名字來命名全局變量和局部變量,從而確保不會引起同名混淆,但是依然無法正常工作。原因同樣在於 GetLine 函數是在另外一個子進程中運行的,它對變量所做的更新隨着子 shell 的退出就消失了。


清單14. 利用間接變量索引也無法解決子 shell 通過變量回傳值的問題
                
[root@localhost shell]# cat -n getline4.sh 
     1  #!/bin/bash
     2
     3  GetLine()
     4  {
     5    string=$1
     6    file=$2
     7    num=$3
     8    rtn=$4
     9
    10    line=`grep -n $string $file`
    11    if [ $? -eq 0 ]
    12    then
    13      printf "$string is found as the %drd line in $file /n"  /
                       `echo $line | cut -f1 -d:`
    14      eval ${num}=`grep $string $file | wc -l`
    15      eval ${rtn}=0
    16    else
    17      printf "$string is not found in $file /n"
    18      eval ${num}=0
    19      eval ${rtn}=1
    20    fi
    21
    22    return ${!rtn};
    23  }
    24
    25  if [ ! -f testfile.$$ ]
    26  then
    27    cat >> testfile.$$ <<EOF
    28  first line .
    29  second line ..
    30  third line ...
    31  EOF
    32  fi
    33
    34  g_num=0
    35  g_rtn=0
    36  for i in "second" "six" "line"
    37  do
    38    echo
    39    (GetLine $i testfile.$$ g_num g_rtn)
    40    echo "return value: $? (g_rtn = $g_rtn)"
    41
    42    if [ $g_num -gt 0 ]
    43    then
    44      echo "$g_num occurence(s) found totally."
    45    fi
    46  done
[root@localhost shell]# ./getline4.sh 

second is found as the 2rd line in testfile.4576 
return value: 0 (g_rtn = 0)

six is not found in testfile.4576 
return value: 1 (g_rtn = 0)

line is found as the 1rd line in testfile.4576 
return value: 0 (g_rtn = 0)

性能問題

儘管編寫 bash 腳本可以實現遞歸函數,但是由於先天性的不足,使用 bash 腳本編寫的遞歸函數的性能都比較差,問題的根本在於它的主要流程都是要不斷地調用其他程序,這會 fork 出很多進程,從而極大地增加運行時的開銷。下面讓我們來看一個計算累加和的例子,清單 15 和清單 16 給出了兩個實現,它們分別利用全局變量和標準輸入輸出設備來傳遞返回值。爲了簡單起見,我們也不對輸入參數進行任何判斷。


清單15. 累加和,利用全局變量傳遞返回值
                
[root@localhost shell]# cat -n sum1.sh 
     1  #!/bin/bash
     2
     3  sum()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 1 ]
     8    then
     9      rtn=1
    10    else
    11      sum `expr $i - 1`
    12      rtn=`expr $i + $rtn `
    13    fi
    14
    15    return $rtn
    16  }
    17
    18  if [ -z $1 ]
    19  then
    20    echo "Need one parameter."
    21    exit 1
    22  fi
    23
    24  sum $1
    25
    26  echo $rtn


清單16. 累加和,利用標準輸入輸出設備傳遞返回值
                
[root@localhost shell]# cat -n sum2.sh 
     1  #!/bin/bash
     2
     3  sum()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 1 ]
     8    then
     9      echo 1
    10    else
    11      local j=`expr $i - 1`
    12      local k=`sum $j` 
    13      echo `expr $i + $k `
    14    fi
    15  }
    16
    17  if [ -z $1 ]
    18  then
    19    echo "Need one parameter."
    20    exit 1
    21  fi
    22
    23  rtn=`sum $1`
    24  echo $rtn

下面讓我們來測試一下這兩個實現的性能會有多大的差距:


清單17. 利用全局變量和標準輸入輸出設備傳遞返回值的性能比較
                
[root@localhost shell]# cat -n run.sh 
     1  #!/bin/bash
     2
     3  if [ $# -lt 2 ] 
     4  then
     5    echo "Usage: $0 [number] [executable list]" 
     6    exit 1
     7  fi
     8
     9  NUM=$1
    10  shift
    11
    12  for i in $*
    13  do
    14    echo "Running command: $i $NUM"
    15    time ./$i $NUM
    16
    17    sleep 5
    18    echo
    19  done
[root@localhost shell]# ./run.sh 500 sum1.sh sum2.sh 
Running command: sum1.sh 500
125250

real    0m8.336s
user    0m0.532s
sys     0m7.772s

Running command: sum2.sh 500
125250

real    0m20.775s
user    0m1.316s
sys     0m17.741s



在計算 1 到 500 的累加和時,利用標準輸入輸出設備傳遞返回值的方法速度要比利用全局變量慢 1 倍以上。隨着迭代次數的增加,二者的差距也會越來越大,主要原因標準輸入輸出設備都是字符設備,從中讀寫數據耗時會很長;而全局變量則是在內存中進行操作的,速度會明顯快很多。

爲了提高 shell 腳本的性能,在編寫 shell 腳本時,應該儘量多使用 shell 的內嵌命令,而不能過多地調用外部腳本或命令,因爲調用內嵌命令時不會 fork 新的進程,而是在當前 shell 環境中直接執行這些命令,這樣可以減少很多系統開銷。以計算表達式的值爲例,前面的例子我們都是通過調用 expr 來對表達式進行求值的,但是 bash 中提供了一些內嵌的計算表達式手段,例如 ((i = $j + $k)) 與 i=`expr $j + $k` 的效果就是完全相同的,都是計算變量 j 與 k 的值,並將結果賦值給變量 i,但是前者卻節省了一次 fork 新進程以及執行 expr 命令的開銷。下面讓我們對清單 15 中的腳本進行一下優化,如清單 18 所示。


清單18. 優化後的計算累加和的腳本
                
[root@localhost shell]# cat -n sum3.sh 
     1  #!/bin/bash
     2
     3  sum()
     4  {
     5    local i=$1
     6
     7    if [ $i -eq 1 ]
     8    then
     9      rtn=1
    10    else
    11      sum $(($i - 1))
    12      ((rtn = rtn + i))
    13    fi
    14
    15    return $rtn
    16  }
    17
    18  sum $1
    19
    20  echo $rtn

現在讓我們來比較一下優化前後的性能差距,如清單 19 所示。


清單19. 優化前後的性能對比
                
[root@localhost shell]# ./run.sh 2000 sum1.sh sum3.sh 
Running command: sum1.sh 2000
2001000

real    1m19.378s
user    0m15.877s
sys     1m3.472s

Running command: sum3.sh 2000
2001000

real    0m12.202s
user    0m10.949s
sys     0m1.220s

可以看出,在迭代 2000 次時,優化後的腳本速度要比優化前快 5 倍以上。但是無論如何,使用 shell 腳本編寫的遞歸函數的執行效率都不高,c 語言的實現與其相比,快了可能都不止一個數量級,詳細數據請參看清單 20。


清單20. c 語言與 bash 腳本實現遞歸函數的對比
                
[root@localhost shell]# cat -n sum.c 
     1  #include <stdlib.h>
     2  #include <stdio.h>
     3
     4  int sum(int i)
     5  {
     6    if (i == 1)
     7      return 1;
     8    else
     9      return i + sum(i-1);
    10  }
    11
    12  int main(int argc, char **argv[])
    13  {
    14    printf("%d/n", sum(atoi((char *)argv[1])));
    15  }
[root@localhost shell]# gcc -O2 -o sum sum.c

[root@localhost shell]# ./run.sh 3000 sum sum3.sh 
Running command: sum 3000
4501500

real    0m0.004s
user    0m0.000s
sys     0m0.004s

Running command: sum3.sh 3000
4501500

real    0m31.182s
user    0m28.998s
sys     0m2.004s

因此,如果編寫對性能要求很高的遞歸程序,還是選擇其他語言實現好了,這並不是 shell 的強項。

小結

本文從經典的 fork 炸彈遞歸函數入手,逐一介紹了在 bash 中編寫遞歸函數時需要注意的問題,包括返回值、參數傳遞和性能等方面的問題以及解決方法,並對如何提高 shell 腳本性能提供了一個建議。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章