Bash 历史扩展是 bash 命令行解析器中的一个非常奇怪的角落,您显然遇到了意外的历史扩展,下面将对此进行解释。但是,脚本中的任何类型的历史扩展都是出乎意料的,因为通常脚本中没有启用历史扩展;甚至脚本都不能使用source
(or .
) 内置函数运行。
如何启用(或禁用)历史扩展
有两个 shell 选项可以控制历史扩展:
必须设置这两个选项才能识别历史扩展。(我发现手册不清楚这种交互,但它是合乎逻辑的。)
根据 bash 手册,这些选项对于非交互式 shell 是未设置的,所以如果你想在脚本中启用历史扩展(我无法想象你想要这个的原因),你需要同时设置它们:
set -o history -o histexpand
运行脚本的情况source
更复杂(我要说的仅适用于 bash v4,因为它没有记录在未来可能会改变)。[注3]
历史记录(以及因此扩展)在 'd 脚本中被关闭source
,但通过一个内部标志,据我所知,该标志不可见。它肯定不会出现在$SHELLOPTS
. 由于source
d 脚本在当前 bash 上下文中运行,因此它共享当前执行环境,包括 shell 选项。因此,在执行source
从交互式会话启动的 d 脚本时,您会看到history
和histexpand
,$SHELLOPTS
但不会发生历史扩展。为了启用它,您需要:
set -o history
这不是空操作,因为它具有重置抑制历史记录的内部标志的副作用。设置histexpand
shell 选项没有这种副作用。
简而言之,我不确定您是如何设法在脚本中启用历史扩展的(如果确实,行为不端的命令在脚本中而不是在交互式 shell 中),但您可能要考虑不这样做,除非您有一个很好的理由。
如何解析历史扩展
历史扩展的 bash 实现旨在与 一起使用readline
,因此可以在命令输入期间执行。(默认情况下,此函数绑定到Meta-^; 通常Meta是ESC,但您也可以自定义它。)但是,它也会在每行输入后立即执行,在执行任何 bash 解析之前。
默认情况下,历史扩展字符是!, 并且 - 正如大部分记录的那样 - 将触发历史扩展,除了:
当它后面跟着空格或=
如果设置了shell选项extglob
,则后面跟着([注1]
如果它出现在单引号字符串中
如果它前面有\[注2,见下文]
如果前面有$or ${[注 1]
如果前面有[[注1]
(从 bash v4.3 开始)如果它是双引号字符串中的最后一个字符。
这里的直接问题是对第三种情况的精确解释,即!出现在单引号字符串中。通常,为命令替换(或不推荐使用的反引号)bash
启动一个新的引用上下文。$(...)
例如:
$ s=SUBSTITUTED
$ # The interior single quotes are just characters
$ echo "'Echoing $s'"
'Echoing SUBSTITUTED'
$ # The interior single quotes are single quotes
$ echo "$(echo 'Echoing $s')"
Echoing $s
然而,历史扩展扫描器并不那么智能。它跟踪引号,但不跟踪命令替换。所以就目前而言,上例中的两个单引号都是双引号单引号,也就是普通字符。所以历史扩展发生在它们两个中:
# A no-op to indicated history expansion
$ HIST() { :; }
# Single-quoted strings inhibit history expansion
$ HIST
$ echo '!!'
!!
# Double-quoted strings allow history expansion
$ HIST
$ echo "'!!'"
echo "'HIST'"
'HIST'
# ... and it applies also to interior command substitution.
$ HIST
$ echo "$(echo '!!')"
echo "$(echo 'HIST')"
HIST
因此,如果您有一个完全正常的命令,例如sed '/foo/!d' file
,您希望单引号可以保护您免受历史扩展,并将其放在双引号命令替换中:
result="$(sed '/foo/!d' file)"
你突然发现,这!是一个历史扩展字符。更糟糕的是,您无法通过转义感叹号的反斜杠来解决此问题,因为虽然"\!"
抑制了历史扩展,但它不会删除反斜杠:
$ echo "\!"
\!
在这个特定的示例中——以及 OP 中的那个——双引号是完全没有必要的,因为变量赋值的右侧既不经历文件名扩展也不经历分词。但是,在其他情况下,删除双引号会改变语义:
# Undesired history expansion
printf "The answer is '%s'\n" "$(sed '/foo/!d' file)"
# Undesired word splitting
printf "The answer is '%s'\n" $(sed '/foo/!d' file)
在这种情况下,最好的解决方案可能是将 sed 参数放在变量中
# Works
sed_prog='/foo/!d'
printf "The answer is '%s'\n" "$(sed "$sed_prog" file)"
(在这种情况下,$sed_prog 周围的引号不是必需的,但通常它们是必需的,而且它们没有害处。)
笔记:
当后面的字符是某种形式的左括号时,历史扩展的抑制只有在字符串的其余部分有相应的右括号时才有效。但是,它不必真正匹配左括号。例如:
# No matching close parenthesis
$ echo "!("
bash: !: event not found
# The matching close parenthesis has nothing to do with the open
$ echo "!(" ")"
!( )
# An actual extended glob: files whose names don't start with a
$ echo "!(a*)"
b
如bash
手册中所述,如果前面紧跟反斜杠,历史扩展字符将被视为普通字符。这确实是真的;反斜杠以后是否会被视为转义字符并不重要:
$ echo \!
!
$ echo \\!
\!
$ echo \\\!
\!
\还禁止双引号内的历史扩展,但\!
不是双引号字符串内的有效转义序列,因此不删除反斜杠:
$ echo "\!"
\!
$ echo "\\!"
\!
$ echo "\\\!"
\\!
我在写这篇文章时指的是 bash v4.2 的源代码,因此任何未记录的行为都可能与 v4.3 完全不同。