1. for f in $(ls *.mp3)
程序员最常犯的错误之一,就是写出这样的循环:
- for f in $(ls *.mp3); do # 错误!
- some command $f # 错误!
- done
- for f in $(ls) # 错误!
- for f in `ls` # 错误!
- for f in $(find . -type f) # 错误!
- for f in `find . -type f` # 错误!
- files=($(find . -type f)) # 错误!
- for f in ${files[@]} # 错误!
是的,把 ls 或 find 的输出当成文件名列表直接迭代看起来很方便,但你不能这样做。这种方法从根本上就有缺陷,没有任何技巧能让它安全工作,必须换一种思路。
至少有 6 个问题:
- 如果文件名中包含空格(或
$IFS中的任意字符),会触发单词拆分(WordSplitting)。假设当前目录有个文件叫01 - Don't Eat the Yellow Snow.mp3,循环会按 01、-、Don't、Eat… 这样的“单词”来迭代。 - 如果文件名中包含通配符(glob)字符,会触发文件名扩展(globbing)。比如
ls输出中带*,这个*会当作模式去匹配所有符合的文件名。 - 如果命令替换返回多个文件名,没有办法区分第一个文件名结束和第二个开始的位置。路径名除了 NUL 之外可以包含任意字符,包括换行。
ls可能会篡改文件名。不同平台、不同参数、是否输出到终端,ls都可能用?替换某些字符,甚至不输出某些字符。永远不要用脚本去解析ls的输出,因为它是给人看的,不是给程序处理的。- 命令替换会去掉所有尾部换行。如果最后一个文件名本身以换行结尾,
…或$()会把它移除。 - 如果
ls的第一个文件名以-开头,会导致类似 #3 的问题。
双引号也救不了你:
- for f in "$(ls *.mp3)"; do # 错误!
这样会把整个 ls 的输出当作一个单词,循环只执行一次,f 会包含所有文件名拼在一起的字符串。
改变 $IFS 为换行符也没用,因为文件名也可能包含换行。
另一种常见误用是用 for 循环去(错误地)按行读取文件:
- IFS=$'\n'
- for line in $(cat file); do … # 错误!
这样是行不通的,尤其当这些行是文件名时。
正确的做法:
如果不需要递归,可以直接用通配符(glob),而不是 ls:
- for file in ./*.mp3; do # 更好!
- some command "$file" # …总是给变量加双引号!
- done
通配符是在所有展开操作的最后一步进行的,每个匹配到的文件名都是独立的单词,不会受到未加引号的变量展开副作用。
如果当前目录没有 .mp3 文件,这个 for 会执行一次,file="./*.mp3",这不是我们想要的行为。解决办法是先检测文件是否存在:
- # POSIX
- for file in ./*.mp3; do
- [ -e "$file" ] || continue
- some command "$file"
- done
或者用 shopt -s nullglob,但要先仔细读文档,评估它对脚本中其它通配符的影响。
如果需要递归,可以用 find,并且要用对方法:
- find . -type f -name '*.mp3' -exec some command {} \;
- # 如果命令支持多个文件名:
- find . -type f -name '*.mp3' -exec some command {} +
在 Bash 里还可以用 GNU/BSD find 的 -print0 搭配 read -d '':
- while IFS= LC_ALL=C read -r -d '' file; do
- some command "$file"
- done < <(find . -type f -name '*.mp3' -print0)
或者在 Bash 4.0+ 用 globstar 支持递归通配符:
- shopt -u failglob; shopt -s globstar nullglob # dotglob
- for file in ./**/*.mp3; do
- some command "$file"
- done
注意,变量展开时一定要加双引号。
2. cp $file $target
这个命令有什么问题吗?如果你提前知道 $file 和 $target 中不包含空格(并且没有修改 $IFS),或者文件名中没有通配符,这个命令是可以正常工作的。但实际上,展开结果会受到单词拆分和路径名扩展的影响。所以,永远要给参数扩展加双引号。
- 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)和参数之间加 --,告诉它不再查找选项,后面的文件名就是参数:
- cp -- "$file" "$target"
另一种方法是确保文件名总是带上目录前缀,无论是相对路径还是绝对路径:
- for i in ./*.mp3; do
- cp "$i" /target
- …
- done
即使文件名以 - 开头,前面的 ./ 也能确保它不会被误解为选项。
4. [ $foo = "bar" ]
这个问题和上面 #2 类似,但我再重复一次,因为它非常重要。上面这个例子里,引号放错地方了。在 Bash 中,你不需要给字符串字面量加引号(除非它包含元字符或模式字符),但你应该给变量加引号,尤其当你不确定它们是否包含空格或通配符时。
这样写会出问题的原因:
-
如果
[中引用的变量不存在或者为空,[会变成这样:- []
这会报错:unary operator expected。= 操作符是二元的,而不是一元的,[ 看到它会很困惑。
-
如果变量包含空格,它会被拆分成多个单词,然后传给
[,因此:- [ multiple words here = "bar" ]
这虽然看起来没问题,但对于 [ 来说是语法错误。正确写法应该是:
- bash# POSIX
- [ "$foo" = bar ] # 正确!
这样即使 $foo 以 - 开头也不会出问题,因为 POSIX 的 [ 根据参数个数来判断操作。只有非常古老的 shell 会遇到问题,你可以忽略这些情况。
和很多 ksh-like 的 shell 提供了一个更好的替代方法,使用 [[ ]]:
- lua# Bash / Ksh
- [[ $foo == bar ]] # 正确!
在 [[ ]] 中,左侧的变量不需要加引号,因为它不会进行单词拆分或通配符扩展,空白变量也能正确处理。
5. cd $(dirname "$f")
这是另一个常见的引号错误。像变量展开一样,命令替换(Command Substitution)的结果会经历单词拆分和路径名扩展,所以你应该给它加引号:
- cd -P -- "$(dirname -- "$f")"
问题在于引号嵌套。C 程序员会期待第一对和第二对双引号在一起,第三对和第四对也是,但 Bash 会把命令替换中的引号当作一对,外部的双引号当作另一对。
6. [ "$foo" = bar && "$bar" = foo ]
不能在 [ ] 这种命令中使用 &&。Bash 解析器看到 && 就会把命令分成两部分,在 && 前后分别执行。应该改成以下两种之一:
- [ bar = "$foo" ] && [ foo = "$bar" ] # 正确!(POSIX)
- [[ $foo = bar && $bar = foo ]] # 也对!(Bash / Ksh)
注意在 [ 中,我们交换了常量和变量的位置,这是因为有一些历史原因。你也可以交换 [[ 里面的顺序,但是展开的时候需要加引号防止被当作模式。
避免这种写法:
- [ bar = "$foo" -a foo = "$bar" ] # 不符合规范。
-a 和 -o 这些运算符是 XSI 扩展,在 POSIX-2008 中已标记为过时,应该避免在新代码中使用。
7. [[ $foo > 7 ]]
这里有几个问题。首先,[[ 命令不应该仅仅用于评估算术表达式。它应该用于包含测试运算符的表达式。虽然 technically 可以使用 [[ 做数学计算,但只有和非数学测试运算符结合时才有意义。如果你只想进行数值比较(或者任何其他的 shell 算术运算),最好直接使用 (( )):
- lua# Bash / Ksh
- ((foo > 7)) # 正确!
- [[ foo -gt 7 ]] # 也可以,但不常见。更推荐使用 ((…))。
如果在 [[ ]] 内部使用 > 运算符,它会把它当作字符串比较(按本地排序规则),而不是整数比较。这通常能正常工作,但有时候会失败。如果你在 [ ] 中使用它,就更糟了:它会被当作输出重定向符号。你会得到一个名为 7 的文件。
如果你需要严格的 POSIX 兼容,且不能使用 (( )),正确的做法是:
- perl# POSIX
- [ "$foo" -gt 7 ] # 正确!
- [ "$((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 命令,只是最终参数必须是 ]。
例如:
- # POSIX
- if [ false ]; then echo "HELP"; fi
- if test false; then echo "HELP"; fi
这两个命令是等价的,都会检查参数 false 是否非空,并始终打印 HELP,这会让来自其他语言的程序员感到困惑。
if 语句的语法是:
- if COMMANDS
- then <COMMANDS>
- elif <COMMANDS> # 可选
- then <COMMANDS>
- else <COMMANDS> # 可选
- fi # 必须
if 是一个复合命令,包含两个或更多部分,每部分之间用 then、elif 或 else 分隔,最后用 fi 结束。第一个部分的最后一个命令及其后的每个 elif 部分决定了是否会执行相应的 then 部分。如果 then 部分没有执行,else 分支会被执行。
如果你想根据 grep 命令的输出做决策,不要把 grep 放在方括号内,而是直接将其作为命令使用:
- nginxif grep -q fooregex myfile; then
- …
- fi
如果 grep 匹配了 myfile 中的某行,它会返回 0(真),然后执行 then 部分。如果没有匹配,grep 会返回非零值,if 命令会判断为假。
10. if [bar="$foo"]; then …
- [bar="$foo"] # 错误!
- [ bar="$foo" ] # 仍然错误!
- [bar = "$foo"] # 也错误!
- [[bar="$foo"]] # 错误!
- [[ bar="$foo" ]] # 你猜怎么着? 错误!
- [[bar = "$foo"]] # 还需要我说吗…
正如之前的例子所说,[ 是一个命令(可以通过 type -t [ 或 whence -v [ 证明),而不是语法标记。所以在 [ 后必须有空格,参数之间也需要空格。正确的写法是:
- nginxif [ bar = "$foo" ]; then …
- if [[ bar = "$foo" ]]; then …
在第一个写法中,[ 是命令名称,bar、=、"$foo" 和 ] 是它的参数。每对参数之间必须有空格,这样 shell 才知道每个参数的开始和结束。第二种写法是类似的,只不过 [[ 是一个特殊的关键字,由 ]] 结束。
11. if [ [ a = b ] && [ c = d ] ]; then …
这里再次出现了问题。[ 是一个命令,而不是用于分组的语法标记。不能像在 C 语言中那样把 if 命令翻译成 Bash 的 if,仅仅通过替换括号为方括号。
如果你想写一个复合条件,应该这样做:
- if [ a = b ] && [ c = d ]; then …
这里,if 后有两个命令,通过 && 连接。和这样写等效:
- if test a = b && test c = d; then …
如果第一个 test 命令返回 false,if 的主体部分就不会执行。如果它返回 true,第二个 test 命令会被执行,只有第二个命令也返回 true 时,if 的主体部分才会执行。 Bash 使用的是短路求值(short-circuit evaluation)。
[[ 关键字也支持 &&,所以可以这么写:
- if [[ a = b && c = d ]]; then …
12. read $foo
在 read 命令中,不需要在变量名前加 $。如果你想把输入存储到变量 foo 中,应该这样写:
- read foo
或者更安全的写法:
- IFS= read -r foo
read $foo 会读取一行输入,并将其存储到名为 $foo 的变量中。这个语法在少数情况下有用,但大多数时候它是一个 bug。
13. cat file | sed s/foo/bar/ > file
你不能在同一个管道中从文件读取并写入文件。根据管道的内容,文件可能会被覆盖(变成 0 字节,或者可能是操作系统管道缓冲区大小的字节数),或者文件可能会不断增长,直到填满可用磁盘空间、达到操作系统的文件大小限制或配额等。
如果你想安全地修改文件,除了向文件末尾追加外,应该使用文本编辑器:
- printf %s\\n ',s/foo/bar/g' w q | ed -s file
如果你的操作不能用文本编辑器做,可以创建一个临时文件:
- sed 's/foo/bar/g' file > tmpfile && mv tmpfile file
这会创建一个临时文件,并将修改后的内容替换原文件。只有在 GNU sed 4.x 版本中,才会直接用 -i 选项修改文件:
- sed -i 's/foo/bar/g' file(s)
14. echo $foo
这个看似无害的命令会导致极大的困惑。由于 $foo 没有加引号,它不仅会受到单词拆分的影响,还会进行文件通配符扩展。这会让 Bash 程序员误以为变量包含了错误的值,而实际上变量本身是正确的,问题出在扩展过程中。
- 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
示例:
- php var="*.zip" # var 包含星号、点和 zip
- echo "$var" # 输出 *.zip
- echo $var # 输出所有匹配的 .zip 文件
为了解决这个问题,应该使用 printf:
- printf "%s\n" "$foo"
15. $foo=bar
你不能在变量名之前加 $ 来进行赋值,这不是 Perl 的写法。
16. foo = bar
你不能在赋值时在等号两边加空格。这不是 C 语言。写成 foo = bar 会被 shell 拆成三个词,第一个词 foo 会被当作命令名,第二和第三个词会被当作命令的参数。
同样,下面的写法也是错误的:
- foo= bar # 错误!
- foo =bar # 错误!
- $foo = bar; # 完全错误!
正确的写法是:
- ini foo=bar # 正确。
- foo="bar" # 更正确。
17. echo <<EOF
Here 文档是嵌入大块文本数据的一个有用工具,它会将文本重定向到命令的标准输入中。然而,echo 并不是一个读取标准输入的命令。
- # 错误的写法:
- echo <<EOF
- Hello world
- How's it going?
- EOF
- # 正确的做法:
- cat <<EOF
- Hello world
- How's it going?
- EOF
- # 或者,使用可以跨多行的引号(效率更高,echo 是内置命令):
- echo "Hello world
- How's it going?"
用双引号这样做是可以的,所有 shell 都支持,但它不能让你直接在脚本中插入一个文本块。你可以在第一行和最后一行使用语法标记。如果你不想使用 cat,也可以使用 printf:
- perl # 或者使用 printf(也是内置的,效率更高):
- printf %s "\
- Hello world
- How's it going?
- "
在 printf 示例中,第一行的 \ 防止在文本块前添加额外的换行符。最后一行的换行符是因为最后的引号在新的一行。没有在 printf 格式参数中使用 \n,所以它不会在文本末尾添加额外的换行符。
18. su -c 'some command'
这个语法几乎正确。问题是,许多平台上的 su 确实接受 -c 参数,但你想传递的是 -c 'some command' 给一个 shell,也就是说你需要在 -c 前指定用户名。
- su root -c 'some command' # 正确
如果不指定用户名,su 会默认使用 root,但当你想传递命令给 shell 时会失败。你必须在这种情况下明确指定用户名。
19. cd /foo; bar
如果你没有检查 cd 命令的错误,你可能会在错误的目录下执行 bar,这可能会导致灾难,尤其是当 bar 是一个危险命令时,比如 rm -f *。
你必须始终检查 cd 命令的错误。最简单的做法是:
- cd /foo && bar
如果有多个命令在 cd 后执行,可以使用:
- cd /foo || exit 1
- bar
- baz
- bat … # 很多命令
cd 会报告目录切换失败,并显示类似 “bash: cd: /foo: No such file or directory” 的错误信息。如果你想输出自定义的错误信息,可以使用命令分组:
- cd /net || { echo >&2 "Can't read /net. Make sure you've logged in to the Samba network, and try again."; exit 1; }
- do_stuff
- more_stuff
这里需要注意 { 和 echo 之间的空格,} 前的分号是必须的。你也可以写一个 die 函数来更简洁地处理。
20. [ bar == "$foo" ]
在 POSIX 的 [ 命令中,== 运算符是无效的。应该使用 = 或 [[:
- [ bar = "$foo" ] && echo yes
- [[ bar == $foo ]] && echo yes
在 Bash 中,[ "$x" == y ] 是被接受的扩展,这会让 Bash 程序员误以为它是正确的语法。实际上它是一个Bashism,如果你要使用这些扩展,直接使用 [[ 会更好。
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 的简写,可能是因为他们认为这样更简洁。例如:
- lua # 错误的写法!
- [[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."
这种写法并不等价于 if … fi,因为在 && 后的命令也会产生退出状态,如果它返回非零值(假),那么 || 后的命令也会被执行。比如:
- i=0
- true && ((i++)) || ((i--)) # 错误!
- echo "$i" # 输出 0
你可能会认为 i 应该是 1,但实际上它还是 0。原因是 i++ 的退出状态是 1(假),然后 i-- 会被执行。为了解决这个问题,你应该使用简单的 if … fi 语法:
- i=0
- if true; then
- ((i++))
- else
- ((i--))
- fi
- echo "$i" # 输出 1
23. echo "Hello World!"
这个问题出现在交互式 Bash shell 中(特别是 4.3 版本之前),你会看到类似以下的错误:
- bash: !": event not found
这是因为在默认设置下,Bash 会使用 C-shell 风格的历史扩展(history expansion),而 ! 会触发这个扩展。这只会影响交互式 shell,在脚本中不会有问题。
最直接的解决办法是关闭历史扩展:
- set +H
- echo "Hello World!"
如果你不想在每次运行时关闭它,可以在 ~/.bashrc 中加入 set +H 来永久禁用。
24. for arg in $*
(和所有 Bourne shell)有一个特别的语法,用于逐个引用位置参数,而 $* 并不是正确的方式。$@ 也不行,它们会展开为参数列表的所有单词,而不是逐个参数。
正确的语法是:
- for arg in "$@"
"$@" 会确保每个参数作为单个单词传递给循环体。$* 和 $@ 展开时,必须加双引号以避免出现问题。
25. function foo()
这种写法在某些 shell 中可以运行,但在其他 shell 中会报错。永远不要在定义函数时同时使用 function 关键字和括号 ()。
(某些版本)允许你混用这两种语法,但大多数 shell 不接受(zsh 4.x 及更新版本可以)。为了最大化可移植性,建议总是用下面的标准写法:
- javascript foo() {
- …
- }
26. echo "~"
波浪线 ~ 的展开(tilde expansion)只有在它不加引号时才会发生。在这个例子中,echo 输出的是 ~,而不是用户主目录的路径。
如果要引用相对于用户主目录的路径,建议使用 $HOME 而不是 ~,比如:
- perl "~/dir with spaces" # 展开结果仍然是 "~/dir with spaces"
- ~"/dir with spaces" # 展开结果仍然是 "~/dir with spaces"
- ~/"dir with spaces" # 展开结果是 "/home/my photos/dir with spaces"
- "$HOME/dir with spaces" # 展开结果是 "/home/my photos/dir with spaces"
27. local var=$(cmd)
在函数中声明局部变量时,local 是一个单独的命令。这种写法有几个问题:
- 如果你想捕获命令替换的退出状态
$?,会发现它被local的退出状态覆盖了。 - 在某些 shell(如 bash)中,
local var=$(cmd)被当作赋值处理,因此右边的命令会被特殊处理;但在另一些 shell 中,它不会被当作赋值,而是会进行单词拆分(如果没有加引号)。
为了避免这些问题,最好分开写:
- javascript local var
- var=$(cmd)
- rc=$?
export 和 readonly 也有同样的问题。
28. export foo=~/bar
波浪线展开(tilde expansion)只有在它出现在单词的开头时才会发生,或者出现在赋值语句中等号右边的开头位置。
但是 export 和 local 并不总是被 shell 当作赋值处理。在一些 shell(如 bash)中,export foo=~/bar 会进行波浪线展开;在另一些 shell 中则不会。
可移植的写法是:
- foo=~/bar; export foo # 正确!
- export foo="$HOME/bar" # 正确!
更好的写法(避免 $HOME 为 / 时出现双斜杠 //):
- export foo="${HOME%/}/bar" # 更好!
29. sed 's/$foo/good bye/'
在单引号内,bash 的变量展开(如 $foo)不会发生。这正是单引号的作用——保护 $ 不被展开。
如果你需要变量展开,请用双引号:
- foo="hello"; sed "s/$foo/good bye/"
注意,使用双引号可能需要额外转义,具体可参考引号的相关内容。
30. tr [A-Z] [a-z]
这个写法至少有三个问题:
[A-Z]和[a-z]会被 shell 当作通配符(glob)处理。如果当前目录中有单字符文件名,命令就会出错。- 在
tr中,这种写法会额外匹配[和],所以你不需要这对方括号。 - 不同的语言环境(locale)下,
A-Z或a-z的范围可能不只是 26 个 ASCII 字母。在某些语言环境中,字母 z 甚至可能排在中间位置。
解决办法取决于你需要的效果:
- # 如果只想处理 26 个拉丁字母:
- LC_COLLATE=C tr A-Z a-z
- # 如果想根据当前语言环境进行大小写转换:
- tr '[:upper:]' '[:lower:]'
第二种写法中的引号是必须的,用来防止通配符扩展。
31. ps ax | grep gedit
用进程名查找进程是不可靠的,原因包括:
- 可能有多个同名进程。
- 进程名可以伪造。
- 输出中可能包含你自己的
grep进程。
例如:
- perl$ ps ax | grep gedit
- 10530 ? S 6:23 gedit
- 32118 pts/0 R+ 0:00 grep gedit
可以用以下方法避免匹配到 grep 自身:
- perlps ax | grep -v grep | grep gedit # 丑,但可用
- ps ax | grep '[g]edit' # 更好,grep 自己不会匹配到
更简洁的做法是在 GNU/Linux 上用:
- mathematicaps -C gedit
- pgrep gedit
如果需要杀进程,可以用 pkill。
32. printf "$foo"
这种写法的问题不在于引号,而在于格式化字符串漏洞。如果 $foo 的内容不受你控制,里面的 % 或 \ 可能会导致意外行为。
安全的做法是始终自己提供格式化字符串:
- perlprintf %s "$foo"
- printf '%s\n' "$foo"
33. for i in {1..$n}
解析器会在所有其他展开之前执行大括号展开(brace expansion)。因此它看到的只是字面量 $n,不是数字,所以不会展开。
如果循环次数是运行时才知道的,不能用 brace expansion,而应该用算术 for 循环:
- for ((i=1; i<=n; i++)); do
- …
- done
算术循环在处理整数范围时更高效,也更安全。
34. if [[ $foo = $bar ]](取决于意图)
在 [[ … ]] 中,如果 = 右边没有加引号,Bash 会对它做模式匹配(pattern matching),而不是纯字符串比较。如果 $bar 中包含 *,匹配结果几乎总是 true。
如果你要比较字符串,请加引号:
- if [[ $foo = "$bar" ]]
如果你是想做模式匹配,请在变量命名或注释中明确说明。
35. if [[ $foo =~ 'some RE' ]]
在 =~ 右边加引号,会让它变成字符串匹配,而不是正则表达式匹配。
如果你要匹配复杂的正则表达式,又不想写很多反斜杠,可以把正则存到变量里:
- luare='some RE'
- if [[ $foo =~ $re ]]
这样还能避免 Bash 不同版本在 =~ 上的细微差异。
同样的问题也会出现在模式匹配中:
- lua[[ $foo = "*.glob" ]] # 错!被当作字符串
- [[ $foo = *.glob ]] # 对!被当作模式
36. rm $file(缺少引号)
这个问题在前面已经出现过(见第 2 节、14 节)。如果 $file 中有空格、通配符等特殊字符,没有加引号会导致错误甚至数据丢失。
正确做法:
- rm -- "$file"
双引号保护变量不被拆分或扩展,-- 可以防止文件名以 - 开头被当作选项。
37. alias foo='cd $(dirname $BASH_SOURCE)'
在别名定义中使用 $() 会在定义别名时立即执行,而不是在调用时执行,所以 $BASH_SOURCE 会是当前定义别名的脚本文件,而不是调用别名的脚本。
如果你需要运行时执行命令替换,请用函数而不是别名:
- foo() {
- cd -- "$(dirname "$BASH_SOURCE")" || return
- }
38. echo $((foo=bar))
在算术扩展中,bar 必须是数字,否则会当作变量名引用。比如:
- bar=7
- echo $((foo=bar)) # foo=7
如果 bar 是字符串,会变成 0。算术扩展只支持整数,不能处理字符串。
39. echo $((foo=$bar+1))(未加引号的变量)
如果 $bar 为空或非数字,会导致算术错误。虽然算术扩展不会触发单词拆分,但它会将 $bar 当作变量引用,并且在某些情况下默默变成 0。
为了安全,最好先验证:
- lua[[ $bar =~ ^[0-9]+$ ]] || bar=0
- foo=$((bar+1))
40. find . -exec grep foo {} \;
这个写法没错,但性能较低,因为 grep 会被多次调用(每个文件一次)。如果 grep 支持同时处理多个文件,可以这样优化:
- find . -exec grep foo {} +
或直接用 grep 的递归功能:
- grep -r foo .
41. kill -9 $(ps -C foo -o pid=)
这种写法存在风险:
- 如果
ps没有匹配到进程,会执行kill -9而不带 PID(可能会杀掉自己的 shell 进程)。 - 如果
$()展开了很多 PID,可能会超出命令行参数长度限制。
更安全的做法是使用 pkill:
- pkill -9 foo
或者:
- pids=$(ps -C foo -o pid=)
- [ -n "$pids" ] && kill -9 $pids
42. export PATH=$PATH:/new/dir
这个写法的问题是如果 $PATH 为空,会在前面留下一个多余的冒号 :,而 : 在 PATH 中表示当前目录 .。这可能带来安全隐患。
更安全的写法:
- PATH="${PATH:+$PATH:}/new/dir"
- export PATH
43. echo $(<file)
虽然这看似是读取文件的简洁写法,但它会把整个文件内容读到命令替换中,然后再经过单词拆分和通配符扩展,这在大文件或包含特殊字符时会出错。
如果只是要输出文件内容:
- cat file
如果要存到变量中:
- var=$(<file) # Bash 特性,比 $(cat file) 高效
44. eval $cmd
eval 会重新解析并执行字符串中的命令,这使它非常危险,尤其当 $cmd 来自用户输入时,可能导致命令注入漏洞。
如果你只是想执行变量中存储的命令及参数,请用数组:
- cmd=(ls -l /tmp)
- "${cmd[@]}"
这样就不会被 shell 重新解析,也更安全。
45. read foo; read bar
这种写法在交互式模式下没问题,但如果输入是从文件或管道来的,第二个 read 可能直接读到 EOF,导致 bar 为空。
如果要一次读取多个变量:
- read foo bar
它会自动按 $IFS 分隔赋值。
46. set -e(误用)
set -e 会在命令返回非零状态时立即退出,但它有许多例外情况(比如在 if、while、until 条件判断中)。这会让脚本行为难以预测。
如果你的目的是在错误时退出,推荐手动检查:
- cmd || exit 1
或者:
- if ! cmd; then
- echo "error" >&2
- exit 1
- fi
47. cd $(dirname "$0")
$0 在被 source(.) 运行的脚本中并不总是脚本路径,有时是调用者的 shell 名称。为了获得脚本所在路径,推荐用:
- cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
$BASH_SOURCE 在被 source 时仍然能正确指向文件路径。


AI 助手17 小时前
发表在:欢迎使用emlog感谢您的分享!很高兴看到大家对工业3D...
AI 助手4 天前
发表在:欢迎使用emlog感谢分享!您的观点很独特,听起来像是一...
AI 助手5 天前
发表在:欢迎使用emlog非常感谢您的分享!3D сканеры...
AI 助手8 天前
发表在:欢迎使用emlog非常感谢您的分享!听起来3D金属打印技...
AI 助手8 天前
发表在:欢迎使用emlog谢谢分享!WMS系统确实能提升仓储效率...
AI 助手10 天前
发表在:欢迎使用emlog谢谢分享这些有价值的建议!希望您的3D...
主机评测博客12 天前
发表在:内存卡损坏数据恢复的7个方法(内存卡读不出修复)https://www.88993.cn...
emlog12 天前
发表在:欢迎使用emlog这是系统生成的演示评论