1. for f in $(ls *.mp3)

程序员最常犯的错误之一,就是写出这样的循环:

 
  1. for f in $(ls *.mp3); do # 错误!
  2. some command $f # 错误!
  3. done
  4.  
  5. for f in $(ls) # 错误!
  6. for f in `ls` # 错误!
  7.  
  8. for f in $(find . -type f) # 错误!
  9. for f in `find . -type f` # 错误!
  10.  
  11. files=($(find . -type f)) # 错误!
  12. for f in ${files[@]} # 错误!
 

是的,把 lsfind 的输出当成文件名列表直接迭代看起来很方便,但你不能这样做。这种方法从根本上就有缺陷,没有任何技巧能让它安全工作,必须换一种思路。

至少有 6 个问题:

  1. 如果文件名中包含空格(或 $IFS 中的任意字符),会触发单词拆分(WordSplitting)。假设当前目录有个文件叫 01 - Don't Eat the Yellow Snow.mp3,循环会按 01-Don'tEat… 这样的“单词”来迭代。
  2. 如果文件名中包含通配符(glob)字符,会触发文件名扩展(globbing)。比如 ls 输出中带 *,这个 * 会当作模式去匹配所有符合的文件名。
  3. 如果命令替换返回多个文件名,没有办法区分第一个文件名结束和第二个开始的位置。路径名除了 NUL 之外可以包含任意字符,包括换行。
  4. ls 可能会篡改文件名。不同平台、不同参数、是否输出到终端,ls 都可能用 ? 替换某些字符,甚至不输出某些字符。永远不要用脚本去解析 ls 的输出,因为它是给人看的,不是给程序处理的。
  5. 命令替换会去掉所有尾部换行。如果最后一个文件名本身以换行结尾, 或 $() 会把它移除。
  6. 如果 ls 的第一个文件名以 - 开头,会导致类似 #3 的问题。

双引号也救不了你:

 
  1. for f in "$(ls *.mp3)"; do # 错误!
 

这样会把整个 ls 的输出当作一个单词,循环只执行一次,f 会包含所有文件名拼在一起的字符串。

改变 $IFS 为换行符也没用,因为文件名也可能包含换行。

另一种常见误用是用 for 循环去(错误地)按行读取文件:

 
  1. IFS=$'\n'
  2. for line in $(cat file); do … # 错误!
 

这样是行不通的,尤其当这些行是文件名时。

正确的做法:

如果不需要递归,可以直接用通配符(glob),而不是 ls

 
  1. for file in ./*.mp3; do # 更好!
  2. some command "$file" # …总是给变量加双引号!
  3. done
 

通配符是在所有展开操作的最后一步进行的,每个匹配到的文件名都是独立的单词,不会受到未加引号的变量展开副作用。

如果当前目录没有 .mp3 文件,这个 for 会执行一次,file="./*.mp3",这不是我们想要的行为。解决办法是先检测文件是否存在:

 
  1. # POSIX
  2. for file in ./*.mp3; do
  3. [ -e "$file" ] || continue
  4. some command "$file"
  5. done
 

或者用 shopt -s nullglob,但要先仔细读文档,评估它对脚本中其它通配符的影响。

如果需要递归,可以用 find,并且要用对方法:

 
  1. find . -type f -name '*.mp3' -exec some command {} \;
  2.  
  3. # 如果命令支持多个文件名:
  4. find . -type f -name '*.mp3' -exec some command {} +
 

在 Bash 里还可以用 GNU/BSD find-print0 搭配 read -d ''

 
  1. while IFS= LC_ALL=C read -r -d '' file; do
  2. some command "$file"
  3. done < <(find . -type f -name '*.mp3' -print0)
 

或者在 Bash 4.0+ 用 globstar 支持递归通配符:

 
  1. shopt -u failglob; shopt -s globstar nullglob # dotglob
  2. for file in ./**/*.mp3; do
  3. some command "$file"
  4. done
 

注意,变量展开时一定要加双引号

2. cp $file $target

这个命令有什么问题吗?如果你提前知道 $file$target 中不包含空格(并且没有修改 $IFS),或者文件名中没有通配符,这个命令是可以正常工作的。但实际上,展开结果会受到单词拆分路径名扩展的影响。所以,永远要给参数扩展加双引号。

 
  1. cp -- "$file" "$target"
 

没有加双引号时,会得到类似 cp 01 - Don't Eat the Yellow Snow.mp3 /mnt/usb 这样的命令,结果会报错:cp: cannot stat '01': No such file or directory。如果 $file 包含通配符(例如 *?),它们会被展开,如果有匹配的文件,命令会误执行。加了双引号后,一切正常,除非 $file- 开头,这时 cp 会误把它当作命令选项(见下面的 #3)。

即便在一些特殊情况下你可以保证变量内容安全,良好的习惯是总是使用双引号,尤其是文件名这种容易出错的情况。经验丰富的脚本作者几乎会在所有地方加引号,除非在某些情况下完全明确该变量内容没有任何特殊字符。

3. 以短横线开头的文件名

以短横线开头的文件名可能会导致很多问题。像 *.mp3 这样的通配符会被扩展成一个列表,在大多数语言环境下,- 排在字母前面。然后这个列表会传给某个命令,而命令可能会误把 -filename 当作选项。解决办法有两种:

一种是通过在命令(如 cp)和参数之间加 --,告诉它不再查找选项,后面的文件名就是参数:

 
  1. cp -- "$file" "$target"
 

另一种方法是确保文件名总是带上目录前缀,无论是相对路径还是绝对路径:

 
  1. for i in ./*.mp3; do
  2. cp "$i" /target
  3. done
 

即使文件名以 - 开头,前面的 ./ 也能确保它不会被误解为选项。

4. [ $foo = "bar" ]

这个问题和上面 #2 类似,但我再重复一次,因为它非常重要。上面这个例子里,引号放错地方了。在 Bash 中,你不需要给字符串字面量加引号(除非它包含元字符或模式字符),但你应该给变量加引号,尤其当你不确定它们是否包含空格或通配符时。

这样写会出问题的原因:

  • 如果 [ 中引用的变量不存在或者为空,[ 会变成这样:

     
    1. [ = "bar" ] # 错误!
     

这会报错:unary operator expected= 操作符是二元的,而不是一元的,[ 看到它会很困惑。

  • 如果变量包含空格,它会被拆分成多个单词,然后传给 [,因此:

     
    1. [ multiple words here = "bar" ]
     

这虽然看起来没问题,但对于 [ 来说是语法错误。正确写法应该是:

 
  1. bash# POSIX
  2. [ "$foo" = bar ] # 正确!
 

这样即使 $foo- 开头也不会出问题,因为 POSIX 的 [ 根据参数个数来判断操作。只有非常古老的 shell 会遇到问题,你可以忽略这些情况。

和很多 ksh-like 的 shell 提供了一个更好的替代方法,使用 [[ ]]

 
  1. lua# Bash / Ksh
  2. [[ $foo == bar ]] # 正确!
 

[[ ]] 中,左侧的变量不需要加引号,因为它不会进行单词拆分或通配符扩展,空白变量也能正确处理。

5. cd $(dirname "$f")

这是另一个常见的引号错误。像变量展开一样,命令替换(Command Substitution)的结果会经历单词拆分路径名扩展,所以你应该给它加引号:

 
  1. cd -P -- "$(dirname -- "$f")"
 

问题在于引号嵌套。C 程序员会期待第一对和第二对双引号在一起,第三对和第四对也是,但 Bash 会把命令替换中的引号当作一对,外部的双引号当作另一对。

6. [ "$foo" = bar && "$bar" = foo ]

不能在 [ ] 这种命令中使用 &&。Bash 解析器看到 && 就会把命令分成两部分,在 && 前后分别执行。应该改成以下两种之一:

 
  1. [ bar = "$foo" ] && [ foo = "$bar" ] # 正确!(POSIX)
  2. [[ $foo = bar && $bar = foo ]] # 也对!(Bash / Ksh)
 

注意在 [ 中,我们交换了常量和变量的位置,这是因为有一些历史原因。你也可以交换 [[ 里面的顺序,但是展开的时候需要加引号防止被当作模式。

避免这种写法:

 
  1. [ bar = "$foo" -a foo = "$bar" ] # 不符合规范。
 

-a-o 这些运算符是 XSI 扩展,在 POSIX-2008 中已标记为过时,应该避免在新代码中使用。

7. [[ $foo > 7 ]]

这里有几个问题。首先,[[ 命令不应该仅仅用于评估算术表达式。它应该用于包含测试运算符的表达式。虽然 technically 可以使用 [[ 做数学计算,但只有和非数学测试运算符结合时才有意义。如果你只想进行数值比较(或者任何其他的 shell 算术运算),最好直接使用 (( ))

 
  1. lua# Bash / Ksh
  2. ((foo > 7)) # 正确!
  3. [[ foo -gt 7 ]] # 也可以,但不常见。更推荐使用 ((…))。
 

如果在 [[ ]] 内部使用 > 运算符,它会把它当作字符串比较(按本地排序规则),而不是整数比较。这通常能正常工作,但有时候会失败。如果你在 [ ] 中使用它,就更糟了:它会被当作输出重定向符号。你会得到一个名为 7 的文件。

如果你需要严格的 POSIX 兼容,且不能使用 (( )),正确的做法是:

 
  1. perl# POSIX
  2. [ "$foo" -gt 7 ] # 正确!
  3. [ "$((foo > 7))" -ne 0 ] # POSIX 兼容的等效写法。
 

如果 $foo 的内容无法保证,或者它来自外部输入,你必须总是验证输入,再进行评估【详细见 Bash FAQ #054】。

8. grep foo bar | while read -r; do ((count++)); done

乍一看这个代码没问题,对吧?但它会有问题,因为管道中的每个命令都在子 Shell中执行。所以即使 count++ 会改变 count,也不会影响外部的 count 变量。

POSIX 并没有规定管道最后的命令是否在子 Shell 中执行。像 ksh93 和 Bash >= 4.2(启用了 shopt -s lastpipe)会在当前 Shell 中执行 while 循环,而其他一些 shell 则在子 Shell 中执行。因此,可移植的脚本应该避免依赖这种行为。

如果你需要解决这个问题,请参考 Bash FAQ #24。

9. if [grep foo myfile]

许多初学者对 if 语句存在错误的直觉,特别是看到 if 后面紧跟着 [[[ 时,容易误以为 [if 语句的一部分,就像 C 语言中的 if 语法一样。

但事实并非如此!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> # 可选
  4. then <COMMANDS>
  5. else <COMMANDS> # 可选
  6. fi # 必须
 

if 是一个复合命令,包含两个或更多部分,每部分之间用 thenelifelse 分隔,最后用 fi 结束。第一个部分的最后一个命令及其后的每个 elif 部分决定了是否会执行相应的 then 部分。如果 then 部分没有执行,else 分支会被执行。

如果你想根据 grep 命令的输出做决策,不要把 grep 放在方括号内,而是直接将其作为命令使用:

 
  1. nginxif grep -q fooregex myfile; then
  2. fi
 

如果 grep 匹配了 myfile 中的某行,它会返回 0(真),然后执行 then 部分。如果没有匹配,grep 会返回非零值,if 命令会判断为假。

10. if [bar="$foo"]; then …

 
  1. [bar="$foo"] # 错误!
  2. [ bar="$foo" ] # 仍然错误!
  3. [bar = "$foo"] # 也错误!
  4. [[bar="$foo"]] # 错误!
  5. [[ bar="$foo" ]] # 你猜怎么着? 错误!
  6. [[bar = "$foo"]] # 还需要我说吗…
 

正如之前的例子所说,[ 是一个命令(可以通过 type -t [whence -v [ 证明),而不是语法标记。所以在 [ 后必须有空格,参数之间也需要空格。正确的写法是:

 
  1. nginxif [ bar = "$foo" ]; then
  2. if [[ bar = "$foo" ]]; then
 

在第一个写法中,[ 是命令名称,bar="$foo"] 是它的参数。每对参数之间必须有空格,这样 shell 才知道每个参数的开始和结束。第二种写法是类似的,只不过 [[ 是一个特殊的关键字,由 ]] 结束。

11. if [ [ a = b ] && [ c = d ] ]; then …

这里再次出现了问题。[ 是一个命令,而不是用于分组的语法标记。不能像在 C 语言中那样把 if 命令翻译成 Bash 的 if,仅仅通过替换括号为方括号。

如果你想写一个复合条件,应该这样做:

 
  1. if [ a = b ] && [ c = d ]; then
 

这里,if 后有两个命令,通过 && 连接。和这样写等效:

 
  1. if test a = b && test c = d; then
 

如果第一个 test 命令返回 falseif 的主体部分就不会执行。如果它返回 true,第二个 test 命令会被执行,只有第二个命令也返回 true 时,if 的主体部分才会执行。 Bash 使用的是短路求值(short-circuit evaluation)。

[[ 关键字也支持 &&,所以可以这么写:

 
  1. if [[ a = b && c = d ]]; then
 

12. read $foo

read 命令中,不需要在变量名前加 $。如果你想把输入存储到变量 foo 中,应该这样写:

 
  1. read foo
 

或者更安全的写法:

 
  1. IFS= read -r foo
 

read $foo 会读取一行输入,并将其存储到名为 $foo 的变量中。这个语法在少数情况下有用,但大多数时候它是一个 bug。

13. cat file | sed s/foo/bar/ > file

不能在同一个管道中从文件读取并写入文件。根据管道的内容,文件可能会被覆盖(变成 0 字节,或者可能是操作系统管道缓冲区大小的字节数),或者文件可能会不断增长,直到填满可用磁盘空间、达到操作系统的文件大小限制或配额等。

如果你想安全地修改文件,除了向文件末尾追加外,应该使用文本编辑器:

 
  1. printf %s\\n ',s/foo/bar/g' w q | ed -s file
 

如果你的操作不能用文本编辑器做,可以创建一个临时文件:

 
  1. sed 's/foo/bar/g' file > tmpfile && mv tmpfile file
 

这会创建一个临时文件,并将修改后的内容替换原文件。只有在 GNU sed 4.x 版本中,才会直接用 -i 选项修改文件:

 
  1. sed -i 's/foo/bar/g' file(s)
 

14. echo $foo

这个看似无害的命令会导致极大的困惑。由于 $foo 没有加引号,它不仅会受到单词拆分的影响,还会进行文件通配符扩展。这会让 Bash 程序员误以为变量包含了错误的值,而实际上变量本身是正确的,问题出在扩展过程中。

 
  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
 

示例:

 
  1. php var="*.zip" # var 包含星号、点和 zip
  2. echo "$var" # 输出 *.zip
  3. echo $var # 输出所有匹配的 .zip 文件
 

为了解决这个问题,应该使用 printf

 
  1. printf "%s\n" "$foo"
 

15. $foo=bar

你不能在变量名之前加 $ 来进行赋值,这不是 Perl 的写法。

16. foo = bar

你不能在赋值时在等号两边加空格。这不是 C 语言。写成 foo = bar 会被 shell 拆成三个词,第一个词 foo 会被当作命令名,第二和第三个词会被当作命令的参数。

同样,下面的写法也是错误的:

 
  1. foo= bar # 错误!
  2. foo =bar # 错误!
  3. $foo = bar; # 完全错误!
 

正确的写法是:

 
  1. ini foo=bar # 正确。
  2. foo="bar" # 更正确。
 

17. echo <<EOF

Here 文档是嵌入大块文本数据的一个有用工具,它会将文本重定向到命令的标准输入中。然而,echo 并不是一个读取标准输入的命令。

 
  1. # 错误的写法:
  2. echo <<EOF
  3. Hello world
  4. How's it going?
  5. EOF
  6.  
  7. # 正确的做法:
  8. cat <<EOF
  9. Hello world
  10. How's it going?
  11. EOF
  12.  
  13. # 或者,使用可以跨多行的引号(效率更高,echo 是内置命令):
  14. echo "Hello world
  15. How's it going?"
 

用双引号这样做是可以的,所有 shell 都支持,但它不能让你直接在脚本中插入一个文本块。你可以在第一行和最后一行使用语法标记。如果你不想使用 cat,也可以使用 printf

 
  1. perl # 或者使用 printf(也是内置的,效率更高):
  2. printf %s "\
  3. Hello world
  4. How's it going?
  5. "
 

printf 示例中,第一行的 \ 防止在文本块前添加额外的换行符。最后一行的换行符是因为最后的引号在新的一行。没有在 printf 格式参数中使用 \n,所以它不会在文本末尾添加额外的换行符。

18. su -c 'some command'

这个语法几乎正确。问题是,许多平台上的 su 确实接受 -c 参数,但你想传递的是 -c 'some command' 给一个 shell,也就是说你需要在 -c 前指定用户名。

 
  1. su root -c 'some command' # 正确
 

如果不指定用户名,su 会默认使用 root,但当你想传递命令给 shell 时会失败。你必须在这种情况下明确指定用户名。

19. cd /foo; bar

如果你没有检查 cd 命令的错误,你可能会在错误的目录下执行 bar,这可能会导致灾难,尤其是当 bar 是一个危险命令时,比如 rm -f *

必须始终检查 cd 命令的错误。最简单的做法是:

 
  1. cd /foo && bar
 

如果有多个命令在 cd 后执行,可以使用:

 
  1. cd /foo || exit 1
  2. bar
  3. baz
  4. bat … # 很多命令
 

cd 会报告目录切换失败,并显示类似 “bash: cd: /foo: No such file or directory” 的错误信息。如果你想输出自定义的错误信息,可以使用命令分组:

 
  1. cd /net || { echo >&2 "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 之间的空格,} 前的分号是必须的。你也可以写一个 die 函数来更简洁地处理。

20. [ bar == "$foo" ]

在 POSIX 的 [ 命令中,== 运算符是无效的。应该使用 =[[

 
  1. [ bar = "$foo" ] && echo yes
  2. [[ bar == $foo ]] && echo yes
 

在 Bash 中,[ "$x" == y ] 是被接受的扩展,这会让 Bash 程序员误以为它是正确的语法。实际上它是一个Bashism,如果你要使用这些扩展,直接使用 [[ 会更好。

21. for i in {1..10}; do ./something &; done

你不能在 & 后面直接加 ;。只需要去掉多余的 ;

 
  1. for i in {1..10}; do ./something & done
 

或者这样写:

 
  1. for i in {1..10}; do
  2. ./something &
  3. done
 

& 已经充当了命令结束符,和 ; 是一样的,你不能混用两者。

一般来说, 可以用换行符替代,但并非所有换行符都能用 替代。

22. cmd1 && cmd2 || cmd3

有些人试图用 &&|| 作为 if … then … else … fi 的简写,可能是因为他们认为这样更简洁。例如:

 
  1. lua # 错误的写法!
  2. [[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."
 

这种写法并不等价于 if … fi,因为在 && 后的命令也会产生退出状态,如果它返回非零值(假),那么 || 后的命令也会被执行。比如:

 
  1. i=0
  2. true && ((i++)) || ((i--)) # 错误!
  3. echo "$i" # 输出 0
 

你可能会认为 i 应该是 1,但实际上它还是 0。原因是 i++ 的退出状态是 1(假),然后 i-- 会被执行。为了解决这个问题,你应该使用简单的 if … fi 语法:

 
  1. i=0
  2. if true; then
  3. ((i++))
  4. else
  5. ((i--))
  6. fi
  7. echo "$i" # 输出 1
 

23. echo "Hello World!"

这个问题出现在交互式 Bash shell 中(特别是 4.3 版本之前),你会看到类似以下的错误:

 
  1. bash: !": event not found
 

这是因为在默认设置下,Bash 会使用 C-shell 风格的历史扩展(history expansion),而 ! 会触发这个扩展。这会影响交互式 shell,在脚本中不会有问题。

最直接的解决办法是关闭历史扩展:

 
  1. set +H
  2. echo "Hello World!"
 

如果你不想在每次运行时关闭它,可以在 ~/.bashrc 中加入 set +H 来永久禁用。

24. for arg in $*

(和所有 Bourne shell)有一个特别的语法,用于逐个引用位置参数,而 $* 并不是正确的方式。$@ 也不行,它们会展开为参数列表的所有单词,而不是逐个参数。

正确的语法是:

 
  1. for arg in "$@"
 

"$@" 会确保每个参数作为单个单词传递给循环体。$*$@ 展开时,必须加双引号以避免出现问题。

25. function foo()

这种写法在某些 shell 中可以运行,但在其他 shell 中会报错。永远不要在定义函数时同时使用 function 关键字和括号 ()

(某些版本)允许你混用这两种语法,但大多数 shell 不接受(zsh 4.x 及更新版本可以)。为了最大化可移植性,建议总是用下面的标准写法:

 
  1. javascript foo() {
  2. }
 

26. echo "~"

波浪线 ~ 的展开(tilde expansion)只有在它不加引号时才会发生。在这个例子中,echo 输出的是 ~,而不是用户主目录的路径。

如果要引用相对于用户主目录的路径,建议使用 $HOME 而不是 ~,比如:

 
  1. perl "~/dir with spaces" # 展开结果仍然是 "~/dir with spaces"
  2. ~"/dir with spaces" # 展开结果仍然是 "~/dir with spaces"
  3. ~/"dir with spaces" # 展开结果是 "/home/my photos/dir with spaces"
  4. "$HOME/dir with spaces" # 展开结果是 "/home/my photos/dir with spaces"
 

27. local var=$(cmd)

在函数中声明局部变量时,local 是一个单独的命令。这种写法有几个问题:

  1. 如果你想捕获命令替换的退出状态 $?,会发现它被 local 的退出状态覆盖了。
  2. 在某些 shell(如 bash)中,local var=$(cmd) 被当作赋值处理,因此右边的命令会被特殊处理;但在另一些 shell 中,它不会被当作赋值,而是会进行单词拆分(如果没有加引号)。

为了避免这些问题,最好分开写:

 
  1. javascript local var
  2. var=$(cmd)
  3. rc=$?
 

exportreadonly 也有同样的问题。


28. export foo=~/bar

波浪线展开(tilde expansion)只有在它出现在单词的开头时才会发生,或者出现在赋值语句中等号右边的开头位置。

但是 exportlocal 并不总是被 shell 当作赋值处理。在一些 shell(如 bash)中,export foo=~/bar 会进行波浪线展开;在另一些 shell 中则不会。

可移植的写法是:

 
  1. foo=~/bar; export foo # 正确!
  2. export foo="$HOME/bar" # 正确!
 

更好的写法(避免 $HOME/ 时出现双斜杠 //):

 
  1. export foo="${HOME%/}/bar" # 更好!
 

29. sed 's/$foo/good bye/'

单引号内,bash 的变量展开(如 $foo)不会发生。这正是单引号的作用——保护 $ 不被展开。

如果你需要变量展开,请用双引号:

 
  1. foo="hello"; sed "s/$foo/good bye/"
 

注意,使用双引号可能需要额外转义,具体可参考引号的相关内容。


30. tr [A-Z] [a-z]

这个写法至少有三个问题:

  1. [A-Z] 和 [a-z] 会被 shell 当作通配符(glob)处理。如果当前目录中有单字符文件名,命令就会出错。
  2. 在 tr 中,这种写法会额外匹配 [ 和 ],所以你不需要这对方括号。
  3. 不同的语言环境(locale)下,A-Z 或 a-z 的范围可能不只是 26 个 ASCII 字母。在某些语言环境中,字母 z 甚至可能排在中间位置。

解决办法取决于你需要的效果:

 
  1. # 如果只想处理 26 个拉丁字母:
  2. LC_COLLATE=C tr A-Z a-z
  3.  
  4. # 如果想根据当前语言环境进行大小写转换:
  5. tr '[:upper:]' '[:lower:]'
 

第二种写法中的引号是必须的,用来防止通配符扩展。


31. ps ax | grep gedit

用进程名查找进程是不可靠的,原因包括:

  • 可能有多个同名进程。
  • 进程名可以伪造。
  • 输出中可能包含你自己的 grep 进程。

例如:

 
  1. perl$ ps ax | grep gedit
  2. 10530 ? S 6:23 gedit
  3. 32118 pts/0 R+ 0:00 grep gedit
 

可以用以下方法避免匹配到 grep 自身:

 
  1. perlps ax | grep -v grep | grep gedit # 丑,但可用
  2. ps ax | grep '[g]edit' # 更好,grep 自己不会匹配到
 

更简洁的做法是在 GNU/Linux 上用:

 
  1. mathematicaps -C gedit
  2. pgrep gedit
 

如果需要杀进程,可以用 pkill


32. printf "$foo"

这种写法的问题不在于引号,而在于格式化字符串漏洞。如果 $foo 的内容不受你控制,里面的 %\ 可能会导致意外行为。

安全的做法是始终自己提供格式化字符串:

 
  1. perlprintf %s "$foo"
  2. printf '%s\n' "$foo"
 

33. for i in {1..$n}

解析器会在所有其他展开之前执行大括号展开(brace expansion)。因此它看到的只是字面量 $n,不是数字,所以不会展开。

如果循环次数是运行时才知道的,不能用 brace expansion,而应该用算术 for 循环:

 
  1. for ((i=1; i<=n; i++)); do
  2. done
 

算术循环在处理整数范围时更高效,也更安全。


34. if [[ $foo = $bar ]](取决于意图)

[[ … ]] 中,如果 = 右边没有加引号,Bash 会对它做模式匹配(pattern matching),而不是纯字符串比较。如果 $bar 中包含 *,匹配结果几乎总是 true

如果你要比较字符串,请加引号:

 
  1. if [[ $foo = "$bar" ]]
 

如果你是想做模式匹配,请在变量命名或注释中明确说明。


35. if [[ $foo =~ 'some RE' ]]

=~ 右边加引号,会让它变成字符串匹配,而不是正则表达式匹配。

如果你要匹配复杂的正则表达式,又不想写很多反斜杠,可以把正则存到变量里:

 
  1. luare='some RE'
  2. if [[ $foo =~ $re ]]
 

这样还能避免 Bash 不同版本在 =~ 上的细微差异。

同样的问题也会出现在模式匹配中:

 
  1. lua[[ $foo = "*.glob" ]] # 错!被当作字符串
  2. [[ $foo = *.glob ]] # 对!被当作模式
 

36. rm $file(缺少引号)

这个问题在前面已经出现过(见第 2 节、14 节)。如果 $file 中有空格、通配符等特殊字符,没有加引号会导致错误甚至数据丢失。

正确做法:

 
  1. rm -- "$file"
 

双引号保护变量不被拆分或扩展,-- 可以防止文件名以 - 开头被当作选项。


37. alias foo='cd $(dirname $BASH_SOURCE)'

在别名定义中使用 $() 会在定义别名时立即执行,而不是在调用时执行,所以 $BASH_SOURCE 会是当前定义别名的脚本文件,而不是调用别名的脚本。

如果你需要运行时执行命令替换,请用函数而不是别名:

 
  1. foo() {
  2. cd -- "$(dirname "$BASH_SOURCE")" || return
  3. }
 

38. echo $((foo=bar))

在算术扩展中,bar 必须是数字,否则会当作变量名引用。比如:

 
  1. bar=7
  2. echo $((foo=bar)) # foo=7
 

如果 bar 是字符串,会变成 0。算术扩展只支持整数,不能处理字符串。


39. echo $((foo=$bar+1))(未加引号的变量)

如果 $bar 为空或非数字,会导致算术错误。虽然算术扩展不会触发单词拆分,但它会将 $bar 当作变量引用,并且在某些情况下默默变成 0。

为了安全,最好先验证:

 
  1. lua[[ $bar =~ ^[0-9]+$ ]] || bar=0
  2. foo=$((bar+1))
 

40. find . -exec grep foo {} \;

这个写法没错,但性能较低,因为 grep 会被多次调用(每个文件一次)。如果 grep 支持同时处理多个文件,可以这样优化:

 
  1. find . -exec grep foo {} +
 

或直接用 grep 的递归功能:

 
  1. grep -r foo .
 

41. kill -9 $(ps -C foo -o pid=)

这种写法存在风险:

  1. 如果 ps 没有匹配到进程,会执行 kill -9 而不带 PID(可能会杀掉自己的 shell 进程)。
  2. 如果 $() 展开了很多 PID,可能会超出命令行参数长度限制。

更安全的做法是使用 pkill

 
  1. pkill -9 foo
 

或者:

 
  1. pids=$(ps -C foo -o pid=)
  2. [ -n "$pids" ] && kill -9 $pids
 

42. export PATH=$PATH:/new/dir

这个写法的问题是如果 $PATH 为空,会在前面留下一个多余的冒号 :,而 : 在 PATH 中表示当前目录 .。这可能带来安全隐患。

更安全的写法:

 
  1. PATH="${PATH:+$PATH:}/new/dir"
  2. export PATH
 

43. echo $(<file)

虽然这看似是读取文件的简洁写法,但它会把整个文件内容读到命令替换中,然后再经过单词拆分和通配符扩展,这在大文件或包含特殊字符时会出错。

如果只是要输出文件内容:

 
  1. cat file
 

如果要存到变量中:

 
  1. var=$(<file) # Bash 特性,比 $(cat file) 高效
 

44. eval $cmd

eval 会重新解析并执行字符串中的命令,这使它非常危险,尤其当 $cmd 来自用户输入时,可能导致命令注入漏洞。

如果你只是想执行变量中存储的命令及参数,请用数组:

 
  1. cmd=(ls -l /tmp)
  2. "${cmd[@]}"
 

这样就不会被 shell 重新解析,也更安全。


45. read foo; read bar

这种写法在交互式模式下没问题,但如果输入是从文件或管道来的,第二个 read 可能直接读到 EOF,导致 bar 为空。

如果要一次读取多个变量:

 
  1. read foo bar
 

它会自动按 $IFS 分隔赋值。


46. set -e(误用)

set -e 会在命令返回非零状态时立即退出,但它有许多例外情况(比如在 ifwhileuntil 条件判断中)。这会让脚本行为难以预测。

如果你的目的是在错误时退出,推荐手动检查:

 
  1. cmd || exit 1
 

或者:

 
  1. if ! cmd; then
  2. echo "error" >&2
  3. exit 1
  4. fi
 

47. cd $(dirname "$0")

$0 在被 source.) 运行的脚本中并不总是脚本路径,有时是调用者的 shell 名称。为了获得脚本所在路径,推荐用:

 
  1. cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
 

$BASH_SOURCE 在被 source 时仍然能正确指向文件路径。