Bash Pitfalls(原创翻译)
By raylin
在总结之前自己写shell的时候犯的错误后,网上搜了下发现这些问题不是只有我犯过。轮子不是闭门造的下面是利用假期翻译的一部分内容。
原文地址:http://mywiki.wooledge.org/BashPitfalls
这里将讲述bash编程者容易犯的一些错误。下面的例子在某些情况下是错的或者不会像期望那样执行。
1、 for i in $(ls *.mp3)
2、 cp $file $target
3、 Filenames with leading dashes
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"]
11、 if [ [ a = b ] && [ c = d ] ]
12、 read $foo
13、 cat file | sed s/foo/bar/ > file
14、 echo $foo
15、 $foo=bar
16、 foo = bar
17、 echo <<EOF
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、 sed 's/$foo/good bye/'
29、 tr [A-Z] [a-z]
30、 ps ax | grep gedit
31、 printf "$foo"
32、 for i in {1..$n}
33、 if [[ $foo = $bar ]] (depending on intent)
34、 if [[ $foo =~ 'some RE' ]]
35、 [ -n $foo ] or [ -z $foo ]
36、 [[ -e "$broken_symlink" ]] returns 1 even though $broken_symlink exists
37、 ed file <<<"g/d\{0,3\}/s//e/g" fails
38、 expr sub-string fails for "match"
39、 On UTF-8 and Byte-Order Marks (BOM)
40、 $() removes trailing newlines
bash程序员犯的最常见的错误是写如下的循环:
for i in $(ls *.mp3);do #wrong!
some command $i #wrong!
done
for i in $(ls) #wrong!
for i in `ls` #wrong!
for i in $(find . -type f) #wrong!
for i in `find . -type f` #wrong!
为什么?当文件名中带空格时会出错。因为$(ls *.mp3)的命令替换的结果是通过WordSplitting来进行的。假设在当前目录有这样一个文件,文件名为:01 - Don't Eat the Yellow Snow.mp3,for循环将遍历文件名中的每个单词。形如:
some command 01
some command -
some command Don't
some command Eat
…
使用双引号替换同样也不行:
for i in "$(ls *.mp3)"; do # Wrong!
上面的命令会将ls命令的输出当作一个单词来处理。而不是将每个文件名做一次迭代,循环只会进行一次,因为所有的文件名连接在一起。
对此,使用ls其实是没必要的。做这件事没必要调用外部命令来做。正确的做法是:
for i in *.mp3; do # Better! and...
some command "$i" # ...see Pitfall #2 for more info.
done
Bash自动扩展文件名列表,扩展不会引起WordSplitting。每个匹配*.mp3的文件名都会作为一个单独的词来对待,循环遍历每个文件名。
> for i in *.mp3; do echo $i; done
01 - Don't Eat the Yellow Snow.mp3
1.mp3
> for i in $(ls *.mp3); do echo $i; done
01
-
Don't
Eat
the
Yellow
Snow.mp3
1.mp3
> for i in "$(ls *.mp3)"; do echo $i; done
01 - Don't Eat the Yellow Snow.mp3 1.mp3
> find . -name "*.mp3"|while read fn; do echo $fn; done #扫描当前目录和子目录中所有的mp3文件
./1.mp3
./mymp3/2.mp3
./01 - Don't Eat the Yellow Snow.mp3
如果恰巧文件名中带有空格或者通配符的话,遇到到WordSplitting或者Bash扩展的问题。
最好是带上双引号(弱引用)来避免WordSplitting。如:
cp "$file" "$target"
没有双引号的话,类似:cp 01 - Don't Eat the Yellow Snow.mp3 mymp3/
file=01 - Don't Eat the Yellow Snow.mp3
target=mymp3
cp 01 - Don't Eat the Yellow Snow.mp3 mymp3
cp: 无法获取"01" 的文件状态(stat): 没有那个文件或目录
…
如果包含通配符(比如"*","[...]"),文件名会被扩展成匹配的文件名。
要创建一个以"-"开始的文件进行实验:touch -- '-testfile'
如果凑巧文件名以 - 开头,这个文件名会被 cp 当作命令行选项来处理。有两种处理方法:
一种是在命令和参数之间加"--"告诉命令停止扫描后面的选项。例如:
cp -- "$file" "$target"
这种方法存在的问题是,需要使用"--"来使命令的"-"选项失效,操作上容易忘记,还有不是所有的命令都支持"--",比如echo。
另一种解决方法是在文件名之前带上目录(.是当前目录)。例如:
for i in ./*.mp3; do
cp "$i" /target
...
这种情况下,即使文件名是以"-"开始,glob也会保证变量的值类似:
"./-foo.mp3"
在bash中没必要用双引号括起一个字符串常量(除非它包含元字符串),但是如果不确定变量中是否包含空格或者通配符那么最好用双引号括起来。
两种情况会出错:
如果[ 中的变量不存在或者为空:
[ $foo = "bar" ]
变成:
[ = "bar" ]
出现"unary operator expected"的错误
变量包含内置空格(whitspace)字符,那么被分割成多个单词。之前的命令变成:
[ multiple words here = "bar" ]
bash: [: 参数太多的错误。
较为正确的书写方式是:
[ "$foo" = bar ] #几乎完美
即使$foo是以"-"开始,在POSIX兼容的系统上也是正常的,因为在POSIX上[是已传递的参数个数来决定下一步的操作。在一些较老的shell上,可能依然会出错。
关键字"[[",兼容和扩展了古老的test命令(就是我们熟悉的"["),同样能解决上述问题。
[[ $foo = bar ]] #right
在[[ ]]中=左边的变量可以不用双引号括起来,变量不会被分割成字(wordsplitting),即使是空变量也能正确解释。当然用双引号括起来也不会有不好的影响。比如:
[[ "$foo" = bar ]]
一句话就是:[[ 关键字能正确处理空白、空格、带横线等问题
下面的代码在老版本的shell中比较常见:
[ x"$foo" = xbar ] #also right
不能解决变量以"-"开始的问题
或者干脆把变量放在右边。[不关心=的右边是否是空白或者以"-"开始,仅仅理解它的字面含义。如:
[ bar = "$foo" ] # Also right!
还是空格的问题。
可以做如下测试:
mkdir -p 'blank dir'/test
file='blank dir/test'
cd $(dirname "$file") #bash: cd: blank: 没有那个文件或目录
正确的做法是:
cd "$(dirname "$f")"
等等~这里我确定没写错。Bash将第一个双引号与第四个引号配对,在$()里面将第二个和第三个配对。具体请参考:为什么$()优于`...`(反引号)?
http://mywiki.wooledge.org/BashFAQ/082
一种解释是:bash解释器将命令替换当作"嵌套级",在嵌套里面的引号与外面的相互独立的。
不能在test(或[)命令里面使用&&。Bash解释器会将&&前和后的[[]]或(())分解成两个命令。
[ bar = "$foo" ] && [ foo = "$bar" ] #Right
[[ $foo = bar && $bar = foo ]] #Also right
[[ $foo = bar ]] && [[ $bar = foo ]] #Also right
尽量避免使用如下:
[ bar = "$foo" -a foo = "$bar" ] #Not portable.
[ A = B -a C = D ](或者-o)的问题是POSIX没有指定test([)命令在多于4个参数的情况下的返回值,具体参考:
http://pubs.opengroup.org/onlinepubs/9699919799/utilities/test.html
可能在大部分的shell下正常工作的,但这在你的控制范围之外。最好在两个test之间用&&(||)来连接。
[[ 只适用于字符串,不能做数学表达式的比较。做数字比较可以使用(())代替。
(( foo > 7 )) #Right
在[[里面使用>,会当作字符串的比较来进行,而不是数字比较。
如果在[]里面使用>,情况跟期望的相差就更远了。会将输出重定向到文件名为7的文件,只要$foo不为空,test的结果就是真。
做如下测试:
> foo=10
> if [[ $foo > 7 ]]; then echo "big"; else echo "small"; fi
small
> foo=5
> if [ $foo > 7 ]; then echo "big"; else echo "small"; fi
big
#重定向是成功了,:-)
> ll 7
-rw-r--r-- 1 user users 0 2011-05-28 23:14 7
历史正确版本:
test $foo -gt 7 #Also right
上述使用 -gt 的写法有个问题,那就是当 $foo 不是数字时就会出错,所以必须做好类型检验。
[[]]同样支持 -gt的语法。
[[ $foo -gt 7 ]] #Also right
乍一看上面的代码是OK的。但是循环结束之后变量count的值较循环之前是没有变化的。不工作的原因是管道中执行的每一条命令都是一个子shell。子shell中count的变化不会同步到父shell中去。
可以试试:
while read line; do ((count++)); done < gnome-terminal.desktop
不要想当然的以为[]是if语法的一部分,[]是一个命令,[]的本质是test命令。
if [ false ]; then echo "HELP"; fi
if test "false"; then echo "HELP"; fi
这两个命令是一致的。检测字符串"false"是否为空,最后都输出 HELP。
if的语法为:
if COMMANDS
then
COMMANDS
elif COMMANDS # optional
then
COMMANDS
else # optional
COMMANDS
fi # required
NOTE:语法里面没有"["吧 !!!
if 会将 if 到 then 之间的所有命令的返回值当作判断条件。
所以这里可以用:
if grep foo myfile >/dev/dull;then … ;fi
或者
if grep -q foo myfile ;then … ;fi
空格问题,[]的本质是test命令。
同样的问题,if []不是C中if()的替换
正确做法是:
if [ a = b ] && [ c = d ]
if [[ a = b && c = d ]]
if test a = b && test c = d
read命令中变量不要带$,将值放入变量foo中 :
read foo
或者
IFS= read -r foo
不能在同一条管道操作中同时读写一个文件。根据管道的实现方式,file要么被截断成0字节,要么会无限增长直到填满整个硬盘。 如果想改变原文件的内容,只能先将输出写到临时文件中再用mv命令。
sed 's/foo/bar/g' file > tmpfile && mv tmpfile file
看似正确的命令可能带来巨大错误,因为$foo没有被引用,$foo不但会被WordSplitting,而且还要面临正则匹配的问题。Bash程序员会误认为变量的值有问题,但实际上变量是OK的---仅仅是因为WordSplitting和文件名扩展导致的。
MSG="Please enter a file name of the form *.zip"
echo $MSG
这个信息被分割成多个单词并且正则表达式也会被扩展,比如当前目录下有reenfss.zip 和lw35nfss.zip这两个文件话。就会输出如下信息:
Please enter a file name of the form freenfss.zip lw35nfss.zip
实验如下:
> var=*.sh # VAR contains an asterisk, a period, and the word "sh"
> echo $var # writes the list of files which end with .sh
chProcess.sh sync.sh
> echo "$var"
*.sh
事实上,echo命令在这里是不可能绝对安全的。假设变量中带有-n ,echo会认为这是echo的一个选项来处理而不是作为打印信息。唯一绝对正确打印变量值的方法是使用printf:
printf "%s\n" "$foo"
赋值不需要在变量前加$,这不是perl。
给一个变量赋值时你不能在=的周围加上空格,这不是C。写成 foo = bar时shell解释器将这条命令分割成3个单词来处理,第一个词是foo,被当作命令名,第二个(=)和第三个(bar)被当作foo命令的参数。
foo= bar # WRONG!
foo =bar # WRONG!
$foo = bar; # COMPLETELY WRONG!
foo=bar # Right.
here document是个好东西,它可以输出成段的文字而不用加引号也不用考虑换行符的处理问题。通过脚本中的文本行重定向到一个命令的标准输入。不幸地是,echo不是从标准输入中读取的。
# This is wrong:
echo <<EOF
Hello world
How's it going?
EOF
# This is what you were trying to do:
cat <<EOF
Hello world
How's it going?
EOF
# Or, use quotes which can span multiple lines (efficient, echo is built-in):
echo "Hello world
How's it going?"
另外一种解决方案是使用printf:
# Or use printf (also efficient, printf is built-in):
printf %s "\
Hello world
How's it going?
"
这个语法基本是正确的。问题是,在很多平台上,su需要-c参数,但这不是你想要的。例如,在OpenBSD上:
$ su -c 'echo hello'
su: only the superuser may specify a login class
传递 -c 'some command'给shell,意味着在-c之前需要用户名。
su root -c 'some command' # Now it's right.
缺省用户名时su默认为root。
如果不检查cd命令的执行状态,可能会在错误的目录执行bar命令。万事小心为好。如果使用rm *,在错误的地方执行了...
一直检查上一条命令的执行状态是种好习惯,最简单的方式:
cd /foo && bar
如果在cd之后有多条命令,下面的你可能会更喜欢:
cd /foo || exit 1
Bar
Bar1
Bat ..... #Lots of commands
cd命令在改变目录失败时会记录类似"bash: cd: /foo: No such file or directory"的标准错误信息。如果想加入标准输出中,可以使用下面的命令组:
cd /net || { echo "Can't read /net. Make sure you've logged in to the Samba network, and try again."; exit 1; }
do_stuff
more_stuff
Note:在"{"与"echo"之间有空格,";"与"}"也有空格。
很多人喜欢设置set -e,在任何命令返回非零状态时终止脚本。但是这要完全使用合适并非易事,因为很多命令返回非零值作为警告,而这种情况你并不想做为错误来对待。
对比下面两组命令:
find ... -type d | while read subdir; do
cd "$subdir" && whatever && ... && cd -
Done
以及
find ... -type d | while read subdir; do
(cd "$subdir" && whatever && ...)
done
第二组命令中cd是在子shell中进行的,下一次的迭代将返回到之前的目录位置,无论cd的结果是成功或者失败。子shell中改变工作目录不会影响到父shell。
第一组命令不一定正确,如果在whatever命令中有失败的话,cd -是不起效果的,回退不到正常的目录中去。
在"["命令中"=="是非法的,使用"="或者"[["来代替
[ bar = "$foo" ] && echo yes
[[ bar == $foo ]] && echo yes
在suse上实验的结果与作者的观点有些出入:
> [ bar == "$foo" ] && echo yes
yes
这里在test命令里面使用了"==",一样可以正确判断。
> [[ bar == "$foo" ]] && echo yes
yes
> [ bar = "$foo" ] && echo yes
yes
> [[ bar = "$foo" ]] && echo yes
yes
不能在"&"之后使用";"。
for i in {1..10}; do ./something & done
或者
for i in {1..10}; do
./something &
done
"&"本身有作为命令终结符的功能,正如";"一样。两者不能混用。
";"可以被换行符替换,但不是所有的换行符都能被";"替换。
很多人喜欢使用&&和||作为if ... then ...else...fi的简写语法。在很多情况下是绝对安全的。比如:
[[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful.
然而,这种结构不是完全等价与if ... fi,因为&&之后的命令也会产生退出状态。如果它的退出状态不为"true"(0),||之后的命令就会执行。比如:
i=0
true && ((i++)) || ((i--))
echo $i # Prints 0
这里会发生什么?看起来i的值应该是1,但实际是0。i++和i--都被执行了。((i++))命令有一个退出状态,这种退出状态起源于类C的括号表达式。那个值恰巧是0(i的初始值),在C语言中,0值是被认为false。因此((i++)) (当i是0时)的退出值为1(false),(i--)也随着被执行。
> i=0
> true && ((i++)) || ((i--))
> echo $i
0
> i=1
> true && ((i++)) || ((i--))
> echo $i
2
如果使用前加操作符,这种情况就不会发生了,++i的退出状态就是真。
i=0
true && (( ++i )) || (( --i ))
echo $i # Prints 1
想要安全,在不确定cmd2如何运行,不确定cmd2的退出状态还是老老实实的用if ... fi的结构吧。
i=0
if true; then
((i++))
else
((i--))
fi
echo $i # Prints 1
在交互式bash中执行上述语句,可以看到错误:
bash: !: event not found
交互式shell中,bash默认沿用csh历史扩展风格,使用采用感叹。在shell脚本中这不是个问题,只有在交互式shell才存在。
使用引号或者set +H选项来解决这个问题。
echo 'Hello World!'
或者
set +H
echo "Hello World!"
本文原创自无线技术运营空间: http://wireless.qzone.qq.com 及 http://blog.csdn.net/wireless_tech (专注无线技术运营——无线技术(操作系统/数据库/WEB前端/负载均衡/系统容灾/系统安全/短信接入/WAP接入/3G等)、无线业务运营、无线开放平台、统计分析(用户行为分析/数据挖掘)、CP合作,联系我们:[email protected])