Bash編程易犯的錯誤
———————————華麗的分割線 begin———————
1. for i in $(ls *.mp3)
2. cp $file $target
3. 文件名中包含短橫'-'
4. [ $foo = "bar" ]
5. cd $(dirname "$f")
6. [ "$foo" = bar && "$bar" = foo ]
7. [[ $foo > 7 ]]
8. grep foo bar | while read -r; do ((count++)); done
9. if [grep foo myfile]
10. if [bar="$foo"]; then ...
11. if [ [ a = b ] && [ c = d ] ]; then ...
12. read $foo
13. cat file | sed s/foo/bar/ > file
14. echo $foo
15. $foo=bar
16. foo = bar
17. echo <
18. su -c 'some command'
19. cd /foo; bar
20. [ bar == "$foo" ]
21. for i in {1..10}; do ./something &; done
22. cmd1 && cmd2 || cmd3
23. echo "Hello World!"
24. for arg in $*
25. function foo()
26. echo "~"
27. local varname=$(command)
28. export foo=~/bar
29. sed 's/$foo/good bye/'
30. tr [A-Z] [a-z]
31. ps ax | grep gedit
32. printf "$foo"
33. for i in {1..$n}
34. if [[ $foo = $bar ]]
35. if [[ $foo =~ 'some RE' ]]
36. [ -n $foo ] or [ -z $foo ]
37. [[ -e "$broken_symlink" ]] returns 1 even though $broken_symlink exists
38. ed file <<<"g/d\{0,3\}/s//e/g" fails
39. expr sub-string fails for "match"
40. On UTF-8 and Byte-Order Marks (BOM)
41. content=$(
42. somecmd 2>&1 >>logfile
43. cmd; (( ! $? )) || die
縮進准則
我一般使用2個空格來縮進(盡管大多人使用4個空格),原因是:
輸入簡單快速;
沒有輸入一個Tab鍵,避免不同環境下顯示的差異問題;
縮進的效果已經足夠,並且沒有浪費太多的空間;
譯者注:本人也是使用4個空格,如果你也與本文作者的風格不一樣,下面說到2個空格的地方請自覺替換成你實際使用的空格數。個人認為,縮進只是一個個人的風格,只要不影響可讀性即可。
順便說一句,盡量不要使用Tab鍵,它們容易帶來麻煩,我只能想到一種情況下它是有用的:here document中的縮進。
分隔長行
如果需要分隔過長的代碼,你可以使用下面的任意一種方法:
1) 使用與命令寬度相同的縮進
activate some_very_long_option \
some_other_option
2) 使用2個空格縮進
activate some_very_long_option \
some_other_option
從個人的角度來說,除非有特別的需要,我更傾向於第一種形式,因為它突出“上下兩行的內容是一起的”這一聯系。
分離復合命令
譯者注:其實這裡的復合命令就是指塊語句,例如for/while循環, if分支結構等等。
HEAD_KEYWORD parameters; BODY_BEGIN
BODY_COMMANDS
BODY_END
我習慣於:
將HEAD_KEYWORD和初始化命令或者參數放在第一行;
將BODY_BEGIN同樣放在第一行;
復合命令中的BODY部分以2個空格縮進;
BODY_END部分獨立一行放在最後;
1)if/then/elif/else分支語句
1
if ...; then
2
...
3
elif ...; then
4
...
5
else
6
...
7
fi
2)for循環
for f in /etc/*; do
...
done
3) while/until循環
while [[ $answer != [YyNn] ]]; do ... done
4) case分支語句
01
case $input in
02
hello)
03
echo "You said hello"
04
;;
05
bye)
06
echo "You said bye"
07
if foo; then
08
bar
09
fi
10
;;
11
*)
12
echo "You said something weird..."
13
;;
14
esac
幾點注意的地方:
如果不是100%需要,匹配部分左右的括號不需要寫(譯者注:例如寫成hello)而不是(hello));
匹配模式與分支的終止符號;;位於同一縮進級別
分支內部的命令多縮進一層;
盡管是可選的,這裡還是把最後一個分支的終止符號也寫上了;
語法和編碼指引
晦澀的語法結構
我們都喜歡一些晦澀的語法結構,因為它們很簡潔。但是如果不是100%需要用到,盡量不要使用它們,否則大多數人無法理解你的代碼。
所以有有時候,我們需要在代碼的智能,效率與可讀性之間找到一個平衡點。
如果你一定要使用這種語法結構,記得在用的地方寫上一小段注釋。
譯者注:Shell提供的一些語法糖很難理解,但是有非常簡潔實用,本人也很喜歡用,這樣可以省下一大堆精力,而且用熟了也沒有什麼難以理解的,但是作者說的也有道理,這一點就仁者見仁,智者見智了
變量名
因為所有保留的變量名都是大寫的,最安全的方法是僅使用小寫字母作為變量名,例如讀入用戶的輸入、循環變量等等……:
變量名盡量選擇小寫字母;
如果你使用大寫的變量名,不要使用保留的變量名(一份不完全的列表參見SUS);
如果你使用大寫的變量名,最後在變量名前面加一個獨特的前綴(例如下面例子中的MY_);
下面是一個例子:
1
#!/bin/bash
2
3
# the prefix 'MY_'
4
MY_LOG_DIRECTORY=/var/adm/
5
6
for file in "$MY_LOG_DIRECTORY"/*; do
7
echo "Found Logfile: $file"
8
done
變量初始化
正如C語言一樣,最好的處理是在變量聲明的時候初始化。
用戶可以將一個變量以環境變量的形式傳遞到腳本中。如果你盲目地假定你使用的所有變量都是未初始化的,其它人可以以環境變量的形式劫持一個變量。
譯者注:一個例子說明這一點:
01
$ cat b.sh
02
03
if [ -z "$var" ]; then
04
echo "$var is not set"
05
var=1
06
fi
07
08
echo "Now, var is equals to $var"
09
var=2 sh b.sh
10
Now, var is equals to 2
解決這個問題的方法很簡單,將變量初始化:
1
my_input=""
2
my_array=()
3
my_number=0
參數展開
除非你知道自己做的事情,請在參數展開的地方使用雙引號
當然,也有一些地方並不需要使用雙引號,例如:
[[ ]]測試表達式內部是不會展開的;
在case $WORD in語法中WORD也不會展開的;
在變量賦值var=$WORD的地方也是不會展開的
但是在這些地方使用引號並不會出錯,如果你習慣於在每個可能展開參數的地方使用引號,你寫得代碼會很安全。
如果你要傳遞一個參數作為一個單詞列表,你可以不使用引號,例如:
1
list="one two three"
2
3
# you MUST NOT quote $list here
4
for word in $list; do
5
...
6
done
函數名稱
函數名稱應該采用小寫的形式,並且有一個很好的意義。函數名稱應該容易讓人理解,比如f1這個名稱雖然容易輸入但是對調試和其它人閱讀代碼造成了很大的困難,它說明不了任何東西。好的函數名稱可以幫助說明代碼,而不需要額外的注釋。
一個或多或少有趣的是:如果你無意這樣做,不要把函數名稱命名為常見的命令名,新手往往比較容易將腳本或者函數名命名成test,這樣就和UNIX的test命令沖突了。
除非絕對必要,僅使用字母、數字和下劃線作為函數名稱。/bin/ls也是一個合法的Bash函數名稱。
譯者注:/bin/ls不是一個合法的函數名稱。
命令替換
正如文章the article about command substitution [Bash Hackers Wiki]中提及的,你應該使用$( .. )形式。
不過,如果可移植性是一個問題,你可能必須使用反引號的形式`...`。
在任何情況,如果其它展開或者單詞分隔並不是你期望的,你應該將命令替換用雙引號引起來。
Eval命令
正如Greg據說的:“If eval is the answer, surely you are asking the wrong question.”。
避免它,除非絕對必要:
eval can be your neckshot(可能是你的麻煩?)
很有可能有其它的方法來實現你需要的;
如果可能,重新思考下腳本的工作過程,當eval的使用不可避免的時候;
如果你實在需要使用,小心慎用;
腳本的基本結構
一個腳本的基本結構是這樣的:
#!SHEBANG CONFIGURATION_VARIABLES
FUNCTION_DEFINITIONS
MAIN_CODE
Shebang
如果可能,請不要忘記shebang。
請小心使用/bin/sh作為shebang,在Linux系統中,/bin/sh就是Bash這是一個錯誤的觀點。
於我而言,shebang有兩個目的:
說明直接執行時以哪個解釋器來執行;
明確該腳本應該以哪個解釋器來執行;
配置變量
在這裡,我將這一類變量——可以被用戶更改的——叫做配置變量。
讓這類變量容易找到,一般放在腳本的頭部,給它們有意義的名稱並且加上注釋說明。正如上面說的,僅當你知道你為什麼這麼做的時候,才用大寫的變量名形式,否則小寫形式更加安全。
函數定義
所有函數定義應該在腳本主要代碼執行之前,這樣可以給人全局的印象,並且確保所有函數在使用之前它是已知的。
你應該使用可移植性高的函數定義形式,即不帶function關鍵字的形式。
腳本行為和健壯性
當腳本檢測到問題時盡早退出,以免執行潛在的問題;
如果你需要用到的命令可能並沒有安裝在系統上,在腳本執行的時候最好檢查命令是否存在並且提醒用戶缺少什麼;
采用有意義的腳本返回值,例如0代碼成功,1代碼錯誤或者失敗;
其它
輸出內容
if the script is interactive, if it works for you and if you think this is a nice feature, you can try to save the terminal content and restore it after execution;(譯者注:不理解這一點是什麼意思)
在屏幕中輸出簡單易理解的消息;
使用顏色或者特別的前綴區分錯誤和警告信息;
輸出正常的內容到STDOUT,而輸出錯誤、警告或者診斷的信息到STDERR;
在日志文件中輸出所有詳細的信息;
輸入
不要盲目地假設任何事情,如果你希望用戶輸入一個數字,請在腳本中主動檢查它是否真得是一個數字,檢查頭部是否包含0,等等。我們都應該知道這一點,用戶僅僅是用戶而不是程序員,他們會做他們想要的,而不是程序想要的。
——————————————————華麗的分割線 begin—————————————
1. for i in $(ls *.mp3)
Bash寫循環代碼的時候,確實比較容易犯下面的錯誤:
01
for i in $(ls *.mp3); do # 錯誤!
02
some command $i # 錯誤!
03
done
04
05
for i in $(ls) # 錯誤!
06
for i in `ls` # 錯誤!
07
08
for i in $(find . -type f) # 錯誤!
09
for i in `find . -type f` # 錯誤!
10
11
files=($(find . -type f)) # 錯誤!
12
for i in ${files[@]} # 錯誤!
使用命令展開時不帶引號,其執行結果會使用IFS作為分隔符,拆分成參數傳遞給for循環處理;
不應該讓腳本去解析ls命令的結果;
我們不能避免某些文件名中包含空格,Shell會對$(ls *.mp3)展開的結果會被做單詞拆分(WordSplitting)的處理。假設有一個文件,名字為01 - Don't Eat the Yellow Snow.mp3,for循環處理的時候,會今次遍歷文件名中的每個單詞:01, -, Don't, Eat等等:
$ for i in $(ls *.mp3); do echo $i; done 01 - Don't
Eat
the
Yellow
Snow.mp3
比這更差的情況是,上面命令展開的結果可能被Shell進一步處理,比如文件名展開。比如,ls執行的結果中包含*號,按照通配符的規則, *號會被展開成當前目錄下的所有文件:
1
$ touch "1*.mp3" "1.mp3" "11.mp3" "12.mp3"
2
$ for i in $(ls *.mp3); do echo $i; done
3
1*.mp3 1.mp3 11.mp3 12.mp3
4
1.mp3
5
11.mp3
6
12.mp3
7
1.mp3
8
11.mp3
9
12.mp3
不過,在這種場景下,你即使加上引號,也是無濟於事的:
1
$ for i in "$(ls *.mp3)"; do echo --$i--; done
2
--1*.mp3 1.mp3 11.mp3 12.mp3--
加上引號後,ls執行的結果會被當成一個整體,所以for循環只會執行一次,達不到預期的效果。
事實上,這種情況下,根本不需要使用ls命令。ls命令的結果本身就設計成給人讀的,而不是給腳本解析的。正確的處理方法是,直接使用文件名展開(通配符)的功能:
1
$ for i in *.mp3; do
2
> echo "$i"
3
> done
4
1*.mp3
5
1.mp3
6
11.mp3
7
12.mp3
文件名展開是位於各種展開(花括號展開、變量替換、命令展開等)功能中的最後一個環節,所以不會有之前不帶引號的命令展開的副作用。如果你需要遞歸地處理文件,可以考慮使用Find命令。
到這一步,之間的問題看樣子已經修復了。但是,如果你進一步思考,假設當前目錄上沒有文件時會怎麼樣?沒有文件的時候,*.mp3不會被展開直接傳遞給for循環處理,所以這個時候循環還是會執行一次。這種情況不是我們預期的行為。保險起見,可以在循環處理的時候,檢查下文件是否存在:
1
# POSIX
2
for i in *.mp3; do
3
[ -e "$i" ] || continue
4
some command "$i"
5
done
如果你有使用引號和避免單詞拆分的習慣,你完全可以避免很多錯誤。
注意下循環體內部的"$i",這裡會導致下面我們要說的另外一個比較容易犯的錯誤。
2. cp $file $target
上面的命令有什麼問題呢?如果你提前知道,$file和$target文件名中不會包含空格或者*號。否則,這行命令執行前在經過單詞拆分和文件名展開的時候會出現問題。所以,兩次強調,在使用展開的地方切勿忘記使用引號:
$ cp -- "$file" "$target"
如果不帶引號,當你執行如下命令時就會出錯:
1
$ file="01 - Don't Eat the Yellow Snow.mp3"
2
$ target="/tmp"
3
$ cp $file $target
4
cp: cannot stat ‘01’: No such file or directory
5
..
如果帶上引號,就不會有上面的問題,除非文件名以'-'開頭,在這種情況下,cp會認為你提供的是一個命令行選項,這個錯誤下面會介紹。
3. 文件名中包含短橫'-'
文件名以'-'開頭會導致許多問題,*.mp3這種通配符會根據當前的locale展開成一個列表,但在絕大多數環境下,'-'排序的時候會排在大多數字母前。這個展開的列表傳遞給有些命令的時候,會錯誤的將-filename解析成命令行選項。這裡有兩種方法來解決這個問題。
第一種方法是在命令和參數之間加上--,這種語法告訴命令不要繼續對--之後的內容進行命令行參數/選項解析:
1
$ cp -- "$file" "$target"
這種方法可以解這個問題,但是你需要在每個命令後面都要加上--,而且依賴具體的命令解析的方式,如果一些命令不兼容這種約定俗成的規范,這種做法是無效的。
另外一種方法是,確保文件名都使用相對或者絕對的路徑,以目錄開頭:
1
for i in ./*.mp3; do
2
cp "$i" /target
3
...
4
done
這種情況下,即使某個文件以-開頭,展開後文件名依然是./-foo.mp3這種形式,完全不會有問題。
4. [ $foo = "bar" ]
這是一個與第2個問題類似的問題,雖然用到了引號,但是放錯了位置,對於字符串字面值,除非有特殊符號,否則不大需要用引號括起來。但是,你應該把變量的值用括號括起來,從而避免它們包含空格或能通配符,這一點我們在前面的問題中都解釋過。
這個例子在以下情況下會出錯:
如果[中的變量不存在,或者為空,這個時候上面的例子最終解析結果是:
1
[ = "bar" ] # 錯誤!
並且執行會出錯:unary operator expected,因為=是二元操作符,它需要左右各一個操作數。
如果變量值包含空格,它首先在執行之前進行單詞拆分,因此[命令看到的樣子可能是這樣的:
1
[ multiple words here = "bar" ];
正確的做法應該是:
1
# POSIX
2
[ "$foo" = bar ]
這種寫法,在POSIX兼容的實現中都不會有問題,即使$foo以短橫"-"開頭,因為POSIX實現的test命令通過傳遞的參數來確定執行的行為。
只有一些非常古老的shell可能會遇到問題,這個時候你可以使用下面的寫法來解決(相信你肯定看到過這種寫法):
1
# POSIX / Bourne
2
[ x"$foo" = xbar ]
在Bash中,還有另外一種選擇是使用[[關鍵字:
1
# Bash / Ksh
2
[[ $foo == bar ]]
這裡你不需要使用引號,因為在[[裡面參數不會進行展開,當然帶上引號也不會有錯。
不過有一點要注意的是,[[裡的==不僅僅是文本比較,它會檢查左邊的值是否匹配右側的表達式,==右側的值加上引號,會讓它成為一個普通的字面量,*?等通配符會失去特殊含義。
5. cd $(dirname "$f")
這又是一個引號的問題,命令展開的結果會進一步地進行單詞拆分或者文件名展開。因此下面的寫法才是正確的:
cd "$(dirname "$f")"
但是,上面引號的寫法可能比較怪異,你可能會認為第一、二個引號,第三、四個引號是一組的。
但是事實上,Bash將命令替換裡面的引號當成一組,外面的當成另外一組。如果你是用反引號的寫法,引號的行為就不是這樣的了,所以$()寫法更加推薦。
6. [ "$foo" = bar && "$bar" = foo ]
不要在test命令內部使用&&,Bash解析器會把你的命令分隔成兩個命令,在&&之前和之後。你應該使用下面的寫法:
1
[ bar = "$foo" ] && [ foo = "$bar" ] # POSIX
2
[[ $foo = bar && $bar = foo ]] # Bash / Ksh
盡量避免使用下面的寫法,雖然它是正確的,但是這種寫法可移植性不好,並且已經在POSIX-2008中被廢棄:
[ bar = "$foo" -a foo = "$bar" ]
7. [[ $foo > 7 ]]
原文作者認為算術比較不應該用[[,而是用((,我沒弄明白是為什麼。
如果有理解的同學,歡迎以評論回復,謝謝。
8. grep foo bar | while read -r; do ((count++)); done
這種寫法初看沒有問題,但是你會發現當執行完後,count變量並沒有變化。原因是管道後面的命令是在一個子Shell中執行的。
POSIX規范並沒有說明管道的最後一個命令是不是在子Shell中執行的。一些shell,例如ksh93或者Bash>=4.2可以通過shopt -s lastpipe命令,指明管道中的最後一個命令在當前shell中執行。由於篇幅限制,在此就不展開,有興趣的可以看Bash FAQ #24。
9. if [grep foo myfile]
初學者會錯誤地認為,[是if語法的一部分,正如C語言中的if ()。但是事實並非如此,if後面跟著的是一個命令,[是一個命令,它是內置命令test的簡寫形式,只不過它要求最後一個參數必須是]。下面兩種寫法是一樣的:
1
# POSIX
2
if [ false ]; then echo "HELP"; fi
3
if test false; then echo "HELP"; fi
兩個都是檢查參數"false"是不是非空的,所以上面兩個語句都會輸出HELP。
if語句的語法是:
1
if COMMANDS
2
then <COMMANDS>
3
elif <COMMANDS> # optional
4
then <COMMANDS>
5
else <COMMANDS> # optional
6
fi # required
再次強調,[是一個命令,它同其它常規的命令一樣接受參數。if是一個復合命令,它包含其它命令,[並不是if語法中的一部分。
如果你想根據grep命令的結果來做事情,你不需要把grep放到[裡面,只需要在if後面緊跟grep即可:
1
if grep -q fooregex myfile; then
2
...
3
fi
如果grep在myfile中找到匹配的行,它的執行結果為0(true),then後面的部分就會執行。
10. if [bar="$foo"]; then ...
正如上一個問題中提到的,[是一個命令,它的參數之間必須用空格分隔。
11. if [ [ a = b ] && [ c = d ] ]; then ...
不要用把[命令看成C語言中if語句的條件一樣,它是一個命令。
如果你想表達一個復合的條件表達式,可以這樣寫:
if [ a = b ] && [ c = d ]; then ...
注意,if後面有兩個命令,它們用&&分開。等價於下面的寫法:
if test a = b && test c = d; then ...
如果第一個test(或者[)命令返回false,then後面的語句不會執行;如果第一個返回true,第二個test命令會執行;只有第二個命令同樣返回true的情況下,then後面的語句才會執行。
除此之外,還可以使用[[關鍵字,因為它支持&&的用法:
if [[ a = b && c = d ]]; then ...
12. read $foo
read命令中你不需要在變量名之前使用$。如果你想把讀入的數據存放到名為foo的變量中,下面的寫法就夠了:
read foo
或者,更加安全地方法:
IFS= read -r foo
read $foo會把一行的內容讀入到變量中,該變量的名稱存儲在$foo中。所以兩者的含義是完全不一樣的。
13. cat file | sed s/foo/bar/ > file
你不應該在一個管道中,從一個文件讀的同時,再往相同的文件裡面寫,這樣的後果是未知的。
你可以為此創建一個臨時文件,這種做法比較安全可靠:
# sed 's/foo/bar/g' file > tmpfile && mv tmpfile file
或者,如果你用得是 GNU Sed 4.x 以上的版本,可以使用-i 選項即時修改文件的內容:
# sed -i 's/foo/bar/g' file
14. echo $foo
這種看似無害的命令往往會給初學者千萬極大的困擾,他們會懷疑是不是因為 $foo 變量的值是錯誤的。事實卻是因為,$foo 變量在這裡沒有使用雙引號,所以在解析的時候會進行單詞拆分和文件名展開,最終導致執行結果與預期大相徑庭:
1
msg="Please enter a file name of the form *.zip"
2
echo $msg
這裡整句話會被拆分成單詞,然後其中的通配符會被展開,例如*.zip。當你的用戶看到如下的結果時,他們會怎樣想:
1
Please enter a file name of the form freenfss.zip lw35nfss.zip
再舉一個例子(假設當前目錄下有以 .zip 結尾的文件):
1
var="*.zip" # var 包括一個星號,一個點號和 zip
2
echo "$var" # 輸出 *.zip
3
echo $var # 輸出所有以 .zip 結尾的文件
實際上,這裡使用 echo 命令並不是絕對的安全。例如,當變量的值包含-n時,echo 會認為它是一個合法的選項而不是要輸出的內容(當然如果你能夠保證不會有-n 這種值,可以放心地使用 echo 命令)。
完全可靠的打印變量值的方法是使用 printf:
printf "%s\n" "$foo"
15. $foo=bar
略過
16. foo = bar
當賦值時,等號兩邊是不允許出現空格的,這同 C 語言不一樣。當你寫下 foo = bar 時,shell 會將該命令解析成三個單詞,然後第一個單詞 foo 會被認為是一個命令,後面的內容會被當作命令參數。
同樣地,下面的寫法也是錯誤的:
1
foo= bar # WRONG!
2
foo =bar # WRONG!
3
$foo = bar; # COMPLETELY WRONG!
4
5
正確的寫法應該是這樣的:
6
<pre class="prettyprint lang-sh">
7
foo=bar # Right.
8
foo="bar" # More Right.
17. echo <<EOF
當腳本需要嵌入大段的文本內容時,here document往往是一個非常有用的工具,它將其中的文本作為命令的標准輸入。不過,echo 命令並不支持從標准輸入讀取內容,所以下面的寫法是錯誤的:
1
# This is wrong:
2
echo <<EOF
3
Hello world
4
How's it going?
5
EOF
正確的方法是,使用 cat 命令來完成:
1
# This is what you were trying to do:
2
cat <<EOF
3
Hello world
4
How's it going?
5
EOF
或者可以使用雙引號,它也可以跨越多行,而且因為 echo 命令是內置命令,相同情況下它會更加高效:
echo "Hello world
How's it going?"
18. su -c 'some command'
這種寫法“幾乎”是正確的。問題是,在許多平台上,su 支持 -c 參數,但是它不一定是你認為的。比如,在 OpenBSD 平台上你這樣執行會出錯:
$ su -c 'echo hello'
su: only the superuser may specify a login class
在這裡,-c是用於指定login-class。如果你想要傳遞 -c 'some command' 給 shell,最好在之前顯示地指定 username:
$ su root -c 'some command' # Now it's right.
19. cd /foo; bar
如果你不檢查 cd 命令執行是否成功,你可以會在錯誤的目錄下執行 bar 命令,這有可能會帶來災難,比如 bar 命令是 rm -rf *。
你必須經常檢查 cd 命令執行是否有錯誤,簡單的做法是:
cd /foo && bar
如果在 cd 命令後有多個命令,你可以選擇這樣寫:
1
cd /foo || exit 1
2
bar
3
baz
4
bat ... # Lots of commands.
出錯時,cd 命令會報告無法改變當前目錄,同時將錯誤消息輸出到標准錯誤,例如"bash: cd: /foo: No such file or directory"。如果你想要在標准輸出同時輸出自定義的錯誤提示,可以使用復合命令(command grouping):
1
cd /net || { echo "Can't read /net. Make sure you've logged in to the Samba network, and try again."; exit 1; }
2
do_stuff
3
more_stuff
注意,在{號和 echo 之間需要有一個空格,同時}之前要加上分號。
順便提一下,如果你要在腳本裡頻繁改變當前目錄,可以看看 pushd/popd/dirs 等命令,可能你在代碼裡面寫的 cd/pwd 命令都是沒有必要的。
說到這,比較下下面兩種寫法:
1
find ... -type d -print0 | while IFS= read -r -d '' subdir; do
2
here=$PWD
3
cd "$subdir" && whatever && ...
4
cd "$here"
5
done
1
find ... -type d -print0 | while IFS= read -r -d '' subdir; do
2
(cd "$subdir" || exit; whatever; ...)
3
done
下面的寫法,在循環中 fork 了一個子 shell 進程,子 shell 進程中的 cd 命令僅會影響當前 shell的環境變量,所以父進程中的環境命令不會被改變;當執行到下一次循環時,無論之前的 cd 命令有沒有執行成功,我們會回到相同的當前目錄。這種寫法相較前面的用法,代碼更加干淨。
20. [ bar == "$foo" ]
正確的用法:
[ bar = "$foo" ] && echo yes
[[ bar == $foo ]] && echo yes
21. for i in {1..10}; do ./something &; done
你不應該在&後面添加分號,刪除它:
for i in {1..10}; do ./something & done
或者改成多行的形式:
for i in {1..10}; do ./something & done
&和分號一樣也可以用作命令終止符,所以你不要將兩個混用到一起。一般情況下,分號可以被換行符替換,但是不是所有的換行符都可以用分號替換。
22. cmd1 && cmd2 || cmd3
有些人喜歡把&&和||作為if...then...else...fi 的簡寫語法,在多數情況下,這種寫法沒有問題。例如:
[[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."
但是,這種結構並不是在所有情況下都完全等價於 if...fi 語法。這是因為在&&後面的命令執行結束時也會生成一個返回碼,如果該返回碼不是真值(0代表 true),||後面的命令也會執行,例如:
1
i=0
2
true && ((i++)) || ((i--))
3
echo $i # 輸出 0
看起來上面的結果應該是返回1,但是結果卻是輸出0,為什麼呢?原因是這裡 i++ 和 i-- 都執行了一遍。
其中,((i++))命令執行算術運算,表達式計算的結果為0。這裡和 C 語言一樣,表達式的結果為0被認為是 false。所以當 i=0 的時候,((i++))命令執行的返回碼為1(false),從而會執行接下來的((i--))命令。
如果我們在這裡使用前綴自增運算符的話,返回的結果恰恰為1,因為((++i))執行的返回碼是0(true):
1
i=0
2
true && (( ++i )) || (( --i ))
3
echo $i # Prints 1
不過在你無法保證 y 的執行結果是,絕對不要依靠 x && y || z這種寫法。上面這種巧合,在 i 初始化為-1時也會有問題。
如果你喜歡代碼更加安全健壯,建議使用 if...fi 語法:
1
i=0
2
if true; then
3
((i++))
4
else
5
((i--))
6
fi
7
8
echo $i # 輸出 1
23. echo "Hello World!"
在交互式的 Shell 環境下,你執行以上命令會遇到下面的錯誤:
bash: !": event not found
這是因為,在默認的交互式 Shell 環境下,Bash 發現感歎號時會執行歷史命令展開。在 Shell 腳本中,這種行為是被禁止的,所以不會發生錯誤。
不幸地是,你認為明顯正確地修復方法,也不能工作,你會發現反斜槓並沒有轉義感歎號:
# echo "hi\!"
hi\!
最簡單地方法是禁用 histexpand 選項,你可以通過 set +H 或者 set +o histexpand 命令來完成。
下面四種寫法都可以解決:
# 1. 使用單引號
echo 'Hello World!'
# 2. 禁用 histexpand 選項
set +H
echo "Hello World!"
# 3. 重置 histchars
histchars=
# 4. 控制 shell 展開的順序,命令行歷史展開是在單詞拆分之前執行的
# 參見:Bash man 手冊的History Expansion一節 exmark='!'
echo "Hello, world$exmark"
24. for arg in $*
和大多數 Shell 一樣,Bash 支持依次讀取單個命令行參數的語法。不過這並是$*或者$@,這兩種寫法都不正確,它們只能得到完整的參數列表,並非單獨的一個個參數。
正確的語法是(沒錯要加上引號):
1
for arg in "$@"
2
3
# 或者更簡單的寫法
4
for arg
在腳本中遍歷所有參數是一個再普遍不過的需求,所以 for arg 默認等價於 for arg in "$@"。$@使用雙引號後就有特殊的魔力,每個參數展開後成為一個獨立的單詞。("$@"等價於"$1" "$2" "$3" ...)
下面是一個錯誤的例子:
for x in $*; do echo "parameter: '$x'" done
執行的結果為:
$ ./myscript 'arg 1' arg2 arg3
parameter: 'arg'
parameter: '1'
parameter: 'arg2'
parameter: 'arg3'
正確的寫法:
for x in "$@"; do echo "parameter: '$x'" done
執行的結果為:
$ ./myscript 'arg 1' arg2 arg3
parameter: 'arg 1'
parameter: 'arg2'
parameter: 'arg3'
上面正確的例子中,第一個參數'arg 1'在展開後依然是一個獨立的單詞,而不會被拆分成兩個。
25. function foo()
這種寫法不一定能夠兼容所有 shell,兼容的寫法是:
foo() { ... }
26. echo "~"
波浪號展開(Tilde expansion)僅當~沒有引號的時候發生,在上面的例子中,只會向標准輸出打印~符號,而不是當前用戶的家目錄路徑。
當用引號將路徑參數引起來時,
如果要用引號將相對於家目錄的路徑引起來時,推薦使用 $HOME 而不是 ~, 假如 $HOME 目錄是"/home/my photos",路徑中包含空格。
下面是幾組例子:
1
"~/dir with spaces" # expands to "~/dir with spaces"
2
~"/dir with spaces" # expands to "~/dir with spaces"
3
~/"dir with spaces" # expands to "/home/my photos/dir with spaces"
4
"$HOME/dir with spaces" # expands to "/home/my photos/dir with spaces"
27. local varname=$(command)
當在函數中聲明局部變量時,local作為一個獨立的命令,這種奇特的行為有時候可能會導致困擾。比如,當你想要捕獲命令替換的返回碼時,你就不能這樣做。local命令的返回碼會覆蓋它。
這種情況下,你只能分成兩行寫:
local varname
varname=$(command) rc=$?
28. export foo=~/bar
export 與 local 命令一樣,並不是賦值語句的一部分。因此,在有些 Shell 下(比如Bash),export foo=~/bar會展開,但是有些(比如 Dash)卻不行。
下面是兩種比較健壯的寫法:
1
foo=~/bar; export foo # Right!
2
export foo="$HOME/bar" # Right!
29. sed 's/$foo/good bye/'
單引號內部不會展開 $foo變量,在這裡可以換成雙引號:
foo="hello"; sed "s/$foo/good bye/"
但是要注意,如果你使用了雙引號,就需要考慮更多轉義的事情,具體可以看Quotes這一頁。.
30. tr [A-Z] [a-z]
這裡至少有三個問題。第一個問題是, [A-Z] 和 [a-z] 會被 shell 認為是通配符。如果在當前目錄下沒用文件名為單個字母的文件,這個命令似乎能正確執行,否則會錯誤地執行,也許你會在周末耗費許多小時來修復這個問題。
第二個問題是,這不是 tr 命令正確的寫法,實際上,上面的命令會把[轉換成[,將任意大寫字符轉換成對應的小寫字符,將]轉換成],所以你根本不需要加上括號,這樣第一個問題就可以解決了。
第三個問題是,上面的命令執行結果依賴於當前的 locale,A-Z 或者 a-z 不一定會代表26個 ASCII 字母。實際上,在一些語言環境下,z 位於字母表的中間位置。這個問題的解法,取決於你希望發生的行為是哪一種。
如果你僅希望改變26個英文字母的大小寫(強制 locale為 C):
LC_COLLATE=C tr A-Z a-z
如果你希望根據實際的語言環境來轉換:
tr '[:upper:]' '[:lower:]'
31. ps ax | grep gedit
這裡的根本問題是正在運行的進程名稱,本質上是不可靠的。可能會有多個合法的gedit進程,也有可能是別的東西偽裝成gedit進程(改變執行命令名稱是一件簡單的事情 ),更多細節可以看ProcessManagement這一篇文章。
執行以上命令,往往會在結果中包含 grep 進程:
# ps ax | grep gedit
10530 ? S 6:23 gedit
32118 pts/0 R+ 0:00 grep gedit
這個時候,需要過濾多余的結果:
# ps ax | grep -v grep | grep gedit
上面的寫法比較丑陋,另外一種方法是:
# ps ax | grep [g]edit
32. printf "$foo"
如果$foo 變量的值中包括\或者%符號,上面命令的執行結果可能會出乎你的意料之外。
下面是正確的寫法:
printf %s "$foo"
printf '%s\n' "$foo"
33. for i in {1..$n}
Bash的命令解釋器會優先展開大括號,所以這時大括號{}表達式裡面看到的是文字上的$n(沒有展開)。$n 不是一個數值,所以這裡的大括號{}並不會展開成數字列表。可見,這導致很難使用大括號來展開大小只能在運行時才知道的列表。
可以用下面的方法:
for ((i=1; i<=n; i++)); do ... done
注:之前我也有寫過一篇文章來介紹這個問題:Shell生成數字序列。
34. if [[ $foo = $bar ]]
在[[內部,當=號右邊的值沒有用引號引起來,bash 會將它當作模式來匹配,而不是一個簡單的字符串。所以,在上面的例子中 ,如果 bar 的值是一個*號,執行的結果永遠是 true。
所以,如果你想檢查兩側的字符串是否相同,等號右側的值一定要用引號引起來。
if [[ $foo = "$bar" ]]
如果你確實要執行模式匹配,聰明的做法是取一個更加有意義的變量名(例如$patt),或者加上注釋說明。
35. if [[ $foo =~ 'some RE' ]]
同上,如果=~號右側的值加上引號,它會散失特殊的正則表達式含義,而變成一個普通的字符串。
如果你想使用一個長的或者復雜的正則表達式,避免大量的反斜槓轉義,建議把它放在一個變量中:
re='some RE' if [[ $foo =~ $re ]]
36. [ -n $foo ] or [ -z $foo ]
這個例子中,$foo 沒有用引號引起來,當$foo包含空格或者$foo為空時都會出問題:
$ foo="some word" && [ -n $foo ] && echo yes -bash: [: some: binary operator expected
$ foo="" && [ -n $foo ] && echo yes
yes
正確的寫法是:
1
[ -n "$foo" ]
2
[ -z "$foo" ]
3
[ -n "$(some command with a "$file" in it)" ]
4
5
[[ -n $foo ]]
6
[[ -z $foo ]]
37. [[ -e "$broken_symlink" ]] returns 1 even though $broken_symlink exists
這裡-e 選項是看文件是否存在,當緊跟的文件是一個軟鏈接時,它不看軟鏈接是否存在,而是看實際指向的文件是否存在。所以當軟鏈接損壞時,即實際指向的文件被刪除後,-e 的結果返回1。
所以如果你確實要判斷後面的文件是否存在,正確的寫法是:
[[ -e "$broken_symlink" || -L "$broken_symlink" ]]
38. ed file <<<"g/d\{0,3\}/s//e/g" fails
ed 命令使用的正則語法,不支持0次出現次數,下面的就可以正常工作:
ed file <<<"g/d\{1,3\}/s//e/g"
略過,現在很少會有人用 ed 命令吧。
39. expr sub-string fails for "match"
下面的例子多數情況下運行不會有問題:
word=abcde
expr "$word" : ".\(.*\)" bcde
但是當 $work 不巧剛好是 match 時,就有可能出錯了(MAC OSX 下的 expr 命令不支持 match,所以依然能正常工作):
word=match
expr "$word" : ".\(.*\)"
原因是 match 是 expr 命令裡面的一個特殊關鍵字,針對 GNU系統,解決方法是在前面加一個'+':
word=match
expr + "$word" : ".\(.*\)" atch
'+'號可以讓 expr 命令忽略後續 token 的特殊含義。
另外一個建議是,不要再使用 expr 命令了,expr 能做的事情都可以用 Bash 原生支持的參數展開(Parameter Expansion)或者字符串展開(Substring Expansion)來完成。並且相同情況下,內置的功能肯定比外部命令的效率要高。
上面的例子,目的是為了刪除單詞中的首字符,可以這樣做:
1
$ word=match
2
$ echo "${word#?}" # PE
3
atch
4
$ echo "${word:1}" # SE
5
atch
40. On UTF-8 and Byte-Order Marks (BOM)
多數情況下,UNIX 下 UTF-8 類型的文本不需要使用 BOM,文本的編碼是根據當前語言環境,MIME類型或者其它文件元數據信息確定的。人為閱讀時,不會因為在文件開始處加 BOM 標記而腚影響,但是當文件要被腳本解釋執行時,BOM 標記會像 MS-DOS 下的換行符(^M)一樣奇怪。
41. content=$(<file)
這裡沒有什麼錯誤,不過你要知道命令替換會刪除結尾多余的換行符。
略過,原文給的優化方法需要 Bash 4.2+ 以上的版本,手頭沒有這樣的環境。
42. somecmd 2>&1 >>logfile
這是一個很常見的錯誤,顯然你本來是想將標准輸出與標准錯誤輸出都重定向到文件logfile 中,但是你會驚訝地發現,標准錯誤依然輸出到屏幕中。
這種行為的原因是,重定向在命令執行之前解析,並且是從左往右解析。上面的命令可以翻譯成,將標准錯誤輸出重定向到標准輸出(此刻是終端),然後將標准輸出重定向到文件 logfile 中。所以,到最後,標准錯誤並沒有重定向到文件中,而是依然輸出到終端:
somecmd >>logfile 2>&1
更加詳細的說明見BashFAQ。
43. cmd; (( ! $? )) || die
只有需要捕獲上一個命令的執行結果進,才需要記錄$?的值,否則如果你只需要檢查上一個命令是否執行成功,直接檢測命令:
if cmd; then ... fi
或者使用 case 語句來檢測多個或能的返回碼:
01
cmd
02
status=$?
03
case $status in
04
0)
05
echo success >&2
06
;;
07
1)
08
echo 'Must supply a parameter, exiting.' >&2
09
exit 1
10
;;
11
*)
12
echo 'Unknown error, exiting.' >&2
13
exit $status
14
esac