我承認,我再一次地當了標題黨。但是不可否認,這一定是一篇精華隨筆。在這一篇中,我將探討 Bash 腳本語言中的美學與哲學。 這不是一篇 Bash 腳本編程的教程,但是卻能讓人更加深入地了解 Bash 腳本編程,更加快速地學習 Bash 腳本編程。 閱讀這篇隨筆,不需要你有 Bash 編程的經驗,但一定要和我一樣熱衷於探索各種編程語言的本質,感悟它們的魅力。
其實早就想寫關於 Bash 的東西了。 我們平時喜歡對編程語言進行分類,比如面向過程的編程語言、面向對象的編程語言、函數式編程語言等等。在我心中,我認為 Bash 就是一個面向字符串的編程語言。Bash 腳本語言的本質:一切皆是字符串。 Bash 腳本語言的一切哲學都圍繞着字符串:它們從哪里來?到哪里去?使命是什么? Bash 腳本語言的一切美學都源自字符串: 由鍵盤上幾乎所有的符號 “$ ~ ! # & ( ) [ ] { } | > < - . , ; * @ ' " ` \ ^” 排列組合而成的極富視覺沖擊力的、功能極其復雜的字符串。
一、一切皆是字符串
Bash 是一個 Shell,Shell 出現的初衷是為了將系統中的各種工具粘合在一起,所以它最根本的功能是調用各種命令。 但是 Bash 又提供了豐富的編程功能。 我們經常對編程語言進行分類,比如面向過程的語言、面向對象的語言、面向函數的語言等等。 可以把 Bash 腳本語言看成是一個面向字符串的語言。 Bash 語言的本質就是:一切都是字符串。 看看下圖中的這些變量:
上圖是我在交互式的 Bash 命令行中做的一些演示。在上圖中,我對變量分別賦值,不管等號右邊是一個沒有引號的字符串,還是帶有引號的字符串,甚至數字,或者數學表達式,最終的結果,變量里面存儲的都是字符串。我使用一個 for 循環顯示所有的變量,可以看到數學表達式也只是以字符串的形式儲存,沒有被求值。
二、引用和元字符
如果一切都是沒有特殊功能的平凡的字符串,那就無法構成一門編程語言。在 Bash 中,有很多符號具有特殊含義,比如“$”符號被用於字符串展開,“&”符號用於讓命令在后台執行, “|”用作管道, “>” “<”用於輸入輸出重定向等等。所以在 Bash 中,雖然同樣是字符串,但是被引號包圍的字符串和不被引號包圍的字符串使用起來是不一樣的,被單引號包圍的字符串和被雙引號包圍起來的字符串也是不一樣的。
究竟帶引號的字符串和不帶引號的字符串使用起來有什么不一樣呢?下圖是我構建的一些比較典型的例子:
在上圖中,我展示了 Bash 中生成字符串的 7 種方法:大括號展開、波浪符展開、參數展開、命令替換、算術展開、單詞分割和文件路徑展開。還有兩種生成字符串的方式沒有講(Process substitution 和歷史命令展開)。在使用 Bash 腳本編程的時候,了解以上 7 種字符串生成的方式就夠了。在交互式使用 Bash 命令行的時候,還需要了解歷史命令展開,熟練使用歷史命令展開可以讓人事半功倍。
在上面的圖片中可以看到,有一些展開方式在被雙引號包圍的字符串中是不起作用的,比如大括號展開、波浪符展開、單詞分割、文件路徑展開,而只有參數展開、命令替換和算術展開是起作用的。從圖片中還可以看出,字符串中的參數展開、命令替換和算術展開都是由“$”符號引導,命令替換還可以由“`”引導。所以,可以進一步總結為,在雙引號包圍的字符串中,只有“$、`、\”這三個字符具有特殊含義。
如果想讓任何一個字符都不具有特殊含義,可以使用單引號將字符串包圍。比如使用正則表達式的時候,還比如使用 sed、awk 等工具的時候,由於 sed 和 awk 自己執行的命令中往往包含有很多特殊字符,所以它們的命令最好用單引號包圍。 比如使用 awk 命令顯示 /etc/passwd 文件中的每個用戶的用戶名和全名,可以使用這個命令 awk -e '{print $1,$5}' ,其中,傳遞給 awk 的命令用單引號包圍,說明 bash 不執行其中的任何替換或展開。
另外一個特殊的字符是“\”,它也是引用的一種。它可以解除緊跟在它后面的一個特殊字符的特殊含義(引用)。之所以需要“\”的存在,是因為在 Bash 中,有些字符稱為元字符,這些字符一旦出現,就會將一個字符串分割為多個子串。如果需要在一個字符串中包含這些元字符本身,就必須對它們進行引用。如下圖:
最常見的元字符就是空格。 從上面幾張圖片可以看出,如果要將一個含有空格的字符串賦值給一個變量,要么把這個字符串用雙引號包圍,要么使用“\”對空格進行引用。 從上圖中可以看出,Bash 中只有9個元字符,它們分別是“| & ( ) ; < > space tab”,而在其它編程語言中經常出現的元字符“. { } [ ]”以及作為數學運算的加減乘除,在 Bash 中都不是元字符。
三、字符串從哪里來、到哪里去
介紹完字符串、介紹完引用和元字符,下一個目標就是來探討這一個哲學問題:字符串從哪里來、到哪里去?通過該哲學問題的探討,可以推導出 Bash 腳本語言的整個語法。字符串從哪里來?很顯然,其中一個很直接的來源就是我們從鍵盤上敲上去的。除此之外,就是我前面提到的七八九種字符串展開的方法了。
字符串展開的流程如下:
1.先用元字符將一個字符串分割為多個子串;
2.如果字符串是用來給變量賦值,則不管它是否被雙引號包圍,都認為它被雙引號包圍;
3.如果字符串不被單引號和雙引號包圍,則進行大括號展開,即將 {a,b}c 展開為 ab ac;
以上三個流程可以通過下圖證明:
4.如果字符串不被單引號或雙引號包圍,則進行波浪符展開,即將 ~/ 展開為用戶的主目錄,將 ~+/ 展開為當前工作目錄(PWD),將 ~-/ 展開為上一個工作目錄(OLDPWD);
5.如果字符串不被單引號包圍,則進行參數和變量展開;這一類的展開全都以“$”開頭,這是整個 Bash 字符串展開中最復雜的,其中包括用戶定義的變量,包括所有的環境變量,以上兩種展開方式都是“$”后跟變量名,還包括位置變量“$1、 $2、 ...、 $9、 ... ”,其它特殊變量:“$@、 $*、 $#、 $-、 $!、 $0、 $?、 $_ ”,甚至還有數組:“${var[i]}”, 還可以在展開的過程中對字符串進行各種復雜的操作,如:“ ${parameter:-word}、 ${parameter:=word}、 ${parameter:+word}、 ;${parameter:?word}、 ${parameter:offset}、 ${parameter:offset:length}、 ${!prefix*}、 ${!prefix@}、 ${name[@]}、 ${!name[*]}、 ${#parameter}、 ${parameter#word}、 ${parameter##word}、 ${parameter%word}、 ${parameter%%word}、 ${parameter/pattern/string}、 ${parameter^pattern}、 ${parameter^^pattern}、 ${parameter,pattern}、 ${parameter,,pattern}”;
6.如果字符串不被單引號包圍,則進行命令替換;命令替換有兩種格式,一種是 $(...),一種是 `...`;也就是將命令的輸出作為字符串的內容;
7.如果字符串不被單引號包圍,則進行算術展開;算術展開的格式為 $((...));
8.如果字符串不被單引號或雙引號包圍,則進行單詞分割;
9.如果字符串不被單引號或雙引號包圍,則進行文件路徑展開;
10.以上流程全部完成后,最后去掉字符串外面的引號(如果有的話)。以上流程只按以上順序進行一遍。比如不會在變量展開后再進行大括號展開,更不會在第 10 步去除引用后執行前面的任何一步。如果需要將流程再走一遍,請使用 eval。
探討完了字符串從哪里來,下面來看看字符串到哪里去。也就是怎么使用這些字符串。使用字符串有以下幾種方式:
1.把它當命令執行;這是 Bash 中的最根本的用法,畢竟 Shell 的存在就是為了粘合各種命令。如果一個字符串出現在本該命令出現的地方(比如一行的開頭,或者關鍵字 then、do 等的后面),它將會被當成命令執行,如果它不是個合法的命令,就會報錯;
2.把它當成表達式;Bash 中本沒有表達式,但是有了 ((...)) 和 [[...]],就有了表達式;((...)) 可以把它里面的字符串當成算術表達式,而 [[...]] 會把它里面的字符串當邏輯表達式,僅此兩個特例;
3.給變量賦值;這也是一個特例,有點破壞 Bash 編程語言語法哲學的完整性。為什么這么說呢?因為“=”即不是一個元字符,也不允許兩邊有空格,而且只有第 1 個等號會被當成賦值運算符。
下面圖片為以上觀點給出證據:
四、再加上一點點的定義,就可以推導出整個 Bash 腳本語言的語法了
前面我已經展示了我對字符串從哪里來、到哪里去這個問題的理解。關於字符串的去向,除了兩個表達式和一個為變量賦值這三個特例,剩下的就只有當命令來執行了。在前面,我提到了元字符和引用的概念,這里,還得再增加一點點定義:
定義1:控制操作符(Control Operator) 前面提到元字符是為了把一個字符串分割為多個子串,而控制操作符就是為了把一系列的字符串分割成多個命令。舉例說明,在 Bash中,一個字符串 cat /etc/passwd 就是一個命令,第一個單詞 cat 是命令,第 2 個單詞 /etc/passwd 是命令的參數,而字符串 cat /etc/passwd | grep youxia 就是兩個命令,這兩個命令分別是 cat 和 grep,它們之間通過“|”分割,所以這里的“|”是控制操作符。熟悉 Shell 的朋友肯定知道“|”代表的是管道,所以它的作用是:1.把一個字符串分割為兩個命令,2.將第一個命令的輸出作為第二個命令的輸入。在 Bash 中,總共只有 10 個控制操作符,它們分別是“|| & && | ; ;; ( ) |& <newline>”。只要看到這些控制操作符,就可以認為它前面的字符串是一個完整的命令。
定義2:關鍵字(Reserved Words) 我沒有將其翻譯成保留字,很顯然,作為編程語言來說,它們應該叫做關鍵字。一門編程語言肯定必須得提供選擇、循環等流程控制語句,還得提供定義函數的功能。這些功能只能通過關鍵字實現。在 Bash 中,只有 22 個關鍵字,它們是“! case coproc do done elif else esac fi for function if in select then until while { } time [[ ]]”。這其中有不少的特別之處,比如“! { } [[ ]]”等符號都是關鍵字,也就是說它們當關鍵字使用時相當於一個單詞,也就是說它們和別的單詞必須以元字符分開(否則無法成為獨立的單詞)。這也是為什么在 Bash 中使用“! { } [[ ]]”時經常要在它們周圍留空格的原因。(再一次證明“=”是一個很變態的特例,因為它既不是元字符,也不是控制操作符,更加不是關鍵字,它到底是什么?)
下面開始推導 Bash 腳本語言的語法:
推導1:簡單命令(Simple command) 就是一條簡單的命令,它可以是一個以上述控制操作符結尾的字符串。比如單獨放在一行的 uname -r 命令(單獨放在一行的命令其實是以<newline>結尾,<newline>是控制操作符),或者雖然不單獨放在一行,但是以“;”或“&”結尾,比如 uname -r; who; pwd; gvim& 其中每一個命令都是一個簡單命令(當然,這四個命令放在一起的這行代碼不叫簡單命令),“;”就是簡單地分割命令,而“&”還有讓命令在后台執行的功能。這里比較特殊的是雙分號“;;”,它只用在 case 語句中。
推導2:管道(Pipe Line) 管道是 Shell 中的精髓,就是讓前一個命令的輸出成為后一個命令的輸入。管道的完整語法是這樣 [time [-p]] [ ! ] command1 | command2 或這樣 [time [-p]] [ ! ] command1 |& command2 的。其中 time 關鍵字和 ! 關鍵字都是可選的(使用[...]指出哪些部分是可選的),time 關鍵字可以計算命令運行的時間,而 ! 關鍵字是將命令的返回狀態取反。看清楚 ! 關鍵字周圍的空格哦。如果使用“|”,就是把第一個命令的標准輸出作為第二個命令的標准輸入,如果使用“|&”,則將第一個命令的標准輸出和標准錯誤輸出都當成第二個命令的輸入。
推導3:命令序列(List) 如果多個簡單命令或多個管道放在一起,它們之間以“; & <newline> || &&”等控制操作符分開,就稱之為一個命令序列。關於“; & <newline>”前面已經講過了,無需重復。關於“||”和“&&”,熟悉 C、C++、Java 等編程語言的朋友們肯定也不會陌生,它們遵循同樣的短路求值的思想。比如 command1 || command2 只有當 command1 執行不成功的時候才執行 command2,而 command1 && command2 只有當 command1 執行成功的時候才執行 command2。
推導4:復合命令(Compound Commands) 如果將前面的簡單命令、管道或者命令序列以更復雜的方式組合在一起,就可以構成復合命令。在 Bash 中,有 4 種形式的復合命令,它們分別是 (list) 、 { list; } 、 ((expression)) 、 [[ expression ]] 。請注意第 2 種形式和第 4 種形式大括號和中括號周圍的空格,也請注意第 2 種形式中 list 后面的“;”,不過如果“}”另起一行,則不需要“;”,因為<newline>和“;”是起同樣作用的。在以上4種復合命令中, (list) 是在一個新的Shell中執行命令序列,這些命令的執行不會影響當前Shell的環境變量,而 { list; } 只是簡單地將命令序列分組。后面兩種表達式求值前面已經講過,這里就不講了。后面可能會詳細列出邏輯表達式求值的選項。
上面的4步推導是一步更進一步的,是由簡單逐漸到復雜的,最簡單的命令可以組合成稍復雜的管道,再組合成更復雜的命令序列,最后組成最復雜的復合命令。
下面是 Bash 腳本語言的流程控制語句,如下:
1. for name [ [ in [ word ... ] ] ; ] do list ; done ;
2. for (( expr1 ; expr2 ; expr3 )) ; do list ; done ;
3. select name [ in word ] ; do list ; done ;
4. case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac ;
5. if list; then list; [ elif list; then list; ] ... [ else list; ] fi ;
6. while list-1; do list-2; done ;
7. until list-1; do list-2; done 。
上面的公式大家看得懂吧,我相信大家肯定看得懂。其中的 [...] 代表的是可以有也可以真沒有的部分。在以上公式中,請注意第 2 個公式 for 循環中的雙括號,它執行的是其中的表達式的算術運算,這是和其它高級語言的 for 循環最像的,但是很遺憾,Bash 中的算術表達式目前只能計算整數。再請注意第 3 個公式,select 語法,和 for...in... 循環的語法比較類似,但是它可以在屏幕上顯示一個菜單。如果我沒有記錯的話,Basic 語言中應該有這個功能。其它的控制結構在別的高級語言中都很常見,就不需要我在這里啰嗦了。
最后,再來展示一下如何定義函數:
name () compound-command [redirection]
或者
function name [()] compound-command [redirection]
可以看出,如果有 function 關鍵字,則“()”是可選的,如果沒有 function 關鍵字,則“()”是必須的。這里需要特別指出的是:函數體只要求是 compound-command,我前面總結過 compound-command 有四種形式,所以有時候定義一個函數並不會出現“{ }”哦。如下圖,這樣的函數也是合法的:
That's all。這就是 Bash 腳本語言的全部語法。就這么簡單。
好像忘了點什么?對了,還有輸入輸出重定向沒有講。輸入輸出重定向是 Shell 中又一個偉大的發明,它的存在有着它獨特的哲學意義。這個請看下一節。
五、輸入輸出重定向
Unix 世界有一個偉大的哲學:一切皆是文件。(這個扯得有點遠。) Unix 世界還有一個偉大的哲學:創建進程比較方便。(這個扯得也有點遠。)而且,每一個進程一創建,就會自動打開三個文件,它們分別是標准輸入、標准輸出、標准錯誤輸出,普通情況下,它們連接到用戶的控制台。在 Shell 中,使用數字來標識一個打開的文件,稱為文件描述符,而且數字 0、 1、 2 分別代表標准輸入、標准輸出和標准錯誤輸出。在 Shell 中,可以通過“>”、“<”將命令的輸入、輸出進行重定向。結合 exec 命令,可以非常方便地打開和關閉文件。需要注意的是,當文件描述符出現在“>”、“<”右邊的時候,前面要使用“&”符號,這可能是為了和數學表達式中的大於和小於進行區別吧。使用“&-”可以關閉文件描述符。
“> < & 數字 exec -”,這就是輸入輸出重定向的全部。下面的公式中,我使用 n 代表數字,如果是兩個不同的數字,則使用 n1、n2,使用 [...] 代表可選參數。輸入輸出重定向的語法如下:
1 [n]> file #重定向標准輸出(或 n)到file。 2 [n]>> file #重定向標准輸出(或 n)到file,追加到file末尾。 3 [n]< file #將file重定向到標准輸入(或 n)。 4 [n1]>&n2 #重定向標准輸出(或 n1)到n2。 5 2> file >&2 #重定向標准輸出和錯誤輸出到file。 6 | command #將標准輸出通過管道傳遞給command。 7 2>&1 | command #將標准輸出和錯誤輸出一起通過管道傳遞給command,等同於|&。
請注意,數字和“>”、“<”符號之間是沒有空格的。結合 exec,可以非常方便地使用一個文件描述符來打開、關閉文件,如下:
1 echo Hello >file1 2 exec 3<file1 4>file2 #打開文件 3 cat <&3 >&4 #重定向標准輸入到 3,標准輸出到 4,相當於讀取file1的內容然后寫入file2 4 exec 3<&- 4>&- #關閉文件 5 cat file2 6 #顯示結果為 Hello 7 8 #還可以暫存和恢復文件描述符,如下: 9 exec 5>&2 #把原來的標准錯誤輸出保存到文件描述符5上 10 exec 2> /tmp/$0.log #重定向標准錯誤輸出 11 ... 12 exec 2>&5 #恢復標准錯誤輸出 13 exec 5>&- #關閉文件描述符5,因為不需要了
還可以將“<>”一起使用,表示打開一個文件進行讀寫。
除了 exec,輸入輸出重定向和 read 命令配合也很好用,read 命令每次讀取文件的一行。但是要注意的是,輸入輸出重定向放到 for、while 等循環的循環體和循環外,效果是不一樣的。如下圖:
另外,輸入輸出重定向符號“>”、“<”還可以和“()”一起使用,表示進程替換(Process substitution),如“>(list)”、“<(list)”。結合前面提到的“<”、“>”、“(list)”的含義,進程替換的作用是很容易猜到的哦。
六、Bash 腳本語言的美學:大道至簡
如果你問我 Bash 腳本語言哪里美?我會回答:簡潔就是美。請看下面逐條論述:
1.使用了簡潔的抽象的符號。Bash 腳本語言幾乎使用到了鍵盤上能夠找到的所有符號,比如“$”用作字符串展開,“|”用作管道,“<”、“>”用作輸入輸出重定向,一點都不浪費;
2.只使用了 9 個元字符、10 個控制操作符和 22 個關鍵字,就構建了一個完整的、面向字符串編程的語言;
3.概念上具有很好的一致性;比如 (list) 復合命令的功能是執行括號內的命令序列,而“$”用於引導字符串展開,所以 $(list) 用於命令替換(所以我前面說“$()”形式的命令替換比“``”形式的命令替換更加具有一致性)。比如 ((expresion)) 用於數學表達式求值,所以 $((expression)) 代表算術展開。再比如“{}”和“,”配合使用,且中間沒有空格時,代表大括號展開,但是當需要使用“{ }”來定義復合命令時,必須把“{ }”當關鍵字,它們和它里面的內容必須以空格隔開,而且“}”和它前面的一條命令之間必須有一個“;”或者“<newline>”。這些概念上的一致性設計得非常精妙,使用起來自然而然可以讓人體會到一種美感;
4.完美解決了一個命令執行時的輸出和運行狀態的分離。有其它編程語言經歷的人也經常會遇到這樣的問題:當我們調用一個函數的時候,函數可能會產生兩個結果,一個是函數的返回值,一個是函數調用是否成功。在 C# 和 Java 等高級語言中,往往使用 try...catch 等捕獲異常的方式來判斷函數調用是否成功,但仍然有程序員讓函數返回 null 代表失敗,而 C 語言這種沒有異常機制的語言,實在是難以判斷一個函數的返回值究竟如何表示該函數調用是否成功(比如就有很多 API 讓函數返回 -1 代表失敗,而有的函數運行失敗是會設置 errno 全局變量)。在 Bash 中,命令運行的狀態和命令的標准輸出區分很明確,如果你需要命令的標准輸出,使用命令替換來生成字符串,如果你只需要命令的運行狀態,直接將命令寫在 if 語句之中即可,或者使用 $? 特殊變量來檢查上一條命令的運行狀態。如果不想在檢查命令運行狀態的時候讓命令的標准輸出影響用戶,可以把它重定向到 /dev/null,比如
if cat /etc/passwd | grep youxia > /dev/null; then echo 'youxia is exist'; fi
5.使用管道和輸入輸出重定向讓文件的讀寫變得簡單。想一想在 C 語言中怎么讀文件吧,除了麻煩的 open、close 不說,每讀一個字符串還得先准備一個 buffer,准備長了怕浪費空間,准備短了怕緩沖區溢出,虐心啦。使用 Bash,那真的是太方便了。
6.它還是一門不折不扣的動態語言哦,eval 命令實在是太強大了,請看下圖,模擬指針進行查表:
當然,自從 Bash 3 之后,Bash 本身就提供了間接引用的功能(使用“${!var}”)。
例外:
Bash 語言也並不是在所有的方面都是完美的,還存在幾個特別的例外。比如前面說的“=”。除了“=”,“()”也有一個使用不一致的地方,那就是對數組的初始化,比如 array=(a b c d e f) ,這和前面講的“()”用於在子Shell中執行命令序列還真的是不一致。
總結:
以上內容是我的胡言亂語,因為以上內容即無法教會大家完整的 Bash 語法,也無法教會大家用 Bash 做任何一點有意義的工作。
如果想用 Bash 干點實事,我送大家一本 O'Reilly 出的《Shell腳本學習指南》:Shell腳本學習指南.part1.rar Shell腳本學習指南.part2.rar Shell腳本學習指南.part3.rar Shell腳本學習指南.part4.rar (為什么一次只能上傳10M啊,好好的一本書被拆分成4部分了。)
另外,我的主要參考資料來自於 Bash 用戶手冊。大家可以在自己的系統中運行 man bash 。
(京山游俠於2014-09-30發布於博客園,轉載請注明出處。)