Bash Pitfalls[1]文章介紹了 40 多條日常 Bash 編程中,老手和新手都容易忽略的錯誤編程習慣。每條作者在給出錯誤的范例上,詳細分析與解釋錯誤的原因,同時給出正確的改寫建議。文中有不少引用的文章,也值得大家仔細閱讀。仔細閱讀了這篇文章后,收獲很多,不感獨享,把這篇文章以半翻譯半筆記的形式分享給大家。
1. for i in $(ls *.mp3)
Bash 寫循環代碼的時候,確實比較容易犯下面的錯誤:
foriin$(ls*.mp3);do#錯誤!
somecommand$i#錯誤!
done
foriin$(ls)#錯誤!
foriin`ls`#錯誤!
foriin$(find.-typef)#錯誤!
foriin`find.-typef`#錯誤!
files=($(find.-typef))#錯誤!
foriin${files[@]}#錯誤!
這里主要兩個問題:
- 使用命令展開時不帶引號,其執行結果會使用 IFS 作為分隔符,拆分成參數傳遞給 for 循環處理;
- 不應該讓腳本去解析 ls 命令的結果[2];
我們不能避免某些文件名中包含空格,Shell 會對$(ls *.mp3)
展開的結果會被做單詞拆分 (WordSplitting[3]) 的處理。假設有一個文件,名字為 01 - Don't Eat the Yellow Snow.mp3,for 循環處理的時候,會今次遍歷文件名中的每個單詞:01, -, Don't, Eat 等等:
$foriin$(ls*.mp3);doecho$i;done
01
-
Don't
Eat
the
Yellow
Snow.mp3
比這更差的情況是,上面命令展開的結果可能被 Shell 進一步處理,比如文件名展開[4]。比如,ls 執行的結果中包含*號,按照通配符的規則 , * 號會被展開成當前目錄下的所有文件 :
$touch"1*.mp3""1.mp3""11.mp3""12.mp3"
$foriin$(ls*.mp3);doecho$i;done
1*.mp31.mp311.mp312.mp3
1.mp3
11.mp3
12.mp3
1.mp3
11.mp3
12.mp3
不過,在這種場景下,你即使加上引號,也是無濟于事的:
$foriin"$(ls*.mp3)";doecho--$i--;done
--1*.mp31.mp311.mp312.mp3--
加上引號后,ls 執行的結果會被當成一個整體,所以 for 循環只會執行一次,達不到預期的效果。
事實上,這種情況下,根本不需要使用 ls 命令。ls 命令的結果本身就設計成給人讀的,而不是給腳本解析的。正確的處理方法是,直接使用文件名展開(通配符)的功能:
$foriin*.mp3;do
>echo"$i"
>done
1*.mp3
1.mp3
11.mp3
12.mp3
文件名展開是位于各種展開(花括號展開、變量替換、命令展開等)功能中的最后一個環節,所以不會有之前不帶引號的命令展開的副作用。如果你需要遞歸地處理文件,可以考慮使用 Find 命令[5]。
到這一步,之間的問題看樣子已經修復了。但是,如果你進一步思考,假設當前目錄上沒有文件時會怎么樣?沒有文件的時候,*.mp3 不會被展開直接傳遞給 for 循環處理,所以這個時候循環還是會執行一次。這種情況不是我們預期的行為。保險起見,可以在循環處理的時候,檢查下文件是否存在:
#POSIX
foriin*.mp3;do
[-e"$i"]||continue
somecommand"$i"
done
如果你有使用引號[6]和避免單詞拆分[7]的習慣,你完全可以避免很多錯誤。
注意下循環體內部的 "$i",這里會導致下面我們要說的另外一個比較容易犯的錯誤。
2. cp $file $target
上面的命令有什么問題呢?如果你提前知道,$file 和 $target 文件名中不會包含空格或者*號。否則,這行命令執行前在經過單詞拆分和文件名展開的時候會出現問題。所以,兩次強調,在使用展開的地方切勿忘記使用引號:
$cp--"$file""$target"
如果不帶引號,當你執行如下命令時就會出錯:
$file="01-Don'tEattheYellowSnow.mp3"
$target="/tmp"
$cp$file$target
cp:cannotstat‘01’:Nosuchfileordirectory
..
如果帶上引號,就不會有上面的問題,除非文件名以 '-' 開頭,在這種情況下,cp 會認為你提供的是一個命令行選項,這個錯誤下面會介紹。
3. 文件名中包含短橫 '-'
文件名以 '-' 開頭會導致許多問題,*.mp3 這種通配符會根據當前的locale[8]展開成一個列表,但在絕大多數環境下,'-' 排序的時候會排在大多數字母前。這個展開的列表傳遞給有些命令的時候,會錯誤的將-filename 解析成命令行選項。這里有兩種方法來解決這個問題。
第一種方法是在命令和參數之間加上--,這種語法告訴命令不要繼續對--之后的內容進行命令行參數 / 選項解析:
$cp--"$file""$target"
這種方法可以解這個問題,但是你需要在每個命令后面都要加上--,而且依賴具體的命令解析的方式,如果一些命令不兼容這種約定俗成的規范,這種做法是無效的。
另外一種方法是,確保文件名都使用相對或者絕對的路徑,以目錄開頭:
foriin./*.mp3;do
cp"$i"/target
...
done
這種情況下,即使某個文件以-開頭,展開后文件名依然是 ./-foo.mp3 這種形式,完全不會有問題。
4. [ $foo = "bar" ]
這是一個與第 2 個問題類似的問題,雖然用到了引號,但是放錯了位置,對于字符串字面值,除非有特殊符號,否則不大需要用引號括起來。但是,你應該把變量的值用括號括起來,從而避免它們包含空格或能通配符,這一點我們在前面的問題中都解釋過。
這個例子在以下情況下會出錯:
-
如果 [中的變量不存在,或者為空,這個時候上面的例子最終解析結果是:
[="bar"]#錯誤!
并且執行會出錯:unary operator expected,因為 = 是二元操作符,它需要左右各一個操作數。
-
如果變量值包含空格,它首先在執行之前進行單詞拆分,因此 [命令看到的樣子可能是這樣的:
[multiplewordshere="bar"];
正確的做法應該是:
#POSIX
["$foo"=bar]
這種寫法,在 POSIX 兼容的實現中都不會有問題,即使 $foo 以短橫 "-" 開頭,因為 POSIX 實現的 test 命令通過傳遞的參數來確定執行的行為。
只有一些非常古老的 shell 可能會遇到問題,這個時候你可以使用下面的寫法來解決(相信你肯定看到過這種寫法):
#POSIX/Bourne
[x"$foo"=xbar]
在 Bash 中,還有另外一種選擇是使用[[關鍵字[9]:
#Bash/Ksh
[[$foo==bar]]
這里你不需要使用引號,因為在 [[里面參數不會進行展開,當然帶上引號也不會有錯。
不過有一點要注意的是,[[里的 == 不僅僅是文本比較,它會檢查左邊的值是否匹配右側的表達式,== 右側的值加上引號,會讓它成為一個普通的字面量,*? 等通配符會失去特殊含義。
5. cd $(dirname "$f")
這又是一個引號的問題,命令展開的結果會進一步地進行單詞拆分或者文件名展開。因此下面的寫法才是正確的:
cd"$(dirname"$f")"
但是,上面引號的寫法可能比較怪異,你可能會認為第一、二個引號,第三、四個引號是一組的。
但是事實上,Bash 將命令替換里面的引號當成一組,外面的當成另外一組。如果你是用反引號的寫法,引號的行為就不是這樣的了,所以[$() 寫法更加推薦](http://mywiki.wooledge.org/BashFAQ/082 "$() 寫法更加推薦")。
6. [ "$foo" = bar && "$bar" = foo ]
不要在test 命令[10]內部使用 &&,Bash 解析器會把你的命令分隔成兩個命令,在 && 之前和之后。你應該使用下面的寫法:
[bar="$foo"]&&[foo="$bar"]#POSIX
[[$foo=bar&&$bar=foo]]#Bash/Ksh
盡量避免使用下面的寫法,雖然它是正確的,但是這種寫法可移植性不好,并且已經在 POSIX-2008 中被廢棄:
[bar="$foo"-afoo="$bar"]
7. [[ $foo > 7 ]]
原文作者認為算術比較不應該用 [[,而是用 ((,我沒弄明白是為什么。
如果有理解的同學,歡迎以評論回復,謝謝。
8. grep foo bar | while read -r; do ((count++)); done
這種寫法初看沒有問題,但是你會發現當執行完后,count 變量并沒有變化。原因是管道后面的命令是在一個子 Shell[11]中執行的。
POSIX 規范并沒有說明管道的最后一個命令是不是在子 Shell 中執行的。一些 shell,例如 ksh93 或者 Bash>=4.2 可以通過shopt -s lastpipe
命令,指明管道中的最后一個命令在當前 shell 中執行。由于篇幅限制,在此就不展開,有興趣的可以看Bash FAQ #24[12]。
9. if [grep foo myfile]
初學者會錯誤地認為,[是 if 語法的一部分,正如 C 語言中的 if ()。但是事實并非如此,if 后面跟著的是一個命令,[是一個命令,它是內置命令 test 的簡寫形式,只不過它要求最后一個參數必須是]。下面兩種寫法是一樣的:
#POSIX
if[false];thenecho"HELP";fi
iftestfalse;thenecho"HELP";fi
兩個都是檢查參數 "false" 是不是非空的,所以上面兩個語句都會輸出 HELP。
if 語句的語法是:
if COMMANDS
then
elif # optional
then
else # optional
fi # required
再次強調,[是一個命令,它同其它常規的命令一樣接受參數。if 是一個復合命令,它包含其它命令,[并不是 if 語法中的一部分。
如果你想根據 grep 命令的結果來做事情,你不需要把 grep 放到 [里面,只需要在 if 后面緊跟 grep 即可:
ifgrep-qfooregexmyfile;then
...
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 后面有兩個命令,它們用 && 分開。等價于下面的寫法:
iftesta=b&&testc=d;then...
如果第一個 test(或者 [) 命令返回 false,then 后面的語句不會執行;如果第一個返回 true,第二個 test 命令會執行;只有第二個命令同樣返回 true 的情況下,then 后面的語句才會執行。
除此之外,還可以使用 [[關鍵字,因為它支持 && 的用法:
if[[a=b&&c=d]];then...
12. read $foo
read 命令中你不需要在變量名之前使用 $。如果你想把讀入的數據存放到名為 foo 的變量中,下面的寫法就夠了:
readfoo
或者,更加安全地方法:
IFS=read-rfoo
read $foo
會把一行的內容讀入到變量中,該變量的名稱存儲在 $foo 中。所以兩者的含義是完全不一樣的。
13. cat file | sed s/foo/bar/ > file
你不應該在一個管道中,從一個文件讀的同時,再往相同的文件里面寫,這樣的后果是未知的。
你可以為此創建一個臨時文件,這種做法比較安全可靠:
#sed's/foo/bar/g'file>tmpfile&&mvtmpfilefile
或者,如果你用得是 GNU Sed 4.x 以上的版本,可以使用-i 選項即時修改文件的內容:
#sed-i's/foo/bar/g'file
14. echo $foo
這種看似無害的命令往往會給初學者千萬極大的困擾,他們會懷疑是不是因為 $foo 變量的值是錯誤的。事實卻是因為,$foo 變量在這里沒有使用雙引號,所以在解析的時候會進行單詞拆分[13]和文件名展開[14],最終導致執行結果與預期大相徑庭:
msg="Pleaseenterafilenameoftheform*.zip"
echo$msg
這里整句話會被拆分成單詞,然后其中的通配符會被展開,例如 *.zip。當你的用戶看到如下的結果時,他們會怎樣想:
Pleaseenterafilenameoftheformfreenfss.ziplw35nfss.zip
再舉一個例子(假設當前目錄下有以 .zip 結尾的文件):
var="*.zip"#var包括一個星號,一個點號和zip
echo"$var"#輸出*.zip
echo$var#輸出所有以.zip結尾的文件
實際上,這里使用 echo 命令并不是絕對的安全。例如,當變量的值包含-n 時,echo 會認為它是一個合法的選項而不是要輸出的內容(當然如果你能夠保證不會有-n 這種值,可以放心地使用 echo 命令)。
完全可靠的打印變量值的方法是使用 printf:
printf"%s
""$foo"
15. $foo=bar
略過
16. foo = bar
當賦值時,等號兩邊是不允許出現空格的,這同 C 語言不一樣。當你寫下 foo = bar 時,shell 會將該命令解析成三個單詞,然后第一個單詞 foo 會被認為是一個命令,后面的內容會被當作命令參數。
同樣地,下面的寫法也是錯誤的:
foo=bar#WRONG!
foo=bar#WRONG!
$foo=bar;#COMPLETELYWRONG!
正確的寫法應該是這樣的:
"prettyprintlang-sh">
foo=bar#Right.
foo="bar"#MoreRight.
17. echo <
當腳本需要嵌入大段的文本內容時,here document[15]往往是一個非常有用的工具,它將其中的文本作為命令的標準輸入。不過,echo 命令并不支持從標準輸入讀取內容,所以下面的寫法是錯誤的:
#Thisiswrong:
echo<'sitgoing?
EOF
正確的方法是,使用 cat 命令來完成:
#Thisiswhatyouweretryingtodo:
cat<'sitgoing?
EOF
或者可以使用雙引號,它也可以跨越多行,而且因為 echo 命令是內置命令,相同情況下它會更加高效:
echo"Helloworld
How'sitgoing?"
18. su -c 'some command'
這種寫法“幾乎”是正確的。問題是,在許多平臺上,su 支持 -c 參數,但是它不一定是你認為的。比如,在 OpenBSD 平臺上你這樣執行會出錯:
$su-c'echohello'
su:onlythesuperusermayspecifyaloginclass
在這里,-c 是用于指定 login-class[16]。如果你想要傳遞 -c 'some command' 給 shell,最好在之前顯示地指定 username:
$suroot-c'somecommand'#Nowit'sright.
19. cd /foo; bar
如果你不檢查 cd 命令執行是否成功,你可以會在錯誤的目錄下執行 bar 命令,這有可能會帶來災難,比如 bar 命令是 rm -rf *。
你必須經常檢查 cd 命令執行是否有錯誤,簡單的做法是:
cd/foo&&bar
如果在 cd 命令后有多個命令,你可以選擇這樣寫:
cd/foo||exit1
bar
baz
bat...#Lotsofcommands.
出錯時,cd 命令會報告無法改變當前目錄,同時將錯誤消息輸出到標準錯誤,例如 "bash: cd: /foo: No such file or directory"。如果你想要在標準輸出同時輸出自定義的錯誤提示,可以使用復合命令(command grouping[17]):
cd/net||{echo"Can'tread/net.Makesureyou'veloggedintotheSambanetwork,andtryagain.";exit1;}
do_stuff
more_stuff
注意,在{號和 echo 之間需要有一個空格,同時}之前要加上分號。
順便提一下,如果你要在腳本里頻繁改變當前目錄,可以看看 pushd/popd/dirs 等命令,可能你在代碼里面寫的 cd/pwd 命令都是沒有必要的。
說到這,比較下下面兩種寫法:
find...-typed-print0|whileIFS=read-r-d''subdir;do
here=$PWD
cd"$subdir"&&whatever&&...
cd"$here"
done
find...-typed-print0|whileIFS=read-r-d''subdir;do
(cd"$subdir"||exit;whatever;...)
done
下面的寫法,在循環中 fork 了一個子 shell 進程,子 shell 進程中的 cd 命令僅會影響當前 shell 的環境變量,所以父進程中的環境命令不會被改變;當執行到下一次循環時,無論之前的 cd 命令有沒有執行成功,我們會回到相同的當前目錄。這種寫法相較前面的用法,代碼更加干凈。
20. [ bar == "$foo" ]
正確的用法 :
[bar="$foo"]&&echoyes
[[bar==$foo]]&&echoyes
21. for i in {1..10}; do ./something &; done
你不應該在 & 后面添加分號,刪除它:
foriin{1..10};do./something&done
或者改成多行的形式:
foriin{1..10};do
./something&
done
& 和分號一樣也可以用作命令終止符,所以你不要將兩個混用到一起。一般情況下,分號可以被換行符替換,但是不是所有的換行符都可以用分號替換。
22. cmd1 && cmd2 || cmd3
有些人喜歡把 && 和 || 作為 if...then...else...fi 的簡寫語法,在多數情況下,這種寫法沒有問題。例如:
[[-s$errorlog]]&&echo"Uhoh,thereweresomeerrors."||echo"Successful."
但是,這種結構并不是在所有情況下都完全等價于 if...fi 語法。這是因為在 && 后面的命令執行結束時也會生成一個返回碼,如果該返回碼不是真值(0 代表 true),|| 后面的命令也會執行,例如:
i=0
true&&((i++))||((i--))
echo$i#輸出0
看起來上面的結果應該是返回 1,但是結果卻是輸出 0,為什么呢?原因是這里 i++ 和 i-- 都執行了一遍。
其中,((i++)) 命令執行算術運算,表達式計算的結果為 0。這里和 C 語言一樣,表達式的結果為 0 被認為是 false。所以當 i=0 的時候,((i++)) 命令執行的返回碼為 1(false),從而會執行接下來的 ((i--)) 命令。
如果我們在這里使用前綴自增運算符的話,返回的結果恰恰為 1,因為 ((++i)) 執行的返回碼是 0(true):
i=0
true&&((++i))||((--i))
echo$i#Prints1
不過在你無法保證 y 的執行結果是,絕對不要依靠 x && y || z 這種寫法。上面這種巧合,在 i 初始化為-1 時也會有問題。
如果你喜歡代碼更加安全健壯,建議使用 if...fi 語法:
i=0
iftrue;then
((i++))
else
((i--))
fi
echo$i#輸出1
23. echo "Hello World!"
在交互式的 Shell 環境下,你執行以上命令會遇到下面的錯誤:
bash:!":eventnotfound
這是因為,在默認的交互式 Shell 環境下,Bash 發現感嘆號時會執行歷史命令展開。在 Shell 腳本中,這種行為是被禁止的,所以不會發生錯誤。
不幸地是,你認為明顯正確地修復方法,也不能工作,你會發現反斜杠并沒有轉義感嘆號[18]:
#echo"hi!"
hi!
最簡單地方法是禁用 histexpand 選項,你可以通過 set +H 或者 set +o histexpand 命令來完成。
下面四種寫法都可以解決:
#1.使用單引號
echo'HelloWorld!'
#2.禁用histexpand選項
set+H
echo"HelloWorld!"
#3.重置histchars
histchars=
#4.控制shell展開的順序,命令行歷史展開是在單詞拆分之前執行的
#參見:Bash man 手冊的History Expansion一節
exmark='!'
echo"Hello,world$exmark"
24. for arg in $*
和大多數 Shell 一樣,Bash 支持依次讀取單個命令行參數的語法。不過這并是 $*或者 $@,這兩種寫法都不正確,它們只能得到完整的參數列表,并非單獨的一個個參數。
正確的語法是(沒錯要加上引號):
forargin"$@"
#或者更簡單的寫法
forarg
在腳本中遍歷所有參數是一個再普遍不過的需求,所以 for arg 默認等價于 for arg in "$@"。$@ 使用雙引號后就有特殊的魔力,每個參數展開后成為一個獨立的單詞。("$@" 等價于 "$1" "$2" "$3" ...)
下面是一個錯誤的例子 :
forxin$*;do
echo"parameter:'$x'"
done
執行的結果為:
$./myscript'arg1'arg2arg3
parameter:'arg'
parameter:'1'
parameter:'arg2'
parameter:'arg3'
正確的寫法:
forxin"$@";do
echo"parameter:'$x'"
done
執行的結果為:
$./myscript'arg1'arg2arg3
parameter:'arg1'
parameter:'arg2'
parameter:'arg3'
上面正確的例子中,第一個參數 'arg 1' 在展開后依然是一個獨立的單詞,而不會被拆分成兩個。
25. function foo()
這種寫法不一定能夠兼容所有 shell,兼容的寫法是:
foo(){
...
}
26. echo "~"
波浪號展開(Tilde expansion)[19]僅當~沒有引號的時候發生,在上面的例子中,只會向標準輸出打印~符號,而不是當前用戶的家目錄路徑。
當用引號將路徑參數引起來時, 如果要用引號將相對于家目錄的路徑引起來時,推薦使用 $HOME 而不是 ~, 假如 $HOME 目錄是 "/home/my photos",路徑中包含空格。
下面是幾組例子:
"~/dirwithspaces"#expandsto"~/dirwithspaces"
~"/dirwithspaces"#expandsto"~/dirwithspaces"
~/"dirwithspaces"#expandsto"/home/myphotos/dirwithspaces"
"$HOME/dirwithspaces"#expandsto"/home/myphotos/dirwithspaces"
27. local varname=$(command)
當在函數中聲明局部變量時,local[20]作為一個獨立的命令,這種奇特的行為有時候可能會導致困擾。比如,當你想要捕獲命令替換[21]的返回碼時,你就不能這樣做。local 命令的返回碼會覆蓋它。
這種情況下,你只能分成兩行寫:
localvarname
varname=$(command)
rc=$?
28. export foo=~/bar
export 與 local 命令一樣,并不是賦值語句的一部分。因此,在有些 Shell 下(比如 Bash),export foo=~/bar 會展開,但是有些(比如 Dash)卻不行。
下面是兩種比較健壯的寫法:
foo=~/bar;exportfoo#Right!
exportfoo="$HOME/bar"#Right!
29. sed 's/$foo/good bye/'
單引號內部不會展開 $foo 變量,在這里可以換成雙引號:
foo="hello";sed"s/$foo/goodbye/"
但是要注意,如果你使用了雙引號,就需要考慮更多轉義的事情,具體可以看Quotes[22]這一頁。.
30. tr [A-Z] [a-z]
這里至少有三個問題。第一個問題是, [A-Z] 和 [a-z] 會被 shell 認為是通配符。如果在當前目錄下沒用文件名為單個字母的文件,這個命令似乎能正確執行,否則會錯誤地執行,也許你會在周末耗費許多小時來修復這個問題。
第二個問題是,這不是 tr 命令正確的寫法,實際上,上面的命令會把 [轉換成 [,將任意大寫字符轉換成對應的小寫字符,將] 轉換成],所以你根本不需要加上括號,這樣第一個問題就可以解決了。
第三個問題是,上面的命令執行結果依賴于當前的locale[23],A-Z 或者 a-z 不一定會代表 26 個 ASCII 字母。實際上,在一些語言環境下,z 位于字母表的中間位置。這個問題的解法,取決于你希望發生的行為是哪一種。
如果你僅希望改變 26 個英文字母的大小寫(強制 locale 為 C):
LC_COLLATE=CtrA-Za-z
如果你希望根據實際的語言環境來轉換:
tr'[]''[]'
31. ps ax | grep gedit
這里的根本問題是正在運行的進程名稱,本質上是不可靠的。可能會有多個合法的 gedit 進程,也有可能是別的東西偽裝成 gedit 進程(改變執行命令名稱是一件簡單的事情 ), 更多細節可以看ProcessManagement[24]這一篇文章。
執行以上命令,往往會在結果中包含 grep 進程:
#psax|grepgedit
10530?S6:23gedit
32118pts/0R+0:00grepgedit
這個時候,需要過濾多余的結果:
#psax|grep-vgrep|grepgedit
上面的寫法比較丑陋,另外一種方法是:
#psax|grep[g]edit
32. printf "$foo"
如果 $foo 變量的值中包括 或者 % 符號,上面命令的執行結果可能會出乎你的意料之外。
下面是正確的寫法:
printf%s"$foo"
printf'%s
'"$foo"
33. for i in {1..$n}
Bash 的命令解釋器[25]會優先展開大括號[26],所以這時大括號{}表達式里面看到的是文字上的 $n(沒有展開)。$n 不是一個數值,所以這里的大括號{}并不會展開成數字列表。可見,這導致很難使用大括號來展開大小只能在運行時才知道的列表。
可以用下面的方法:
for((i=1;i<=n;?i++));?do
...
done
注:之前我也有寫過一篇文章來介紹這個問題:Shell 生成數字序列[27]。
34. if [[ $foo = $bar ]]
在 [[內部,當 = 號右邊的值沒有用引號引起來,bash 會將它當作模式來匹配,而不是一個簡單的字符串。所以,在上面的例子中 ,如果 bar 的值是一個*號,執行的結果永遠是 true。
所以,如果你想檢查兩側的字符串是否相同,等號右側的值一定要用引號引起來。
if[[$foo="$bar"]]
如果你確實要執行模式匹配,聰明的做法是取一個更加有意義的變量名(例如 $patt),或者加上注釋說明。
35. if [[ $foo =~ 'some RE' ]]
同上,如果 =~號右側的值加上引號,它會散失特殊的正則表達式含義,而變成一個普通的字符串。
如果你想使用一個長的或者復雜的正則表達式,避免大量的反斜杠轉義,建議把它放在一個變量中:
re='someRE'
if[[$foo=~$re]]
36. [ -n $foo ] or [ -z $foo ]
這個例子中,$foo 沒有用引號引起來,當 $foo 包含空格或者 $foo 為空時都會出問題:
$foo="someword"&&[-n$foo]&&echoyes
-bash:[:some:binaryoperatorexpected
$foo=""&&[-n$foo]&&echoyes
yes
正確的寫法是:
[-n"$foo"]
[-z"$foo"]
[-n"$(somecommandwitha"$file"init)"]
[[-n$foo]]
[[-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 次出現次數,下面的就可以正常工作:
edfile<<<"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[28])或者字符串展開(Substring Expansion)來完成。并且相同情況下,內置的功能肯定比外部命令的效率要高。
上面的例子,目的是為了刪除單詞中的首字符,可以這樣做:
$word=match
$echo"${word#?}"#PE
atch
$echo"${word:1}"#SE
atch
40. On UTF-8 and Byte-Order Marks (BOM)
多數情況下,UNIX 下 UTF-8 類型的文本不需要使用 BOM,文本的編碼是根據當前語言環境,MIME 類型或者其它文件元數據信息確定的。人為閱讀時,不會因為在文件開始處加 BOM 標記而腚影響,但是當文件要被腳本解釋執行時,BOM 標記會像 MS-DOS 下的換行符(^M)一樣奇怪。
41. content=$(
這里沒有什么錯誤,不過你要知道命令替換會刪除結尾多余的換行符。
略過,原文給的優化方法需要 Bash 4.2+ 以上的版本,手頭沒有這樣的環境。
42. somecmd 2>&1 >>logfile
這是一個很常見的錯誤,顯然你本來是想將標準輸出與標準錯誤輸出都重定向到文件 logfile 中,但是你會驚訝地發現,標準錯誤依然輸出到屏幕中。
這種行為的原因是,重定向[29]在命令執行之前解析,并且是從左往右解析。上面的命令可以翻譯成,將標準錯誤輸出重定向到標準輸出(此刻是終端),然后將標準輸出重定向到文件 logfile 中。所以,到最后,標準錯誤并沒有重定向到文件中,而是依然輸出到終端:
somecmd>>logfile2>&1
更加詳細的說明見BashFAQ[30]。
43. cmd; (( ! $? )) || die
只有需要捕獲上一個命令的執行結果進,才需要記錄 $? 的值,否則如果你只需要檢查上一個命令是否執行成功,直接檢測命令:
ifcmd;then
...
fi
或者使用 case 語句來檢測多個或能的返回碼:
cmd
status=$?
case$statusin
0)
echosuccess>&2
;;
1)
echo'Mustsupplyaparameter,exiting.'>&2
exit1
;;
*)
echo'Unknownerror,exiting.'>&2
exit$status
esac
當腳本需要嵌入大段的文本內容時,here document[15]往往是一個非常有用的工具,它將其中的文本作為命令的標準輸入。不過,echo 命令并不支持從標準輸入讀取內容,所以下面的寫法是錯誤的:
#Thisiswrong:
echo<'sitgoing?
EOF
正確的方法是,使用 cat 命令來完成:
#Thisiswhatyouweretryingtodo:
cat<'sitgoing?
EOF
或者可以使用雙引號,它也可以跨越多行,而且因為 echo 命令是內置命令,相同情況下它會更加高效:
echo"Helloworld
How'sitgoing?"
18. su -c 'some command'
這種寫法“幾乎”是正確的。問題是,在許多平臺上,su 支持 -c 參數,但是它不一定是你認為的。比如,在 OpenBSD 平臺上你這樣執行會出錯:
$su-c'echohello'
su:onlythesuperusermayspecifyaloginclass
在這里,-c 是用于指定 login-class[16]。如果你想要傳遞 -c 'some command' 給 shell,最好在之前顯示地指定 username:
$suroot-c'somecommand'#Nowit'sright.
19. cd /foo; bar
如果你不檢查 cd 命令執行是否成功,你可以會在錯誤的目錄下執行 bar 命令,這有可能會帶來災難,比如 bar 命令是 rm -rf *。
你必須經常檢查 cd 命令執行是否有錯誤,簡單的做法是:
cd/foo&&bar
如果在 cd 命令后有多個命令,你可以選擇這樣寫:
cd/foo||exit1
bar
baz
bat...#Lotsofcommands.
出錯時,cd 命令會報告無法改變當前目錄,同時將錯誤消息輸出到標準錯誤,例如 "bash: cd: /foo: No such file or directory"。如果你想要在標準輸出同時輸出自定義的錯誤提示,可以使用復合命令(command grouping[17]):
cd/net||{echo"Can'tread/net.Makesureyou'veloggedintotheSambanetwork,andtryagain.";exit1;}
do_stuff
more_stuff
注意,在{號和 echo 之間需要有一個空格,同時}之前要加上分號。
順便提一下,如果你要在腳本里頻繁改變當前目錄,可以看看 pushd/popd/dirs 等命令,可能你在代碼里面寫的 cd/pwd 命令都是沒有必要的。
說到這,比較下下面兩種寫法:
find...-typed-print0|whileIFS=read-r-d''subdir;do
here=$PWD
cd"$subdir"&&whatever&&...
cd"$here"
done
find...-typed-print0|whileIFS=read-r-d''subdir;do
(cd"$subdir"||exit;whatever;...)
done
下面的寫法,在循環中 fork 了一個子 shell 進程,子 shell 進程中的 cd 命令僅會影響當前 shell 的環境變量,所以父進程中的環境命令不會被改變;當執行到下一次循環時,無論之前的 cd 命令有沒有執行成功,我們會回到相同的當前目錄。這種寫法相較前面的用法,代碼更加干凈。
20. [ bar == "$foo" ]
正確的用法 :
[bar="$foo"]&&echoyes
[[bar==$foo]]&&echoyes
21. for i in {1..10}; do ./something &; done
你不應該在 & 后面添加分號,刪除它:
foriin{1..10};do./something&done
或者改成多行的形式:
foriin{1..10};do
./something&
done
& 和分號一樣也可以用作命令終止符,所以你不要將兩個混用到一起。一般情況下,分號可以被換行符替換,但是不是所有的換行符都可以用分號替換。
22. cmd1 && cmd2 || cmd3
有些人喜歡把 && 和 || 作為 if...then...else...fi 的簡寫語法,在多數情況下,這種寫法沒有問題。例如:
[[-s$errorlog]]&&echo"Uhoh,thereweresomeerrors."||echo"Successful."
但是,這種結構并不是在所有情況下都完全等價于 if...fi 語法。這是因為在 && 后面的命令執行結束時也會生成一個返回碼,如果該返回碼不是真值(0 代表 true),|| 后面的命令也會執行,例如:
i=0
true&&((i++))||((i--))
echo$i#輸出0
看起來上面的結果應該是返回 1,但是結果卻是輸出 0,為什么呢?原因是這里 i++ 和 i-- 都執行了一遍。
其中,((i++)) 命令執行算術運算,表達式計算的結果為 0。這里和 C 語言一樣,表達式的結果為 0 被認為是 false。所以當 i=0 的時候,((i++)) 命令執行的返回碼為 1(false),從而會執行接下來的 ((i--)) 命令。
如果我們在這里使用前綴自增運算符的話,返回的結果恰恰為 1,因為 ((++i)) 執行的返回碼是 0(true):
i=0
true&&((++i))||((--i))
echo$i#Prints1
不過在你無法保證 y 的執行結果是,絕對不要依靠 x && y || z 這種寫法。上面這種巧合,在 i 初始化為-1 時也會有問題。
如果你喜歡代碼更加安全健壯,建議使用 if...fi 語法:
i=0
iftrue;then
((i++))
else
((i--))
fi
echo$i#輸出1
23. echo "Hello World!"
在交互式的 Shell 環境下,你執行以上命令會遇到下面的錯誤:
bash:!":eventnotfound
這是因為,在默認的交互式 Shell 環境下,Bash 發現感嘆號時會執行歷史命令展開。在 Shell 腳本中,這種行為是被禁止的,所以不會發生錯誤。
不幸地是,你認為明顯正確地修復方法,也不能工作,你會發現反斜杠并沒有轉義感嘆號[18]:
#echo"hi!"
hi!
最簡單地方法是禁用 histexpand 選項,你可以通過 set +H 或者 set +o histexpand 命令來完成。
下面四種寫法都可以解決:
#1.使用單引號
echo'HelloWorld!'
#2.禁用histexpand選項
set+H
echo"HelloWorld!"
#3.重置histchars
histchars=
#4.控制shell展開的順序,命令行歷史展開是在單詞拆分之前執行的
#參見:Bash man 手冊的History Expansion一節
exmark='!'
echo"Hello,world$exmark"
24. for arg in $*
和大多數 Shell 一樣,Bash 支持依次讀取單個命令行參數的語法。不過這并是 $*或者 $@,這兩種寫法都不正確,它們只能得到完整的參數列表,并非單獨的一個個參數。
正確的語法是(沒錯要加上引號):
forargin"$@"
#或者更簡單的寫法
forarg
在腳本中遍歷所有參數是一個再普遍不過的需求,所以 for arg 默認等價于 for arg in "$@"。$@ 使用雙引號后就有特殊的魔力,每個參數展開后成為一個獨立的單詞。("$@" 等價于 "$1" "$2" "$3" ...)
下面是一個錯誤的例子 :
forxin$*;do
echo"parameter:'$x'"
done
執行的結果為:
$./myscript'arg1'arg2arg3
parameter:'arg'
parameter:'1'
parameter:'arg2'
parameter:'arg3'
正確的寫法:
forxin"$@";do
echo"parameter:'$x'"
done
執行的結果為:
$./myscript'arg1'arg2arg3
parameter:'arg1'
parameter:'arg2'
parameter:'arg3'
上面正確的例子中,第一個參數 'arg 1' 在展開后依然是一個獨立的單詞,而不會被拆分成兩個。
25. function foo()
這種寫法不一定能夠兼容所有 shell,兼容的寫法是:
foo(){
...
}
26. echo "~"
波浪號展開(Tilde expansion)[19]僅當~沒有引號的時候發生,在上面的例子中,只會向標準輸出打印~符號,而不是當前用戶的家目錄路徑。
當用引號將路徑參數引起來時, 如果要用引號將相對于家目錄的路徑引起來時,推薦使用 $HOME 而不是 ~, 假如 $HOME 目錄是 "/home/my photos",路徑中包含空格。
下面是幾組例子:
"~/dirwithspaces"#expandsto"~/dirwithspaces"
~"/dirwithspaces"#expandsto"~/dirwithspaces"
~/"dirwithspaces"#expandsto"/home/myphotos/dirwithspaces"
"$HOME/dirwithspaces"#expandsto"/home/myphotos/dirwithspaces"
27. local varname=$(command)
當在函數中聲明局部變量時,local[20]作為一個獨立的命令,這種奇特的行為有時候可能會導致困擾。比如,當你想要捕獲命令替換[21]的返回碼時,你就不能這樣做。local 命令的返回碼會覆蓋它。
這種情況下,你只能分成兩行寫:
localvarname
varname=$(command)
rc=$?
28. export foo=~/bar
export 與 local 命令一樣,并不是賦值語句的一部分。因此,在有些 Shell 下(比如 Bash),export foo=~/bar 會展開,但是有些(比如 Dash)卻不行。
下面是兩種比較健壯的寫法:
foo=~/bar;exportfoo#Right!
exportfoo="$HOME/bar"#Right!
29. sed 's/$foo/good bye/'
單引號內部不會展開 $foo 變量,在這里可以換成雙引號:
foo="hello";sed"s/$foo/goodbye/"
但是要注意,如果你使用了雙引號,就需要考慮更多轉義的事情,具體可以看Quotes[22]這一頁。.
30. tr [A-Z] [a-z]
這里至少有三個問題。第一個問題是, [A-Z] 和 [a-z] 會被 shell 認為是通配符。如果在當前目錄下沒用文件名為單個字母的文件,這個命令似乎能正確執行,否則會錯誤地執行,也許你會在周末耗費許多小時來修復這個問題。
第二個問題是,這不是 tr 命令正確的寫法,實際上,上面的命令會把 [轉換成 [,將任意大寫字符轉換成對應的小寫字符,將] 轉換成],所以你根本不需要加上括號,這樣第一個問題就可以解決了。
第三個問題是,上面的命令執行結果依賴于當前的locale[23],A-Z 或者 a-z 不一定會代表 26 個 ASCII 字母。實際上,在一些語言環境下,z 位于字母表的中間位置。這個問題的解法,取決于你希望發生的行為是哪一種。
如果你僅希望改變 26 個英文字母的大小寫(強制 locale 為 C):
LC_COLLATE=CtrA-Za-z
如果你希望根據實際的語言環境來轉換:
tr'[]''[]'
31. ps ax | grep gedit
這里的根本問題是正在運行的進程名稱,本質上是不可靠的。可能會有多個合法的 gedit 進程,也有可能是別的東西偽裝成 gedit 進程(改變執行命令名稱是一件簡單的事情 ), 更多細節可以看ProcessManagement[24]這一篇文章。
執行以上命令,往往會在結果中包含 grep 進程:
#psax|grepgedit
10530?S6:23gedit
32118pts/0R+0:00grepgedit
這個時候,需要過濾多余的結果:
#psax|grep-vgrep|grepgedit
上面的寫法比較丑陋,另外一種方法是:
#psax|grep[g]edit
32. printf "$foo"
如果 $foo 變量的值中包括 或者 % 符號,上面命令的執行結果可能會出乎你的意料之外。
下面是正確的寫法:
printf%s"$foo"
printf'%s
'"$foo"
33. for i in {1..$n}
Bash 的命令解釋器[25]會優先展開大括號[26],所以這時大括號{}表達式里面看到的是文字上的 $n(沒有展開)。$n 不是一個數值,所以這里的大括號{}并不會展開成數字列表。可見,這導致很難使用大括號來展開大小只能在運行時才知道的列表。
可以用下面的方法:
for((i=1;i<=n;?i++));?do
...
done
注:之前我也有寫過一篇文章來介紹這個問題:Shell 生成數字序列[27]。
34. if [[ $foo = $bar ]]
在 [[內部,當 = 號右邊的值沒有用引號引起來,bash 會將它當作模式來匹配,而不是一個簡單的字符串。所以,在上面的例子中 ,如果 bar 的值是一個*號,執行的結果永遠是 true。
所以,如果你想檢查兩側的字符串是否相同,等號右側的值一定要用引號引起來。
if[[$foo="$bar"]]
如果你確實要執行模式匹配,聰明的做法是取一個更加有意義的變量名(例如 $patt),或者加上注釋說明。
35. if [[ $foo =~ 'some RE' ]]
同上,如果 =~號右側的值加上引號,它會散失特殊的正則表達式含義,而變成一個普通的字符串。
如果你想使用一個長的或者復雜的正則表達式,避免大量的反斜杠轉義,建議把它放在一個變量中:
re='someRE'
if[[$foo=~$re]]
36. [ -n $foo ] or [ -z $foo ]
這個例子中,$foo 沒有用引號引起來,當 $foo 包含空格或者 $foo 為空時都會出問題:
$foo="someword"&&[-n$foo]&&echoyes
-bash:[:some:binaryoperatorexpected
$foo=""&&[-n$foo]&&echoyes
yes
正確的寫法是:
[-n"$foo"]
[-z"$foo"]
[-n"$(somecommandwitha"$file"init)"]
[[-n$foo]]
[[-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 次出現次數,下面的就可以正常工作:
edfile<<<"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[28])或者字符串展開(Substring Expansion)來完成。并且相同情況下,內置的功能肯定比外部命令的效率要高。
上面的例子,目的是為了刪除單詞中的首字符,可以這樣做:
$word=match
$echo"${word#?}"#PE
atch
$echo"${word:1}"#SE
atch
40. On UTF-8 and Byte-Order Marks (BOM)
多數情況下,UNIX 下 UTF-8 類型的文本不需要使用 BOM,文本的編碼是根據當前語言環境,MIME 類型或者其它文件元數據信息確定的。人為閱讀時,不會因為在文件開始處加 BOM 標記而腚影響,但是當文件要被腳本解釋執行時,BOM 標記會像 MS-DOS 下的換行符(^M)一樣奇怪。
41. content=$(
這里沒有什么錯誤,不過你要知道命令替換會刪除結尾多余的換行符。
略過,原文給的優化方法需要 Bash 4.2+ 以上的版本,手頭沒有這樣的環境。
42. somecmd 2>&1 >>logfile
這是一個很常見的錯誤,顯然你本來是想將標準輸出與標準錯誤輸出都重定向到文件 logfile 中,但是你會驚訝地發現,標準錯誤依然輸出到屏幕中。
這種行為的原因是,重定向[29]在命令執行之前解析,并且是從左往右解析。上面的命令可以翻譯成,將標準錯誤輸出重定向到標準輸出(此刻是終端),然后將標準輸出重定向到文件 logfile 中。所以,到最后,標準錯誤并沒有重定向到文件中,而是依然輸出到終端:
somecmd>>logfile2>&1
更加詳細的說明見BashFAQ[30]。
43. cmd; (( ! $? )) || die
只有需要捕獲上一個命令的執行結果進,才需要記錄 $? 的值,否則如果你只需要檢查上一個命令是否執行成功,直接檢測命令:
ifcmd;then
...
fi
或者使用 case 語句來檢測多個或能的返回碼:
cmd
status=$?
case$statusin
0)
echosuccess>&2
;;
1)
echo'Mustsupplyaparameter,exiting.'>&2
exit1
;;
*)
echo'Unknownerror,exiting.'>&2
exit$status
esac
這里沒有什么錯誤,不過你要知道命令替換會刪除結尾多余的換行符。
略過,原文給的優化方法需要 Bash 4.2+ 以上的版本,手頭沒有這樣的環境。
42. somecmd 2>&1 >>logfile
這是一個很常見的錯誤,顯然你本來是想將標準輸出與標準錯誤輸出都重定向到文件 logfile 中,但是你會驚訝地發現,標準錯誤依然輸出到屏幕中。
這種行為的原因是,重定向[29]在命令執行之前解析,并且是從左往右解析。上面的命令可以翻譯成,將標準錯誤輸出重定向到標準輸出(此刻是終端),然后將標準輸出重定向到文件 logfile 中。所以,到最后,標準錯誤并沒有重定向到文件中,而是依然輸出到終端:
somecmd>>logfile2>&1
更加詳細的說明見BashFAQ[30]。
43. cmd; (( ! $? )) || die
只有需要捕獲上一個命令的執行結果進,才需要記錄 $? 的值,否則如果你只需要檢查上一個命令是否執行成功,直接檢測命令:
ifcmd;then
...
fi
或者使用 case 語句來檢測多個或能的返回碼:
cmd
status=$?
case$statusin
0)
echosuccess>&2
;;
1)
echo'Mustsupplyaparameter,exiting.'>&2
exit1
;;
*)
echo'Unknownerror,exiting.'>&2
exit$status
esac
原文標題:Bash 編程易錯總結大全
文章出處:【微信公眾號:馬哥Linux運維】歡迎添加關注!文章轉載請注明出處。
-
編程
+關注
關注
88文章
3649瀏覽量
94346 -
Bash
+關注
關注
0文章
57瀏覽量
10257
原文標題:Bash 編程易錯總結大全
文章出處:【微信號:magedu-Linux,微信公眾號:馬哥Linux運維】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論