Bash陷阱 #1
1. for f in $(ls *.mp3)
一个最常见的bash程序员会犯的错误就是使用如下的循环:
for f in $(ls *.mp3); do # Wrong!
some command $f # Wrong!
done
for f in $(ls) # Wrong!
for f in `ls` # Wrong!
for f in $(find . -type f) # Wrong!
for f in `find . -type f` # Wrong!
files=($(find . -type f)) # Wrong!
for f in ${files[@]} # Wrong!
如果您可以仅将ls
或find
的输出作为文件名列表并对其进行迭代,那将是很好的。但你不能,因为整个方法都存在致命缺陷,没有任何技巧可以使它起作用。您必须使用完全不同的方法。
其中至少存在有6个问题:
- 如果文件名包含空格(或
$IFS
当前值中的任何字符),它将面临WordSplitting。假设在当前目录中有一个名为01 - Don't Eat the Yellow Snow.mp3
的文件,那么for
循环将遍历结果文件名中的每个单词:01,-,Don't,Eat等。 - 如果文件名包含glob字符,则会对其进行文件名扩展(globbing)。如果
ls
产生任何包含*****字符的输出,则包含该字符的单词将被识别为模式,并替换为与之匹配的所有文件名的列表。 - 如果命令替换返回多个文件名,则无法分辨第一个文件在哪里结束,第二个文件在哪里开始。路径名可以包含除NUL之外的任何字符。是的,包括换行符。
ls
可能会割裂(mangle)文件名。根据您所使用的平台,使用(或未使用)的参数以及其标准输出是否指向终端,ls
可能会随机决定将文件名中的某些字符替换为?
,或根本不打印它们。切勿尝试解析ls的输出。ls
完全没有必要。这是一个外部命令,其输出专门供人类读取,而不应当由脚本解析。- CommandSubstitution将会从其所有输出行中去除尾随的换行符。由于
ls
添加了换行符,因此这似乎是合乎需要的,除非列表中的最后一个文件名以换行符结尾。或$()
也有类似的作用。 - 在
ls
示例中,如果第一个文件名以连字符开头,则可能会导致陷阱3。
您也不能简单地用双引号代替:
for f in "$(ls *.mp3)"; do # Wrong!
这使得ls
的整个输出被视为一个单词。循环只执行一次,而不会遍历每个文件名,所有文件名都将被压(rammed)在一起,作为一整个字符串分配给f
。
您也不能简单地将IFS更改为换行符。这是因为文件名也可以包含换行符。
此主题的另一个变体是滥用分词并使用for
循环来(错误地)读取文件的行。例如:
IFS=$'\n'
for line in $(cat file); do ... # Wrong!
这行不通!特别是当这些行是文件名时。Bash(或任何其他Bourne家族的shell)都无法通过这种方式工作。
那么,什么是正确的方法呢?
有几种方法,主要取决于您是否需要递归扩展。
如果您不需要递归,则可以使用简单的glob代替ls
:
for file in ./*.mp3; do # Better! and...
some command "$file" # ...always double-quote expansions!
done
POSIX shell程序(例如Bash)具有专门用于此目的的globing
功能——允许shell使用模式(patterns)来匹配文件名列表。这无需解释外部实用程序的结果。因为globbing是最后一个扩展步骤,所以./*.mp3
模式的每个匹配项都正确地扩展为一个单独的单词,并且不受未引用扩展的影响。
*问题:如果当前目录中没有.mp3-文件该怎么办?程序将使用file="./*.mp3"
执行一次for循环,这不是预期的行为!解决方法是测试是否有匹配的文件:
# POSIX
for file in ./*.mp3; do
[ -e "$file" ] || continue
some command "$file"
done
另一个解决方案是使用Bash的shopt -s nullglob
功能,但仅应在阅读文档并仔细考虑此设置对脚本中所有其他glob的影响后才能使用。
如果您需要递归,则find
就是标准解决方案。请确保您合理地使用find。为了实现POSIX sh的可移植性,请使用-exec
选项:
find . -type f -name '*.mp3' -exec some command {} \;
# Or, if the command accepts multiple input filenames:
find . -type f -name '*.mp3' -exec some command {} +
如果您使用的是bash,则还有两个附加选项。一种是使用GNU或BSD find
的-print0
选项,以及bash的read -d ''
选项和ProcessSubstitution:
while IFS= read -r -d '' file; do
some command "$file"
done << (find . -type f -name '*.mp3' -print0)
这样做的好处是,some command
(整个while
循环主体)将在当前shell中执行。您可以设置变量并使它们在循环结束后得以保持(persist)。
在Bash 4.0及更高版本中可用的另一个选项是globstar
,它允许对glob
进行递归扩展:
shopt -s globstar
for file in ./**/*.mp3; do
some command "$file"
done
请注意上面示例中$file
周围的双引号。这导致了我们的第二个陷阱:
2. cp $file $target
上面的命令其实并没有什么问题,前提是您事先知道$file
和$target
没有空格(并且您确保没有修改$IFS
或者调用任何会修改$IFS
的代码)或通配符。但是,拓展的结果仍受WordSplitting和pathname扩展的影响。
因此,请保证始终对扩展参数使用双引号。
cp -- "$file" "$target"
如果没有双引号,您将得到类似cp 01 - Don't Eat the Yellow Snow.mp3 /mnt/usb
,这将导致类似cp
运行出现错误cp: cannot stat '01': No such file or directory
。如果$file
中包含通配符(*
或?
或[
),并存在匹配通配符的文件时,它们将被扩展。使用双引号能保证一切正常,除非$file
恰好以-
开始。在这种情况下,cp
将会认为你想向它传入命令行选项(见陷阱#3)。
即使保证变量内容在某种程度上比较少见,但对扩展参数(尤其是包含文件名的参数)使用引号都是惯例和优良作法。有经验的脚本编写者将始终使用引号,除非在少数情况下(从代码上下文中可以明显看出参数包含有保证的安全值)。
3. Filenames with leading dashes
开头带有破折号的文件名会导致很多问题。像*.mp3
这样的glob会被划分到扩展列表(根据您当前的区域),并且在大多数地区-
排在字母前。列表传递给某些命令时,命令可能会错误地将-filename
解释为选项。有两个主要解决方案:
一种解决方案是在命令(如cp
)及其参数之间插入--
。这会告诉命令停止扫描选项,从而保证一切正常:
cp -- "$file" "$target"
但这种方法存在潜在的问题。您必须确保将--
插入每个参数的每种用法(在可能会将其解释为选项的上下文中),这很容易遗漏并且可能会产生很多冗余。
大多数写得很好的选项解析库都很清楚这一点,正确引用这些解析库的程序都能够使用这些功能。但是,仍然请注意,最终还是应当由应用程序来识别选项的结尾。某些手动分析选项,操作不正确或使用较糟糕的第三方库的程序可能无法识别它。标准实用程序应该具有POSIX所指定的一些例外情况。echo
就是一个例子。
另一个选择是通过使用相对或绝对路径名来确保文件名始终以目录开头。
for i in ./*.mp3; do
cp "$i" /target
...
done
在这种情况下,即使我们有一个以-
开头的文件,glob也会确保该变量始终为./-foo.mp3
的形式,就cp
而言,这是绝对安全的。
最后,如果你可以保证所有结果都具有相同的前缀,并且仅在循环体内使用了几次变量,那么你可以简单地将前缀与扩展名连接起来。理论上,这节省了为每个单词生成和存储额外字符的时间。
for i in *.mp3; do
cp "./$i" /target
...
done
4. [ $foo = "bar" ]
这与陷阱2中的问题非常相似,我之所以重复一遍,因为它非常重要。在上面的示例中,引号放在了错误的位置。你不需要在bash使用引号引用一个字符串(除非它包含元字符或模式字符)。但是,如果不确定变量是否会包含空格或通配符,那就应加引号。
此示例可能因以下几个原因而中断:
如果[
中引用的变量不存在或为空,则[
命令最终看起来像:
[ = "bar" ] # Wrong!
并会引发错误:unary operator expected
。(=
运算符是二元运算符,而不是一元的,因此[
命令无法理解此命令)
如果变量包含内部空格,则在[
命令看到它之前,它会被分成单独的单词。因此:
[ multiple words here = "bar" ]
尽管这对您来说似乎不错,但就[
而言,这是一个语法错误。正确的写法是:
# POSIX
[ "$foo" = bar ] # Right!
即使$foo
以-
开头,这在符合POSIX的实现上也可以正常工作,因为POSIX中 [
取决于传递给它的参数数量来确定其操作。只有非常古老的shell对此有问题,在编写新代码时,您不必担心它们(请参见下面的x"$foo"
的解决方法)。
在Bash和许多其他类似ksh的shell中,有一个更好的选择,那就是使用[[
关键字。
# Bash / Ksh
[[ $foo == bar ]] # Right!
您不需要在[[]]
中的=
的左侧使用引号引用变量,因为它们不会被拆分或globbing,甚至空白变量也将得到正确处理。同时使用引号引用它们也不会造成任何伤害。与[
和test
不同,您还可以使用==
。但是请注意,使用[[
进行比较时,将对右侧的字符串进行模式匹配,而不仅仅是普通的字符串比较。为了使字符串成为正确的文字,如果右侧的字符串使用了在模式匹配上下文中任何具有特殊含义的字符,则必须使用引号引用它。
# Bash / Ksh
match=b*r
[[ $foo == "$match" ]] # Good! Unquoted would also match against the pattern b*r.
您可能已经看过这样的代码:
# POSIX / Bourne
[ x"$foo" = xbar ] # Ok, but usually unnecessary.
当代码需要运行在那些缺少[[
的古老的shell中时,x"$foo"
技巧是相当必要的,而且当$foo
以-
开头时,使用[
会使它会很困惑。在上述较旧的系统上,[
仍然不关心=
右侧的令牌是否以-
开头。它只是按字面意义使用它。因此需要格外注意左侧字符串。
请注意,需要此解决方法的shell不符合POSIX规范。甚至连Heirloom Bourne外壳都不需要这样做。这种极端的可移植性很少是必需的,并且会使您的代码可读性更差(更难看)。
5. cd f")
这是另一个引用错误。与变量扩展一样,CommandSubstitution的结果也会经历WordSplitting和pathname扩展。所以你应该使用引号引用它:
cd -P -- "$(dirname -- "$f")"
这里不明显的是引号的嵌套方式。一个C语言在处理此内容时,会将第一双引号和第二双引号组合在一起;然后是第三个和第四个双引号。但是,在Bash中并非如此。Bash将子命令内的双引号视为一对,将子命令外的双引号视为另一对。
也就是说,解析器将子命令视为“nesting level”,其内部的引用与外部的引用是分开的。