GNU Bash 参考手册(中文版)(二)

3 基本 Shell 功能

Bash 是「Bourne-Again SHell」的缩写。Bourne Shell 是由 Stephen Bourne 最初编写的传统 Unix Shell。Bourne Shell 的所有内建命令在 Bash 中都可用,求值与引号机制的规则取自 POSIX 规范中对「标准」Unix Shell 的定义。

本章简要概括 Shell 的「构件」:命令、控制结构、Shell 函数、Shell 参数、Shell 展开、重定向(一种把输入和输出导向或取自命名文件的方式),以及 Shell 如何执行命令。

3.1 Shell 语法

当 Shell 读取输入时,它会按一系列操作依次进行。如果输入表明某处是注释的开始,Shell 就忽略注释符号(#)以及该行的其余部分。

否则,粗略地说,Shell 读取其输入,依据引号规则把输入划分为单词和操作符,以决定各个单词和字符被赋予何种含义。

然后 Shell 把这些记号解析为命令及其他结构,去除某些单词或字符的特殊含义,展开另一些,按需重定向输入和输出,执行指定的命令,等待命令的退出状态,并让该退出状态可供进一步检查或处理。

译者注:这里的「单词(word)」指 Shell 认为的一个完整单元(如 ls-lfile.txt),「操作符(operator)」指 |&;()<> 等具有特殊含义的符号。空格、制表符、换行以及上述符号统称为「元字符(metacharacter)」,它们把输入切分成一个个记号(token)。

3.1.1 Shell 的运行过程

以下是 Shell 读取并执行一条命令时运行过程的简要描述。基本上,Shell 会做以下事情:

  1. 从某个文件(参见第 3.8 节(Shell 脚本))、从作为 -c 调用选项参数提供的字符串(参见第 6.1 节(调用 Bash))或从用户的终端读取输入。
  2. 按照第 3.1.2 节(引号机制)所述的引号规则,把输入分解成单词和操作符。这些记号由「元字符」分隔。此步骤还会执行别名展开(参见第 6.6 节(别名))。
  3. 把记号解析成简单命令和复合命令(参见第 3.2 节(Shell 命令))。
  4. 执行各种 Shell 展开(参见第 3.5 节(Shell 展开)),把展开后的记号分解为文件名列表(参见第 3.5.8 节(文件名展开))以及命令和参数。
  5. 执行所有必要的重定向(参见第 3.6 节(重定向)),并从参数列表中移除重定向操作符及其操作数。
  6. 执行命令(参见第 3.7 节(命令执行))。
  7. 视情况等待命令完成,并收集其退出状态(参见第 3.7.5 节(退出状态))。

译者注:第 1 步中的 -c 选项允许直接以字符串形式给出要执行的命令,无需创建脚本文件:

# 译者注示例:bash -c 直接执行字符串形式的命令
$ bash -c 'echo hello from -c'
hello from -c

3.1.2 引号机制

引号机制(quoting)用于去除某些字符或单词对 Shell 的特殊含义。引号可以用来禁用特殊字符的特殊处理,防止保留字被识别为保留字,并防止参数展开。

每个 Shell 元字符(参见第 2 章(定义))对 Shell 都有特殊含义;如果它要表示其自身,就必须加引号。

当使用命令历史展开设施时(参见第 9 章(交互式使用历史记录)),「历史展开」字符(通常是 !)必须加引号,以防止历史展开。有关历史展开的更多细节,参见第 9.1 节(Bash 的历史记录设施)。

共有四种引号机制:「转义字符」、单引号、双引号和美元符-单引号($'...')。

译者注:四种引号机制可以概括为:

  • 反斜杠 \:只对其后紧跟的一个字符去特殊化;
  • 单引号 '...':其内所有字符一律按字面处理(最彻底);
  • 双引号 "..."$、反引号以及部分情况下的 \ 仍保留特殊含义;
  • ANSI-C 引号 $'...':按 ANSI C 规则解释反斜杠转义序列,其余按字面处理(详见下文第 3.1.2.4 节(ANSI-C 引号))。

3.1.2.1 转义字符

未被引用的反斜杠 \ 是 Bash 的转义字符。它保留其后下一个字符的字面值,去掉它可能具有的任何特殊含义,但「换行」除外。如果出现 \换行 这一组合,且反斜杠本身未被引用,则 \换行 被当作行继续符处理(也就是说,它从输入流中被移除,实际相当于被忽略)。

译者注:空格是一个元字符,反斜杠可以让它失去分隔作用:

# 译者注示例:\ 转义空格,空格失去单词分隔作用
$ echo a\ b
a b

行继续符(\ 加换行)的典型用途是把过长的命令折成多行书写,最终执行时是一个整体:

# 译者注示例:反斜杠 + 换行 = 行继续
$ echo abc\
def
abcdef

交互式 Shell 中续行会显示 > 次提示符。

3.1.2.2 单引号

用单引号(')括起字符,会保留引号内每个字符的字面值。单引号内不能出现单引号,即使前面有反斜杠也不行。

译者注:单引号内的一切都按字面处理,包括 $、反引号和反斜杠:

# 译者注示例:单引号内的 $、反引号、\ 均不展开
$ echo 'no $HOME here, no `cmd`, no \n'
no $HOME here, no `cmd`, no \n

若要在单引号字符串中包含单引号本身,标准写法是:结束单引号、用 \' 转义出一个单引号、再重新开始单引号:

# 译者注示例:拼接单引号字符串以包含 ' 字符
$ echo 'it'\''s'
it's

注意 'it'\''s' 实际是 'it' + \' + 's' 三段拼接的结果——反斜杠在单引号内不生效,但可以在单引号之间转义出单引号。

3.1.2.3 双引号

用双引号(")括起字符,会保留引号内所有字符的字面值,但 $、反引号(`)、\ 以及(当历史展开被启用时)! 除外。当 Shell 处于 POSIX 模式时(参见第 6.11.2 节(Bash 的 POSIX 模式)),即使在双引号内、即使历史展开已启用,! 也没有特殊含义。$ 和反引号在双引号内保留其特殊含义(参见第 3.5 节(Shell 展开))。反斜杠仅在后跟下列字符之一时保留特殊含义:$`"\ 或换行。在双引号内,后跟这些字符的反斜杠会被移除。位于没有特殊含义的字符之前的反斜杠则原样保留。

双引号可以通过在其前面加反斜杠的方式在双引号内表示。若历史展开已启用,除非双引号中出现的 ! 用反斜杠转义,否则将执行历史展开。! 之前的反斜杠不会被移除。

特殊参数 *@ 在双引号内具有特殊含义(参见第 3.5.3 节(Shell 参数展开))。

译者注:双引号内的 $ 和反引号仍然会被展开,这是它与单引号最大的区别:

# 译者注示例:双引号内 $ 变量与反引号命令替换照常进行
$ name=world
$ echo "hi $name"
hi world
$ echo "backtick: `echo cmd`"
backtick: cmd

反斜杠的行为分两种:在 $`"\ 或换行之前——转义生效且反斜杠被移除;在其他字符之前——反斜杠原样保留:

# 译者注示例:\$ 转义生效;\n 前的反斜杠原样保留
$ echo "price: \$5"
price: $5
$ echo "literal \n stays"
literal \n stays

在双引号内输出双引号本身,用 \"

# 译者注示例:\" 在双引号内输出双引号
$ echo "she said \"hi\""
she said "hi"

双引号内的 *@ 也有特殊行为("$*" 把所有位置参数拼成单个单词、"$@" 逐个保持独立),详见第 3.5.3 节(Shell 参数展开)。

译者注:交互式 Shell 默认启用历史展开(histexpand 选项),此时双引号内的 ! 会被当作历史展开字符,例如 echo "wow!" 中的 ! 可能被展开为历史中的某条命令,产生意外结果。转义 ! 所用的反斜杠不会被移除:

# 译者注示例:\! 抑制历史展开,但反斜杠原样保留
$ echo "bang\!"
bang\!

非交互式 Shell(脚本、-c 字符串等)默认不启用历史展开,! 没有特殊含义,可放心使用。另外在 POSIX 模式下,即使历史展开已启用,双引号内的 ! 也不作特殊处理。

3.1.2.4 ANSI-C 引号

形如 $'STRING' 的字符序列被当作一种特殊的单引号。该序列展开为 STRING,其中 STRING 内以反斜杠转义的字符按 ANSI C 标准的规定替换。反斜杠转义序列(如有)按如下方式解码:

转义序列 含义
\a 响铃(bell)
\b 退格(backspace)
\e\E 转义字符(Escape;ANSI C 标准中没有此序列)
\f 换页(form feed)
\n 换行(newline)
\r 回车(carriage return)
\t 水平制表符(horizontal tab)
\v 垂直制表符(vertical tab)
\\ 反斜杠
\' 单引号
\" 双引号
\? 问号
\NNN 值为八进制数 NNN(一至三位八进制数字)的八位字符
\xHH 值为十六进制数 HH(一至两位十六进制数字)的八位字符
\uHHHH 值为十六进制数 HHHH(一至四位十六进制数字)的 Unicode(ISO/IEC 10646)字符
\UHHHHHHHH 值为十六进制数 HHHHHHHH(一至八位十六进制数字)的 Unicode(ISO/IEC 10646)字符
\cX 控制字符 control-X

展开结果按单引号处理,就好像美元符不存在一样。

译者注:ANSI-C 引号是在字符串中嵌入控制字符和任意字节值的最直接手段。\n\t 等序列在双引号内只是普通文本,只有在 $'...' 内才真正转义:

# 译者注示例:\t 生成真正的制表符
$ echo $'a\tb'
a	b
# 译者注示例:\x 用十六进制、\NNN 用八进制表示字符
$ echo $'\x41 \101'
A A

\u(四位十六进制)与 \U(八位十六进制)按 Unicode 码点生成字符(需要支持多字节的区域设置才能正常显示):

# 译者注示例:\u 转义生成 Unicode 字符(U+4E2D,即「中」)
$ echo $'中'
中

od 可以确证 \cX\e 生成的控制字符(输出末尾的 0aecho 自动追加的换行):

# 译者注示例:\cX 生成 Ctrl-X(0x18)
$ echo $'\cX' | od -An -tx1
 18 0a
# 译者注示例:\e 生成 ESC(0x1b)
$ echo $'\e' | od -An -tx1
 1b 0a

\cX 中的 X 大小写均可(如 $'\c['$'\e' 等价);$'...' 的展开结果整体按单引号处理,其中的 $、反引号不会再被展开。

3.1.2.5 区域设置相关的翻译($"..."

在双引号字符串前加上美元符($),例如 $"hello, world",会使该字符串按照当前区域设置(locale)进行翻译。gettext 基础设施利用 LC_MESSAGESTEXTDOMAINDIRTEXTDOMAIN 这些 Shell 变量完成查找与翻译,具体方式如下所述。此处未涉及的更多细节参见 gettext 文档。如果当前区域设置为 CPOSIX,或者没有可用的翻译,或者字符串没有被翻译,那么美元符被忽略,字符串按上述双引号规则处理。由于这是双引号的一种形式,无论字符串是否被翻译和替换,它默认都保持双引号语义。如果使用 shopt 内建命令启用了 noexpand_translation 选项(参见第 4.3.2 节(shopt 内建命令)),翻译后的字符串将按单引号而不是双引号处理。

译者注$"..." 依赖 gettext 翻译系统。在区域设置为 CPOSIX、或没有可用翻译时,美元符被忽略,字符串退化为普通双引号:

# 译者注示例:无翻译时 $"..." 等价于 "...",$ 仍会展开
$ echo $"hello, world"
hello, world
$ name=world
$ echo $"hi $name"
hi world

若用 shopt -s noexpand_translation 开启该选项,翻译后的字符串按单引号处理,其中的 $、反引号不再展开。

本节其余部分简要概述如何用 gettext 为名为 SCRIPTNAME 的 Shell 脚本中的字符串创建翻译。更多细节参见 gettext 文档。

$"..." 标记好脚本中想要翻译的字符串之后,用下列命令创建 gettext「模板」文件:

bash --dump-po-strings SCRIPTNAME > DOMAIN.pot

DOMAIN 就是你的「消息域(message domain)」。它只是一个任意的字符串,用于标识 gettext 需要的文件,比如包名或脚本名。在安装翻译的系统上,它必须与所有其他消息域不重名,这样 gettext 才能知道哪些翻译对应你的脚本。你将用这个模板文件为每种目标语言创建翻译。模板文件按惯例使用 .pot 后缀。

你要为想支持的每种目标语言把模板文件复制到一个单独的文件(称为「PO」文件,使用 .po 后缀)。PO 文件的命名有多种约定,但当你把一个模板文件翻译成某种特定语言时,首先要把它复制为以该语言命名、带 .po 后缀的文件。例如,你的字符串的西班牙语翻译放在名为 es.po 的文件中;要开始使用名为 "example" 的消息域,你可以运行:

cp example.pot es.po

最终 PO 文件通常命名为 DOMAIN.po,并安装在包含某种语言的多个翻译文件的目录中。

无论你选择哪种命名约定,都需要把 PO 文件中的字符串翻译成相应的语言。这必须手工完成。

翻译和 PO 文件都完成后,你用 gettext 工具生成所谓的「MO」文件——PO 文件的编译版本,gettext 工具用它们来高效地查找翻译。MO 文件也称为「消息目录(message catalog)」文件。用 msgfmt 程序完成这一步。例如,如果你有一个包含西班牙语翻译的文件,可以运行:

msgfmt -o es.mo es.po

来生成相应的 MO 文件。

MO 文件就绪后,你决定把它们安装到哪里,并用 TEXTDOMAINDIR 这个 Shell 变量告诉 gettext 工具它们的位置。安装 MO 文件时,命名所用消息域要与 PO 文件一致。

你的用户将使用 LANGLC_MESSAGES 这些 Shell 变量来选择所需语言。

你把 TEXTDOMAIN 变量设置为脚本的消息域。如上所述,你用消息域来命名翻译文件。

你(或者你的用户)把 TEXTDOMAINDIR 变量设置为存放消息目录文件的目录名。如果你把消息文件安装到系统的标准消息目录,就不必操心这个变量。

存放消息目录文件的目录因系统而异。有些系统使用由 LC_MESSAGES 这个 Shell 变量选定的消息目录。另一些则根据 TEXTDOMAIN 这个 Shell 变量的值构造消息目录名,可能还会加上 .mo 后缀。如果你使用 TEXTDOMAIN 变量,可能需要像上面那样把 TEXTDOMAINDIR 变量设置为消息目录文件的位置。通常两个变量这样配合使用:$TEXTDOMAINDIR/$LC_MESSAGES/LC_MESSAGES/$TEXTDOMAIN.mo。

如果你使用最后那种约定,并想把西班牙语(es)和世界语(eo)翻译的消息目录文件存到用于自定义翻译文件的本地目录,可以运行:

TEXTDOMAIN=example
TEXTDOMAINDIR=/usr/local/share/locale

cp es.mo ${TEXTDOMAINDIR}/es/LC_MESSAGES/${TEXTDOMAIN}.mo
cp eo.mo ${TEXTDOMAINDIR}/eo/LC_MESSAGES/${TEXTDOMAIN}.mo

全部就绪、包含编译后翻译的消息目录文件安装到正确位置后,用户只需在运行你的脚本前设置 LANGLC_MESSAGES 环境变量,就能以任何受支持的语言看到翻译后的字符串。

3.1.3 注释

在非交互式 Shell 中,或者启用了 shopt 内建命令的 interactive_comments 选项(参见第 4.3.2 节(shopt 内建命令))的交互式 Shell 中,以 # 开头的单词表示注释的开始。单词在行首、未加引号的空白之后或操作符之后开始。该注释使该单词以及该行上的其余所有字符都被忽略。未启用 interactive_comments 选项的交互式 Shell 不允许注释。interactive_comments 选项在交互式 Shell 中默认启用。什么使 Shell 成为交互式的,参见第 6.3 节(交互式 Shell)。

译者注:注释从 # 处延伸到行尾,前提是 # 位于行首、未加引号的空白之后或操作符之后——即它不能是某个单词的一部分,也不能被引号括起:

# 译者注示例:行尾注释,以及被引号括起的 # 不是注释
$ echo hi # 行尾注释
hi
$ echo '#' # 被引号括起的 # 不是注释
#

非交互式 Shell 中始终支持注释;交互式 Shell 中由 interactive_comments 选项控制(默认开启),若关闭该选项,交互式提示符下 # 就不再表示注释。

3.2 Shell 命令

一条简单的 Shell 命令(如 echo a b c)由命令本身和其后的参数组成,参数间以空格分隔。

更复杂的 Shell 命令由简单命令以各种方式组合而成:组成管道(一个命令的输出成为第二个命令的输入)、组成循环或条件结构,或采用其他某种分组方式。

3.2.1 保留字

保留字(reserved word)是对 Shell 有特殊含义的单词。它们用于开始和结束 Shell 的复合命令。

下列单词在未加引号且作为命令的第一个单词时,被识别为保留字(例外情况见下文):

if    then  elif  else  fi      time
for   in    until while do      done
case  esac  coproc select function
{     }     [[    ]]    !

in 如果是 caseselect 命令的第三个单词,则被识别为保留字。indo 如果是 for 命令中的第三个单词,则被识别为保留字。

译者注:保留字只在「命令的第一个单词」这个位置被识别,因此把它们用作变量名、参数值等是安全的:

# 译者注示例:保留字 function 用作变量名没问题
$ function=hello
$ echo $function
hello

但若想以保留字命名命令(例如把脚本命名为 time),直接执行会出错,需要写成 ./time 或加引号。time 保留字的实际用途是作为管道、内建命令和 Shell 函数的计时前缀(参见第 3.2.3 节(管道))。

3.2.2 简单命令

简单命令是最常执行的命令类型。它只是一串以「空白符」分隔、以 Shell 的某个控制操作符(参见第 2 章(定义))结尾的单词。第一个单词通常指定要执行的命令,其余单词是该命令的参数。

简单命令的返回状态(参见第 3.7.5 节(退出状态))是 POSIX 1003.1 的 waitpid 函数提供的退出状态;若命令被信号 N 终止,则为 128+N。

译者注:被信号终止时退出码为 128+信号编号,这是区分「正常退出」与「被信号杀死」的常用手段:

# 译者注示例:SIGTERM(编号 15)杀死的进程退出码为 143
$ sleep 10 & p=$!
$ kill -TERM $p; wait $p; echo $?
143

143 = 128 + 15(SIGTERM)。后台进程 ID $! 每次运行都不同;判断「是否被信号杀死」常用 wait $pid 后检查退出码是否大于 128。

3.2.3 管道

「管道(pipeline)」是由控制操作符 ||& 分隔的一个或多个命令组成的序列。

管道的格式为:

[time [-p]] [!] COMMAND1 [ | or |& COMMAND2 ] ...

管道中每个命令的输出通过管道连接到下一个命令的输入。也就是说,每个命令读取前一个命令的输出。这种连接在 COMMAND1 指定的任何重定向之前完成。

如果管道操作符是 |&,那么 COMMAND1 的标准错误除了标准输出之外,也通过管道连接到 COMMAND2 的标准输入;它是 2>&1 | 的简写。这种把标准错误隐式重定向到标准输出的操作在 COMMAND1 指定的任何重定向之后执行,与上述简写形式一致。

如果保留字 time 放在管道之前,Bash 会在管道结束时打印计时统计。目前的统计内容包括经过的(墙钟)时间,以及命令执行消耗的用户时间和系统时间。-p 选项把输出格式改为 POSIX 规定的格式。当 Shell 处于 POSIX 模式时(参见第 6.11.2 节(Bash 的 POSIX 模式)),如果下一个记号以 - 开头,它就不把 time 识别为保留字。TIMEFORMAT 变量的值是决定计时信息如何显示的格式字符串。可用格式的描述参见第 5.2 节(Bash 变量)。把 time 作为保留字,就可以对 Shell 内建命令、Shell 函数和管道计时。外部 time 命令很难对这些对象计时。

当 Shell 处于 POSIX 模式时(参见第 6.11.2 节(Bash 的 POSIX 模式)),你可以把 time 单独用作简单命令。此时 Shell 显示其自身及其子进程消耗的用户和系统时间总量。TIMEFORMAT 变量指定时间信息的格式。

如果管道不是异步执行的(参见第 3.2.4 节(命令列表)),Shell 会等待管道中的所有命令完成。

多命令管道(即创建了管道的管道)中的每个命令都在各自的「子 Shell」中执行;子 Shell 是独立进程(参见第 3.7.3 节(命令执行环境))。如果使用 shopt 内建命令启用了 lastpipe 选项(参见第 4.3.2 节(shopt 内建命令)),且作业控制未激活,管道的最后一个元素可以由 Shell 进程本身运行。

管道的退出状态是管道中最后一个命令的退出状态,除非启用了 pipefail 选项(参见第 4.3.1 节(set 内建命令))。若启用了 pipefail,管道的返回状态是最后一个(最右边的)以非零状态退出的命令的值;如果所有命令都成功退出则为零。如果保留字 ! 放在管道之前,退出状态就是上述状态的逻辑取反。如果管道不是异步执行的(参见第 3.2.4 节(命令列表)),Shell 会等待管道中的所有命令终止后才返回值。异步管道的返回状态是 0。

译者注pipefail 是理解管道真实成败的关键。默认情况下管道只认最后一个命令的退出状态,这常常掩盖前面命令的失败:

# 译者注示例:默认管道状态只看最后一个命令
$ false | true
$ echo $?
0
# 译者注示例:启用 pipefail 后,取最右边非零退出的命令
$ set -o pipefail
$ false | true
$ echo $?
1
$ true | false | true
$ echo $?
1
$ set +o pipefail

注意 pipefail 取的是「最右边」那个非零退出状态,所以 true | false | true 仍返回 1。set -o pipefail 之后建议随手 set +o pipefail 恢复,或改用 shopt -s pipefail(同义)。

译者注|& 同时连接标准输出与标准错误,相当于 2>&1 |

# 译者注示例:|& 把标准错误也送入管道
$ (echo out; echo err >&2) |& sed 's/^/piped: /'
piped: out
piped: err

若改用单个 |err 会直接显示在终端上而不是进入管道。由于 |& 等效于在 COMMAND1 之后追加 2>&1,COMMAND1 自身对 2> 的重定向优先。

译者注time 的输出格式由 TIMEFORMAT 控制:%R 是经过的墙钟秒数,%U 是用户态 CPU 秒数,%S 是内核态 CPU 秒数;计时数值随机器负载略有差异:

# 译者注示例:TIMEFORMAT 自定义 time 输出(数值随运行环境略有不同)
$ TIMEFORMAT='elapsed %R seconds'
$ time sleep 1
elapsed 1.005 seconds
$ time -p sleep 1
real 1.01
user 0.00
sys 0.01

译者注:管道中的每个命令都在独立子进程中运行,因此在管道里给变量赋值不会影响外部 Shell。这就是著名的「管道里 read 之后变量仍为空」问题:

# 译者注示例:read 在子 Shell 中运行,赋值不影响外部
$ echo x | read var
$ echo "var=[$var]"
var=[]

解决办法之一:在非交互式 Shell(未启用作业控制)中开启 shopt -s lastpipe,让管道最后一个元素由 Shell 进程本身执行,此时 echo x | read var 会把 var 设为 x;或者改用进程替换(参见第 3.5.6 节(进程替换)):read var < <(echo x)

3.2.4 命令列表

「列表(list)」是由操作符 ;&&&|| 分隔的一个或多个管道组成的序列,并可选地以 ;& 或换行结尾。

在这些列表操作符中,&&|| 优先级相等,其次是 ;&,后两者优先级也相等。

列表中可出现一个或多个换行来分隔命令,其作用等同于分号。

如果命令以控制操作符 & 结尾,Shell 就在子 Shell 中异步执行该命令。这称为在「后台」执行命令,这样的命令称为「异步」命令。Shell 不等待命令完成,返回状态为 0(真)。当作业控制未激活时(参见第 7 章(作业控制)),异步命令的标准输入在没有显式重定向的情况下从 /dev/null 重定向。

; 分隔的命令按顺序执行;Shell 依次等待每条命令终止。返回状态是最后执行的那条命令的退出状态。

AND 列表和 OR 列表是由控制操作符 &&|| 分别分隔的一个或多个管道组成的序列。AND 列表和 OR 列表按左结合执行。

AND 列表的形式为:

COMMAND1 && COMMAND2

仅当 COMMAND1 返回的退出状态为零(成功)时,才执行 COMMAND2。

OR 列表的形式为:

COMMAND1 || COMMAND2

仅当 COMMAND1 返回非零退出状态时,才执行 COMMAND2。

AND 列表和 OR 列表的返回状态是列表中最后执行的那条命令的退出状态。

译者注&&|| 同级、左结合,按从左到右的顺序求值。混合使用时这一点与多数语言「&& 优先于 ||」的惯例不同:

# 译者注示例:&& 与 || 同级左结合
$ true && echo yes
yes
$ false || echo fallback
fallback
$ false && echo never
# 上面命令无输出:false 已足以确定 && 的结果
$ true || false && echo "still runs"
still runs

最后一条命令中,true || false 整体为真,所以 && 后面的命令照样执行。需要改变顺序时用花括号或子 Shell 分组,例如 true || { false && echo no; }

译者注:以 & 结尾的命令立即返回 0,Shell 不等待它完成。$! 保存最近启动的后台进程的进程 ID,wait 可以等待其结束:

# 译者注示例:后台执行、取得进程 ID 并等待
$ sleep 2 & bg=$!
$ echo "bg pid: $bg"
bg pid: 44789
$ wait $bg; echo "wait status: $?"
wait status: 0

后台进程 ID 每次运行都不同。注意:未启用作业控制时,后台命令的标准输入默认来自 /dev/null,因此它不会与前台抢占终端输入。

3.2.5 复合命令

复合命令是 Shell 编程语言的结构。每个结构以保留字或控制操作符开始,并以相应的保留字或操作符结束。与复合命令关联的任何重定向(参见第 3.6 节(重定向))都应用于该复合命令内的所有命令,除非被显式覆盖。

多数情况下,复合命令描述中的命令列表可以用一个或多个换行与命令的其余部分分隔,也可以用换行代替分号。

Bash 提供循环结构、条件命令,以及把命令分组作为一个整体执行的机制。

3.2.5.1 循环结构

Bash 支持下列循环结构。

注意:命令语法描述中任何出现 ; 的地方,都可以用一个或多个换行代替。

until

until 命令的语法是:

until TEST-COMMANDS; do CONSEQUENT-COMMANDS; done

只要 TEST-COMMANDS 的退出状态非零,就反复执行 CONSEQUENT-COMMANDS。返回状态是 CONSEQUENT-COMMANDS 中最后执行的命令的退出状态;如果一条都没执行,则为零。

while

while 命令的语法是:

while TEST-COMMANDS; do CONSEQUENT-COMMANDS; done

只要 TEST-COMMANDS 的退出状态为零,就反复执行 CONSEQUENT-COMMANDS。返回状态是 CONSEQUENT-COMMANDS 中最后执行的命令的退出状态;如果一条都没执行,则为零。

for

for 命令的语法是:

for NAME [ [in WORDS ...] ; ] do COMMANDS; done

展开 WORDS(参见第 3.5 节(Shell 展开)),然后对结果列表中的每个单词执行一次 COMMANDS,并把 NAME 绑定到当前单词。如果没有 in WORDSfor 命令对每个已设置的位置参数执行一次 COMMANDS,就如同指定了 in "$@" 一样(参见第 3.4.2 节(特殊参数))。

返回状态是最后执行的命令的退出状态。如果 WORDS 展开后没有任何条目,则一条命令也不执行,返回状态为零。

for 还有一种与 C 语言相似的替代形式:

for (( EXPR1 ; EXPR2 ; EXPR3 )) [;] do COMMANDS ; done

首先按下文所述规则(参见第 6.5 节(Shell 算术))对算术表达式 EXPR1 求值。然后反复对算术表达式 EXPR2 求值,直到它求值为零。每次 EXPR2 求值为非零时,执行 COMMANDS 并对算术表达式 EXPR3 求值。如果省略某个表达式,它就如同求值为 1。返回值是 COMMANDS 中最后执行的那条命令的退出状态;如果任一表达式无效,则为非零。

使用 breakcontinue 内建命令(参见第 4.1 节(Bourne Shell 内建命令))控制循环执行。

译者注untilwhile 的唯一差别在于测试条件:while 在测试状态为 0(真)时继续,until 在测试状态非零(假)时继续:

# 译者注示例:until 与 while 的对照
$ i=0; until [ $i -ge 3 ]; do echo "u$i"; i=$((i+1)); done
u0
u1
u2
$ j=0; while [ $j -lt 3 ]; do echo "w$j"; j=$((j+1)); done
w0
w1
w2

译者注for 的三种常见写法——遍历列表、省略 in(遍历位置参数)、C 风格计数循环:

# 译者注示例:遍历列表
$ for i in a b c; do echo $i; done
a
b
c
# 译者注示例:省略 in 时遍历位置参数(等价于 in "$@")
$ set -- x y z
$ for v; do echo $v; done
x
y
z
# 译者注示例:C 风格计数循环
$ for ((k=0; k<3; k++)); do echo "c$k"; done
c0
c1
c2

C 风格循环中 EXPR2 为 0 时结束;省略表达式按 1 处理,因此 for ((;;)) 是无限循环,需配合 break 退出。

3.2.5.2 条件结构

if

if 命令的语法是:

if TEST-COMMANDS; then
  CONSEQUENT-COMMANDS;
[elif MORE-TEST-COMMANDS; then
  MORE-CONSEQUENTS;]
[else ALTERNATE-CONSEQUENTS;]
fi

执行 TEST-COMMANDS 列表,如果其返回状态为零,则执行 CONSEQUENT-COMMANDS 列表。如果 TEST-COMMANDS 返回非零状态,则依次执行每个 elif 列表;若其退出状态为零,则执行相应的 MORE-CONSEQUENTS 并结束命令。如果存在 else ALTERNATE-CONSEQUENTS,且最后一个 ifelif 子句中的最后一条命令的退出状态非零,则执行 ALTERNATE-CONSEQUENTS。返回状态是最后执行的命令的退出状态;如果没有任何条件测试为真,则为零。

译者注if 测试的是「命令的退出状态」,不限于 [ ][[ ]]——任何命令都可以直接充当测试条件:

# 译者注示例:if 后接任意命令(测试其退出状态)
$ if [ 2 -gt 1 ]; then echo big; else echo small; fi
big
$ if grep -q root /etc/passwd; then echo "root exists"; fi
root exists
$ if ! false; then echo "not false"; fi
not false

case

case 命令的语法是:

case WORD in
    [ [(] PATTERN [| PATTERN]...) COMMAND-LIST ;;]...
esac

case 会从第一个模式到最后一个模式依次匹配,选择性地执行与第一个匹配 WORD 的 PATTERN 相对应的 COMMAND-LIST。匹配按下文第 3.5.8.1 节(模式匹配)所述规则进行。如果启用了 nocasematch Shell 选项(参见第 4.3.2 节(shopt 内建命令)中的 shopt 描述),匹配时不区分字母大小写。| 用于分隔模式列表中的多个模式,) 操作符结束模式列表。一个模式列表及其关联的 COMMAND-LIST 合称一个子句(clause)。

每个子句必须以 ;;;&;;& 结尾。Shell 在尝试匹配模式之前,先对 WORD 进行波浪号展开、参数展开、命令替换、进程替换、算术展开和引号去除(参见第 3.5.3 节(Shell 参数展开))。每个 PATTERN 都进行波浪号展开、参数展开、命令替换、算术展开、进程替换和引号去除。

case 子句的数量不限,每个子句以 ;;;&;;& 结尾。第一个匹配的模式决定执行哪个命令列表。常见的惯用法是用 * 作为最后一个模式来定义默认情形,因为该模式总会匹配。

下面是一个使用 case 的脚本示例,可以用来描述动物的一个有趣特征:

echo -n "Enter the name of an animal: "
read ANIMAL
echo -n "The $ANIMAL has "
case $ANIMAL in
  horse | dog | cat) echo -n "four";;
  man | kangaroo ) echo -n "two";;
  *) echo -n "an unknown number of";;
esac
echo " legs."

如果使用 ;; 操作符,case 命令在第一次模式匹配后即结束。用 ;& 代替 ;; 会使执行继续到下一个子句(如果有)的 COMMAND-LIST。用 ;;& 代替 ;; 会使 Shell 测试下一个子句(如果有)的模式,匹配成功则执行其关联的 COMMAND-LIST,并像该模式列表未匹配那样继续执行 case 语句。

如果没有模式匹配,返回状态为零。否则,返回状态是最后执行的 COMMAND-LIST 的退出状态。

译者注:用管道输入运行上面的脚本示例(read 从标准输入读取):

# 译者注示例:case 脚本的运行结果
$ printf 'dog\n' | bash -c '
read ANIMAL
echo -n "The $ANIMAL has "
case $ANIMAL in
  horse | dog | cat) echo -n "four";;
  man | kangaroo) echo -n "two";;
  *) echo -n "an unknown number of";;
esac
echo " legs."
'
The dog has four legs.

译者注;;;&;;& 的区别是重点也是排错的常客:

  • ;;:匹配后立即结束整个 case;
  • ;&:不测试下一个模式,直接继续执行下一个子句的命令列表(类似 C 语言的 fall-through);
  • ;;&:继续测试下一个子句的模式,匹配才执行其命令列表,然后像本子句未匹配那样继续向下。
# 译者注示例:;& 直接落入下一个子句
$ case abc in
> a*) echo first;&
> *c) echo second;&
> *) echo third;;
> esac
first
second
third
# 译者注示例:;;& 重新测试下一模式(*d 不匹配,被跳过)
$ case abc in
> a*) echo first;;&
> *d) echo second;;&
> *) echo third;;
> esac
first
third

第二个例子中 *dabc 不匹配,;;& 跳过其命令列表继续测试 *;若把 ;;& 换成 ;&,则会不加测试直接执行 second

select

select 结构可以轻松生成菜单。它的语法与 for 命令几乎相同:

select NAME [in WORDS ...]; do COMMANDS; done

首先,展开 in 后面的单词列表,生成条目列表,并把展开后的单词集合打印到标准错误流,每个单词前面带一个编号。如果省略 in WORDS,则打印位置参数,如同指定了 in "$@" 一样。然后 select 显示 PS3 提示符,并从标准输入读取一行。如果该行是与某个显示单词对应的编号,select 就把 NAME 的值设为该单词。如果该行为空,select 再次显示单词和提示符。如果读到 EOFselect 结束并返回 1。读取到的任何其他值都会使 NAME 被设为 null。读取到的行保存在变量 REPLY 中。

每次选择后执行 COMMANDS,直到执行了 break 命令,此时 select 命令结束。

下面是一个示例:让用户从当前目录挑选一个文件名,并显示所选文件的名称和编号。

select fname in *;
do
	echo you picked $fname \($REPLY\)
	break;
done

译者注select 的典型交互过程如下——菜单打印到标准错误,编号提示符默认为 #? (即 PS3 的值);用户输入编号后,所选单词存入 NAME,原始输入存入 REPLY

# 译者注示例:select 菜单(用管道预置输入 2)
$ printf '2\n' | select fname in apple banana cherry; do echo "you picked $fname, REPLY=$REPLY"; break; done
1) apple
2) banana
3) cherry
#? you picked banana, REPLY=2

输入空行会重新显示菜单;输入非法内容(如字母)时 NAME 被置为 null,但循环体仍会执行——所以循环体内通常要检查 REPLY 是否为合法编号。select 默认反复循环,需用 break 退出。

((...))

(( EXPRESSION ))

按下文所述规则(参见第 6.5 节(Shell 算术))对算术 EXPRESSION 求值。EXPRESSION 经历的展开与它位于双引号内时相同,但 EXPRESSION 中未转义的双引号字符不被特殊对待,且会被移除。由于这可能会产生空字符串,本命令把空字符串视为求值为 0 的表达式。如果表达式的值非零,返回状态为 0;否则返回状态为 1。

译者注((...)) 是算术求值命令,其返回状态即「表达式是否非零」。注意空表达式 (( )) 按求值为 0 处理,返回 1:

# 译者注示例:((...)) 的返回状态
$ (( 3 > 2 )); echo $?
0
$ (( 7 )); echo $?
0
$ (( 0 )); echo $?
1
$ (( )); echo $?
1

它返回的是状态而不是数值;想取得计算出的数值,用算术展开 $(( ... ))

# 译者注示例:算术展开返回数值
$ echo $((2**10))
1024

[[...]]

[[ EXPRESSION ]]

对条件表达式 EXPRESSION 求值,并返回零(真)或非零(假)状态。表达式由下文第 6.4 节(Bash 条件表达式)描述的初级表达式(primary)组成。[[]] 之间的单词不进行单词拆分和文件名展开。Shell 对这些单词进行波浪号展开、参数和变量展开、算术展开、命令替换、进程替换和引号去除。条件操作符(如 -f)必须不加引号,才能被识别为初级表达式。

[[ 一起使用时,<> 操作符按当前区域设置(locale)进行字典序排序。

使用 ==!= 操作符时,操作符右侧的字符串被视为模式,按下文第 3.5.8.1 节(模式匹配)所述规则匹配,就如同启用了 extglob Shell 选项一样。= 操作符与 == 相同。如果启用了 nocasematch Shell 选项(参见第 4.3.2 节(shopt 内建命令)中的 shopt 描述),匹配时不区分字母大小写。若字符串与模式匹配(==)或不匹配(!=),返回值为 0,否则为 1。

如果用 Shell 的任何引号机制引用模式的任何部分,被引用的部分按字面匹配。这意味着被引用部分的每个字符都匹配其自身,而不具有任何模式匹配的特殊含义。

另一个二元操作符 =~ 也可用,优先级与 ==!= 相同。使用 =~ 时,操作符右侧的字符串被视为 POSIX 扩展正则表达式模式并按此匹配(使用 POSIX 的 regcompregexec 接口,通常见 regex(3) 描述)。若字符串匹配模式,返回值为 0;不匹配则为 1。如果正则表达式语法不正确,条件表达式返回 2。如果启用了 nocasematch Shell 选项(参见第 4.3.2 节(shopt 内建命令)中的 shopt 描述),匹配时不区分字母大小写。

你可以引用模式的任何部分,强制被引用部分按字面匹配而不是作为正则表达式(见上文)。如果模式存放在 Shell 变量中,给变量展开加引号会强制整个模式按字面匹配。

只要模式匹配字符串的任何部分,匹配就算成功。若要强制模式匹配整个字符串,用 ^$ 正则表达式操作符锚定模式。

例如,若变量 line 的值中任何位置存在这样一个字符序列——space 字符类中的任意多个(包括零个)字符、紧接着零个或一个 a、然后一个 b——下面这条命令就会匹配:

[[ $line =~ [[:space:]]*(a)?b ]]

也就是说,line 的取值如 aab aaaaaabxaby ab 都会匹配,值中任何位置含 b 的行也会匹配。

如果要匹配对正则表达式语法特殊的字符(^$|[]()\.*+?),必须对其加引号以去除其特殊含义。这意味着在模式 xxx.txt 中,. 匹配字符串中的任意字符(其通常的正则表达式含义),而在模式 "xxx.txt" 中,它只能匹配字面意义上的 .

同样,如果你想在模式中包含一个对正则表达式语法有特殊含义的字符,必须确保它没有被加引号。例如,如果想把模式锚定在字符串的开头或结尾,就不能用任何形式的 Shell 引号引用 ^$ 字符。

如果想在行首匹配 initial string,下面的写法有效:

[[ $line =~ ^"initial string" ]]

但下面的写法无效:

[[ $line =~ "^initial string" ]]

因为第二个例子中 ^ 被加引号,失去了其通常的特殊含义。

有时,不用引号就很难正确指定正则表达式,或者很难在留意 Shell 引号和 Shell 的引号去除的同时,跟踪正则表达式使用的引号。把正则表达式存放在 Shell 变量中,往往是避免 Shell 特殊字符引号问题的一个有用办法。例如,下面与上面用过的模式等价:

pattern='[[:space:]]*(a)?b'
[[ $line =~ $pattern ]]

Shell 程序员应特别小心反斜杠,因为 Shell 和正则表达式都使用反斜杠去除下一个字符的特殊含义。这意味着 Shell 的单词展开(参见第 3.5 节(Shell 展开))完成后,模式中原本未加引号的部分里残留的任何反斜杠,都可能去除模式字符的特殊含义。如果模式的任何部分被加引号,Shell 会尽其所能确保:出现在被引用部分中的残留反斜杠,会被正则表达式按字面量处理。

下面两组命令并不等价:

pattern='\.'

[[ . =~ $pattern ]]
[[ . =~ \. ]]

[[ . =~ "$pattern" ]]
[[ . =~ '\.' ]]

前两个匹配会成功,后两个不会,因为后两个中的反斜杠将成为要匹配的模式的一部分。在前两个例子中,传给正则表达式解析器的模式是 \.。反斜杠去除了 . 的特殊含义,所以字面意义上的 . 匹配。在后两个例子中,传给正则表达式解析器的模式中反斜杠被引号保护(例如 \\\.),它不会匹配该字符串,因为字符串中不含反斜杠。如果第一个例子中的字符串不是 . 而是别的字符(比如 a),模式就不会匹配,因为模式中被加引号的 . 失去了匹配任意单个字符的特殊含义。

正则表达式中的方括号表达式也可能成为错误之源,因为通常在正则表达式中特殊的字符在方括号内会失去特殊含义。不过,你可以用方括号表达式来匹配特殊的模式字符而无需加引号,所以它们有时正适合这个用途。

虽然写法看起来有点奇怪,但下面的模式会匹配字符串中的 .

[[ . =~ [.] ]]

Shell 在把模式传给正则表达式函数之前会执行所有单词展开,所以可以认为 Shell 的引号优先。如上所述,正则表达式解析器会按它自己的规则解释 Shell 展开后残留在模式中的任何未加引号的反斜杠。其意图是尽可能避免让 Shell 程序员重复加引号,因此在必要时,Shell 引号足以引用特殊的模式字符。

数组变量 BASH_REMATCH 记录字符串的哪些部分匹配了模式。BASH_REMATCH 中索引为 0 的元素包含匹配整个正则表达式的字符串部分。正则表达式内带括号的子表达式匹配的子串保存在 BASH_REMATCH 的其余索引中。索引为 N 的 BASH_REMATCH 元素是匹配第 N 个带括号子表达式的字符串部分。

Bash 在全局作用域中设置 BASH_REMATCH;把它声明为局部变量会导致意想不到的结果。

表达式可以用下列操作符组合,按优先级从高到低列出:

( EXPRESSION )

返回 EXPRESSION 的值。可用于覆盖操作符的正常优先级。

! EXPRESSION

EXPRESSION 为假时为真。

EXPRESSION1 && EXPRESSION2

EXPRESSION1 和 EXPRESSION2 都为真时为真。

EXPRESSION1 || EXPRESSION2

EXPRESSION1 或 EXPRESSION2 为真时为真。

如果 EXPRESSION1 的值已足以确定整个条件表达式的返回值,&&|| 操作符就不再对 EXPRESSION2 求值。

译者注[[ ]][ ]test 命令)最重要的区别:[[ ]] 内不进行单词拆分与文件名展开,而 [ ] 会。最经典的演示是未加引号的变量在 [ ] 中因含空白而裂成多个参数:

# 译者注示例:[[ ]] 内无需引号;[ ] 内不加引号会拆分(报错文本随区域设置不同,此处为 LC_ALL=C 环境)
$ x='a b'
$ [[ $x = 'a b' ]] && echo yes
yes
$ [ "$x" = 'a b' ] && echo yes
yes
$ [ $x = 'a b' ] && echo yes
bash: [: too many arguments

此外,[[ ]]</> 是字典序比较操作符,直接可用;在 [ ] 中它们会被当作重定向符号,必须转义为 \>\< 或改用其他写法:

# 译者注示例:字典序比较的两种写法
$ [[ b > a ]] && echo "b > a"
b > a
$ [ b \> a ] && echo "b > a"
b > a

译者注[[ ]]==/!= 的右侧是模式(如同启用了 extglob),支持 *? 等通配符;而 [ ] 中的 = 是纯字符串比较,且未加引号的模式会先做文件名展开:

# 译者注示例:[[ ]] 的 == 右侧是模式;[ ] 的 = 是字面量比较
$ x=foobar
$ [[ $x == f* ]] && echo "pattern matched"
pattern matched
$ [ "$x" = 'f*' ] && echo "literal matched"
# 上面命令无输出:'f*' 是字面字符串,与 foobar 不相等

若想强制按字面比较,给模式加引号即可:[[ $x == "f*" ]] 不匹配。

译者注=~ 把右侧当作 POSIX 扩展正则表达式,配合 BASH_REMATCH 可以提取捕获组:

# 译者注示例:=~ 与 BASH_REMATCH 捕获组
$ [[ 'abc123' =~ ([a-z]+)([0-9]+) ]]
$ echo "${BASH_REMATCH[0]} / ${BASH_REMATCH[1]} / ${BASH_REMATCH[2]}"
abc123 / abc / 123

正则表达式语法错误时 [[ ]] 返回 2(而不是 0/1)。锚定与引号的配合要格外小心:^ 必须不加引号才有锚定含义,而被锚定的字面文本可以加引号:

# 译者注示例:^ 不加引号才有效;整个模式加引号则全为字面量
$ line='initial string here'
$ [[ $line =~ ^"initial string" ]] && echo yes
yes
$ [[ $line =~ "^initial string" ]] && echo yes
# 上面命令无输出:^ 被加引号后失去锚定含义

译者注:反斜杠同时是 Shell 和正则表达式的转义字符,这是 =~ 最容易踩坑的地方。把正则表达式存入变量、展开时不加引号,反斜杠才按正则语义起作用;一旦变量展开被加引号,整个模式变成字面量:

# 译者注示例:变量展开加不加引号,\ 的语义截然不同
$ pattern='\.'
$ [[ . =~ $pattern ]]; echo "unquoted variable: $?"
unquoted variable: 0
$ [[ . =~ \. ]]; echo "unquoted literal: $?"
unquoted literal: 0
$ [[ . =~ "$pattern" ]]; echo "quoted variable: $?"
quoted variable: 1
$ [[ . =~ '\.' ]]; echo "quoted literal: $?"
quoted literal: 1

需要匹配特殊字符本身(如 .)又不想费心处理反斜杠时,可以用方括号表达式:

# 译者注示例:方括号内的 . 失去特殊含义
$ [[ . =~ [.] ]]; echo $?
0

译者注BASH_REMATCH 由 Bash 在全局作用域设置,函数内若用 local BASH_REMATCH 声明会导致记录失效,应避免。把正则表达式存入变量、展开时不加引号,语义与直接书写模式完全相同,是规避引号纠缠的常用技巧:

# 译者注示例:模式存于变量后展开(与直接书写等价)
$ pattern='[[:space:]]*(a)?b'
$ for line in ' aab' 'xaby' 'a b'; do
>   [[ $line =~ $pattern ]] && echo "match: [$line]"
> done
match: [ aab]
match: [xaby]
match: [a b]

3.2.5.3 组合命令

Bash 提供两种把命令列表分组为一个整体执行的方式。命令分组后,重定向可以应用于整个命令列表。例如,列表中所有命令的输出都可以重定向到单一数据流。

()

( LIST )

把命令列表放在括号之间,会强制 Shell 创建一个子 Shell(参见第 3.7.3 节(命令执行环境)),LIST 中的每条命令都在该子 Shell 环境中执行。由于 LIST 在子 Shell 中执行,变量赋值在子 Shell 结束后不会保持有效。

{}

{ LIST; }

把命令列表放在花括号之间,会使该列表在当前 Shell 环境中执行。不创建子 Shell。LIST 之后的分号(或换行)是必需的。

除了是否创建子 Shell,这两种结构之间还存在一个历史原因造成的微妙差别。花括号是保留字,因此必须用「空白符」或其他 Shell 元字符与 LIST 分隔。圆括号是操作符,即使它们与 LIST 之间没有空白分隔,Shell 也会把它们识别为独立的记号。

这两种结构的退出状态都是 LIST 的退出状态。

译者注( ){ } 的核心差别——子 Shell 隔离 vs 当前 Shell 内执行:

# 译者注示例:( ) 中修改变量不影响外部
$ x=1
$ ( x=2; echo "subshell: $x" )
subshell: 2
$ echo "after subshell: $x"
after subshell: 1
# 译者注示例:{ } 中修改变量会保留到当前 Shell
$ { x=3; echo "brace: $x"; }
brace: 3
$ echo "after brace: $x"
after brace: 3

语法要点:{} 是保留字,{ 后面必须有空格或换行,} 前必须有 ; 或换行(上例中写成 { x=3; ...; });而 ( ) 是操作符,写成 (ls)( ls ) 都可以。

3.2.6 协程

「协程(coprocess)」是前面带有 coproc 保留字的 Shell 命令。协程在子 Shell 中异步执行,就如同命令以 & 控制操作符结尾一样,并在执行 Shell 与协程之间建立双向管道。

协程的语法是:

coproc [NAME] COMMAND [REDIRECTIONS]

这会创建一个名为 NAME 的协程。COMMAND 可以是简单命令(参见第 3.2.2 节(简单命令)),也可以是复合命令(参见第 3.2.5 节(复合命令))。NAME 是一个 Shell 变量名。如果不提供 NAME,默认名为 COPROC

推荐使用的协程形式是:

coproc NAME { COMMAND; }

推荐这种形式,是因为简单命令形式会导致协程总是被命名为 COPROC,而且它比其他复合命令形式更简单易用、也更完整。

还有其他协程形式:

coproc NAME COMPOUND-COMMAND
coproc COMPOUND-COMMAND
coproc SIMPLE-COMMAND

如果 COMMAND 是复合命令,NAME 是可选的。coproc 后面的单词是否被解释为变量名,取决于该单词是什么:如果它不是引入复合命令的保留字,就被解释为 NAME。如果 COMMAND 是简单命令,不允许出现 NAME;这是为了避免 NAME 与简单命令的第一个单词混淆。

执行协程时,Shell 在执行 Shell 的上下文中创建一个名为 NAME 的数组变量(参见第 6.7 节(数组))。COMMAND 的标准输出通过管道连接到执行 Shell 中的一个文件描述符,该文件描述符被赋给 NAME[0]。COMMAND 的标准输入通过管道连接到执行 Shell 中的一个文件描述符,该文件描述符被赋给 NAME[1]。这条管道在命令指定的任何重定向(参见第 3.6 节(重定向))之前建立。这些文件描述符可以用标准单词展开作为 Shell 命令和重定向的参数。除为了执行命令替换和进程替换而创建的文件描述符外,这些文件描述符在子 Shell 中不可用。

用于执行协程而派生的 Shell 的进程 ID 可以用变量 NAME_PID 的值取得。wait 内建命令可用于等待协程终止。

由于协程是作为异步命令创建的,coproc 命令总是返回成功。协程的返回状态是 COMMAND 的退出状态。

译者注:协程可以理解为「与 Shell 双向对话的后台进程」:向 NAME[1] 写入相当于向协程的标准输入送数据,从 NAME[0] 读出即读取协程的标准输出。下面是一个完整例子——协程读一行再回显,Shell 与它一来一回:

# 译者注示例:命名协程——写入 MY[1],从 MY[0] 读回
$ coproc MY { read line; echo "got: $line"; }
$ echo hello >&${MY[1]}
$ read -u ${MY[0]} reply
$ echo "$reply"
got: hello
$ echo "pid: $MY_PID"
pid: 44795

协程进程 ID 每次运行都不同;可用 wait $MY_PID 等待协程结束。

不写名字时,数组变量名默认为 COPROC(进程 ID 变量相应为 COPROC_PID):

# 译者注示例:默认名 COPROC 的协程
$ coproc { read x; echo "x=$x"; }
$ echo hi >&${COPROC[1]}
$ read -u ${COPROC[0]} out
$ echo "$out"
x=hi

下标方向是常见的混淆点:NAME[0] 是「读端」(对应协程的标准输出),NAME[1] 是「写端」(对应协程的标准输入)。协程的管道在命令自身的重定向之前建立;这些文件描述符只在创建它们的 Shell 中可用,子 Shell 中拿不到。

3.2.7 GNU Parallel

有一些运行并行命令的方式并未内建于 Bash。GNU Parallel 正是完成这一任务的工具。

GNU Parallel,正如其名所示,可以用来并行地构建并运行命令。你可以用不同的参数运行同一条命令,无论这些参数是文件名、用户名、主机名还是从文件读取的行。GNU Parallel 为许多最常见的操作提供了简写引用(输入行、输入行的各个部分、指定输入源的不同方式,等等)。Parallel 可以取代 xargs,也可以从其输入源把命令馈送给多个不同的 Bash 实例。

完整的描述参见 GNU Parallel 文档,网址为 https://www.gnu.org/software/parallel/parallel_tutorial.html

译者注:GNU Parallel 是独立于 Bash 发行的外部工具,需要单独安装(Debian/Ubuntu 可用 apt install parallel,RHEL/Fedora 可用 dnf install parallel)。典型用法是把输入行作为参数并行运行同一条命令,{} 代表当前输入行,例如:

# 译者注示例:需要先安装 GNU Parallel 才能运行;并行执行时输出顺序不定
$ seq 1 4 | parallel echo "job {}"

Bash 自身的小规模并行手段(& 后台任务、coproc 协程,参见第 3.2.6 节(协程))适合少量任务;大规模批处理任务则更适合用 GNU Parallel。

3.3 Shell 函数

Shell 函数(shell function)是把一组命令组合起来、日后用一个名称加以执行的方式。它们执行起来与「普通」简单命令完全一样。当某个 Shell 函数的名称被用作简单命令名时,Shell 会执行与该函数名关联的命令列表。Shell 函数在当前的 Shell 上下文中执行;不会创建新进程来解释它们。

译者注:函数是在「调用它的那个 Shell」里直接执行的,不派生新进程(这与执行脚本不同)。因此在函数里 cd、设置变量等操作会直接影响当前的 Shell。例如:

$ mycd() { cd /tmp; }   # 在函数里切换目录
$ mycd
$ pwd
/tmp                    # 当前 Shell 的目录被函数改变了

函数使用以下语法声明:

FNAME () COMPOUND-COMMAND [ REDIRECTIONS ]

function FNAME [()] COMPOUND-COMMAND [ REDIRECTIONS ]

这定义了一个名为 FNAME 的 Shell 函数。保留字 function 是可选的。如果提供了 function 保留字,括号也是可选的。函数的「主体」是复合命令 COMPOUND-COMMAND(参见第 3.2.5 节(复合命令))。该命令通常是用 {} 括起来的命令列表(LIST),但也可以是上面列出的任意复合命令。如果使用了 function 保留字而未提供括号,建议使用花括号。当 Shell 处于 POSIX 模式(参见第 6.11.2 节(Bash 的 POSIX 模式))时,FNAME 必须是合法的 Shell 名称,且不能与某个特殊内建命令同名(参见第 4.4 节(特殊内建命令))。不在 POSIX 模式下时,函数名可以是任何不带引号、不含 $ 的 Shell 单词。

译者注:下面这几种写法等价,都定义名为 hello 的函数:

hello() { echo hi; }
function hello() { echo hi; }
function hello { echo hi; }

注意:省略括号时必须有 function 保留字;反之,省略 function 时必须有括号。

与 Shell 函数关联的任何重定向(参见第 3.6 节(重定向))都在函数执行时执行。函数定义用 unset 内建命令的 -f 选项删除(参见第 4.1 节(Bourne Shell 内建命令))。

函数定义的退出状态为零,除非出现语法错误,或已存在同名的只读函数。函数执行时,函数的退出状态是函数体中最后执行的命令的退出状态。

注意,出于历史原因,在最常见的用法中,围绕函数体的花括号必须用「空白」或换行与函数体分开。这是因为花括号是保留字,只有当它们用空白或其他 Shell 元字符与命令列表隔开时,才会被识别为保留字。使用花括号时,命令列表必须以分号、& 或换行结尾。

译者注{} 是保留字,必须与其他词用空白、分号或换行隔开,Bash 才会把 { 当作分组开始而不是普通单词的一部分。例如 { echo hello } 会报语法错误——} 之前必须有分号或换行。正确写法:

$ f() { echo hello; }   # 函数体里的分号必不可少
$ f
hello

每当 FNAME 被指定为简单命令名时,就会执行 COMPOUND-COMMAND。函数在调用它的 Shell 的上下文中执行;不会创建新进程来解释它们(请将这一点与 Shell 脚本的执行对比)。

函数执行时,函数的参数在执行期间成为位置参数(参见第 3.4.1 节(位置参数))。展开为位置参数个数的特殊参数 # 会被更新,以反映新的位置参数集合。特殊参数 0 不变。FUNCNAME 变量的第一个元素在函数执行期间被设置为函数名。

Shell 执行环境的其他所有方面在函数与其调用者之间都相同,但有以下例外:DEBUGRETURN 陷阱不会继承,除非函数已通过 declare 内建命令被赋予 trace 属性,或已用 set 内建命令启用了 -o functrace 选项(此时所有函数都会继承 DEBUGRETURN 陷阱);ERR 陷阱也不会继承,除非已启用 -o errtrace Shell 选项。trap 内建命令的描述参见第 4.1 节(Bourne Shell 内建命令)。

FUNCNEST 变量如果被设置为大于 0 的数值,则定义了函数的最大嵌套层数。超过该限制的函数调用会导致整个命令中止。

译者注FUNCNEST 用来防止无限递归把调用栈耗尽。例如:

$ FUNCNEST=3
$ f() { echo "进入第 $1 层"; f $(($1+1)); }
$ f 1
进入第 1 层
进入第 2 层
进入第 3 层
bash: f: maximum function nesting level exceeded (3)

想恢复默认(不限层数),执行 unset FUNCNEST。报错消息的文字因区域设置而异,括号里的数字就是上限值。

如果在函数中执行内建命令 return,函数即完成,执行从函数调用之后的下一条命令继续。任何与 RETURN 陷阱关联的命令都会在执行继续之前执行。函数完成时,位置参数和特殊参数 # 的值被恢复为函数执行之前的值。如果给 return 提供了数值参数,那就是函数的返回状态;否则函数的返回状态是 return 之前最后执行的命令的退出状态。

函数内的局部变量用 local 内建命令声明(这些变量即「局部变量」)。通常,变量及其值在函数与调用者之间是共享的。局部变量只对函数及其调用的命令可见。当 Shell 函数调用其他函数时,这一点尤为重要。

在下面的描述中,「当前作用域」是当前正在执行的函数。之前的作用域由该函数的调用者构成,再往前逐层回溯,直到「全局」作用域,即 Shell 不执行任何 Shell 函数的作用域。当前局部作用域中的局部变量,是指当前正在执行的函数中用 localdeclare 内建命令声明的变量。

局部变量会「遮蔽」在之前作用域中声明的同名变量。例如,函数中声明的局部变量会隐藏之前作用域中声明的同名变量,包括全局变量:对它的引用和赋值都作用于局部变量,之前作用域中的变量保持不变。函数返回后,全局变量再次可见。

Shell 使用「动态作用域」(dynamic scoping)来控制变量在函数内的可见性。在动态作用域下,可见的变量及其值取决于使执行到达当前函数的函数调用序列。函数看到的变量值,取决于它在调用者(如果有的话)中的值,无论该调用者是全局作用域还是另一个 Shell 函数。这也是局部变量声明所遮蔽的值,也是函数返回时恢复的值。

例如,如果变量 var 在函数 func1 中被声明为局部变量,而 func1 调用了另一个函数 func2,那么从 func2 内部对 var 的引用解析到 func1 中的局部变量 var,从而遮蔽任何名为 var 的全局变量。

下面的脚本演示了这一行为。执行时,脚本显示:

In func2, var = func1 local

func1()
{
    local var='func1 local'
    func2
}

func2()
{
    echo "In func2, var = $var"
}

var=global
func1

译者注:与许多编程语言(如 C、Python)的「词法作用域」不同,Bash 采用动态作用域:函数里看到什么变量,不是由「函数定义的位置」决定,而是由「谁调用了它」决定。例如 g$var 的值取决于调用链:

$ var=global
$ f() { local var=local; echo "f 中: $var"; g; }
$ g() { echo "g 中: $var"; }
$ f
f 中: local
g 中: local        # g 被 f 调用,看到的是 f 的局部变量
$ echo "外面: $var"
外面: global       # f 返回后,全局变量恢复可见

unset 内建命令也按照同样的动态作用域起作用:如果变量是当前作用域的局部变量,unset 会取消设置它;否则按上述说明,unset 作用于在某个调用作用域中找到的变量。如果当前局部作用域中的变量被取消设置,它会保持未设置状态(表现为未设置),直到在该作用域中重新设置它或函数返回。函数一旦返回,之前作用域中该变量的任何实例都会重新可见。如果 unset 作用于之前作用域中的变量,则之前被遮蔽的同名变量的任何实例都会重新可见(参见下文 localvar_unset Shell 选项如何改变这一行为)。

declaretypeset)内建命令的 -f 选项(参见第 4.2 节(Bash 内建命令))列出函数名称和定义。declaretypeset-F 选项只列出函数名称(如果启用了 extdebug Shell 选项,还可选地列出源文件和行号)。函数可以导出,使子 Shell 进程(执行单独的 Shell 调用时创建的进程)自动拥有它们,方法是使用 export 内建命令的 -f 选项(参见第 4.1 节(Bourne Shell 内建命令))。unset 内建命令的 -f 选项(参见第 4.1 节(Bourne Shell 内建命令))删除函数定义。

函数可以递归。FUNCNEST 变量可用于限制函数调用栈的深度、限制函数调用的次数。默认情况下,Bash 对递归调用次数不设任何限制。

3.4 Shell 参数

「参数」(parameter)是存储值的实体。它可以是「名称」(name)、数字,或下面列出的特殊字符之一。「变量」(variable)是由「名称」标识的参数。变量具有「值」(value)和零个或多个「属性」(attribute)。属性用 declare 内建命令赋值(参见第 4.2 节(Bash 内建命令)中对 declare 内建命令的描述)。exportreadonly 内建命令也赋予特定属性。

参数一旦被赋过值,即被「设置」(set)。空字符串也是合法值。变量一旦被设置,只能通过 unset 内建命令取消设置。

变量用如下形式的语句赋值:

NAME=[VALUE]

如果未给出 VALUE,变量被赋值为空字符串。所有 VALUE 都要经受波浪号展开、参数和变量展开、命令替换、算术展开和引号去除(参见第 3.5.3 节(Shell 参数展开))。如果变量设置了 integer 属性,那么即使不使用 $((...)) 展开,VALUE 也会被当作算术表达式求值(参见第 3.5.5 节(算术展开))。赋值时不执行单词拆分和文件名展开。赋值语句也可以作为 aliasdeclaretypesetexportreadonlylocal 内建命令的参数出现(这些命令即「声明命令」(declaration command))。在 POSIX 模式(参见第 6.11.2 节(Bash 的 POSIX 模式))下,这些内建命令可以出现在一个或多个 command 内建命令实例之后,并保留这些赋值语句的性质。例如:

command export var=value

译者注:赋值时不做单词拆分和文件名展开,这一点常被误解。var=* 时,* 被原样保存;只有之后对 $var 的展开(且不加引号)才会触发这些展开。例如:

$ dir=/tmp/*.log     # 赋值时 `*.log` 原样保存,不做文件名展开
$ echo "$dir"
/tmp/*.log

在赋值语句给 Shell 变量或数组下标(参见第 6.7 节(数组))赋值的情况下,+= 运算符会追加到或累加到变量之前的值。这包括接受赋值语句的声明命令(如 declare)的参数。当 += 应用于设置了 integer 属性的变量时,变量的当前值和 VALUE 各自被作为算术表达式求值,并把两个结果的和赋给变量作为其值。当前值通常是整数常量,但也可以是表达式。当 += 应用于使用复合赋值的数组变量(参见第 6.7 节(数组))时,变量的值不会被取消设置(使用 = 时则会),新值从数组最大下标加 1 处开始追加(对于索引数组),或作为额外的键值对加入关联数组。当应用于字符串值变量时,VALUE 经展开后追加到变量的值上。

译者注+= 对字符串是「追加」,对整数(integer 属性)是「累加」:

$ x=hello
$ x+=world           # 字符串追加
$ echo "$x"
helloworld
$ declare -i n=5
$ n+=3               # 整数累加(算术求值)
$ echo $n
8
$ arr=(a b)
$ arr+=(c d)         # 数组追加元素
$ echo "${arr[@]}"
a b c d

可以用 declarelocal 内建命令的 -n 选项(参见第 4.2 节(Bash 内建命令))为变量赋予 nameref 属性,从而创建「nameref」,即对另一个变量的引用。这允许间接操作变量。每当 nameref 变量被引用、赋值、取消设置或修改其属性(使用或修改 nameref 属性本身除外)时,实际执行的操作都是对 nameref 变量的值所指定的那个变量进行的。nameref 常用于 Shell 函数内部,用来引用名称以参数形式传入函数的变量。例如,如果某个变量名作为第一个参数传入 Shell 函数,在函数内运行:

declare -n ref=$1

会创建一个局部 nameref 变量 ref,其值就是作为第一个参数传入的变量名。对 ref 的引用、赋值以及对其属性的修改,都被视为对以 $1 传入的名称所对应的变量的引用、赋值和属性修改。

译者注:nameref 相当于「变量的别名」,特别适合在函数里修改调用者的变量:

$ x=100
$ declare -n r=x     # r 是 x 的引用
$ echo "$r"
100
$ r=200              # 通过 r 赋值,改的是 x
$ echo "$x"
200

如果 for 循环中的控制变量具有 nameref 属性,则单词列表可以是 Shell 变量的列表,循环执行时依次为列表中的每个单词建立名称引用。数组变量不能赋予 nameref 属性。但 nameref 变量可以引用数组变量和带下标的数组变量。nameref 可以用 unset 内建命令的 -n 选项取消设置(参见第 4.1 节(Bourne Shell 内建命令))。否则,如果对 nameref 变量名执行 unset,被取消设置的将是 nameref 变量所引用的变量。

Shell 启动时,会读取它的环境,并为每个名称合法的环境变量创建一个 Shell 变量,如下所述(参见第 3.7.4 节(环境))。

3.4.1 位置参数

「位置参数」(positional parameter)是由一个或多个数字标识的参数,但单个数字 0 除外。位置参数在 Shell 被调用时从其参数获得赋值,也可以用 set 内建命令重新赋值。位置参数 N 可以引用为 ${N};当 N 是单个数字时,也可以引用为 $N。位置参数不能用赋值语句赋值。setshift 内建命令用于设置和取消设置它们(参见第 4 章(Shell 内建命令))。执行 Shell 函数时,位置参数会被临时替换(参见第 3.3 节(Shell 函数))。

展开由多个数字组成的位置参数时,必须用花括号括起来。没有花括号时,$ 后面的数字只能引用前九个位置参数($1$9)之一或特殊参数 $0(见下文)。

译者注$10 不是「第十个参数」,而是「参数 1 的值后面跟一个字符 0」。多位数位置参数必须用花括号:

$ set -- a b c d e f g h i j k
$ echo "$10"
a0                # $1 的值 a 加上字面字符 0
$ echo "${10}"
j                 # 第十个位置参数

3.4.2 特殊参数

Shell 特殊对待若干参数。这些参数只能被引用,不允许赋值。特殊参数由下列字符之一表示。

$*

展开为从 1 开始的位置参数。当展开不在双引号内时,每个位置参数展开为单独的单词。在执行单词展开的上下文中,这些单词还要经受进一步的单词拆分和文件名展开。当展开发生在双引号内时,它展开为单个单词,各参数的值用 IFS 变量的第一个字符分隔。也就是说,"$*" 等价于 "$1C$2C...",其中 C 是 IFS 变量的值的第一个字符。如果 IFS 未设置,参数之间用空格分隔。如果 IFS 为空,参数被连接在一起、中间没有分隔符。

$@

展开为从 1 开始的位置参数。在执行单词拆分的上下文中,它把每个位置参数展开为单独的单词;如果不在双引号内,这些单词还要经受单词拆分。在不执行单词拆分的上下文中,例如赋值语句的值部分,它展开为单个单词,各位置参数之间用空格分隔。当展开发生在双引号内且执行单词拆分时,每个参数展开为单独的单词。也就是说,"$@" 等价于 "$1" "$2" ...。如果双引号展开发生在某个单词内部,则第一个参数的展开与原始单词开头部分的展开连接在一起,最后一个参数的展开与原始单词结尾部分的展开连接在一起。当没有位置参数时,"$@"$@ 展开为空(即被移除)。

译者注$*$@ 的关键区别在于双引号内:"$*" 把所有参数合并成「一个」单词(用 IFS 的首字符分隔),"$@" 保持每个参数为「独立」单词(等价于 "$1" "$2" ...)。不加引号时两者一样,都会把结果再做单词拆分和文件名展开。用 printf 让每个参数各占一行,区别一目了然:

$ set -- "a b" "c d"
$ printf '<%s>\n' "$*"
<a b c d>          # 合并为一个单词
$ printf '<%s>\n' "$@"
<a b>              # 两个参数各自独立
<c d>
$ printf '<%s>\n' $*    # 不加引号:先展开再拆分
<a>
<b>
<c>
<d>

$#

展开为位置参数的个数(十进制)。

译者注:脚本中最常见的用法是检查参数个数,例如:

$ set -- a b c
$ echo "$#"
3

$?

展开为最近执行的命令的退出状态。

译者注:每条命令执行后 $? 都会被更新;退出状态为 0 表示成功,非 0 表示失败:

$ true; echo $?
0
$ false; echo $?
1

$-

(连字符。)展开为当前的选项标志,即调用时指定的、set 内建命令设置的,或 Shell 自身设置的选项标志(如 -i 选项)。

译者注$- 列出当前生效的标志位。常见的有 i(交互式)、h(hashall)、B(花括号展开)、u(nounset)、x(xtrace)等。例如:

$ bash -c 'echo $-; set -u; echo $-'
hBc               # 非交互式 Shell 的基线标志(c:以 -c 方式调用;Bash 5.3 起)
huBc              # 开启 nounset 后多出 u

具体标志集及其顺序随 Shell 版本和启动方式而异,但「开启选项后 $- 里出现对应字母」这一点是稳定的。若以 -s(从标准输入读取命令)方式调用,看到的将是 hBs

$$

展开为 Shell 的进程 ID。在子 Shell 中,它展开为调用方 Shell 的进程 ID,而不是子 Shell 的进程 ID。

译者注$$ 是「当前 Shell 的 PID」;在子 Shell 中它仍指向外层 Shell(这也是用 $$ 生成临时文件名会撞车的原因)。要获得真正当前进程的 PID,可用 Bash 特有的 $BASHPID

$ echo $$; (echo $$)
43137
43137              # 子 Shell 里 $$ 不变
$ echo $BASHPID; (echo $BASHPID)
43137
43167              # $BASHPID 在子 Shell 里变成子进程自己的 PID

(示例中的数字每次运行都会不同。)

$!

展开为最近放入后台的作业的进程 ID,无论是作为异步命令执行,还是用 bg 内建命令(参见第 7.2 节(作业控制内建命令))放入后台的。

译者注$! 常用于记录后台任务的 PID,以便之后管理它:

$ sleep 1 &        # 后台运行 sleep
$ pid=$!
$ echo "$pid"
12345              # 这是 sleep 进程的 PID(每次运行不同)
$ kill "$pid"      # 可用它来结束该后台进程

$0

展开为 Shell 或 Shell 脚本的名称。这在 Shell 初始化时设置。如果 Bash 是通过命令文件调用的(参见第 3.8 节(Shell 脚本)),$0 被设置为该文件的名称。如果 Bash 以 -c 选项启动(参见第 6.1 节(调用 Bash)),那么 $0 被设置为要执行的字符串之后的第一个参数(如果存在)。否则,它被设置为调用 Bash 时使用的文件名,也就是参数零给出的文件名。

译者注:脚本里 $0 就是脚本自己的路径;以 bash -c 启动时,-c 后面的第一个单词会变成 $0,可以利用这一点给脚本起「别名」名:

$ bash -c 'echo $0' hello
hello

3.5 Shell 展开

展开(expansion)在命令行被拆分为「记号」(token)之后执行。Bash 执行以下展开:

  • 花括号展开(brace expansion)
  • 波浪号展开(tilde expansion)
  • 参数和变量展开(parameter and variable expansion)
  • 命令替换(command substitution)
  • 算术展开(arithmetic expansion)
  • 单词拆分(word splitting)
  • 文件名展开(filename expansion)
  • 引号去除(quote removal)

展开的顺序是:花括号展开;波浪号展开、参数和变量展开、算术展开和命令替换(以从左到右的方式进行);单词拆分;文件名展开;引号去除。

在支持的系统上,还有另一种可用的展开:「进程替换」(process substitution)。它与波浪号、参数、变量和算术展开及命令替换同时进行。

「引号去除」总是最后执行。它移除原始单词中存在的引号字符,而不是移除其他某个展开所产生的引号字符,除非这些引号字符本身已被引用。更多细节参见第 3.5.9 节(引号去除)。

只有花括号展开、单词拆分和文件名展开能增加展开结果的单词数量;其他展开把单个单词展开为单个单词。唯一的例外是 "$@"$*(参见第 3.4.2 节(特殊参数))以及 "${NAME[@]}"${NAME[*]}(参见第 6.7 节(数组))的展开。

译者注:理解展开顺序对排查问题很重要。例如参数展开(第 3 步)的结果要到单词拆分(第 6 步)才会被拆开,文件名展开(第 7 步)发生在单词拆分之后:

$ x="a  b"                 # 两个字符中间有两个空格
$ printf '<%s>\n' $x       # 参数展开 → 单词拆分
<a>
<b>                        # 连续空格被压缩,拆成两个单词
$ printf '<%s>\n' "$x"     # 双引号阻止拆分
<a  b>

又因为 "$@" 这类展开是「一个单词变多个」的例外,循环里遍历参数应写成:

$ set -- a b
$ for i in "$@"; do echo "$i"; done
a
b

3.5.1 花括号展开

花括号展开(brace expansion)是一种生成任意字符串的机制,这些字符串共享公共的前缀和后缀,两者都可以为空。这种机制与「文件名展开」(参见第 3.5.8 节(文件名展开))类似,但生成的文件名不必存在。要进行花括号展开的模式,由一个可选的前缀(PREAMBLE)构成,后接花括号对之间的一系列逗号分隔的字符串或一个序列表达式,再接可选的后缀(POSTSCRIPT)。前缀被加到花括号内每个字符串之前,后缀随后被追加到每个结果字符串之后,按从左到右的顺序展开。

花括号展开可以嵌套。每个展开字符串的结果不会被排序;花括号展开保持从左到右的顺序。例如:

bash$ echo a{d,c,b}e
ade ace abe

译者注:嵌套的花括号会做「笛卡尔积」式组合:

$ echo {a,b}{1,2}
a1 a2 b1 b2

序列表达式形式为 X..Y[..INCR],其中 X 和 Y 是整数或字母,INCR(可选的增量)是整数。提供整数时,表达式展开为 X 和 Y 之间(含两端)的每个数字。如果 X 或 Y 以 0 开头,则生成的每一项都包含相同位数的数字,必要时补零。提供字母时,表达式使用 C 区域设置展开为 X 和 Y 之间(含两端)按字典序排列的每个字符。注意 X 和 Y 必须是同一类型(同为整数或同为字母)。提供增量时,它被用作每项之间的差值。默认增量视情况为 1 或 -1。

译者注:序列表达式的几个实际例子:

$ echo {1..5}
1 2 3 4 5
$ echo {01..10..2}     # 补零到两位,步长为 2
01 03 05 07 09
$ echo {a..e} {z..v}   # 字母序列(C 区域设置)
a b c d e z y x w v
$ echo {5..1}
5 4 3 2 1              # X > Y 时步长自动取 -1

花括号展开在任何其他展开之前执行,其他展开中特殊的任何字符都会保留在结果中。它是严格文本性的。Bash 不会对展开的上下文或花括号之间的文本进行任何语法解释。

格式正确的花括号展开必须包含未加引号的左、右花括号,以及至少一个未加引号的逗号或有效的序列表达式。任何格式错误的花括号展开都原样保留。

{, 可以用反斜杠加引号,以防止被当作花括号表达式的一部分。为避免与参数展开冲突,字符串 ${ 不被视为可进行花括号展开,并且会抑制花括号展开,直到与之匹配的右花括号 }

译者注:没有逗号或序列表达式的花括号不会展开;用反斜杠转义可以按字面输出:

$ echo {a}          # 没有逗号:不展开
{a}
$ echo {a,b}        # 有逗号:展开
a b
$ echo \$\{a,b\}    # 用反斜杠转义 $、{、}
${a,b}

另外,${...}(参数展开)绝不会被当作花括号展开处理,所以 echo "${HOME}" 输出的是 HOME 变量的值,而不是先对花括号做展开。

当要生成的字符串的公共前缀比上面示例中的更长时,这种结构通常用作简写:

mkdir /usr/local/src/bash/{old,new,dist,bugs}

chown root /usr/{ucb/{ex,edit},lib/{ex?.?*,how_ex}}

花括号展开与历史上的 sh 版本存在轻微的不兼容。sh 不特殊对待作为单词一部分出现的左、右花括号,并在输出中保留它们。Bash 则作为花括号展开的结果从单词中移除花括号。例如,输入给 sh 的单词 file{1,2} 在输出中原样出现。Bash 在花括号展开后将该单词输出为 file1 file2。以 +B 选项启动 Bash,或用 set 命令的 +B 选项禁用花括号展开(参见第 4 章(Shell 内建命令)),即可获得严格的 sh 兼容性。

3.5.2 波浪号展开

如果单词以未加引号的波浪号字符(~)开头,那么直到第一个未加引号的斜杠之前的所有字符(如果没有未加引号的斜杠,则是所有字符)都被视为「波浪号前缀」(tilde-prefix)。如果波浪号前缀中的任何字符都没有加引号,则波浪号之后的波浪号前缀字符被当作可能的「登录名」。如果这个登录名是空字符串,波浪号被替换为 HOME Shell 变量的值。如果 HOME 未设置,波浪号改为展开为执行 Shell 的用户的主目录。否则,波浪号前缀被替换为与指定登录名关联的主目录。

如果波浪号前缀是 ~+,则 Shell 变量 PWD 的值替换该波浪号前缀。如果波浪号前缀是 ~-,Shell 用 Shell 变量 OLDPWD 的值替换(如果已设置)。

如果波浪号前缀中、波浪号之后的字符由一个数字 N 组成,可选地以 +- 开头,则波浪号前缀被替换为目录栈中的相应元素,就像 dirs 内建命令以波浪号前缀中波浪号之后的字符为参数调用时所显示的那样(参见第 6.8 节(目录栈))。如果波浪号前缀去掉波浪号后,由不带前导 +- 的数字组成,波浪号展开假定为 +

波浪号展开的结果被视为已加引号,因此替换结果不进行单词拆分和文件名展开。

如果登录名无效,或波浪号展开失败,波浪号前缀原样保留。

Bash 会检查每个变量赋值中紧跟 : 或第一个 = 之后的未加引号的波浪号前缀,并在这些情况下执行波浪号展开。因此,可以在对 PATHMAILPATHCDPATH 的赋值中使用带波浪号的文件名,Shell 会把展开后的值赋给它们。

译者注:赋值语句中 =: 之后的 ~ 也会被展开,PATH=~/bin:$PATH 这类写法正是依赖这一点。而普通单词里的波浪号只在开头展开(例如 echo a:~ 里的 ~ 不展开):

$ x=~/foo
$ echo "$x"
/home/jack/foo
$ echo a:~
a:~

下表显示 Bash 如何处理未加引号的波浪号前缀:

波浪号前缀 展开结果
~ $HOME 的值
~/foo $HOME/foo
~fred/foo 用户 fred 的主目录中的目录或文件 foo
~+/foo $PWD/foo
~-/foo ${OLDPWD-'~-'}/foo
~N dirs +N 显示出的字符串
~+N dirs +N 显示出的字符串
~-N dirs -N 显示出的字符串

译者注~+ 是当前目录($PWD)的快捷方式,~- 是切换前的目录($OLDPWD),~N/~+N/~-N 引用目录栈中的第 N 项(目录栈参见第 6.8 节(目录栈)):

$ cd /tmp && echo ~+     # 当前目录
/tmp
$ echo ~-                # 切换前的目录(OLDPWD)
/home/jack/claude/bash-jc
$ pushd /usr >/dev/null  # 把 /usr 压入目录栈(栈变为 /usr /tmp)
$ echo ~0                # dirs +0:栈顶
/usr
$ echo ~1                # dirs +1:第二项
/tmp
$ popd >/dev/null        # 恢复

Bash 还在作为简单命令参数出现、满足变量赋值条件的单词上执行波浪号展开(参见第 3.4 节(Shell 参数))。在 POSIX 模式下,除上面列出的声明命令外,Bash 不做此展开。

3.5.3 Shell 参数展开

$ 字符引入参数展开、命令替换或算术展开。要展开的参数名或符号可以用花括号括起来;花括号是可选的,但用于保护要展开的变量,使其不受紧随其后、可能被解释为名称一部分的字符影响。例如,如果第一个位置参数的值为 a,那么 ${11} 展开为第十一个位置参数的值,而 $11 展开为 a1

使用花括号时,与之匹配的结束花括号是第一个未被反斜杠转义、不在带引号的字符串内、也不在嵌入式算术展开、命令替换或参数展开内的 }

参数展开的基本形式是 ${PARAMETER},它替换 PARAMETER 的值。PARAMETER 是上文所述的 Shell 参数(参见第 3.4 节(Shell 参数))或数组引用(参见第 6.7 节(数组))。当 PARAMETER 是多位数位置参数,或 PARAMETER 后面跟着一个不应被解释为其名称一部分的字符时,花括号是必需的。

译者注:没有花括号时,$ 后面能作为名称一部分的字符会被贪婪地吸收进变量名。想明确「变量名到此为止」就必须用花括号:

$ x=foo
$ echo "$xbar"
                    # 空:Bash 找的是未设置的变量 xbar
$ echo "${x}bar"
foobar

如果 PARAMETER 的第一个字符是感叹号(!),且 PARAMETER 不是 nameref,它就引入一层间接。Bash 用展开 PARAMETER 其余部分所形成的值作为新的 PARAMETER;这个新参数随后被展开,在展开的其余部分使用该值,而不是原始 PARAMETER 的展开。这被称为「间接展开」(indirect expansion)。该值经受波浪号展开、参数展开、命令替换和算术展开。如果 PARAMETER 是 nameref,出于兼容性,这展开为 PARAMETER 所引用的变量的名称,而不是执行完整的间接展开。例外是下文描述的 ${!PREFIX*}${!NAME[@]} 的展开。要引入间接展开,感叹号必须紧跟左花括号。

译者注:间接展开的典型用途是「变量名本身存放在另一个变量里」:

$ varname=user
$ user=jack
$ echo "${!varname}"   # 先展开 varname 得 user,再展开 user
jack

在下面每种情况下,WORD 都要经受波浪号展开、参数展开、命令替换和算术展开。

当不执行子串展开时,使用下面描述的形式(如 :-),Bash 测试参数是未设置还是为空。省略冒号则只测试参数未设置。换句话说,如果包含冒号,操作符同时测试 PARAMETER 是否存在且其值非空;如果省略冒号,操作符只测试存在性。

译者注:这里要区分两个概念:「未设置」(unset,从未被赋过值)和「为空」(null,被赋值成空字符串)。带冒号的形式(:- 等)两者都测试;不带冒号的形式(- 等)只测试是否未设置:

$ v=
$ echo "${v-未设置}"

$ echo "${v:-未设置或为空}"
未设置或为空
$ unset v
$ echo "${v-未设置}"
未设置

第一行 echo 输出为空:v 已设置(只是为空),不是「未设置」,所以不带冒号的 - 不采用缺省值;带冒号的 :- 则输出缺省值。unset v 之后两种形式都会输出「未设置」。

${PARAMETER:-WORD}

如果 PARAMETER 未设置或为空,替换 WORD 的展开。否则,替换 PARAMETER 的值。

$ v=123
$ echo ${v-unset}
123
$ echo ${v:-unset-or-null}
123
$ unset v
$ echo ${v-unset}
unset
$ v=
$ echo ${v-unset}

$ echo ${v:-unset-or-null}
unset-or-null

${PARAMETER:=WORD}

如果 PARAMETER 未设置或为空,WORD 的展开被赋给 PARAMETER,展开的结果是 PARAMETER 的最终值。位置参数和特殊参数不能以这种方式赋值。

$ unset var
$ : ${var=DEFAULT}
$ echo $var
DEFAULT
$ var=
$ : ${var=DEFAULT}
$ echo $var

$ var=
$ : ${var:=DEFAULT}
$ echo $var
DEFAULT
$ unset var
$ : ${var:=DEFAULT}
$ echo $var
DEFAULT

译者注= 只在参数未设置时赋值,:= 在「未设置或为空」时都赋值。上面的例子显示:var=(为空)时 ${var=DEFAULT} 不再赋值,而 ${var:=DEFAULT} 仍会赋。常见写法是配合空命令 : 一次性地设置缺省值:

$ : "${config:=/etc/my.conf}"   # 未设置/为空时补上缺省值
$ echo "$config"
/etc/my.conf

${PARAMETER:?WORD}

如果 PARAMETER 为空或未设置,Shell 将 WORD 的展开(如果未提供 WORD,则是一条相应含义的消息)写入标准错误;如果 Shell 不是交互式的,则用非零状态退出。交互式 Shell 不退出,但不执行与该展开关联的命令。否则,替换 PARAMETER 的值。

$ var=
$ : ${var:?var is unset or null}
bash: var: var is unset or null
$ echo ${var?var is unset}

$ unset var
$ : ${var?var is unset}
bash: var: var is unset
$ : ${var:?var is unset or null}
bash: var: var is unset or null
$ var=123
$ echo ${var:?var is unset or null}
123

译者注:脚本开头用它检查必需参数非常常见——缺参数时直接报错并退出,后续命令不再执行:

$ LC_ALL=C bash -c 'var=; : "${var:?var is empty}"; echo "not reached"'
bash: line 1: var: var is empty

注意:非交互式 Shell 会立刻以非零状态退出(上面的 echo "not reached" 没有执行);交互式 Shell 只打印错误、不退出。

${PARAMETER:+WORD}

如果 PARAMETER 为空或未设置,不替换任何内容;否则替换 WORD 的展开。不使用 PARAMETER 的值。

$ var=123
$ echo ${var:+var is set and not null}
var is set and not null
$ echo ${var+var is set}
var is set
$ var=
$ echo ${var:+var is set and not null}

$ echo ${var+var is set}
var is set
$ unset var
$ echo ${var+var is set}

$ echo ${var:+var is set and not null}

$

译者注+:- 正好相反——参数「有值」时才用 WORD,常被用作简易开关(比如调试标志):

$ debug=1
$ echo "${debug:+当前处于调试模式}"
当前处于调试模式
$ debug=
$ echo "${debug:+当前处于调试模式}"

上面最后一个 echo 的输出为空行:debug 为空时 :+ 什么都不输出。

${PARAMETER:OFFSET}${PARAMETER:OFFSET:LENGTH}

这被称为「子串展开」(substring expansion)。它展开为 PARAMETER 的值中从 OFFSET 指定的字符开始、最多 LENGTH 个字符。如果 PARAMETER 是 @*、用 @* 作下标的索引数组、或关联数组名,结果按下文所述有所不同。如果省略 :LENGTH(上面的第一种形式),则展开为 PARAMETER 的值中从 OFFSET 指定的字符开始、延伸到值末尾的子串。如果省略 OFFSET,视为 0。如果省略 LENGTH 但 OFFSET 后的冒号存在,视为 0。LENGTH 和 OFFSET 是算术表达式(参见第 6.5 节(Shell 算术))。

如果 OFFSET 求值为小于 0 的数,该值被用作从 PARAMETER 的值末尾算起的字符偏移量。如果 LENGTH 求值为小于 0 的数,它被解释为从 PARAMETER 的值末尾算起的字符偏移量,而不是字符个数,展开结果是 OFFSET 与该结果之间的字符。

注意,负偏移量必须与冒号之间至少隔一个空格,以免被误认为 :- 展开。

下面是说明参数和带下标数组的子串展开的一些示例:

$ string=01234567890abcdefgh
$ echo ${string:7}
7890abcdefgh
$ echo ${string:7:0}

$ echo ${string:7:2}
78
$ echo ${string:7:-2}
7890abcdef
$ echo ${string: -7}
bcdefgh
$ echo ${string: -7:0}

$ echo ${string: -7:2}
bc
$ echo ${string: -7:-2}
bcdef
$ set -- 01234567890abcdefgh
$ echo ${1:7}
7890abcdefgh
$ echo ${1:7:0}

$ echo ${1:7:2}
78
$ echo ${1:7:-2}
7890abcdef
$ echo ${1: -7}
bcdefgh
$ echo ${1: -7:0}

$ echo ${1: -7:2}
bc
$ echo ${1: -7:-2}
bcdef
$ array[0]=01234567890abcdefgh
$ echo ${array[0]:7}
7890abcdefgh
$ echo ${array[0]:7:0}

$ echo ${array[0]:7:2}
78
$ echo ${array[0]:7:-2}
7890abcdef
$ echo ${array[0]: -7}
bcdefgh
$ echo ${array[0]: -7:0}

$ echo ${array[0]: -7:2}
bc
$ echo ${array[0]: -7:-2}
bcdef

如果 PARAMETER 是 @*,结果是始于 OFFSET 的 LENGTH 个位置参数。负 OFFSET 相对于最大位置参数加 1 来计算,因此偏移量 -1 求值为最后一个位置参数(如果没有位置参数则为 0)。如果 LENGTH 求值为小于 0 的数,这是展开错误。

下面示例说明使用位置参数的子串展开:

$ set -- 1 2 3 4 5 6 7 8 9 0 a b c d e f g h
$ echo ${@:7}
7 8 9 0 a b c d e f g h
$ echo ${@:7:0}

$ echo ${@:7:2}
7 8
$ echo ${@:7:-2}
bash: -2: substring expression < 0
$ echo ${@: -7:2}
b c
$ echo ${@:0}
./bash 1 2 3 4 5 6 7 8 9 0 a b c d e f g h
$ echo ${@:0:2}
./bash 1
$ echo ${@: -7:0}

如果 PARAMETER 是用 @* 作下标的索引数组名,结果是始于 ${PARAMETER[OFFSET]} 的数组的 LENGTH 个成员。负 OFFSET 相对于指定数组的最大下标加 1 来计算。如果 LENGTH 求值为小于 0 的数,这是展开错误。

下面示例说明如何将子串展开用于索引数组:

$ array=(0 1 2 3 4 5 6 7 8 9 0 a b c d e f g h)
$ echo ${array[@]:7}
7 8 9 0 a b c d e f g h
$ echo ${array[@]:7:2}
7 8
$ echo ${array[@]: -7:2}
b c
$ echo ${array[@]: -7:-2}
bash: -2: substring expression < 0
$ echo ${array[@]:0}
0 1 2 3 4 5 6 7 8 9 0 a b c d e f g h
$ echo ${array[@]:0:2}
0 1
$ echo ${array[@]: -7:0}

对关联数组应用子串展开会产生未定义的结果。

子串索引从 0 开始,除非使用位置参数,此时索引默认从 1 开始。如果 OFFSET 为 0 且使用位置参数,$0 会被加到列表前面。

译者注:负偏移最常见的错误是忘记空格——${s:-2} 会被解析成缺省值展开 :-,而不是子串。另外对 @ 取子串时偏移从 1 开始,且 :0 会把 $0 前缀到结果里:

$ s=0123456789
$ echo "${s: -3}"
789                  # 负偏移:从末尾数 3 个字符
$ echo "${s:-3}"
0123456789           # 没有空格:被当成 :- 展开,输出整个值
$ bash -c 'set -- a b c; echo "${@:1:2}"; echo "${@:0:2}"'
a b                  # 位置参数子串从 1 开始
bash a               # :0 会把 $0(此处为 bash)加到最前面

${!PREFIX*}${!PREFIX@}

展开为名称以 PREFIX 开头的变量名,用 IFS 特殊变量的第一个字符分隔。使用 @ 且展开出现在双引号内时,每个变量名展开为单独的单词。

译者注:用于「找出所有以某前缀开头的变量」,输出按字典序排列:

$ declare A1=x A2=y AB=z
$ echo "${!A*}"
A1 A2 AB
$ printf '<%s>\n' "${!A@}"   # @ 形式:双引号内每个名称一个单词
<A1>
<A2>
<AB>

(如果环境中还有别的以 A 开头的变量,它们也会出现在输出中。)

${!NAME[@]}${!NAME[*]}

如果 NAME 是数组变量,展开为 NAME 中已赋值的数组下标(键)的列表。如果 NAME 不是数组,则 NAME 已设置时展开为 0,否则为空。使用 @ 且展开出现在双引号内时,每个键展开为单独的单词。

译者注:稀疏数组(下标不连续)中想遍历「已存在的下标」就用它:

$ arr=([2]=x [5]=y)
$ echo "${!arr[@]}"
2 5
$ echo "元素个数: ${#arr[@]}"
元素个数: 2

${#PARAMETER}

替换 PARAMETER 的值的字符长度。如果 PARAMETER 是 *@,替换的值为位置参数的个数。如果 PARAMETER 是以 *@ 作下标的数组名,替换的值为数组中元素的个数。如果 PARAMETER 是以负数作下标的索引数组名,该数字被解释为相对于 PARAMETER 的最大下标加 1,因此负下标从数组末尾往回数,下标 -1 引用最后一个元素。

译者注${#...} 的三种常见用法——字符串长度、位置参数个数、数组元素个数:

$ s=hello
$ echo "${#s}"
5
$ set -- a b c
$ echo "$#"
3                      # 参数个数的特殊参数 $#
$ arr=(x y z)
$ echo "${#arr[@]}"
3
$ arr[9]=w
$ echo "${#arr[@]}"    # 下标不连续不影响计数
4

${PARAMETER#WORD}${PARAMETER##WORD}

WORD 被展开产生一个模式,并按下面的规则(参见第 3.5.8.1 节(模式匹配))与 PARAMETER 的展开值进行匹配。如果模式与 PARAMETER 展开值的开头匹配,则展开的结果是 PARAMETER 的展开值删除了最短匹配模式(# 情形)或最长匹配模式(## 情形)后的结果。如果 PARAMETER 是 @*,模式删除操作依次应用于每个位置参数,展开是结果列表。如果 PARAMETER 是以 @* 作下标的数组变量,模式删除操作依次应用于数组的每个成员,展开是结果列表。

译者注# 从开头删「最短」匹配,## 从开头删「最长」匹配。模式里的 */ 用哪个效果截然不同:

$ path=/usr/local/bin
$ echo "${path#*/}"     # 最短匹配 "*/":只删掉第一个 /
usr/local/bin
$ echo "${path##*/}"    # 最长匹配 "*/":删到最后一个 /
bin                     # 相当于取「文件名部分」

${PARAMETER%WORD}${PARAMETER%%WORD}

WORD 被展开产生一个模式,并按下面的规则(参见第 3.5.8.1 节(模式匹配))与 PARAMETER 的展开值进行匹配。如果模式与 PARAMETER 展开值的结尾部分匹配,则展开的结果是 PARAMETER 的值删除了最短匹配模式(% 情形)或最长匹配模式(%% 情形)后的结果。如果 PARAMETER 是 @*,模式删除操作依次应用于每个位置参数,展开是结果列表。如果 PARAMETER 是以 @* 作下标的数组变量,模式删除操作依次应用于数组的每个成员,展开是结果列表。

译者注% 从末尾删「最短」匹配,%% 从末尾删「最长」匹配。去掉文件扩展名的经典写法:

$ f=archive.tar.gz
$ echo "${f%.*}"        # 最短:只删最后一个 . 之后的内容
archive.tar
$ echo "${f%%.*}"       # 最长:删到第一个 . 为止
archive

${PARAMETER/PATTERN/STRING}${PARAMETER//PATTERN/STRING}${PARAMETER/#PATTERN/STRING}${PARAMETER/%PATTERN/STRING}

PATTERN 被展开产生一个模式,并按下面的说明(参见第 3.5.8.1 节(模式匹配))与 PARAMETER 的展开值匹配。PATTERN 在展开值中的最长匹配被替换为 STRING。STRING 经受波浪号展开、参数和变量展开、算术展开、命令和进程替换以及引号去除。

在上面的第一种形式中,只替换第一个匹配。如果 PARAMETER 和 PATTERN 之间有两个斜杠(上面的第二种形式),PATTERN 的所有匹配都被替换为 STRING。如果 PATTERN 前面有 #(上面的第三种形式),它必须匹配 PARAMETER 展开值的开头。如果 PATTERN 前面有 %(上面的第四种形式),它必须匹配 PARAMETER 展开值的结尾。

如果 STRING 的展开为空,PATTERN 的匹配被删除,PATTERN 后面的 / 可以省略。

如果使用 shopt 启用了 patsub_replacement Shell 选项(参见第 4.3.2 节(shopt 内建命令)),STRING 中任何未加引号的 & 实例都会被替换为 PATTERN 的匹配部分。这是为了复现常见的 sed 习惯用法。

引用 STRING 的任何部分都会抑制该部分在展开中的替换,包括存储在 Shell 变量中的替换字符串。反斜杠转义 STRING 中的 &;反斜杠被移除,以便在替换字符串中允许字面的 &。如果 STRING 是双引号括起的,用户应小心避免反斜杠与双引号之间不必要的相互作用,因为反斜杠在双引号内有特殊含义。模式替换在展开 STRING 之后才检查未加引号的 &,因此用户应确保正确引用任何想按字面处理的 &,并确保任何想被替换的 & 未加引号。

例如:

var=abcdef
rep='& '
echo ${var/abc/& }
echo "${var/abc/& }"
echo ${var/abc/$rep}
echo "${var/abc/$rep}"

将显示四行 "abc def",而

var=abcdef
rep='& '
echo ${var/abc/\& }
echo "${var/abc/\& }"
echo ${var/abc/"& "}
echo ${var/abc/"$rep"}

将显示四行 "& def"。与模式删除操作符一样,包围替换字符串的双引号会给展开后的字符加引号,而包围整个参数替换的双引号则不会,因为展开是在一个不考虑任何包围它的双引号的上下文中执行的。

由于反斜杠可以转义 &,它也可以转义替换字符串中的反斜杠。这意味着 \\ 会在替换中插入一个字面反斜杠,因此这两条 echo 命令

var=abcdef
rep='\\&xyz'
echo ${var/abc/\\&xyz}
echo ${var/abc/$rep}

都会输出 \abcxyzdef

通常很少有必要只用双引号括起 STRING。

如果启用了 nocasematch Shell 选项(参见第 4.3.2 节(shopt 内建命令)中对 shopt 的描述),匹配时不考虑字母字符的大小写。

如果 PARAMETER 是 @*,替换操作依次应用于每个位置参数,展开是结果列表。如果 PARAMETER 是以 @* 作下标的数组变量,替换操作依次应用于数组的每个成员,展开是结果列表。

译者注:四种形式的区别——/ 只替换第一处,// 替换所有,/# 限定开头,/% 限定结尾:

$ v=abcabc
$ echo "${v/a/X}"     # 只替换第一处
Xbcabc
$ echo "${v//a/X}"    # 替换所有
XbcXbc
$ echo "${v/#a/X}"    # 只在开头匹配
Xbcabc
$ echo "${v/%c/X}"    # 只在结尾匹配
abcabX

开启 patsub_replacement 后,替换串里的 & 表示「被匹配到的部分」(类似 sed&):

$ var=abcdef
$ shopt -s patsub_replacement
$ echo "${var/abc/&}"     # & = 匹配到的 abc
abcdef
$ echo "${var/abc/\&}"    # 反斜杠转义后是字面 & 本身
&def
$ shopt -u patsub_replacement
$ echo "${var/abc/&}"     # 未开启时 & 只是普通字符
&def

${PARAMETER^PATTERN}${PARAMETER^^PATTERN}${PARAMETER,PATTERN}${PARAMETER,,PATTERN}

这种展开修改 PARAMETER 中字母字符的大小写。首先,PATTERN 被展开产生一个模式,如第 3.5.8.1 节(模式匹配)中所述。然后 Bash 按下面的说明用 PATTERN 检查 PARAMETER 展开值中的字符。如果某个字符与模式匹配,就转换其大小写。模式不应试图匹配多个字符。

使用 ^ 把匹配 PATTERN 的小写字母转换为大写;, 把匹配的大写字母转换为小写。^, 变体检查展开值中的第一个字符,如果它匹配 PATTERN 就转换其大小写;^^,, 变体检查展开值中的所有字符,并转换每个匹配 PATTERN 的字符。如果省略 PATTERN,它被视为 ?,匹配每个字符。

如果 PARAMETER 是 @*,大小写修改操作依次应用于每个位置参数,展开是结果列表。如果 PARAMETER 是以 @* 作下标的数组变量,大小写修改操作依次应用于数组的每个成员,展开是结果列表。

译者注^/^^ 变大写,,/,, 变小写;单符号只处理第一个字符,双符号处理全部。可选的 PATTERN 用来限定哪些字符被转换:

$ x="hello world"
$ echo "${x^}"       # 只转第一个字符
Hello world
$ echo "${x^^}"      # 全部大写
HELLO WORLD
$ y="HELLO World"
$ echo "${y,,}"      # 全部小写
hello world
$ echo "${y,}"       # 只转第一个字符
hELLO World
$ z=abc123def
$ echo "${z^^[a-z]}" # 模式限定:只转换字母
ABC123DEF

${PARAMETER@OPERATOR}

展开是 PARAMETER 的值的变换,或者是关于 PARAMETER 自身的信息,取决于 OPERATOR 的值。每个 OPERATOR 是单个字母:

  • U:展开是一个字符串,是 PARAMETER 的值把所有小写字母字符转换为大写后的结果。
  • u:展开是一个字符串,是 PARAMETER 的值把第一个字符(如果它是字母)转换为大写后的结果。
  • L:展开是一个字符串,是 PARAMETER 的值把所有大写字母字符转换为小写后的结果。
  • Q:展开是一个字符串,是 PARAMETER 的值以可复用为输入的格式加引号后的结果。
  • E:展开是一个字符串,是 PARAMETER 的值把反斜杠转义序列按 $'...' 引号机制的规则展开后的结果。
  • P:展开是一个字符串,是 PARAMETER 的值当作提示符字符串展开后的结果(参见第 6.9 节(控制提示符))。
  • A:展开是一个赋值语句或 declare 命令形式的字符串,如果对它求值,将重建带属性和值的 PARAMETER。
  • K:产生 PARAMETER 的值的可能带引号的版本,区别在于它把索引数组和关联数组的值打印为一系列带引号的键值对(参见第 6.7 节(数组))。键和值以可复用为输入的格式加引号。
  • a:展开是一个由表示 PARAMETER 属性的标志值组成的字符串。
  • k:与 K 变换类似,但把索引数组和关联数组的键和值在单词拆分后展开为单独的单词。

如果 PARAMETER 是 @*,操作依次应用于每个位置参数,展开是结果列表。如果 PARAMETER 是以 @* 作下标的数组变量,操作依次应用于数组的每个成员,展开是结果列表。

展开的结果经受单词拆分和文件名展开,如下文所述。

译者注@ 操作符族用几个例子最好理解(@A 的确切输出格式可能因 Bash 版本略有不同):

$ s='aBc'
$ echo "${s@U}"       # 全部大写
ABC
$ echo "${s@u}"       # 首字母大写
ABc
$ echo "${s@L}"       # 全部小写
abc
$ echo "${s@Q}"       # 加引号(结果可重新作为输入)
'aBc'
$ s='a\tb'
$ echo "${s@E}"       # 展开转义序列(同 $'...' 机制)
a	b
$ s='\u'              # \u 是提示符转义:当前用户名
$ echo "${s@P}"
jack
$ arr=(aa bb)
$ echo "${arr[@]@K}"  # 键值对输出(可复用为输入)
0 "aa" 1 "bb"
$ printf '<%s>\n' "${arr[@]@k}"   # k:键和值拆成独立单词
<0>
<aa>
<1>
<bb>

还有两个与属性相关的操作符:${s@A} 生成可重建变量的赋值语句(例如对 s='aBc' 输出 s='aBc'),${s@a} 输出属性标志(如只读变量输出 r)。它们都不会改变参数本身。

3.5.4 命令替换

命令替换(command substitution)允许用命令的输出替换命令本身。命令替换的标准形式是把命令括起来:

$(COMMAND)

或者(已废弃的形式)

`COMMAND`

Bash 执行命令替换时,在子 Shell 环境中执行 COMMAND,并用命令的标准输出替换命令替换本身,输出末尾的换行符会被删除。内嵌的换行符不会被删除,但它们可能会在单词拆分时被移除。命令替换 $(cat FILE) 可以用等效但更快的 $(< FILE) 代替。

译者注$( ) 与反引号在转义规则上有本质区别:反引号内部,反斜杠只有在 $`\ 之前才失去字面含义;而 $( ) 内部的所有字符都按原样构成命令,反斜杠一律保持字面含义(除非整个 $( ) 位于双引号内)。下面的例子直观地说明了这种差别:

# 译者注示例:$( ) 与反引号对反斜杠的处理不同
$ echo `echo \$HOME`      # 反引号内 \$ 变成 $,于是展开为家目录
/home/jack
$ echo $(echo \$HOME)     # $( ) 内 \$ 保持字面,原样输出
$HOME

使用旧式反引号形式的替换时,反斜杠保留其字面含义,但后面紧跟 $`\ 时除外。未被反斜杠转义的第一个反引号终止命令替换。使用 $(COMMAND) 形式时,括号之间的所有字符构成该命令,没有任何字符会被特殊处理。

译者注:两种形式的嵌套写法不同。$( ) 可以直接嵌套;反引号形式嵌套时,内层的反引号必须用反斜杠转义,多级嵌套时转义层数逐级递增,很容易出错,这也是推荐优先使用 $( ) 的原因之一。

# 译者注示例:命令替换的嵌套写法
$ echo $(echo $(echo hi))
hi
$ echo `echo \`echo hi\``
hi

还有一种命令替换的替代形式:

${C COMMAND; }

它在当前执行环境中执行 COMMAND 并捕获其输出,同样会删除末尾的换行符。

译者注${ ...; } 是 Bash 5.3 新增的命令替换形式(借鉴自 ksh93):命令在当前 Shell 环境中直接执行,不 fork 子进程。它与 $( ) 的关键区别是副作用保留——对变量、位置参数等的修改不会丢失。注意它不是参数展开:左花括号后面必须是空白或 |,否则就变成 ${var:=...} 一类的参数展开。

# 译者注示例:${ } 不产生子 Shell,$( ) 会
$ n=1
$ x=${ n=2; echo $n; }     # 当前环境执行,n 的修改保留
$ echo "x=$x n=$n"
x=2 n=2
$ n=1
$ x=$( n=2; echo $n; )     # 子 Shell 执行,n 的修改不保留
$ echo "x=$x n=$n"
x=2 n=1

左花括号后面的字符 C 必须是空格、制表符、换行符或 |,而右花括号必须出现在保留字(reserved word)可以出现的位置(即前面是分号之类的命令终止符)。Bash 允许右花括号与单词中其余的字符连在一起,不必像保留字通常要求的那样在后面跟随 Shell 元字符。

COMMAND 的任何副作用都会立即在当前执行环境中生效,并在命令结束后继续保留在当前环境中(例如,exit 内建命令会退出 Shell)。

这种命令替换形式表面上类似于执行一个未命名的 Shell 函数:局部变量像执行 Shell 函数时那样被创建,return 内建命令会强制 COMMAND 结束;但执行环境的其余部分(包括位置参数)与调用者共享。

如果左花括号后面的第一个字符是 |,则此结构在 COMMAND 执行后展开为 REPLY Shell 变量的值,不删除任何末尾换行符,且 COMMAND 的标准输出与调用 Shell 中的保持一致。Bash 在 COMMAND 执行时把 REPLY 创建为初始未设置的局部变量,并在 COMMAND 结束后把 REPLY 恢复到命令替换之前的值,与任何局部变量一样。

例如,此结构展开为 12345,并且在当前执行环境中保持 Shell 变量 X 不变:

${ local X=12345 ; echo $X; }

(如果不把 X 声明为局部变量,就会像普通 Shell 函数执行时那样修改当前环境中的值),而下面的结构不需要任何输出即可展开为 12345

${| REPLY=12345; }

并会把 REPLY 恢复到命令替换之前的值。

译者注${| ...; } 形式不依赖命令的输出,而是把 REPLY 变量的值作为展开结果;REPLY 是 Bash 自动创建的局部变量,命令结束后恢复到原来的值。注意:展开结果会被当作一个单词——如果整个结构单独放在一行,这个单词会被当作命令名去执行,所以通常应把它放在赋值语句或 echo 等命令中。

# 译者注示例:${| ...; } 展开为 REPLY 的值
$ echo ${| REPLY=12345; }
12345
$ echo "REPLY=${REPLY:-未设置}"     # 展开完成后 REPLY 已恢复
REPLY=未设置
$ ${| REPLY=12345; }                # 单独使用:展开结果被当作命令名
bash: 12345: 未找到命令

命令替换可以嵌套。使用反引号形式时进行嵌套,需要用反斜杠转义内部的反引号。

如果替换出现在双引号内,Bash 不对结果执行单词拆分和文件名展开。

译者注:命令替换的输出只有放在双引号内才不会参与单词拆分和文件名展开;未加引号时,输出中的空白会被 IFS 拆分成多个单词。另外注意「末尾换行符被删除、内嵌换行符保留」这一行为。

# 译者注示例:加不加双引号的差别
$ x=$(printf 'a b\nc')
$ printf '<%s>\n' $x        # 未加引号:被拆分为 3 个单词
<a>
<b>
<c>
$ printf '<%s>\n' "$x"      # 加双引号:作为整体传递
<a b
c>
$ printf '<%s>\n' "$(printf 'a\n\n')"   # 末尾的两个换行被删除
<a>

3.5.5 算术展开

算术展开(arithmetic expansion)对算术表达式求值,并用结果替换。算术展开的格式为:

$(( EXPRESSION ))

EXPRESSION 经历与双引号内相同的展开,但 EXPRESSION 中未转义的双引号字符不会被特殊对待,而是被移除。表达式中的所有记号都要经历参数和变量展开、命令替换和引号去除。结果被当作要求值的算术表达式。由于 Bash 处理双引号的方式可能产生空字符串,算术展开把这样的空字符串当作求值为 0 的表达式。算术展开可以嵌套。

译者注$(( )) 内支持完整的算术语法:+ - * / %、括号、变量名(按值参与运算)、三目运算符 ?: 等;除法是整数除法。算术展开可以嵌套,也可以在表达式中直接引用变量名。

# 译者注示例:算术展开的基本用法
$ echo $(( 2 + 3 * 4 ))      # 乘法优先
14
$ echo $(( (2 + 3) * 4 ))    # 括号改变优先级
20
$ x=7; echo $(( x % 3 ))
1
$ echo $(( $((1 + 2)) * 3 )) # 嵌套
9

译者注$(( )) 中引用未设置或为空的变量按 0 参与运算;而 $( (cmd) )$ 后跟空格,再跟括号组合命令)是完全不同的东西——那是命令替换。注意区分 $(((算术)与 $( ((命令替换加组合命令)。

# 译者注示例:空值按 0 计,以及 $( ( ) ) 的区别
$ unset y; echo $(( y + 1 ))   # 未设置的变量按 0 计
1
$ echo $( (echo hi) )          # 命令替换(先开子 Shell,再执行组合命令)
hi

求值按照下面列出的规则进行(参见第 6.5 节(Shell 算术))。如果表达式无效,Bash 向标准错误打印一条指示失败的消息,不执行替换,也不执行与该展开关联的命令。

译者注:表达式无效时(例如除数为 0),Bash 打印错误消息,替换不执行,关联的命令也不会执行。错误消息随区域设置(locale)变化,下面是中文区域设置下的实际输出。

# 译者注示例:无效的算术表达式
$ echo $(( 1/0 ))
bash: 1/0 : 除以 0(错误记号是 "0 ")
$ echo $?
1

3.5.6 进程替换

进程替换(process substitution)允许用文件名来引用某个进程的输入或输出。它的形式为

<(LIST)

>(LIST)

进程 LIST 异步运行,它的输入或输出以一个文件名呈现。这个文件名作为展开的结果,作为参数传给当前命令。

译者注<( ) 最常见的用途是让接受文件名的命令直接读取另一个命令的输出,例如把两个命令的输出直接交给 diff 比较,而无需生成临时文件:

# 译者注示例:diff 直接比较两个命令的输出
$ diff <(printf 'a\nb\n') <(printf 'a\nc\n')
2c2
< b
---
> c

如果使用 >(LIST) 形式,向该文件写入即为 LIST 提供输入。如果使用 <(LIST) 形式,读取该文件即可获得 LIST 的输出。注意,<> 与左括号之间不能有空格,否则该结构会被解释为重定向。

译者注>( ) 常用于把某个程序的输出「喂」给另一个程序,例如把 echo 的输出导入异步的 cat 进程并写入文件。进程替换不是 POSIX 标准的一部分,且只在系统支持命名管道(FIFO)或 /dev/fd 方法时可用——在 Linux 上展开结果是 /dev/fd/63 之类的路径:

# 译者注示例:>( ) 的输出方向与展开路径
$ echo hello > >(cat > /tmp/po.txt); cat /tmp/po.txt
hello
$ echo <(true)
/dev/fd/63

若在 <>( 之间加了空格,解析就会失败(< (cmd) 被当作重定向操作符后跟一个组合命令,报语法错误):

$ cat < (echo hi)
bash: 未预期的记号 "(" 附近有语法错误

进程替换在支持命名管道(FIFO)或使用 /dev/fd 方法命名已打开文件的系统上受支持。

在可用的情况下,进程替换与参数和变量展开、命令替换以及算术展开同时进行。

3.5.7 单词拆分

Shell 扫描参数展开、命令替换和算术展开的结果(这些展开没有发生在双引号内)以进行单词拆分(word splitting)。未展开的单词不会被拆分。

译者注:单词拆分只作用于「展开出来的」内容——命令行里直接写的单词、命令名本身不会被拆分。加双引号可以完全关闭拆分。下面是最经典的一个例子:未加引号的变量展开被拆成多个单词,多个连续的空白只算一个分隔符。

# 译者注示例:未加引号的变量展开如何被拆分
$ x="a  b"
$ printf '<%s>\n' $x        # 未加引号:连续空白合并为一个分隔符
<a>
<b>
$ printf '<%s>\n' "$x"      # 加引号:不拆分
<a  b>

Shell 将 $IFS 的每个字符视为分隔符,并使用这些字符作为字段终止符,把其他展开的结果拆分为字段。

「IFS 空白字符」(IFS whitespace)指出现在 IFS 值中的空白字符(定义参见第 2 章(定义))。空格、制表符和换行符始终被认为是 IFS 空白字符,即使它们不在区域设置(locale)的 space 类别中。

如果 IFS 未设置,单词拆分的行为与它的值是空格、制表符、换行符一样,并将这些字符视为 IFS 空白字符。如果 IFS 的值为空,则不进行单词拆分,但隐式空参数(见下文)仍会被移除。

译者注IFS 未设置与 IFS 为空是两种不同的状态:前者按默认值(空格、制表符、换行)拆分;后者完全关闭拆分——想「原样传递」展开结果时常用 IFS=。另外注意,某些命令(如 set --)执行后可能把 IFS 置空,这与「未设置」不是一回事。

# 译者注示例:IFS 未设置与 IFS 为空的区别
$ unset IFS; x="a b"; printf '<%s>\n' $x
<a>
<b>
$ IFS=; x="a b"; printf '<%s>\n' $x   # IFS 为空 → 不拆分
<a b>

单词拆分首先从先前展开的结果的开头和结尾删除 IFS 空白字符序列,然后拆分其余单词。

如果 IFS 的值仅由 IFS 空白字符组成,则任何 IFS 空白字符序列都分隔一个字段,因此字段由非未加引号的 IFS 空白字符组成,空字段只能由引号产生。

如果 IFS 包含非空白字符,那么 IFS 值中任何不是 IFS 空白字符的字符,连同任何相邻的 IFS 空白字符一起,分隔一个字段。这意味着相邻的非 IFS 空白分隔符会产生一个空字段。IFS 空白字符序列也分隔一个字段。

译者注:把 IFS 设为逗号之类的非空白字符(常用于解析 CSV 风格的文本)时,规则略有不同:非空白分隔符相邻时会生成空字段,而连续的一段 IFS 空白只当作一个分隔符。

# 译者注示例:IFS 含非空白字符时的拆分
$ IFS=','; x="a,,b, ,c"; printf '<%s>\n' $x
<a>
<>
<b>
< >
<c>

显式空参数(""'')会被保留,并作为空字符串传给命令。由没有值的参数展开产生的未加引号的隐式空参数会被移除。在双引号内展开一个没有值的参数会产生一个空字段,该字段被保留并作为空字符串传给命令。

当引号括起的空参数作为单词的一部分出现、而该单词的展开非空时,单词拆分会移除空参数部分,保留非空的展开。也就是说,单词 -d'' 在单词拆分和空参数移除后变成 -d

译者注""(显式空参数)传给命令后参数个数会加 1,而 $unset_var 之类的隐式空参数则完全消失——这是初学者常踩的坑。-d'' 的例子说明:即使空引号与别的字符连在一起,空引号也只起「语法保护」作用,最终不会出现在参数里。

# 译者注示例:显式空参数保留,隐式空参数被移除
$ unset v; set -- $v ""; printf '参数个数=%s\n' "$#"
参数个数=1
$ set -- -d''; printf '<%s>\n' "$1"
<-d>

3.5.8 文件名展开

单词拆分之后,除非已设置 -f 选项(参见第 4.3.1 节(set 内建命令)),Bash 扫描每个单词中的 *?[ 字符。如果出现其中一个字符且未被引号括起,则该单词被视为模式(PATTERN),并被替换为与模式匹配的文件名的排序列表(参见第 3.5.8.1 节(模式匹配)),排序方式受 GLOBSORT Shell 变量的值控制(参见第 5.2 节(Bash 变量))。

如果没有找到匹配的文件名,且 Shell 选项 nullglob 未启用,则单词保持不变。如果设置了 nullglob 选项且未找到匹配项,则移除该单词。如果设置了 failglob Shell 选项且未找到匹配项,Bash 打印一条错误消息并且不执行该命令。如果启用了 Shell 选项 nocaseglob,则匹配时忽略字母字符的大小写。

译者注:三个选项对应「无匹配」时的三种策略:默认原样保留(通配符会原样传给命令,例如 echo * 真的打印 * 的场景)、nullglob 删除整个单词、failglob 报错且不执行命令。另有 nocaseglob 忽略大小写;set -f-f 选项)则彻底关闭文件名展开。

# 译者注示例:nullglob 与 failglob 的行为(目录中没有 *.nomatch 文件)
$ shopt -s nullglob; echo before *.nomatch after
before  after
$ shopt -u nullglob; shopt -s failglob; echo *.nomatch
bash: 无匹配:*.nomatch

当模式用于文件名展开时,文件名开头的 . 或紧跟斜杠的 . 必须被显式匹配,除非设置了 Shell 选项 dotglob。为了匹配文件名 ...,模式必须以 . 开头(例如 .?),即使设置了 dotglob 也是如此。如果启用了 globskipdots Shell 选项,文件名 ... 永远不会匹配,即使模式以 . 开头。在不匹配文件名时,. 字符不会被特殊对待。

译者注:Bash 5.2 起 globskipdots 默认启用:.* 不再匹配 ...(旧版本中 .* 会同时匹配 ... 和所有隐藏文件,这正是 rm .* 误删的经典陷阱,新行为算是安全改进)。dotglob 则让 * 也能匹配隐藏文件(但仍不含 ...)。

# 译者注示例:globskipdots 默认启用(Bash 5.2+)时 .* 的行为
$ mkdir -p /tmp/g2 && cd /tmp/g2 && touch a.txt b.txt c.TXT .hidden
$ echo .*
.hidden
$ shopt -u globskipdots; echo .*
. .. .hidden
$ shopt -s globskipdots dotglob; echo *
a.txt b.txt c.TXT .hidden

匹配文件名时,斜杠字符必须始终由模式中的斜杠显式匹配,但在其他匹配上下文中,它可以由特殊模式字符匹配,如下所述(参见第 3.5.8.1 节(模式匹配))。

关于 nocaseglobnullglobglobskipdotsfailglobdotglob 选项的描述,参见第 4.3.2 节(shopt 内建命令)中 shopt 的描述。

GLOBIGNORE Shell 变量可用于限制匹配某个模式的文件名集合。如果设置了 GLOBIGNORE,则每个匹配的文件名如果同时也匹配 GLOBIGNORE 中的某个模式,就会从匹配列表中移除。如果设置了 nocaseglob 选项,则与 GLOBIGNORE 中模式的匹配不区分大小写。当 GLOBIGNORE 被设置为非空值时,文件名 ... 始终被忽略。但是,将 GLOBIGNORE 设置为非空值会启用 dotglob Shell 选项,因此所有其他以 . 开头的文件名都会被匹配。要恢复忽略以 . 开头的文件名的旧行为,请把 .* 设为 GLOBIGNORE 中的模式之一。当 GLOBIGNORE 被取消设置时,dotglob 选项被禁用。GLOBIGNORE 模式匹配遵循 extglob Shell 选项的设置。

译者注GLOBIGNOREdotglob 的联动是常见困惑点:只要把 GLOBIGNORE 设为非空值,dotglob 就会被自动启用(因此 * 会匹配隐藏文件);若想恢复「忽略隐藏文件」,就把 .* 也写进 GLOBIGNORE。匹配是区分大小写的,除非同时开启 nocaseglob

# 译者注示例:GLOBIGNORE 从匹配结果中排除文件
$ cd /tmp/g2   # 目录内:a.txt b.txt c.TXT .hidden
$ GLOBIGNORE='*.txt'; echo *   # 排除 *.txt;GLOBIGNORE 非空 → dotglob 生效
c.TXT .hidden
$ unset GLOBIGNORE             # 取消设置后 dotglob 随之禁用

GLOBSORT Shell 变量的值控制路径名展开结果的排序方式,如下所述(参见第 5.2 节(Bash 变量))。

3.5.8.1 模式匹配

模式中出现的任何字符,除下面描述的特殊模式字符外,都匹配其自身。NUL 字符不能出现在模式中。反斜杠转义后面的字符;匹配时转义的反斜杠被丢弃。要按字面匹配特殊模式字符,必须对它们加引号。

特殊模式字符的含义如下:

*:匹配任何字符串,包括空字符串。当启用 globstar Shell 选项,且 * 用于文件名展开上下文时,作为单个模式使用的两个相邻 * 匹配所有文件以及零个或多个目录和子目录。如果后面跟着 /,两个相邻的 * 只匹配目录和子目录。

译者注globstar 启用后 ** 递归匹配子目录。**/ 只匹配目录本身,**/*.txt 匹配当前目录及所有子目录下的 .txt 文件;注意 ** 也匹配零层目录,即当前目录本身。

# 译者注示例:globstar 的递归匹配
$ mkdir -p /tmp/g3/sub && touch /tmp/g3/a.txt /tmp/g3/sub/d.txt
$ cd /tmp/g3 && shopt -s globstar
$ echo **/*.txt
a.txt sub/d.txt
$ echo **/
sub/

?:匹配任何单个字符。

[...]:匹配括号之间括起的任何字符之一。这称为「括号表达式」(bracket expression),匹配单个字符。由连字符分隔的一对字符表示「范围表达式」(range expression);任何位于这两个字符之间(含两端)的字符都匹配,使用当前区域设置的排序序列和字符集。如果 [ 后面的第一个字符是 !^,则匹配不在该范围内的任何字符。要匹配字面的连字符 -,请把它作为集合中的第一个或最后一个字符。要匹配 ],请把它作为集合中的第一个字符。

范围表达式中字符的排序顺序,以及范围内包含的字符,由当前区域设置以及 LC_COLLATELC_ALL Shell 变量的值(如果已设置)决定。

例如,在默认的 C 区域设置中,[a-dx-z] 等价于 [abcdxyz]。许多区域设置按字典顺序对字符排序,在这些区域设置中 [a-dx-z] 通常不等价于 [abcdxyz];例如它可能等价于 [aBbCcDdxYyZz]。要获得括号表达式中范围的传统解释,可以通过把 LC_COLLATELC_ALL 环境变量设置为 C 来强制使用 C 区域设置,或者启用 globasciiranges Shell 选项。

译者注[a-z] 到底匹配哪些字符取决于区域设置的排序规则:在 C locale 下就是 a 到 z 共 26 个小写字母,而在按字典序排序的区域设置里可能包含大小写混排,甚至漏掉个别小写字母。脚本里若要得到可预期的范围,可显式使用 LC_ALL=C

# 译者注示例:范围受 LC_COLLATE 影响
$ mkdir -p /tmp/ctest && cd /tmp/ctest && touch a b c d x y z B
$ LC_ALL=C echo [a-dx-z]      # C locale:等价于 [abcdxyz]
a b c d x y z

在括号表达式内,可以使用语法 [:类别:] 指定「字符类别」(character class),其中 CLASS 是 POSIX 标准定义的以下类别之一:

alnum   alpha   ascii   blank   cntrl   digit   graph   lower
print   punct   space   upper   word    xdigit

字符类别匹配属于该类别的任何字符。word 字符类别匹配字母、数字和字符 _

例如,下面的模式将匹配当前区域设置中属于 space 字符类别的任何字符,然后是任何大写字母或 !、一个点,最后是任何小写字母或连字符。

[[:space:]][[:upper:]!].[-[:lower:]]

译者注:字符类别也常用于 [[ ]] 条件表达式和 case 分支的模式中。注意 [:space:] 必须整体出现在方括号之内,写作 [[:space:]]

# 译者注示例:字符类别匹配
$ [[ " " == [[:space:]] ]] && echo "是空白字符"
是空白字符
$ [[ "x" == [[:upper:]] ]] || echo "x 不是大写字母"
x 不是大写字母

在括号表达式内,可以使用语法 [=C=] 指定「等价类别」(equivalence class),它匹配所有与字符 C 具有相同排序权重(由当前区域设置定义)的字符。

在括号表达式内,语法 [.SYMBOL.] 匹配排序符号(collating symbol)SYMBOL。

如果使用 shopt 内建命令启用了 extglob Shell 选项,Shell 会识别几个扩展模式匹配操作符。在下面的描述中,模式列表(PATTERN-LIST)是由 | 分隔的一个或多个模式的列表。匹配文件名时,dotglob Shell 选项决定被测试的文件名集合,如上所述。复合模式可以由下面一个或多个子模式构成:

?(模式列表):匹配给定模式的零次或一次出现。

*(模式列表):匹配给定模式的零次或多次出现。

+(模式列表):匹配给定模式的一次或多次出现。

@(模式列表):匹配给定模式之一。

!(模式列表):匹配除给定模式之一以外的任何内容。

译者注:五个扩展操作符分别相当于正则表达式的 ?*+、分组和否定。启用 extglob 后可用于 [[ ]]case 以及文件名展开。注意 ?()*() 都允许「零次」出现,即能匹配空串。在 [[ ]]==/!= 右侧,模式按「如同启用了 extglob」的方式匹配(参见第 3.2.5.2 节(条件结构))。

# 译者注示例:extglob 五个操作符([[ ]] 中)
$ shopt -s extglob
$ [[ "" == ?(a) ]] && echo "?(a) 匹配空串"        # 零次或一次
?(a) 匹配空串
$ [[ "abab" == *(ab) ]] && echo "*(ab) 匹配 abab"  # 零次或多次
*(ab) 匹配 abab
$ [[ "ab" == +(ab) ]] && echo "+(ab) 匹配 ab"      # 一次或多次
+(ab) 匹配 ab
$ [[ "" == +(ab) ]] || echo "+(ab) 不匹配空串"
+(ab) 不匹配空串
$ [[ "abc" == @(abc|xyz) ]] && echo "@(abc|xyz) 匹配 abc"  # 其中之一
@(abc|xyz) 匹配 abc
$ [[ "xyz" == !(abc) ]] && echo "!(abc) 匹配 xyz"          # 除外
!(abc) 匹配 xyz

extglob 选项会改变解析器的行为,因为括号通常被当作具有语法意义的操作符。为确保扩展匹配模式被正确解析,请确保在解析包含这些模式的结构(包括 Shell 函数和命令替换)之前启用 extglob

译者注extglob 必须在「解析到该行之前」就已启用——Shell 对每一行(或复合命令)先整体解析再执行,所以 shopt -s extglob; echo @(...) 写在同一行时,解析阶段 extglob 尚未生效,会直接报语法错误。函数定义、case 分支的模式同样受此影响。

# 译者注示例:extglob 影响解析器,需在解析之前启用
$ shopt -s extglob; echo @(*.txt|*.md)   # 整行先解析后执行,解析时未生效
bash: 未预期的记号 "(" 附近有语法错误
$ shopt -s extglob                       # 单独一行启用
$ echo @(*.txt|*.md)                     # 本行解析时 extglob 已生效
file.md file.txt note.txt

匹配文件名时,dotglob Shell 选项决定被测试的文件名集合:启用 dotglob 时,该集合包含所有以 . 开头的文件,但文件名 ... 必须由以点开头的模式或子模式匹配;禁用时,该集合不包含任何以 . 开头的文件名,除非模式或子模式以 . 开头。如果启用了 globskipdots Shell 选项,文件名 ... 永远不会出现在集合中。如上所述,. 只在匹配文件名时具有特殊含义。

对长字符串进行复杂的扩展模式匹配很慢,尤其是当模式包含交替(alternation)而字符串包含多处匹配时。对较短的字符串分别匹配,或者使用字符串数组而不是单个长字符串,可能会更快。

3.5.9 引号去除

在上述展开之后,所有未加引号的 \'" 字符(不是由上述展开之一产生的)都被移除。

译者注:引号的作用发生在「语法解析」阶段:它们划定边界、抑制展开,但最终不会作为内容传给命令。这也是为什么 echo "hello"echo hello 输出相同、rm 'my file' 能删除带空格的文件——引号只是语法标记。

# 译者注示例:引号本身不会传给命令
$ echo "hello" 'world'
hello world
$ x="a b"; printf '<%s>\n' "$x"   # 引号抑制拆分与文件名展开,随后被去除
<a b>

3.6 重定向

在命令执行之前,它的输入和输出可以使用由 Shell 解释的一种特殊记法「重定向」(redirection)。重定向允许复制、打开、关闭命令的文件句柄,使其指向不同的文件,并且可以改变命令读取和写入的文件。与 exec 内建命令一起使用时,重定向会修改当前 Shell 执行环境中的文件句柄。下面的重定向操作符可以出现在简单命令之前或简单命令中的任何位置,也可以跟在命令后面。重定向按出现的顺序从左到右处理。

每个可以带文件描述符编号前缀的重定向,也可以改用一个形如 {VARNAME} 的单词作前缀。在这种情况下,对于除 >&-<&- 之外的每个重定向操作符,Shell 分配一个大于或等于 10 的文件描述符并把它赋给 {VARNAME}。如果 {VARNAME} 位于 >&-<&- 之前,则 VARNAME 的值定义要关闭的文件描述符。如果提供了 {VARNAME},则该重定向在命令的作用域之外仍然有效,这允许 Shell 程序员在不使用 exec 内建命令的情况下手动管理文件描述符的生命周期。varredir_close Shell 选项管理此行为(参见第 4.3.2 节(shopt 内建命令))。

译者注{fd}>file 让 Shell 自动分配一个大于等于 10 的空闲文件描述符并存入变量 fd,省去了自己挑选编号的麻烦,也不会撞上 Shell 内部使用的描述符。变量名外面的大括号是语法的一部分,不是参数展开。

# 译者注示例:用变量管理文件描述符
$ exec {fd}>/tmp/vr.txt      # Shell 分配描述符(本例为 10)
$ echo "分配的 fd=$fd"
分配的 fd=10
$ echo via-var >&$fd         # 通过变量使用该描述符
$ exec {fd}>&-               # 通过变量关闭它
$ cat /tmp/vr.txt
via-var

在下面的描述中,如果省略文件描述符编号,且重定向操作符的第一个字符是 <,则重定向涉及标准输入(文件描述符 0)。如果重定向操作符的第一个字符是 >,则重定向涉及标准输出(文件描述符 1)。

下面描述中重定向操作符后面的 WORD,除非另有说明,都要经历花括号展开、波浪号展开、参数和变量展开、命令替换、算术展开、引号去除、文件名展开和单词拆分。如果它展开为多个单词,Bash 会报告错误。

重定向的顺序很重要。例如,命令

ls > DIRLIST 2>&1

把标准输出(文件描述符 1)和标准错误(文件描述符 2)都定向到文件 DIRLIST,而命令

ls 2>&1 > DIRLIST

只把标准输出定向到文件 DIRLIST,因为在标准输出被重定向到 DIRLIST 之前,标准错误已经是标准输出的副本。

译者注2>&1 是「复制文件描述符」:把文件描述符 2 指向文件描述符 1 当前指向的目标。因此顺序至关重要——2>&1 > file2>&1 先执行,此时 1 还指向终端,于是 2 也指向终端,随后 > file 只改变 1;把两者都送进文件必须写成 > file 2>&1&>&>> 则是「两者同时」的简写,与顺序无关。

# 译者注示例:2>&1 与 > 的顺序问题
$ ls /no/such/file >/dev/null 2>&1    # 两者都进入 /dev/null,无输出
$ ls /no/such/file 2>&1 >/dev/null    # 先复制 2→1(终端),后改 1:错误仍可见
ls: cannot access '/no/such/file': No such file or directory
$ ls /no/such/file &>/tmp/e.txt; cat /tmp/e.txt   # &> 等价于 >file 2>&1
ls: cannot access '/no/such/file': No such file or directory

当在重定向中使用下面几个文件名时,Bash 会特殊处理它们,如下表所述。如果 Bash 运行的操作系统提供这些特殊文件,Bash 就使用它们;否则,Bash 在内部按照下面描述的行为模拟它们。

文件名 行为
/dev/fd/FD 如果 FD 是有效整数,复制文件描述符 FD。
/dev/stdin 复制文件描述符 0。
/dev/stdout 复制文件描述符 1。
/dev/stderr 复制文件描述符 2。
/dev/tcp/HOST/PORT 如果 HOST 是有效的主机名或 Internet 地址,且 PORT 是整数端口号或服务名,Bash 尝试打开相应的 TCP 套接字。
/dev/udp/HOST/PORT 如果 HOST 是有效的主机名或 Internet 地址,且 PORT 是整数端口号或服务名,Bash 尝试打开相应的 UDP 套接字。

译者注/dev/tcp/dev/udp 是 Bash 独有的「虚拟文件」,配合 <> 可以用纯 Shell 发起网络请求,而不需要 nc 之类的工具。下面的例子用 Bash 自己读取了 example.com 的 HTTP 响应头。注意这一能力受编译时配置(--enable-net-redirections)影响,并非所有平台都可用。

# 译者注示例:用 /dev/tcp 发起 HTTP 请求(需网络可用)
$ exec 3<>/dev/tcp/example.com/80
$ printf 'GET / HTTP/1.0\r\n\r\n' >&3
$ head -1 <&3
HTTP/1.1 403 Forbidden
$ exec 3>&-

打开或创建文件失败会导致重定向失败。

使用大于 9 的文件描述符的重定向应小心使用,因为它们可能与 Shell 内部使用的文件描述符冲突。

3.6.1 重定向输入

重定向输入以只读方式打开文件(文件名由 WORD 的展开得到),并把它作为文件描述符 n;如果未指定 n,则作为标准输入(文件描述符 0)。

重定向输入的一般格式为:

[n]<WORD

3.6.2 重定向输出

重定向输出以只写方式打开文件(文件名由 WORD 的展开得到),并把它作为文件描述符 N;如果未指定 N,则作为标准输出(文件描述符 1)。如果文件不存在则创建它;如果文件已存在则截断为零长度。

重定向输出的一般格式为:

[n]>[|]WORD

如果重定向操作符是 >,且已启用 set 内建命令的 noclobber 选项,那么当 WORD 展开得到的文件已存在且是普通文件时,重定向失败。如果重定向操作符是 >|,或者重定向操作符是 > 且未启用 set 内建命令的 noclobber 选项,那么即使 WORD 指定的文件已存在,Bash 也会尝试重定向。

译者注noclobberset -C)防止 > 意外覆盖已有文件,>| 则显式强制覆盖。注意 noclobber 不阻止追加操作符 >>

# 译者注示例:noclobber 与 >|
$ echo hi > /tmp/nc.txt
$ set -C
$ echo hi > /tmp/nc.txt
bash: /tmp/nc.txt: 无法覆盖已存在的文件
$ echo hi >| /tmp/nc.txt; echo $?    # >| 强制覆盖,成功
0
$ set +C

3.6.3 追加重定向输出

这种形式的重定向输出以追加方式打开文件(文件名由 WORD 的展开得到),并把它作为文件描述符 N;如果未指定 N,则作为标准输出(文件描述符 1)。如果文件不存在则创建它。

追加输出的一般格式为:

[n]>>WORD

3.6.4 重定向标准输出与标准错误

此结构把标准输出(文件描述符 1)和标准错误输出(文件描述符 2)都重定向到文件(文件名是 WORD 的展开)。

重定向标准输出和标准错误有两种格式:

&>WORD

>&WORD

两种形式中,第一种是首选。它在语义上等价于

>WORD 2>&1

使用第二种形式时,WORD 不能展开为数字或 -。如果它展开为数字或 -,则按其他重定向操作符处理(参见下文第 3.6.8 节(复制文件描述符)),这是出于兼容性原因。

3.6.5 追加标准输出与标准错误

此结构把标准输出(文件描述符 1)和标准错误输出(文件描述符 2)都追加到文件(文件名是 WORD 的展开)。

追加标准输出和标准错误的格式为:

&>>WORD

它在语义上等价于

>>WORD 2>&1

(参见下文第 3.6.8 节(复制文件描述符))。

3.6.6 Here-Documents(此处文档)

这种类型的重定向指示 Shell 从当前源读取输入,直到读到只包含 DELIMITER(后面没有尾随空白)的一行为止。读到的所有行随后成为命令的标准输入(如果指定了 N,则为文件描述符 N)。

here-document 的格式为:

[n]<<[-]WORD
        HERE-DOCUMENT
DELIMITER

Shell 不对 WORD 执行参数和变量展开、命令替换、算术展开或文件名展开。

如果 WORD 的任何部分被引号括起,则 DELIMITER 是对 WORD 进行引号去除的结果,且 here-document 中的行不被展开。如果 WORD 未加引号,则 DELIMITER 就是 WORD 本身,here-document 文本的处理方式类似于双引号字符串:here-document 的所有行都要经历参数展开、命令替换和算术展开,字符序列 \newline 按字面处理,\ 必须用于转义 \$` 字符;但双引号字符没有特殊含义。

译者注:定界符的规则是常见误区:定界符必须独占一行,且与行内其他内容完全一致——EOF 后面多一个空格或缩进都匹配不上(<<- 除外)。给定界符加引号(<<'EOF')会关闭正文的所有展开,常用于原样输出脚本内容。

# 译者注示例:定界符未加引号时正文会展开
$ cat <<EOF
> 家目录是 $HOME,2+3 等于 $((2+3))
> EOF
家目录是 /home/jack,2+3 等于 5
$ cat <<'EOF'
> $HOME 和 $((2+3)) 原样输出
> EOF
$HOME 和 $((2+3)) 原样输出

如果重定向操作符是 <<-,Shell 会从输入行和包含 DELIMITER 的行中去除行首的制表符。这允许 Shell 脚本中的 here-document 以自然的方式缩进。

译者注<<- 只去除行首的制表符(Tab),空格缩进不会被去除。这个特性让 here-document 可以嵌套在脚本的缩进结构中而不破坏定界符匹配。

# 译者注示例:<<- 去除行首制表符(示例中的缩进是真正的 Tab 字符)
$ cat <<-EOF
> 	Tab 缩进行的正文
> 	EOF
Tab 缩进行的正文

如果定界符未加引号,\<newline> 序列被视为行继续符:两行被连接起来,反斜杠换行被移除。这发生在读取 here-document 时、检查结束定界符之前,因此连接起来的行可以构成结束定界符。

译者注\ 后紧跟换行会把两行合并为一行,因此「拼接后的行」也能充当结束定界符。同时,要在未加引号的正文里输出字面的 \$`,需分别写成 \\\$\`

# 译者注示例:\newline 行继续与转义
$ cat <<EOF
> 拼接\
> 成一行
> 转义:\$HOME \\ \`date\`
> EOF
拼接成一行
转义:$HOME \ `date`

3.6.7 Here-Strings(此处字符串)

here-document 的一种变体,格式为:

[n]<<< WORD

WORD 要经历波浪号展开、参数和变量展开、命令替换、算术展开和引号去除。不执行文件名展开和单词拆分。结果作为单个字符串(末尾附加一个换行符)提供给命令的标准输入(如果指定了 N,则为文件描述符 N)。

译者注<<< 会自动在末尾补一个换行符,且不进行单词拆分——把未加引号的变量展开放在 <<< 后面也会作为整体传入。

# 译者注示例:here-string 的基础用法
$ cat <<< "hello world"
hello world
$ wc -w <<< "a b c"
3
$ IFS=; x="a b"; cat <<< $x   # 不做单词拆分,整串传入
a b

3.6.8 复制文件描述符

重定向操作符

[n]<&WORD

用于复制输入文件描述符。如果 WORD 展开为一个或多个数字,则文件描述符 N 成为该文件描述符的副本。如果 WORD 中的数字没有指定一个为输入打开的文件描述符,则是一个重定向错误。如果 WORD 求值为 -,则关闭文件描述符 N。如果未指定 N,则使用标准输入(文件描述符 0)。

操作符

[n]>&WORD

类似地用于复制输出文件描述符。如果未指定 N,则使用标准输出(文件描述符 1)。如果 WORD 中的数字没有指定一个为输出打开的文件描述符,则是一个重定向错误。如果 WORD 求值为 -,则关闭文件描述符 N。作为一种特殊情况,如果省略 N,且 WORD 不展开为一个或多个数字或 -,则此操作符按前述方式重定向标准输出和标准错误。

译者注<&->&- 是关闭文件描述符的写法。复制描述符的典型用途是「保存现场」:先把标准输出复制到 4,再改变 1,最后用 exec 1>&4 恢复。

# 译者注示例:复制与关闭文件描述符
$ exec 4>&1                     # 把标准输出复制到 4
$ echo 写到4 >&4                # 写入 4(即原标准输出)
写到4
$ exec 4>&-                     # 关闭 4
$ echo x >&4 2>/dev/null; echo $?   # 已关闭,写入失败
1

3.6.9 移动文件描述符

重定向操作符

[n]<&DIGIT-

把文件描述符 DIGIT 移动到文件描述符 N;如果未指定 N,则移动到标准输入(文件描述符 0)。DIGIT 在复制到 N 之后被关闭。

类似地,重定向操作符

[n]>&DIGIT-

把文件描述符 DIGIT 移动到文件描述符 N;如果未指定 N,则移动到标准输出(文件描述符 1)。

译者注>&N->&N 的区别在于:移动后原描述符 N 被关闭,可用于「转移」而不是「复制」。典型用途是把某个描述符重新编号,或转移给子进程后不留下多余的打开句柄。

# 译者注示例:移动文件描述符
$ exec 6> /tmp/m.txt            # 6 指向文件
$ exec 3>&6-                    # 移动到 3,6 被关闭
$ echo moved >&3
$ exec 3>&-
$ cat /tmp/m.txt
moved
$ echo x >&6 2>/dev/null; echo $?   # 6 已关闭
1

3.6.10 以读写方式打开文件描述符

重定向操作符

[n]<>WORD

以读写方式打开文件(文件名是 WORD 的展开),并把它作为文件描述符 N;如果未指定 N,则作为文件描述符 0。如果文件不存在,则创建它。

译者注<> 以「读写」方式打开:不像 > 会截断文件,也不像 < 只读。配合 read -u N 可以在不关闭重开的情况下先读后写。

# 译者注示例:<> 读写打开
$ echo abc > /tmp/rw.txt
$ exec 3<>/tmp/rw.txt           # 读写方式打开(不截断)
$ IFS= read -r -u 3 line; echo "读到的内容: $line"
读到的内容: abc
$ exec 3>&-

下表汇总了本节介绍的所有重定向操作符(译者整理,供速查)。

操作符 作用
[n]<word 以只读方式打开 word,作为标准输入(或文件描述符 n)
[n]>word 以只写方式打开/截断 word,作为标准输出(或文件描述符 n)
`[n]> word`
[n]>>word 以追加方式打开 word
&>word / >&word 同时重定向标准输出与标准错误(等价于 >word 2>&1
&>>word 同时追加标准输出与标准错误(等价于 >>word 2>&1
[n]<<word here-document(此处文档),正文可展开
[n]<<'word' here-document,正文不展开
[n]<<-word here-document,去除行首制表符
[n]<<<word here-string(此处字符串),自动追加换行符
[n]<&word 复制输入文件描述符;word 为 - 时关闭 n
[n]>&word 复制输出文件描述符;word 为 - 时关闭 n;省略 n 且 word 为文件名时同 &>word
[n]<&digit- 把文件描述符 digit 移动到 n(原 digit 被关闭)
[n]>&digit- 把文件描述符 digit 移动到 n(原 digit 被关闭)
[n]<>word 以读写方式打开 word(不存在则创建)

3.7 命令执行

3.7.1 简单命令的展开

当 Shell 执行简单命令时,它按下面的顺序从左到右执行以下展开、赋值和重定向。

  1. 解析器标记为变量赋值(位于命令名称之前的那些)和重定向的单词被保存起来,留待以后处理。

  2. 不是变量赋值或重定向的单词被展开(参见第 3.5 节(Shell 展开))。展开后如果还有单词,第一个单词被认为是命令的名称,其余单词是参数。

  3. 按前述方式执行重定向(参见第 3.6 节(重定向))。

  4. 每个变量赋值中 = 之后的文本,在赋给变量之前要经历波浪号展开、参数展开、命令替换、算术展开和引号去除。

如果没有得到命令名称,变量赋值会影响当前 Shell 环境。对于这样的命令(只由赋值语句和重定向组成的命令),赋值语句在重定向之前执行。否则,这些变量被添加到所执行命令的环境中,不影响当前 Shell 环境。如果任何赋值试图给只读变量赋值,则发生错误,命令以非零状态退出。

译者注VAR=value 出现在命令名之前时,赋值只进入该命令的环境,命令结束后 Shell 里的 VAR 不变;单独一行 VAR=value(后面没有命令)才是对当前 Shell 的赋值。给只读变量赋值会报错并返回非零状态。

# 译者注示例:命令前的赋值只影响该命令的环境
$ FOO=1
$ FOO=2 sh -c 'echo "子进程看到 FOO=$FOO"'
子进程看到 FOO=2
$ echo "当前 Shell 的 FOO=$FOO"
当前 Shell 的 FOO=1
$ readonly r=1; r=2 2>/dev/null; echo $?   # 只读变量赋值失败
1

如果没有得到命令名称,重定向会被执行,但不会影响当前 Shell 环境。重定向错误导致命令以非零状态退出。

如果展开后还有命令名称,则按下文所述继续执行。否则,命令退出。如果某个展开包含命令替换,则命令的退出状态是最后执行的命令替换的退出状态。如果没有命令替换,命令以零状态退出。

译者注:只含变量赋值的命令,其退出状态由其中的命令替换决定——例如 x=$(false) 之后 $? 是 1,而 x=$(true) 之后是 0。

# 译者注示例:命令替换的退出状态
$ x=$(false); echo $?
1
$ x=$(true); echo $?
0

3.7.2 命令的查找与执行

在命令被拆分为单词之后,如果结果是一个简单命令和可选的参数列表,Shell 执行以下操作。

  1. 如果命令名称不包含斜杠,Shell 尝试定位它。如果存在同名的 Shell 函数,则按第 3.3 节(Shell 函数)所述调用该函数。

  2. 如果名称不匹配函数,Shell 在 Shell 内建命令列表中搜索它。如果找到匹配项,则调用该内建命令。

  3. 如果名称既不是 Shell 函数也不是内建命令,并且不包含斜杠,Bash 搜索 $PATH 的每个元素,查找包含同名可执行文件的目录。Bash 使用哈希表记住可执行文件的完整路径名,以避免多次 PATH 搜索(参见第 4.1 节(Bourne Shell 内建命令)中 hash 的描述)。只有命令在哈希表中找不到时,Bash 才对 $PATH 中的目录进行全面搜索。如果搜索不成功,Shell 会查找已定义的名为 command_not_found_handle 的 Shell 函数。如果该函数存在,则在单独的执行环境中调用它,以原始命令和原始命令的参数作为它的参数,函数的退出状态成为该子 Shell 的退出状态。如果该函数未定义,Shell 打印一条错误消息并返回退出状态 127。

  4. 如果搜索成功,或者命令名称包含一个或多个斜杠,Shell 在单独的执行环境中执行指定的程序。参数 0 被设置为给定的名称,命令的其余参数被设置为提供的参数(如果有)。

  5. 如果执行失败是因为文件不是可执行格式,且文件不是目录,则假定它是一个「Shell 脚本」(包含 Shell 命令的文件),Shell 按第 3.8 节(Shell 脚本)所述执行它。

  6. 如果命令不是异步开始的,Shell 等待命令完成并收集其退出状态。

译者注:命令的查找顺序是:别名(参见第 6.6 节(别名))→ Shell 函数 → 内建命令 → $PATH。函数优先级高于同名内建命令和外部命令,因此可以用函数「包装」命令。另外,Bash 用哈希表记住命令的完整路径,之后即使 $PATH 变化或文件被移动,也仍按记忆的路径执行,直到 hash -r 清空哈希表。

# 译者注示例:函数优先于外部命令
$ ls() { echo "自定义 ls: $*"; }
$ ls /tmp
自定义 ls: /tmp
$ unset -f ls

译者注command_not_found_handle 让「命令找不到」变得可编程——发行版常利用它提示用户安装缺失的命令包。函数的返回值就是最终退出状态;未定义该函数时,默认返回 127。

# 译者注示例:自定义 command_not_found_handle
$ command_not_found_handle() { echo "未找到命令: $1"; return 66; }
$ no_such_cmd_xyz
未找到命令: no_such_cmd_xyz
$ echo $?
66
$ unset -f command_not_found_handle

3.7.3 命令执行环境

Shell 有一个「执行环境」(execution environment),由以下内容组成:

  • Shell 调用时继承的打开文件,按提供给 exec 内建命令的重定向修改。

  • cdpushdpopd 设置的当前工作目录,或 Shell 调用时继承的当前工作目录。

  • umask 设置或从 Shell 的父进程继承的文件创建模式掩码。

  • trap 设置的当前陷阱。

  • 通过变量赋值或 set 设置、或从 Shell 的父进程在环境中继承的 Shell 参数。

  • 执行期间定义的或从 Shell 的父进程在环境中继承的 Shell 函数。

  • 调用时启用的选项(默认或通过命令行参数)或由 set 启用的选项。

  • shopt 启用的选项(参见第 4.3.2 节(shopt 内建命令))。

  • alias 定义的 Shell 别名(参见第 6.6 节(别名))。

  • 各种进程 ID,包括后台作业的进程 ID(参见第 3.2.4 节(命令列表))、$$ 的值和 $PPID 的值。

当要执行一个除内建命令或 Shell 函数之外的简单命令时,它在单独的执行环境中被调用,该环境由以下内容组成。除非另有说明,这些值都是从 Shell 继承的。

  • Shell 的打开文件,加上命令的重定向所指定的修改和添加。

  • 当前工作目录。

  • 文件创建模式掩码。

  • 标记为导出的 Shell 变量和函数,以及为命令导出的变量,都在环境中传递(参见第 3.7.4 节(环境))。

  • Shell 捕获的陷阱被重置为从 Shell 父进程继承的值,Shell 忽略的陷阱则继续被忽略。

在这个单独环境中调用的命令不能影响 Shell 的执行环境。

「子 Shell」(subshell)是 Shell 进程的副本。

命令替换、用括号组合的命令以及异步命令都在子 Shell 环境中调用,该环境是 Shell 环境的副本,不同之处在于:Shell 捕获的陷阱被重置为 Shell 调用时从其父进程继承的值。作为管道一部分调用的内建命令也在子 Shell 环境中执行(最后一个元素除外,取决于 lastpipe Shell 选项的值,参见第 4.3.2 节(shopt 内建命令))。对子 Shell 环境所做的更改不能影响 Shell 的执行环境。

译者注:子 Shell 是进程副本:修改变量、切换目录等副作用都不会传回父 Shell。想在「当前 Shell」里执行一组命令并保留副作用,用大括号组合 { ...; } 而不是括号 ( ... )

# 译者注示例:括号组合产生子 Shell,大括号组合不产生
$ x=1; (x=2); echo "括号后 x=$x"
括号后 x=1
$ x=1; { x=2; }; echo "大括号后 x=$x"
大括号后 x=2
$ ( cd /tmp && pwd )   # 子 Shell 里的 cd 不影响当前目录
/tmp
$ pwd
/home/jack/claude/bash-jc/parts

译者注echo hi | read y 之后 $y 通常是空的——因为管道中的内建命令也运行在子 Shell 中。启用 lastpipe 后,管道的最后一个元素在当前 Shell 中执行,read 的结果得以保留(仅在非交互式 Shell 且未启用作业控制时生效)。

# 译者注示例:lastpipe 改变管道中 read 的行为
$ echo hi | read y; echo "[$y]"
[]
$ shopt -s lastpipe; echo hi | read y; echo "[$y]"
[hi]

当 Shell 处于 POSIX 模式时,为执行命令替换而产生的子 Shell 从父 Shell 继承 -e 选项的值。不在 POSIX 模式时,Bash 在这些子 Shell 中清除 -e 选项。有关如何在非 POSIX 模式下控制此行为的描述,参见 inherit_errexit Shell 选项(参见第 4.2 节(Bash 内建命令))。

译者注-e(errexit,出错即退出)默认不传入命令替换的子 Shell,所以 set -ex=$(false) 通常不会让脚本退出。想改变这一点,可以启用 inherit_errexit 或在 POSIX 模式下运行。下面的例子展示了二者的差别(示例以脚本方式运行,因为交互式 Shell 会忽略 -e)。

# 译者注示例:inherit_errexit 关闭与开启
$ bash -c 'set -e; x=$(false; echo 子命令继续); echo "x=$x"'
x=子命令继续
$ bash -c 'set -e; shopt -s inherit_errexit; x=$(false; echo 子命令继续); echo "x=$x"'
$ echo "第二条命令退出码: $?"
第二条命令退出码: 1

如果命令后面跟着 & 且作业控制未激活,该命令的默认标准输入是空文件 /dev/null。否则,被调用的命令继承调用 Shell 的文件描述符,按重定向修改。

译者注:后台命令默认从 /dev/null 读输入,避免它与前台命令争抢终端输入。下面的例子在 Linux 上直接验证了这一行为:

# 译者注示例:后台命令的标准输入是 /dev/null
$ sleep 100 & pid=$!
$ readlink /proc/$pid/fd/0
/dev/null
$ kill $pid

3.7.4 环境

当程序被调用时,它被赋予一个字符串数组,称为「环境」(environment)。这是一个名称-值对的列表,形式为 name=value

Bash 提供几种操作环境的方法。调用时,Shell 扫描自己的环境,并为找到的每个名称创建一个参数,自动标记为向子进程 export。执行的命令继承环境。exportdeclare -xunset 命令通过添加和删除参数和函数来修改环境。如果环境中某个参数的值被修改,新值自动成为环境的一部分,替换旧值。任何已执行命令继承的环境由以下部分组成:Shell 的初始环境(其值可以在 Shell 中被修改),减去 unsetexport -n 命令移除的任何项,再加上 exportdeclare -x 命令添加的任何项。

如果任何参数赋值语句(如第 3.4 节(Shell 参数)所述)出现在简单命令之前,则变量赋值在该命令执行期间是其环境的一部分。这些赋值语句只影响该命令看到的环境。如果这些赋值位于对 Shell 函数的调用之前,则这些变量在函数中是局部变量,并被导出到该函数的子进程。

译者注:Shell 变量与「环境」是两回事:只有被 export(或 declare -x)标记的变量才会进入子进程的环境;export -n 只是取消标记,变量本身仍然存在。子进程对环境的修改永远传不回父进程。

# 译者注示例:export 与 export -n
$ FOO=1; export FOO
$ bash -c 'echo "子进程看到 FOO=$FOO"'
子进程看到 FOO=1
$ export -n FOO
$ bash -c 'echo "子进程看到 FOO=${FOO:-空}"'
子进程看到 FOO=空
$ echo "当前 Shell 中 FOO=$FOO"    # 变量本身还在
当前 Shell 中 FOO=1

如果设置了 -k 选项(参见第 4.3.1 节(set 内建命令)),则所有参数赋值都被放入命令的环境中,而不仅仅是位于命令名称之前的那些。

译者注set -kset -o keyword)开启后,命令名之后的 VAR=value 也会进入该命令的环境——命令本身不受影响,只是它看到的环境里多了这个变量。

# 译者注示例:-k 让命令名之后的赋值也进入环境
$ set -k; echo hi ZZZ=bar; set +k
hi
$ echo "当前 Shell 的 ZZZ=${ZZZ:-未设置}"   # 赋值没有留在当前 Shell
当前 Shell 的 ZZZ=未设置

当 Bash 调用外部命令时,变量 $_ 被设置为该命令的完整路径名,并在其环境中传给该命令。

译者注$_ 在每次简单命令执行后都会被设置为该命令的最后一个参数(传给外部命令的环境里则是其完整路径名)。脚本刚开始时 $_ 也常被用来取得脚本自身的路径。

# 译者注示例:$_ 是上一条命令的最后一个参数
$ ls /etc/passwd >/dev/null
$ echo "$_"
/etc/passwd

3.7.5 退出状态

已执行命令的退出状态是 waitpid 系统调用或等效函数返回的值。退出状态在 0 到 255 之间,不过如下所述,Shell 可能特殊使用大于 125 的值。来自 Shell 内建命令和复合命令的退出状态也限于此范围。在某些情况下,Shell 会使用特殊值来表示特定的失败模式。

就 Shell 而言,以零退出状态退出的命令是成功的。因此,虽然退出状态为零表示成功,但非零退出状态表示失败。使用这种看似反直觉的方案,是为了有一种明确定义的方式表示成功,同时有多种方式表示各种失败模式。

当命令因编号为 N 的致命信号而终止时,Bash 使用值 128+N 作为退出状态。

译者注:退出状态只有一个字节,范围是 0–255:exit 300 实际得到 44(300−256)。「128+信号编号」是 Bash 表示「被信号杀死」的方式,例如 SIGKILL(9)→ 137、SIGTERM(15)→ 143。排查后台作业或脚本是否被信号杀死时,可用这个规则反推。

# 译者注示例:退出状态的范围与 128+信号编号
$ bash -c 'exit 300'; echo $?       # 300 mod 256 = 44
44
$ (kill -9 $BASHPID); echo $?       # 128 + 9(SIGKILL)= 137
137
$ bash -c 'kill -TERM $$'; echo $?  # 128 + 15(SIGTERM)= 143
143

如果找不到命令,为执行它而创建的子进程返回状态 127。如果找到了命令但不可执行,返回状态为 126。

译者注:127 与 126 是调试脚本时最常见的两个退出码:127 表示「找不到命令」(多半是拼写错误,或 $PATH 没包含目标目录),126 表示「找到了但没有执行权限」。如果脚本首行的解释器路径写错,也会得到 127 或 126。

# 译者注示例:127 与 126
$ no_such_cmd_xyz 2>/dev/null; echo $?
127
$ /etc/passwd 2>/dev/null; echo $?   # 文件存在但不可执行
126

如果命令因展开或重定向过程中的错误而失败,退出状态大于零。

退出状态被 Bash 条件命令(参见第 3.2.5.2 节(条件结构))和一些列表结构(参见第 3.2.4 节(命令列表))使用。

所有 Bash 内建命令在成功时返回退出状态零,失败时返回非零状态,因此它们可以被条件和列表结构使用。所有内建命令在用法不正确时(通常是非法的选项或缺少参数)返回退出状态 2。

译者注:内建命令把「用法错误」与「运行失败」区分开:用法错误(如非法选项、缺少参数)返回 2,运行失败(如 cd 到一个不存在的目录)返回 1。写脚本时可以根据退出码区分这两种情况。

# 译者注示例:内建命令用法错误返回 2,运行失败返回 1
$ cd /no/such/dir 2>/dev/null; echo $?
1
$ cd /tmp /tmp 2>/dev/null; echo $?   # 参数过多:用法错误
2

最后一条命令的退出状态可以在特殊参数 $? 中获得(参见第 3.4.2 节(特殊参数))。

译者注$? 只保存上一条命令的退出状态,任何命令(包括 echo 本身)执行后都会被覆盖。因此需要多次使用时,要先把它存下来,例如 status=$?

# 译者注示例:$? 的保存
$ false
$ echo "status=$?"; echo "现在 \$? 变成: $?"
status=1
现在 $? 变成: 0

Bash 本身返回最后执行的命令的退出状态,除非发生语法错误,在这种情况下它以非零值退出。另见 exit 内建命令(参见第 4.1 节(Bourne Shell 内建命令))。

3.7.6 信号

当 Bash 是交互式时,在没有陷阱的情况下,它忽略 SIGTERM(这样 kill 0 不会杀死交互式 Shell),并捕获和处理 SIGINT(这样 wait 内建命令是可中断的)。当 Bash 收到 SIGINT 时,它跳出任何正在执行的循环。在所有情况下,Bash 都忽略 SIGQUIT。如果作业控制生效(参见第 7 章(作业控制)),Bash 忽略 SIGTTINSIGTTOUSIGTSTP

trap 内建命令修改 Shell 的信号处理,如下所述(参见第 4.1 节(Bourne Shell 内建命令))。

译者注:下面是 Linux 上常见的信号编号(其他平台可能不同,可用 kill -l 查看)。本手册中反复出现的这几个信号与交互式 Shell 的日常操作关系最密切。

编号 名称 默认行为 说明
1 SIGHUP 终止 挂断(终端关闭、退出登录);Shell 退出前会转发给所有作业
2 SIGINT 终止 键盘中断(^C);交互式 Shell 捕获后中断循环
3 SIGQUIT 终止并转储 键盘退出(^\);Bash 一律忽略
9 SIGKILL 终止 不可捕获、不可忽略
15 SIGTERM 终止 kill 的默认信号;交互式 Shell 忽略它
17 SIGCHLD 忽略 子进程状态发生变化
18 SIGCONT 继续 让已停止的作业继续运行
19 SIGSTOP 停止 不可捕获、不可忽略
20 SIGTSTP 停止 键盘停止(^Z
21 SIGTTIN 停止 后台作业读取终端
22 SIGTTOU 停止 后台作业写入终端

Bash 执行的非内建命令具有的信号处理程序被设置为 Shell 从其父进程继承的值,除非 trap 把它们设置为忽略,在这种情况下子进程也会忽略它们。当作业控制不生效时,异步命令除了继承的这些处理程序外还忽略 SIGINTSIGQUIT。作为命令替换结果运行的命令忽略键盘产生的作业控制信号 SIGTTINSIGTTOUSIGTSTP

Shell 在收到 SIGHUP 时默认退出。退出前,交互式 Shell 将 SIGHUP 重新发送给所有作业,无论运行中还是已停止。Shell 向已停止的作业发送 SIGCONT 以确保它们收到 SIGHUP(关于运行中和已停止作业的更多信息,参见第 7 章(作业控制))。为防止 Shell 向特定作业发送 SIGHUP 信号,用 disown 内建命令(参见第 7.2 节(作业控制内建命令))把它从作业表中移除,或用 disown -h 标记它不接收 SIGHUP

如果已使用 shopt 设置了 huponexit Shell 选项(参见第 4.3.2 节(shopt 内建命令)),则当交互式登录 Shell 退出时,Bash 会向所有作业发送 SIGHUP

如果 Bash 正在等待一个命令完成并收到已设置陷阱的信号,它不会执行该陷阱,直到命令完成。如果 Bash 通过 wait 内建命令等待异步命令,并且收到已设置陷阱的信号,则 wait 内建命令会立即返回,退出状态大于 128,紧接着 Shell 执行陷阱。

译者注trap '命令' 信号 中的命令在信号到达时才执行;trap - 信号 恢复默认处理,trap '' 信号 忽略信号。要点是:等待前台命令时收到的信号要等命令跑完才执行陷阱,但 wait 等待后台作业时例外——wait 会立即返回(状态大于 128)以便尽快执行陷阱。

# 译者注示例:trap 捕获 SIGINT
$ trap 'echo 收到 SIGINT' INT
$ kill -INT $$
收到 SIGINT
$ trap - INT

当作业控制未启用,且 Bash 正在等待前台命令完成时,Shell 会收到键盘产生的信号,如 SIGINT(通常由 ^C 产生),用户通常是想把这些信号发送给该命令。这是因为 Shell 和命令与终端处于同一进程组,^C 向该进程组中的所有进程发送 SIGINT。由于当 Shell 非交互式时 Bash 默认不启用作业控制,这种情况在非交互式 Shell 中最常见。

当作业控制启用,且 Bash 正在等待前台命令完成时,Shell 不会收到键盘产生的信号,因为它与终端不在同一进程组。这种情况在交互式 Shell 中最常见,Bash 默认尝试启用作业控制。关于进程组的深入讨论,参见第 7 章(作业控制)。

译者注:交互式 Shell 对信号的处理可概括为:忽略 SIGTERM(所以 kill 0 不会误杀终端会话)、捕获 SIGINT^C 中断当前命令并回到提示符,而不是退出 Shell)、忽略 SIGQUIT;作业控制生效时忽略 SIGTTINSIGTTOUSIGTSTP。非交互式 Shell 没有这些保护,^Ckill 都可能直接终止它。

当作业控制未启用,且 Bash 在等待前台命令时收到 SIGINT,它会等到该前台命令终止,然后决定如何处理 SIGINT

  1. 如果命令因 SIGINT 而终止,Bash 断定用户也想把 SIGINT 发送给 Shell,并对 SIGINT 采取行动(例如,运行 SIGINT 陷阱、退出非交互式 Shell,或返回顶层读取新命令)。

  2. 如果命令不是因 SIGINT 终止的,则该程序自己处理了 SIGINT,没有把它当作致命信号。在这种情况下,Bash 也不把 SIGINT 当作致命信号,而是假定 SIGINT 被用作程序正常运行的一部分(例如,emacs 用它中止编辑命令)或被故意丢弃。但是,Bash 会运行在 SIGINT 上设置的任何陷阱,就像它对在等待前台命令完成时收到的任何其他被捕获信号所做的那样,这是为了兼容性。

当作业控制启用时,Bash 在等待前台命令时不会收到键盘产生的信号,如 SIGINT。交互式 Shell 不理会 SIGINT,即使前台命令因此终止,也只记录其退出状态。如果 Shell 不是交互式的,且前台命令因 SIGINT 终止,Bash 假装自己收到了 SIGINT(上面的场景 1),这是为了兼容性。

3.8 Shell 脚本

Shell 脚本(shell script)是包含 Shell 命令的文本文件。当这样的文件在调用 Bash 时被用作第一个非选项参数,且没有提供 -c-s 选项(参见第 6.1 节(调用 Bash))时,Bash 从该文件读取并执行命令,然后退出。这种操作模式创建一个非交互式 Shell。如果文件名不包含任何斜杠,Shell 首先在当前目录中搜索该文件,如果在那里找不到,则在 $PATH 的目录中查找。

译者注bash script 与直接执行脚本不同:文件名不带斜杠时,Bash 先在当前目录找,找不到才查 $PATH——这与命令查找(只查 $PATH,通常不含当前目录)不同。当前目录存在同名文件时,bash script 可能与 ./script 执行不同的文件。

Bash 尝试确定该文件是文本文件还是二进制文件,不会执行它确定为二进制的文件。

当 Bash 运行 Shell 脚本时,它把特殊参数 0 设置为文件名,而不是 Shell 的名称,位置参数被设置为其余参数(如果有的话)。如果没有提供额外的参数,位置参数不被设置。

Shell 脚本可以通过使用 chmod 命令打开执行位来使其可执行。当 Bash 在 $PATH 中搜索命令时找到这样的文件,它会创建自身的一个新实例来执行它。换句话说,执行

filename ARGUMENTS

等价于执行

bash filename ARGUMENTS

如果 filename 是一个可执行的 Shell 脚本。这个子 Shell 会重新初始化自身,因此效果就像调用了一个新的 Shell 来解释脚本,但有一个例外:父 Shell 记住的命令位置(参见第 4.1 节(Bourne Shell 内建命令)中 hash 的描述)会被子 Shell 保留。

译者注:验证脚本是否按预期拿到参数,最直接的方式是写一个打印位置参数的脚本:

# 译者注示例:脚本中的位置参数
$ cat > /tmp/hello.sh <<'EOF'
> #!/bin/bash
> echo "参数个数: $#"
> echo "第一个参数: $1"
> echo "脚本名: $0"
> EOF
$ chmod +x /tmp/hello.sh
$ /tmp/hello.sh world
参数个数: 1
第一个参数: world
脚本名: /tmp/hello.sh
$ bash /tmp/hello.sh second   # 用 bash 显式解释,无需执行位
参数个数: 1
第一个参数: second
脚本名: /tmp/hello.sh

GNU 操作系统和大多数 Unix 版本把这作为操作系统命令执行机制的一部分。如果脚本的第一行以 #! 两个字符开头,该行的其余部分指定该程序的解释器,并根据操作系统的不同,为解释器指定一个或多个可选参数。因此,你可以指定 Bash、awk、Perl 或其他解释器,并用该语言编写脚本文件的其余部分。

解释器的参数由脚本文件第一行中解释器名称后面的一个或多个可选参数组成,后面是脚本文件的名称,再后面是提供给脚本的其余参数。解释器行如何拆分为解释器名称和一组参数的细节因系统而异。Bash 会在不自己处理此操作的操作系统上执行此操作。注意,一些较旧的 Unix 版本把解释器名称和单个参数限制为最多 32 个字符,因此假设使用多个参数一定有效是不可移植的。

Bash 脚本通常以 #! /bin/bash 开头(假设 Bash 已安装在 /bin 中),因为这确保即使脚本在另一个 Shell 下执行,也会使用 Bash 来解释脚本。使用 env 来查找 bash 是常见的习惯用法,即使它安装在另一个目录中:#!/usr/bin/env bash 会找到 $PATHbash 的第一个出现位置。

译者注#!/usr/bin/env bash 通过 env$PATH 中查找 bash,脚本的可移植性更好(例如 Bash 安装在 /usr/local/bin 时无需修改脚本)。代价是解释器由 $PATH 决定,且各系统对解释器行的拆分规则不一——这一行最多附带一个选项(如 #!/bin/bash -e),多个选项不可移植。

posted @ 2026-08-07 04:39  立体风  阅读(28)  评论(0)    收藏  举报