Bash的陷阱

下面就逐個分析一下這篇文章中提到的錯誤。不是完全的翻譯,有些沒用的話就略過了, 有些地方則加了些註釋。

  • for i in `ls *.mp3`

常見的錯誤寫法:

for i in `ls *.mp3`; do     # Wrong!

為什麼錯誤呢?因為for...in語句是按照空白來分詞的,包含空格的文件名會被拆成多個詞。 如遇到 01 - Don't Eat the Yellow Snow.mp3 時,i的值會依次取 01,-,Don't,等等。

用雙引號也不行,它會將ls *.mp3的全部結果當成一個詞來處理。

for i in "`ls *.mp3`"; do   # Wrong!

正確的寫法是

for i in *.mp3; do
  • cp $file $target

這句話基本上正確,但同樣有空格分詞的問題。所以應當用雙引號:

cp "$file" "$target"

但是如果湊巧文件名以 - 開頭,這個文件名會被 cp 當作命令行選項來處理,依舊很頭疼。可以試試下面這個。

cp -- "$file" "$target"

運氣差點的再碰上一個不支持 -- 選項的系統,那隻能用下面的方法了:使每個變量都以目錄開頭。

for i in ./*.mp3; do
  cp "$i" /target
  ...
  • [ $foo = "bar" ]

當$foo為空時,上面的命令就變成了

[ = "bar" ]

類似地,當$foo包含空格時:

[ multiple words here = "bar" ]

兩者都會出錯。所以應當用雙引號將變量括起來:

[ "$foo" = bar ]      # 幾乎完美了。

但是!當$foo以 - 開頭時依然會有問題。 在較新的bash中你可以用下面的方法來代替,[[ 關鍵字能正確處理空白、空格、帶橫線等問題。

[[ $foo = bar ]]      # 正確

舊版本bash中可以用這個技巧(雖然不好理解):

[ x"$foo" = xbar ]    # 正確

或者乾脆把變量放在右邊,因為 [ 命令的等號右邊即使是空白或是橫線開頭,依然能正常工作。 (Java編程風格中也有類似的做法,雖然目的不一樣。)

[ bar = "$foo" ]      # 正確
  • cd `dirname "$f"`

同樣也存在空格問題。那麼加上引號吧。

cd "`dirname "$f"`"

問題來了,是不是寫錯了?由於雙引號的嵌套,你會認為`dirname 是第一個字符串,`是第二個字符串。 錯了,那是C語言。在bash中,命令替換(反引號``中的內容)裡面的雙引號會被正確地匹配到一起, 不用特意去轉義。

$()語法也相同,如下面的寫法是正確的。

cd "$(dirname "$f")"
  • [ "$foo" = bar && "$bar" = foo ]

[ 中不能使用 && 符號!因為 [ 的實質是 test 命令,&& 會把這一行分成兩個命令的。應該用以下的寫法。

[ bar = "$foo" -a foo = "$bar" ]       # Right!
[ bar = "$foo" ] && [ foo = "$bar" ]   # Also right!
[[ $foo = bar && $bar = foo ]]         # Also right!
  • [ $foo > 7 ]

很可惜 [[ 只適用於字符串,不能做數字比較。數字比較應當這樣寫:

(( $foo > 7 ))

或者用經典的寫法:

[ $foo -gt 7 ]

但上述使用 -gt 的寫法有個問題,那就是當 $foo 不是數字時就會出錯。你必須做好類型檢驗。

這樣寫也行。

[[ $foo -gt 7 ]]
  • grep foo bar | while read line; do ((count++)); done

這行代碼數出bar文件中包含foo的行數,雖然很麻煩(等同於grep -c foo bar或者 grep foo bar | wc -l)。 乍一看沒有問題,但執行之後count變量卻沒有值。因為管道中的每個命令都放到一個新的子shell中執行, 所以子shell中定義的count變量無法傳遞出來。

  • if [grep foo myfile]

初學者常犯的錯誤,就是將 if 語句後面的 [ 當作if語法的一部分。實際上它是一個命令,相當於 test 命令, 而不是 if 語法。這一點C程序員特別應當注意。

if 會將 if 到 then 之間的所有命令的返回值當作判斷條件。因此上面的語句應當寫成

if grep foo myfile > /dev/null; then
  • if [bar="$foo"]

同樣,[ 是個命令,不是 if 語句的一部分,所以要注意空格。

if [ bar = "$foo" ]
  • if [ [ a = b ] && [ c = d ] ]

同樣的問題,[ 不是 if 語句的一部分,當然也不是改變邏輯判斷的括號。它是一個命令。可能C程序員比較容易犯這個錯誤?

if [ a = b ] && [ c = d ]        # 正確
  • cat file | sed s/foo/bar/ > file

你不能在同一條管道操作中同時讀寫一個文件。根據管道的實現方式,file要麼被截斷成0字節,要麼會無限增長直到填滿整個硬盤。 如果想改變原文件的內容,只能先將輸出寫到臨時文件中再用mv命令。

sed 's/foo/bar/g' file > tmpfile && mv tmpfile file
  • echo $foo

這句話還有什麼錯誤碼?一般來說是正確的,但下面的例子就有問題了。

MSG="Please enter a file name of the form *.zip"
echo $MSG         # 錯誤!

如果恰巧當前目錄下有zip文件,就會顯示成

Please enter a file name of the form freenfss.zip lw35nfss.zip

所以即使是echo也別忘記給變量加引號。

  • $foo=bar

變量賦值時無需加 $ 符號——這不是Perl或PHP。

  • foo = bar

變量賦值時等號兩側不能加空格——這不是C語言。

  • echo <<EOF

here document是個好東西,它可以輸出成段的文字而不用加引號也不用考慮換行符的處理問題。 不過here document輸出時應當使用cat而不是echo。

# This is wrong:
echo <<EOF
Hello world
EOF


# This is right:
cat <<EOF
Hello world
EOF
  • su -c 'some command'

原文的意思是,這條基本上正確,但使用者的目的是要將 -c 'some command' 傳給shell。 而恰好 su 有個 -c 參數,所以su 只會將 'some command' 傳給shell。所以應該這麼寫:

su root -c 'some command'

但是在我的平臺上,man su 的結果中關於 -c 的解釋為

-c, --commmand=COMMAND
            pass a single COMMAND to the shell with -c

也就是說,-c 'some command' 同樣會將 -c 'some command' 這樣一個字符串傳遞給shell, 和這條就不符合了。不管怎樣,先將這一條寫在這裡吧。

  • cd /foo; bar

cd有可能會出錯,出錯後 bar 命令就會在你預想不到的目錄裡執行了。所以一定要記得判斷cd的返回值。

cd /foo && bar

如果你要根據cd的返回值執行多條命令,可以用 ||。

cd /foo || exit 1;
bar
baz

關於目錄的一點題外話,假設你要在shell程序中頻繁變換工作目錄,如下面的代碼:

find ... -type d | while read subdir; do
  cd "$subdir" && whatever && ... && cd -
done

不如這樣寫:

find ... -type d | while read subdir; do
  (cd "$subdir" && whatever && ...)
done

括號會強制啟動一個子shell,這樣在這個子shell中改變工作目錄不會影響父shell(執行這個腳本的shell), 就可以省掉cd - 的麻煩。

你也可以靈活運用 pushd、popd、dirs 等命令來控制工作目錄。

  • [ bar == "$foo" ]

[ 命令中不能用 ==,應當寫成

[ bar = "$foo" ] && echo yes
[[ bar == $foo ]] && echo yes
  • for i in {1..10}; do ./something &; done

& 後面不應該再放 ; ,因為 & 已經起到了語句分隔符的作用,無需再用;。

for i in {1..10}; do ./something & done
  • cmd1 && cmd2 || cmd3

有人喜歡用這種格式來代替 if...then...else 結構,但其實並不完全一樣。如果cmd2返回一個非真值,那麼cmd3則會被執行。 所以還是老老實實地用 if cmd1; then cmd2; else cmd3 為好。

  • UTF-8的BOM(Byte-Order Marks)問題

UTF-8編碼可以在文件開頭用幾個字節來表示編碼的字節順序,這幾個字節稱為BOM。但Unix格式的UTF-8編碼不需要BOM。 多餘的BOM會影響shell解析,特別是開頭的 #!/bin/sh 之類的指令將會無法識別。

MS-DOS格式的換行符(CRLF)也存在同樣的問題。如果你將shell程序保存成DOS格式,腳本就無法執行了。

$ ./dos
-bash: ./dos: /bin/sh^M: bad interpreter: No such file or directory
  • echo "Hello World!"

交互執行這條命令會產生以下的錯誤:

-bash: !": event not found

因為 !" 會被當作命令行歷史替換的符號來處理。不過在shell腳本中沒有這樣的問題。

不幸的是,你無法使用轉義符來轉義!:

$ echo "hi\!"
hi\!

解決方案之一,使用單引號,即

$ echo 'Hello, world!'

如果你必須使用雙引號,可以試試通過 set +H 來取消命令行歷史替換。

set +H
echo "Hello, world!"
  • for arg in $*

$*表示所有命令行參數,所以你可能想這樣寫來逐個處理參數,但參數中包含空格時就會失敗。如:

#!/bin/bash
# Incorrect version
for x in $*; do
  echo "parameter: '$x'"
done


$ ./myscript 'arg 1' arg2 arg3
parameter: 'arg'
parameter: '1'
parameter: 'arg2'
parameter: 'arg3'

正確的方法是使用 $@

#!/bin/bash
# Correct version
for x in "$@"; do
  echo "parameter: '$x'"
done

$ ./myscript 'arg 1' arg2 arg3
parameter: 'arg 1'
parameter: 'arg2'
parameter: 'arg3'

在 bash 的手冊中對 $* 和 $@ 的說明如下:

*    Expands to the positional parameters, starting from one.
     When the expansion occurs within double quotes, it
     expands to a single word with the value of each parameter
     separated by the first character of the IFS special variable.
     That is, "$*" is equivalent to "$1c$2c...",
@    Expands to the positional parameters, starting from one.
     When the expansion occurs within double quotes, each
     parameter expands to a separate word.  That  is,  "$@"
     is equivalent to "$1" "$2" ...

可見,不加引號時 $*$@ 是相同的,但$* 會被擴展成一個字符串,而 $@ 會 被擴展成每一個參數。

  • function foo()

在bash中沒有問題,但其他shell中有可能出錯。不要把 function 和括號一起使用。 最為保險的做法是使用括號,即

foo() {
  ...
}

书籍推荐