Bash 历史扩展是 bash 命令行解析器中的一个非常奇怪的角落,您显然遇到了意外的历史扩展,下面将对此进行解释。但是,脚本中的任何类型的历史扩展都是出乎意料的,因为通常脚本中没有启用历史扩展;甚至脚本都不能使用内置的source(或.)运行。
如何启用(或禁用)历史扩展
有两个控制历史扩展的shell选项:
必须设置这两个选项才能识别历史扩展。 (我发现手册对这种交互不清楚,但它足够合乎逻辑。)
根据 bash 手册,对于非交互式 shell,这些选项未设置,因此如果您想在脚本中启用历史扩展(我无法想象您想要这样做的原因),您需要同时设置他们:
set -o history -o histexpand
使用source 运行的脚本的情况更复杂(我要说的仅适用于 bash v4,因为它没有记录在未来可能会改变)。 [注3]
在source'd 脚本中关闭了历史记录(以及随之而来的扩展),但通过一个内部标志,据我所知,该标志不可见。它肯定不会出现在$SHELLOPTS 中。由于sourced 脚本在当前 bash 上下文中运行,因此它共享当前执行环境,包括 shell 选项。因此,在执行从交互式会话启动的sourced 脚本时,您将在$SHELLOPTS 中看到history 和histexpand,但不会发生历史扩展。为了启用它,您需要:
set -o history
这不是空操作,因为它具有重置抑制历史记录的内部标志的副作用。设置histexpand shell 选项没有这个副作用。
简而言之,我不确定您是如何设法在脚本中启用历史扩展的(如果行为不端的命令确实在脚本中而不是在交互式 shell 中),但您可能要考虑不这样做,除非你有充分的理由。
如何解析历史扩展
历史扩展的 bash 实现被设计为与readline 一起使用,因此它可以在命令输入期间执行。 (默认情况下,此函数绑定到 Meta-^;通常 Meta 是 ESC,但您也可以自定义。)但是,它也在每行输入后立即执行,在执行任何 bash 解析之前。
默认情况下,历史扩展字符是 !,并且 - 正如大部分文档所记录的那样 - 这将触发历史扩展,除了:
当它后跟空格或 =
如果设置了shell选项extglob,后面跟着( [注1]
如果它出现在单引号字符串中
如果前面有 \ [注 2 见下文]
如果前面有$或${[注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 完全不同。