Zsh和Bash,,究竟有何不同
已經(jīng)有不少人寫過類似“為什么Zsh比Bash好”“為什么Zsh比* shell好”的文章了,講解如何配置Zsh或折騰各種oh-my-zsh主題的教程也是一搜一大籮,,但是卻極少看到Zsh和Bash這兩個Shell作為腳本語言時的具體差異比較,。那么,這里就是一篇,,從語言特性的角度上簡單整理了兩者一些細微的不兼容之處,,供編寫可移植Shell腳本時參考,。(僅僅是從我自己過去的經(jīng)驗教訓(xùn)中總結(jié)出來的,,所以應(yīng)該也是不完全的。)
開始之前:理解Zsh的仿真模式(emulation mode)
一種流行的說法是,,Zsh是與Bash兼容的,。這種說法既對,也不對,,因為Zsh本身作為一種腳本語言,,是與Bash不兼容的。符合Bash規(guī)范的腳本無法保證被Zsh解釋器正確執(zhí)行,。但是,,Zsh實現(xiàn)中包含了一個屌炸天的仿真模式(emulation mode),支持對兩種主流的Bourne衍生版shell(bash、ksh)和C shell的仿真(csh的支持并不完整),。在Bash的仿真模式下,,可以使用與Bash相同的語法和命令集合,從而達到近乎完全兼容的目的,。為了激活對Bash的仿真,,需要顯式執(zhí)行:
等效于:
Zsh是不會根據(jù)文件開頭的shebang(如#!/bin/sh 和#!/bin/bash )自動采取兼容模式來解釋腳本的,因此,,要讓Zsh解釋執(zhí)行一個其他Shell的腳本,,你仍然必須手動emulate sh 或者emulate ksh ,告訴Zsh對何種Shell進行仿真,。
那么,,Zsh究竟在何時能夠自動仿真某種Shell呢?
對于如今的絕大部分GNU/Linux(Debian系除外)和Mac OS X用戶來說,,系統(tǒng)默認的/bin/sh 指向的是bash :
$ file /bin/sh
/bin/sh: symbolic link to `bash'
不妨試試用zsh 來取代bash 作為系統(tǒng)的/bin/sh :
# ln -sf /bin/zsh /bin/sh
所有的Bash腳本仍然能夠正確執(zhí)行,,因為Zsh在作為/bin/sh 存在時,能夠自動采取其相應(yīng)的兼容模式(emulate sh )來執(zhí)行命令,。也許正是因為這個理由,,Grml直接選擇了Zsh作為它的/bin/sh ,對現(xiàn)有的Bash腳本能做到近乎完美的兼容,。
無關(guān)主題:關(guān)于/bin/sh 和shebang的可移植性
說到/bin/sh ,,就不得不提一下,在Zsh的語境下,,sh 指的是大多數(shù)GNU/Linux發(fā)行版上/bin/sh 默認指向的bash ,,或者至少是一個Bash的子集(若并非全部GNU Bash的最新特性都被實現(xiàn)的話),而非指POSIX shell,。因此,,Zsh中的emulate sh 可以被用來對Bash腳本進行仿真。
眾所周知,,Debian的默認/bin/sh 是
dash(Debian Almquist shell),,這是一個純粹POSIX shell兼容的實現(xiàn),基本上你要的bash和ksh里的那些高級特性它都沒有,。“如果你在一個#!/bin/sh 腳本中用到了非POSIX shell的東西,,說明你的腳本寫得是錯的,不關(guān)我們發(fā)行版的事情,?!?/em>Debian開發(fā)者們在把默認的/bin/sh 換成dash ,導(dǎo)致一些腳本出錯時這樣宣稱道,。當(dāng)然,,我們應(yīng)該繼續(xù)假裝與POSIX shell標(biāo)準(zhǔn)保持兼容是一件重要的事情,,即使現(xiàn)在大家都已經(jīng)用上了更高級的shell。
因為有非GNU的Unix,,和Debian GNU/Linux這類發(fā)行版的存在,,你不能夠假設(shè)系統(tǒng)的/bin/sh 總是GNU Bash,也不應(yīng)該把#!/bin/sh 用作一個Bash腳本的shebang(——除非你愿意放棄你手頭Shell的高級特性,,寫只與POSIX shell兼容的腳本),。如果想要這個腳本能夠被方便地移植的話,應(yīng)指定其依賴的具體Shell解釋器:
這樣系統(tǒng)才能夠總是使用正確的Shell來運行腳本,。
(當(dāng)然,,顯式地調(diào)用bash 命令來執(zhí)行腳本,shebang怎樣寫就無所謂了)
echo 命令 / 字符串轉(zhuǎn)義
Zsh比之于Bash,,可能最容易被注意到的一點不同是,,Zsh中的echo 和printf 是內(nèi)置的命令。
$ which echo
echo: shell built-in command
$ which printf
printf: shell built-in command
Bash中的echo 和printf 同樣是內(nèi)置命令:
$ type echo
echo is a shell builtin
$ type printf
echo is a shell builtin
感謝讀者提醒,,在Bash中不能通過which 來確定一個命令是否為外部命令,,因為which 本身并不是Bash中的內(nèi)置命令。which 在Zsh中是一個內(nèi)置命令,。
Zsh內(nèi)置的echo 命令,,與我們以前在GNU Bash中常見的echo 命令,使用方式是不兼容的,。
首先,,請看Bash:
我們知道,因為這里傳遞給echo 的只是一個字符串(允許使用反斜杠\ 轉(zhuǎn)義),,所以不加引號與加上雙引號是等價的,。Bash輸出了我們預(yù)想中的結(jié)果:每兩個連續(xù)的\ 轉(zhuǎn)義成一個\ 字符輸出,最終2個變1個,,4個變2個,。沒有任何驚奇之處。
你能猜到Zsh的輸出結(jié)果么,?
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
(゜Д゜*)
解釋稍后,。
我們還知道,要想避免一個字符串被反斜杠轉(zhuǎn)義,,可以把它放進單引號,。正如我們在Bash中所清楚看到的這樣,,所有的反斜杠都照原樣輸出:
$ echo '\\'
\
$ echo '\\\\'
\\\
再一次,,你能猜到Zsh的輸出結(jié)果么?
↓
↓
↓
↓
↓
↓
↓
↓
↓
↓
$ echo '\\'
$ echo '\\\\'
\
((((((゜Д゜*))))))))))))
這個解釋是這樣的:在前一種不加引號(或者加了雙引號)的情形下,,傳遞給echo 內(nèi)部命令的字符串將首先被轉(zhuǎn)義,,echo \\ 中的\\ 被轉(zhuǎn)義成\ ,echo \\\\ 中的\\\\ 被轉(zhuǎn)義成\\ 。然后,,在echo 這個內(nèi)部命令輸出到終端的時候,,它還要把這個東西再轉(zhuǎn)義一遍,一個單獨的\ 沒法轉(zhuǎn)義,,所以仍然是作為\ 輸出,;連續(xù)的\\ 被轉(zhuǎn)義成\ ,所以輸出就是\ ,。因此,,echo \\ 和echo \\\\ 的輸出相同,都是\ ,。
為了讓Zsh中echo 的輸出不被轉(zhuǎn)義,,需要顯式地指明-E 選項:
$ echo -E \
$ echo -E \\\\
于是,我們也就知道在后一種加單引號的情形下,,如何得到與原字符串完全相同的輸出了:
$ echo -E '\\'
\
$ echo -E '\\\\'
\\\
而Bash的echo 默認就是不對輸出進行轉(zhuǎn)義的,,若要得到轉(zhuǎn)義的效果,需顯式地指定-e 選項,。Bash和Zsh中echo 命令用法的不兼容,,在這里體現(xiàn)出來了。
變量的自動分字(word splitting)
在Bash中,,你可以通過調(diào)用外部命令echo 輸出一個字符串:
我們知道,,Bash會對傳遞給命令的字符串進行分字(根據(jù)空格或換行符),然后作為多個參數(shù)傳給echo ,。當(dāng)然,,作為分隔符的換行,在最終輸出時就被抹掉了,。于是,,更好的習(xí)慣是把變量名放在雙引號中,把它作為一個字符串傳遞,,這樣就可以保留文本中的換行符,,將其原樣輸出。
在Zsh中,,你不需要通過雙引號來告訴解釋器“$text 是一個字符串”,。解釋器不會把它轉(zhuǎn)換成一個由空格或者\n 分隔的參數(shù)列表或者別的什么。所以,,沒有Bash中的trick,,直接echo $text 就可以保留換行符。但是,,如前一節(jié)所說,,我們需要一個多余的工作來保證輸出的是未轉(zhuǎn)義的原始文本,,那就是-E 選項:
從這里我們看到,Zsh中的變量在傳遞給命令時是不會被自動切分成words然后以多個參數(shù)的形式存在的,。它仍然保持為一個量,。這是它與傳統(tǒng)的Bourne衍生shell(ksh、bash)的一個重要不兼容之處,。這是Zsh的特性,,而不是一個bug。
通配符展開(globbing)
通配符展開(globbing)也許是Unix shell中最為實用化的功能之一,。比起正則表達式,,它的功能相當(dāng)有限,不過它的確能滿足大部分時候的需求:依據(jù)固定的前綴或后綴匹配文件,。需要更復(fù)雜模式的時候其實是很少見的,,至少在文件的命名和查找上。
Bash和Zsh對通配符展開的處理方式有何不同呢,?舉個例子,,假如我們想要列舉出當(dāng)前目錄下所有的.markdown 文件,但實際上又不存在這樣的文件,。在Zsh中:(注意到這里使用了內(nèi)置的echo ,,因為我們暫時還不想用到外部的系統(tǒng)命令)
$ echo *.markdown
zsh: no matches found: *.markdown
Bash中:
$ echo *.markdown
*.markdown
Zsh因為通配符展開失敗而報錯;而Bash在通配符展開失敗時,,會放棄把它作為通配符展開,、直接把它當(dāng)做字面量返回??雌饋?,Zsh的處理方式更優(yōu)雅,因為這樣你就可以知道這個通配符確實無法展開,;而在Bash中,,你很難知道究竟是不存在這樣的文件,還是存在一個文件名為'*.markdown' 的文件,。
接下來就是不那么和諧的方面了,。
在Zsh中,用ls 查看當(dāng)然還是報錯:
$ ls *.markdown
zsh: no matches found: *.markdown
Bash,,這時候調(diào)用ls 也會報錯,。因為當(dāng)前目錄下沒有.markdown 后綴的文件,通配符展開失敗后變成字面的'*.markdown' ,,這個文件自然也不可能存在,,所以外部命令ls 報錯:
$ ls *.markdown
ls: cannot access *.markdown: No such file or directory
同樣是錯誤,差別在哪里,?對于Zsh,,這是一個語言級別的錯誤;對于Bash,,這是一個外部命令執(zhí)行的錯誤,。這件差別很重要,因為它意味著后者可以被輕易地catch,,而前者不能,。
想象一個常見的命令式編程語言,Java或者Python,。你可以用try...catch或類似的語言結(jié)構(gòu)來捕獲運行時的異常,,比較優(yōu)雅地處理無法預(yù)料的錯誤。Shell當(dāng)然沒有通用的異常機制,,但是,,你可以通過檢測某一段命令的返回值來模擬捕獲運行時的錯誤。例如,,在Bash里可以這樣:
$ if ls *.markdown &>/dev/null; then :; else echo $?; fi
2
于是,,在通配符展開失敗的情形下,我們也能輕易地把外部命令的錯誤輸出重定向到/dev/null ,,然后根據(jù)返回的錯誤碼執(zhí)行后續(xù)的操作,。
不過在Zsh中,這個來自Zsh解釋器自身的錯誤輸出卻無法被重定向:
$ if ls *.markdown &>/dev/null; then :; else echo $?; fi
zsh: no matches found: *.markdown
1
大部分時候,,我們并不想看到這些丑陋多余的錯誤輸出,,我們期望程序能完全捕獲這些錯誤,然后完成它該完成的工作,。但這也許是一種正常的行為,。理由是,在程序語言里,,syntax error一般是無法簡單地由用戶在運行階段自行catch的,,這個報錯工作將直接由解釋器來完成。除非,,當(dāng)然,,除非我們用了邪惡的eval 。
$ if eval "ls *.markdown" &>/dev/null; then :; else echo $?; fi
1
Eval is evil. 但在Zsh中捕獲這樣的錯誤,,似乎沒有更好的辦法了,。必須這么做的原因就是:Zsh中,通配符展開失敗是一個語法錯誤,。而在Bash中則不是,。
基于上述理由,依賴于Bash中通配符匹配失敗而直接把"*" 當(dāng)作字面量傳遞給命令的寫法,,在Zsh中是無法正常運行的,。例如,,在Bash中你可以:(雖然在大部分情況下能用,但顯然不加引號是不科學(xué)的)
$ find /usr/share/git -name *.el
因為Zsh不會在glob擴展失敗后自動把"*" 當(dāng)成字面量,,而是直接報錯終止運行,,所以在Zsh中你必須給"*.el" 加上引號,來避免這種擴展:
$ find /usr/share/git -name "*.el"
字符串比較
在Bash中判斷兩個字符串是否相等:
或與之等效的(現(xiàn)代編程語言中更常見的== 比較運算符):
注意等號左右必須加空格,,變量名一定要放在雙引號中,。(寫過Shell的都知道這些規(guī)則的重要性)
在條件判斷的語法上,Zsh基本和Bash相同,,沒有什么改進,。除了它的解釋器想得太多,以至于不小心把== 當(dāng)做了一個別的東西:
$ [ foo == bar ]; echo $?
zsh: = not found
要想使用我們最喜歡的== ,,只有把它用引號給保護起來,,不讓解釋器做多余的解析:
$ [ foo "==" bar ]; echo $?
1
所以,為了少打幾個字符,,還是老老實實用更省事的= 吧,。
數(shù)組
同樣用一個簡單的例子來說明。Bash:
array=(alpha bravo charlie delta)
echo $array
echo ${array[*]}
echo ${#array[*]}
for ((i=0; i < ${#array[*]}; i++)); do
echo ${array[$i]}
done
輸出:
alpha
alpha bravo charlie delta
4
alpha
bravo
charlie
delta
很容易看到,,Bash的數(shù)組下標(biāo)是從0開始的,。$array 取得的實際上是數(shù)組的第一個元素的值,也就是${array[0]} (這些行為和C有點像),。要想取得整個數(shù)組的值,,必須使用${array[*]} 或${array[@]} ,因此,,獲取數(shù)組的長度可以使用${#array[*]} ,。在Bash中,必須記得在訪問數(shù)組元素時給整個數(shù)組名連同下標(biāo)加上花括號,,比如,,${array[*]} 不能寫成$array[*] ,否則解釋器會首先把$array 當(dāng)作一個變量來處理,。
再來看這段Zsh:
array=(alpha bravo charlie delta)
echo $array
echo $array[*]
echo $#array
for ((i=1; i <= $#array[*]; i++)); do
echo $array[$i]
done
輸出:
alpha bravo charlie delta
alpha bravo charlie delta
4
alpha
bravo
charlie
delta
在Zsh中,,$array 和$array[*] 一樣,可以用來取得整個數(shù)組的值,。因此獲取數(shù)組的長度可直接用$#array ,。
Zsh的默認數(shù)組下標(biāo)是從1而不是0開始的,這點更像C shell,。(雖然一直無法理解一個名字叫C的shell為何會采用1作為數(shù)組下標(biāo)開始這種奇葩設(shè)定)
最后,,Zsh不需要借助花括號來訪問數(shù)組元素,因此Bash中必需的花括號都被略去了。
關(guān)聯(lián)數(shù)組
Bash 4.0+和Zsh中都提供了對類似AWK關(guān)聯(lián)數(shù)組的支持,。
declare -A array
array[mort]=foo
和普通的數(shù)組一樣,,在Bash中,必須顯式地借助花括號來訪問一個數(shù)組元素:
而Zsh中則沒有必要:
說到這里,,我們注意到Zsh有一個不同尋常的特性:支持使用方括號進行更復(fù)雜的globbing,,array[mort] 這樣的寫法事實上會造成二義性:究竟是取array 這個關(guān)聯(lián)數(shù)組以mort 為key的元素值呢,還是以通配符展開的方式匹配當(dāng)前目錄下以"array" 開頭,,以"m" ,、"o" ,、"r" 或"t" 任一字符結(jié)尾的文件名呢,?
在array[mort]= 作為命令開始的情況下,不存在歧義,,這是一個對關(guān)聯(lián)數(shù)組的賦值操作,。在前面帶有$ 的情況下,Zsh會自動把$array[mort] 識別成取關(guān)聯(lián)數(shù)組的值,,這也沒有太大問題,。問題出在它存在于命令中間,卻又不帶$ 的情況,,比如:
read -r -d '' array[mort] << 'EOF'
hello world
EOF
我們的本意是把這個heredoc賦值給array[mort] 數(shù)組元素,。在Bash中,這是完全合法的,。然而,,在Zsh中,解釋器會首先試圖對"array[mort]" 這個模式進行g(shù)lob展開,,如果當(dāng)前目錄下沒有符合該模式的文件,,當(dāng)然就會報出一個語法錯誤:
zsh: no matches found: array[mort]
這是一件很傻的事情,為了讓這段腳本能夠被Zsh解釋器正確執(zhí)行,,我們需要把array[mort] 放在引號中以防止被展開:
read -r -d '' 'array[mort]' << 'EOF'
hello world
EOF
這是Zsh在擴展了一些強大功能的同時帶來的不便之處(或者說破壞了現(xiàn)有腳本兼容性的安全隱患,,又或者是讓解釋器混亂的pitfalls)。
順便說一句,,用Rake構(gòu)建過項目的Rails程序員都知道,,有些時候需要在命令行下通過方括號給rake 傳遞參數(shù)值,如:
Zsh這個對方括號展開的特性確實很不方便,。如果不想每次都用單引號把參數(shù)括起來,,可以完全禁止Zsh對某條命令后面的參數(shù)進行g(shù)lob擴展:(~/.zshrc )
嗯,對于rake 命令來說,,glob擴展基本是沒有用的,。你可以關(guān)掉它。
分號與空語句
雖然有點無聊,但還是想提一下:Bash不允許語句塊中使用空語句,,最小化的語句是一個noop命令(: ),;而Zsh允許空語句。
剛開始寫B(tài)ash的時候,,總是記不得什么時候該加分號什么時候不該加,。比如
如果放在一行里寫,應(yīng)該是
then 后面是不能接分號的,,如果寫成
就會報錯:
bash: syntax error near unexpected token `;'
解釋是:then 表示一個代碼段的開始,,fi 表示結(jié)束,這中間的內(nèi)容必須是若干行命令,,或者以分號; 結(jié)尾的放在同一行內(nèi)的多條命令,。我們知道在傳統(tǒng)的shell中,分號本身并不是一條命令,,空字符串也不是一條命令,,因此,then 后面緊接著的分號就會帶來一條語法錯誤,。(有些時候?qū)δ硞€“語言特性”的所謂解釋只是為了掩飾設(shè)計者在一開始犯的錯誤,,所以就此打住)
在Zsh中,,上述兩種寫法都合法,。因為它允許只包含一個分號的空命令。
當(dāng)然,,因為分號只是一個語句分隔符,,所以沒有也是可以的。這種寫法在Zsh中合法:(then 的語句塊為空)
第二彈
其實只是先挖個坑而已,。我也不知道有沒有時間寫,,暫且記上。
Zsh vs. Bash:不完全對比解析(2)
- 別名,,函數(shù)定義和作用域
- 協(xié)進程(coprocess)
- 重定向
- 信號和陷阱(trap)
|