[edit this page]

变量与扩展

我要如何存储和使用数据?

Bash 参数与变量;环境变量,特殊参数与数组参数;扩展参数,扩展运算符,命令替换以及进程替换;路径名扩展,波浪号扩展以及花括号扩展。

[edit this page]
本章内容的写作目标已达成,现等待像你这样的读者的审阅与反馈。
如果你对本篇指南的进展感兴趣,可以点击这里为源仓库加星。

什么是扩展?

我们已知道如何使用 bash 编写和管理简单命令,这些命令使我们直抵系统内众多强有力的功能。我们已学习命令如何让 bash 为程序新建进程从而运行他们。我们已理解何为命令参数,以及如何将信息传递给命令,从而让他们完成我们需要做的事。

基于以上所有这些知识,我们已开始体验到 shell 成事的力量。这种感觉就好像我们现在正使用一种全新的语言直接与系统进行交流,命令就是任务,而参数是关于这些任务该如何被执行的具体指令。

当前,我们面临的一个主要局限是,以参数的形式、如此明确具体地向命令传递信息其实非常受限。如果我们需要拼写出每个待操作文件的名称、每一个要在屏幕上显示或被程序操纵的字节,这就意味着,如果我们对于要做的事没有绝对清晰的认识,我们是无法编写程序的。因此我们需要找到一种方式,使命令更加动态(dynamic),将他们转化成关于执行某种操作的类似模板的东西,这样我们就可以时不时地复用(re-use)他们。

假设我们现在想要删除 下载(Downloads) 目录中的所有文件。基于我们已掌握的知识,可以先查看该目录下当前都有什么文件,然后再一一删除他们:

$ cd ~/Downloads
$ ls
05 Between Angels and Insects.ogg
07 Wake Up.ogg
$ rm -v '05 Between Angels and Insects.ogg' '07 Wake Up.ogg'
removed '05 Between Angels and Insects.ogg'
removed '07 Wake Up.ogg'
$ ls
$ 

真棒。我们的 ls 命令没有再返回任何结果,说明当前目录已为空。
但是,如果我们不需要全知一切岂不是很好?毕竟,我们的 意图 只是要清空 下载(Downloads) 目录罢了。为了实现这一目的,当前我们需要手动进入目录下,找出所有文件,下达 rm 命令,通过列举所有文件名进而清空目录。现在让我们来改进一下这个流程,使代码再 动态(dynamic) 一些。我们想要实现的是获得一种可重复使用的、执行某任务(job)的模板。该任务模板描述出一种实现我们意图的方式,且这种方式不受我们当前实际所处的具体情境的限制。

为了做到这一点,就需要把代码中涉及具体情境的部分全部移除。上面例子中,与之对应的就是我们想要删除的所有文件的具体名称。每当我们要清空下载目录时,并非都是要删除这两个文件。我们想要做的只是删除 下载(Downloads) 目录下的所有文件,不管他们的名称到底是什么。上面例子中的方法之所以行得通,是因为有中间一步,作为人类的我们,要去查看目录中所有文件的名称,并把他们写作 rm 命令的参数。我们怎样将这个过程自动化实现呢?

路径名扩展(Pathname Expansion)

在 bash 为我们提供的众多扩展形式中,答案就随第一种而来。欢迎来到 路径名扩展

$ cd ~/Downloads
$ rm -v *
removed '05 Between Angels and Insects.ogg'
removed '07 Wake Up.ogg'
$ ls
$ 

那些我们想要删除的文件名称呢,发生了什么?我们已用一种模式替换了他们,这种模式会使 bash 自动 为我们扩展路径名扩展(expansion) 是用具体情境相关的代码替换我们命令中某部分代码的操作。在这个例子中,我们想用下载目录内每一个文件的路径名替换命令中的 *。用路径名替换模式因此就被称为路径名扩展。

在上面例子中,bash 会注意到在命令行内它期待看到参数的位置上,你现在放上了一种路径名模式。于是它会采用这种路径名模式,继续去文件系统中寻找所有它能找到的、与这个模式匹配的路径名。结果就是,* 这种模式与当前路径下所有的文件名都匹配。因此,bash 会用当前路径下的每一个文件名替换我们命令行中的模式。我们自己就什么都不用做啦!一旦 bash 用 '05 Between Angels and Insects.ogg' '07 Wake Up.ogg' 替换掉我们的 *,它就会召唤 rm 命令使用完整的参数 -v '05 Between Angels and Insects.ogg' '07 Wake Up.ogg' 工作。最终结果就是我们的下载目录如愿被清空。棒极了。

Bash 可以为我们执行各种路径名扩展,我们仅需要在想要扩展路径名的位置上,写下一个句法性的 glob 模式。Glob 是 bash shell 支持的一种模式类型的名字。下面是 bash shell 支持的多种基础性 glob 模式:

Glob 意义
* 星号匹配任何类型的文本,甚至包括空。
? 问号匹配任何单个字符。
[characters] 方括号内的一组字符匹配内含其中的任一单个字符。
[[:classname:]] 当方括号内直接含有一组冒号的时候,你无需再一一列举写下所有字符,可以直接指定这一类字符的类名。
Bash 知道很多种字符类。例如,如果你使用 [[:alnum:]] 模式,bash 就只会与字母数字型的字符做匹配。Bash 支持的字符类包括:
alnumalphaasciiblankcntrldigitgraphlowerprintpunctspaceupperwordxdigit

结合使用上述 glob 模式,我们就可以描述各种可能的路径名组合。我们也可以将 glob 模式与表示字面含义的字符结合使用,来告诉 bash 某部分模式内要严格包含什么文本:

$ ls没有参数,ls 就列出某路径下的全部内容。
myscript.txt
mybudget.xsl
hello.txt
05 Between Angels and Insects.ogg
07 Wake Up.ogg
$ ls *虽然效果相同,但这个命令实际上在 ls 的参数中
myscript.txt列举了路径下每一个文件的名称
mybudget.xsl
hello.txt
05 Between Angels and Insects.ogg
07 Wake Up.ogg
$ ls *.txt当模式中包含了字面性字符串 .txt 后,仍与模式匹配的是
myscript.txt那些以任意文本起始但以字面性字符串 .txt 结束的路径名
hello.txt
$ ls 0?' '*.ogg这里我们将模式结合使用,要找的是这样的路径名,以 0 开始,
05 Between Angels and Insects.ogg后面跟一个任意的字符,之后是一个 字面性的 空格, 最终以 .ogg 结束
07 Wake Up.ogg
$ ls [0-9]*在一个字符集合中,我们可以使用 - 表示字符的范围
05 Between Angels and Insects.ogg这个模式匹配的路径名需以 09 中的任一字符起始,后面可跟任意文本
07 Wake Up.ogg
$ ls [[:digit:]][[:digit:]]*字符类是真的好,因为他们替我们说话:在此他们完全表达出了我们的意图
05 Between Angels and Insects.ogg即我们想要任何以两位数字起始的路径名
07 Wake Up.ogg
$ ls [[:digit:]][[:digit:]]你的模式一定要完整!我们没有任何仅为两位数字的路径名
$ 

另外需要理解的是这些 glob 永远不会跳进子路径(subdirectory)中去。他们只会匹配当前路径下的文件名。如果我们想让某个 glob 去查看另一目录下的路径名,就需要明确告诉它这个路径名:

$ ls ~/Downloads/*.txt列举 ~/Downloads 目录下所有以 .txt 结尾的路径名
/Users/lhunath/Downloads/myscript.txt
/Users/lhunath/Downloads/hello.txt
$ ls ~/*/hello.txtGlob 甚至可以在许多路径下搜索!这里 bash 就会搜索
/Users/lhunath/Documents/hello.txt主目录下 所有的路径,寻找一个叫做 hello.txt 的文件
/Users/lhunath/Downloads/hello.txt

路径名扩展是一种强有力的工具,使我们无需在参数中具体声明确切的路径名称,还可以搜遍文件系统查找我们需要的文件。

最后,bash 还内置对一些高级 glob 模式的支持。这些 glob 被称作:扩展(extended) glob。默认状态下,对他们的支持是被关闭的,但是我们很容易就可以通过命令开启他们:

$ shopt -s extglob

一旦开启扩展 glob,前面列表中的 glob 模式运算符就得到了下面这些新增运算符的补充:

扩展 Glob 意义
+(pattern[ | pattern ... ]) 列表内的任一种模式出现一次或多次,即为匹配。读作:至少有一个 ...
*(pattern[ | pattern ... ]) 列表内的任一种模式出现一次,或 没出现,或出现多次,都为匹配。读作:无论多少次...
?(pattern[ | pattern ... ]) 列表内的任一种模式出现一次或没出现,即为匹配。读作:或许有一个...
@(pattern[ | pattern ... ]) 列表内的任一种模式出现仅一次,即为匹配。读作:一个 ...
!(pattern[ | pattern ... ]) 只有当列表内任一种模式都没有出现,才算匹配。读作:没有一个 ...

这些运算符乍看起来可能会让人有些困惑,但他们其实是给模式增加逻辑性的一种非常好的方式:

$ ls +([:digit:])' '*.ogg文件名以一位或多位数值起始
05 Between Angels and Insects.ogg
07 Wake Up.ogg
$ ls *.jp?(e)g文件名以 .jpg.jpeg 结束
img_88751.jpg
igpd_45qr.jpeg
$ ls *.@(jpg|jpeg)效果与前面相同,但这样表达可能更清楚!
img_88751.jpg
igpd_45qr.jpeg
$ ls !(my*).txt所有 不是my 起始的 .txt 文件 
hello.txt
$ ls !(my)*.txt你能猜到为什么这个会匹配到  myscript.txt 文件吗?
myscript.txt
hello.txt

扩展 glob 模式有时会极为有用,但同时也可能使人困惑或误解。让我们专注来看最后一个例子:为什么 !(my)*.txt 会扩展出路径名 myscript.txt ? 难道 !(my) 匹配的不该是 包括 my 的路径名吗?没错,的确是这样!但是,bash 确实又扩展出了一个以 my 起始的路径名!

这里的解释是 bash 会开心地将 m 起始(并非 my )、甚至是空格起始的文件名判断为与 blob 的这部分模式匹配。这就意味着,如果这个文件名仍要匹配扩展,那么路径名的剩余部分就要匹配 模式的余下部分 。余下部分也的确匹配,因为 !(my) 模式之后紧跟的是 * glob, 如此就能匹配剩下全部的文件名。所以在这个例子中,!(my) 部分匹配文件名首字母 m 字符, * 匹配 yscript 部分,模式的后缀 .txt 匹配路径名的后缀 .txt。 因此,模式匹配文件名,所以文件名被扩展!如果我们将 * 放在 !() 模式的括号内,这个路径名将不再与之匹配:

$ ls !(my)*.txt
myscript.txt
hello.txt
$ ls !(my*).txt
hello.txt

波浪号扩展(Tilde Expansion)

有一种不同类型的扩展我们已在这份指南里悄悄地使用,但尚未明确解释它。它就是 波浪号扩展,也就是用当前用户的主目录路径替换路径名中的波浪号( ~ ):

$ echo 'I live in: ' ~注意扩展一定不能被引用,否则他们就会成为 字面性 的字符!
I live in: /Users/lhunath

相比路径名扩展,波浪号扩展在 bash 中稍有些特殊,因为它在解析阶段很早期的时候发生。这只是一个很小的细节,但理解波浪号扩展与路径名扩展有所不同是很重要的。波浪号扩展中,我们并不会执行搜索,试图将文件名与 glob 模式作匹配,我们仅仅是用确切的路径名替换波浪号。

除了简单的波浪号,我们也可以通过将其他用户的用户名紧放在波浪号之后,来扩展他们的主目录:

$ echo 'My boss lives in: ' ~root
My boss lives in: /var/root

命令替换(Command Substitution)

现在我们对 扩展 的含义已有很好的认识:即使用具体情境相关的信息作为标识(token)的值来替换命令中的标识。到目前为止,无论是路径名扩展还是波浪号扩展,我们都只是扩展了路径名。

但扩展能做的事如此之多,几乎任何种类的数据都可以通过扩展传递至我们命令的参数中。命令替换 就是一种极为流行的将数据扩展至命令参数的方法。借助 命令替换,我们实际是在命令中写入命令,然后让 bash 把内层命令扩展成它的 输出结果,再把这个输出结果作为参数传给外层主命令:

$ echo 'Hello world.' > hello.txt
$ cat hello.txt
Hello world.
$ echo "The file <hello.txt> contains: $(cat hello.txt)"
The file <hello.txt> contains: Hello world.

我们刚做了什么?
初始很简单:我们首先创建了一个叫做 hello.txt 的文件,写入字符串 Hello world.,然后使用 cat 命令将文档的内容输出显示。我们可以看到,文档包含刚写入的字符串。

之后有趣的事发生了:我们在此想做的是向用户输出一条消息,用一句话清楚解释文档中存有什么字符串。为了实现这一点,我们想让文档内容作为 echo 出的这句话的“一部分”。然而,当我们为要输出的这句话写代码的时候,并不知道文件里有什么内容,那如何在脚本中完整打出那句话呢?答案就是使用扩展:因为我们知道如何通过 cat 命令得到文档内容,将可以将 cat 命令的输出结果 扩展至echo 的那句话中。Bash 首先会运行 cat hello.txt,拿到它的输出结果(即字符串 Hello world. ),然后把 命令替换 语法(即 $(cat ...) 部分)扩展至输出结果。只有经过这样的扩展,bash 才会尝试运行 echo 命令。你能猜到,在我们的 命令替换 扩展之后,echo 命令的参数变成了什么吗?答案就是:
echo "The file <hello.txt> contains: Hello world."

以上是我们学习到的第一种值的扩展(value expansion)。值的扩展允许我们讲数据扩展至命令的参数。他们非常有用,你之后会反复使用到。对于值的扩展,bash 有非常一致的语法:他们全都以符号 $ 起始。

命令替换 本质上是 扩展 bash subshell 执行的某条命令的值 。因此,它的语法是由值扩展前缀符号 $ 加上待扩展的 subshell 的部分 (...) 构成。Subshell 本质上是一个小的新开启的 bash 进程,用来运行某条命令,而主 bash shell 会等待它的运行结果。在后面的章节中,我们会学习更多关于 subshell 的内容。当前只需要知道 bash 的扩展语法既一致又缜密,这对学习它当然非常有利!

作为结束语,我需简单提及已被废弃的反引号 `...`语法。旧式 bourne shell 使用这种语法表示 命令替换,而非更现代的 $(...) 语法。虽然 bash 以及所有当代 POSIX shell 同时支持这两种语法,但还是强烈建议你 停止 使用反引号(`)语法,而且每当你看到这种用法时,都请将他们转为值扩展的等价语法。虽然他们在功能上等价,但后引号语法有一些严重的缺陷:

我如何储存和再利用数据?

我们现在知道如何使用 bash 编写和管理简单命令,这些命令使我们直抵系统内许多强大的功能。我们也已学习命令如何使 bash 为程序新建进程从而运行他们。我们甚至还学习了操纵进程的基本输入与输出,从而读取或写入任意文件。

你们之中那些极为留心的人肯定也已注意到,我们如何通过像 here 文档与 here 字符串这样的建构,向进程中传输任意数据。

现在,我们面临的最大限制是不能灵活地操纵数据。没错,借助文件重定向,我们是可以把数据写入文档之后再读取出来,也可以通过 here 文档与 here 字符串传入静态的、事先定义好的数据。但是我们渴望更多。

是时候解锁下一水平的惊奇:bash 参数。

什么是 bash 参数?

简单说,bash 参数就是内存中的一些位置,你可以暂时用来储存一些之后会用到的信息。

和文档类似,我们将信息写入参数并在之后需要提取的时候从中读出。但是,因为我们读写信息使用的是系统内存而非硬盘,所以速度会更快。相比重定向文档的输入输出,使用参数容易很多,语法也更强大。

Bash 提供了几种不同类型的参数:位置参数(positional parameters),特殊参数(special parameters)和 shell 变量(shell variables)。最后一种是最有趣的类型,前两种则主要帮助我们访问 bash 提供的特定信息。我们接下来会通过变量介绍 bash 参数的使用与实践,然后再解释位置和特殊参数的不同之处。

Shell 变量

Shell 变量本质上是被命名的 bash 参数。你可以利用变量储存值,之后还可以修改或读取这个值以再次使用。

使用变量非常简单。你可以通过变量赋值将信息储存其中,之后任何时间都可以再通过参数扩展访问存储的信息:

$ name=lhunathlhunath 赋值给变量 name
$ echo "Hello, $name.  How are you?"将变量 name 的值扩展到 echo 参数中
Hello, lhunath.  How are you?

如你所见,我们通过赋值创建了一个叫作 name 的变量,并存入一个值。之后,通过在变量名的前面加一个前缀符号 $,我们又将变量值扩展置入到 echo 的参数中。

赋值(Assignment)

赋值使用等号 = 运算符。你必须要理解等号运算符的前后不能有任何语法空格。虽然其他语言可能允许这样,但 bash 不允许。还记得在前面章节中我们已强调过吗,空格在 bash 里有特殊的含义:他们会将命令分割成参数。如果我们在等号 = 前后放置空格,他们会误导 bash 将命令分割成命令名和参数,以为你想执行某个程序而不是赋值给变量:

$ name = lhunath运行命令 name,参数是 =lhunath.
-bash: name: command not found

为了修改以上代码,只需移除 = 运算符前后导致单词分割的空格即可。如果我们赋给某变量的值的确以字面性的空格字符起始,就需要使用引用来告诉 bash 这些空格是字面性的,不要启动单词分割:

$ name=lhunath
$ item='    4. Milk'使用引用将空格转成字面性的字符

我们甚至可以将赋值的语法与其他值的扩展结合使用:

$ contents="$(cat hello.txt)"

这里我们执行了一个 命令替换,将 hello.txt 文档中的内容扩展到我们的赋值语法中,进而将其赋值给 contents 变量

参数扩展(Parameter Expansion)

赋值给变量很棒但不是立即就有用。也正是这些变量值在之后可以被任意调用才使得参数如此有趣。复用参数值是通过扩展实现的。参数扩展 有效地将数据从你的参数中取出,并置入命令的数据中。正如我们在前面简单看到的,扩展参数是通过在他们的名称前使用前缀符号 $。每当你在 bash 中看到这个符号,什么东西很可能正在被扩展。可能是某个参数,或是某个命令的输出结果,又或是某个数学运算的结果。我们后面还会学习更多其他扩展。

此外,参数扩展允许你使用大括号({})包裹扩展。这些大括号是用来告诉 bash 你参数名称的起始和终止。他们的使用通常是可选的,因为 bash 自己基本都可以识别出参数名称。不过有些时候使用他们是必要的:

$ name=Britta time=23.73我们想要扩展 time 并加上 s 表示秒
$ echo "$name's current record is $times."但是 bash 将其错误地理解为名称 times,而这个变量是空的
Britta's current record is .
$ echo "$name's current record is ${time}s."括号会明确告诉 bash 变量名称在哪里终止
Britta's current record is 23.73s.

参数扩展对于在命令指令中插入用户或程序数据非常好用,但它手里其实还藏有另一张王牌:参数扩展运算符。扩展某个参数的时候,还可以对扩展出的值施加一个运算符,用来以某种方式改变当前这个扩展值。记住,这个运算符只会改变本次扩展时的值,并不会影响变量中原始存储的值。

$ name=Britta time=23.73
$ echo "$name's current record is ${time%.*} seconds and ${time#*.} hundredths."
Britta's current record is 23 seconds and 73 hundredths.
$ echo "PATH currently contains: ${PATH//:/, }"
PATH currently contains: /Users/lhunath/.bin, /usr/local/bin, /usr/bin, /bin, /usr/libexec

在扩展结果之前,以上例子分别使用 %#// 三种运算符对参数值施加了多种操作。参数本身并没有改变,运算符只影响扩展到相应位置的值。你会注意到我们在这里也可以使用 glob 模式,就像在路径名扩展中那样,来匹配参数中的值。

在第一个例子中,扩展之前,我们使用 % 移除变量 time 值内的 . 以及它之后的数字。结果只剩下 . 以前的部分,也就是秒数。第二个例子类似,我们使用 # 移除 time 值中从开始到 .的部分。最后,我们使用 // 运算符( 运算符 / 的特殊形式),以逗号 , 替代 PATH 值中全部的冒号 。结果就是一列逗号分隔的路径名,相比原来冒号分隔的 PATH,更方便人们阅读。

url='https://guide.bash.academy/variables.html'
运算符 示例 结果
${parameter#pattern} "${url#*/}"
https://guide.bash.academy/variables.html
    ↓
/guide.bash.academy/variables.html
删除从值的起始算起,符合 模式(pattern)最短 字符串
${parameter##pattern} "${url##*/}"
https://guide.bash.academy/variables.html
    ↓
variables.html
删除从值的起始算起,符合 模式(pattern)最长 字符串
${parameter%pattern} "${url%/*}"
https://guide.bash.academy/variables.html
    ↓
https://guide.bash.academy
删除到值的终止为止,符合 模式(pattern)最短 字符串
${parameter%%pattern} "${url%%/*}"
https://guide.bash.academy/variables.html
    ↓
https:
删除到值的终止为止,符合 模式(pattern)最长 字符串
${parameter/pattern/replacement} "${url/./-}"
https://guide.bash.academy/variables.html
    ↓
https://guide-bash.academy/variables.html
用替换值替换 符合 模式(pattern) 的第一个字符串
${parameter//pattern/replacement} "${url//./-}"
https://guide.bash.academy/variables.html
    ↓
https://guide-bash-academy/variables-html
用替换值替换 符合 模式(pattern)的 每一个字符串
${parameter/#pattern/replacement} "${url/#*:/http:}"
https://guide.bash.academy/variables.html
    ↓
http://guide.bash.academy/variables.html
用替换值替换 从值的起始算起 符合 模式(pattern)的 字符串
${parameter/%pattern/replacement} "${url/%.html/.jpg}"
https://guide.bash.academy/variables.html
    ↓
https://guide.bash.academy/variables.jpg
用替换值替换 到值的终止为止 符合 模式(pattern)的 字符串
${#parameter} "${#url}"
https://guide.bash.academy/variables.html
    ↓
40
扩展值的长度(字节数)
${parameter:start[:length]} "${url:7}"
https://guide.bash.academy/variables.html
    ↓
guide.bash.academy/variables.html
start 开始,length 字节长,扩展出值的一部分。你甚至可以通过使用负数,从值的末尾 start 算起
${parameter[^|^^|,|,,][pattern]} "${url^^[ht]}"
http://guide.bash.academy/variables.html
    ↓
HTTps://guide.basH.academy/variables.HTml
扩展转换后的值,将符合 模式(pattern)的 的第一个或全部字符转换成小写或大写形式。你也可以忽略模式转换全部字符

 

练习时间!

EXPAN.1. 将 hello 作为值赋给变量 greeting

greeting=hello

EXPAN.2. 显示变量 greeting 的值

echo "$greeting"
hello

EXPAN.3. 将字符串 world 赋在变量当前内容的后面

greeting="$greeting world"
greeting+=" world"+= 将字符串附在当前值的末尾

EXPAN.4. 显示变量 greeting 中最后一个单词

echo "${greeting##* }"
world

EXPAN.5. 显示变量 greeting 的内容,第一个字母大写,以 (.) 结束

echo "${greeting^}."
Hello world.

EXPAN.6. 用 big 替换变量内容中第一个空格符号

greeting=${greeting/ / big }

EXPAN.7. 将变量 greeting 的值重定向输出到一个文档中,用下划线 (_) 替换变量值中的全部空格并在最后加上 .txt,以此作为文档的名称

echo "$greeting" > "${greeting// /_}.txt"

EXPAN.8. 显示变量 greeting 的值,中间单词的全部字母大写

middle=${greeting% *} middle=${middle#* }; echo "${greeting%% *} ${middle^^} ${greeting##* }"
hello BIG world

什么是环境,它是用来做什么的?

变量可以保存在两种不同的空间内。这两种空间经常被混淆,进而导致许多误解。你已经熟悉了其中的一种:shell 变量。除此之外,变量还可以保存在进程环境中。接下来我们要介绍的就是环境变量,并会向你解释他们与 shell 变量的差别。

环境变量(Environment Variables)

不同于 shell 变量,环境变量存在于进程水平。这就意味着他们不是 bash shell 本身的特性,而是你系统内任一种程序进程的特性。如果我们将进程想象为你购买的一块土地,那么土地之上的建筑物就是运行在你进程中的代码。你可以在这块土地上盖一座 bash 房子,或是一座 grep 棚屋,又或是一座 firefox 高塔。环境变量是储存在你进程土地上的变量,而 shell 变量保存在你土地之上 bash 房子的内部。
你可以将变量保存在环境中,也可以将他们存在 bash shell 内。环境是每一个进程共有的,而 shell 空间则只对 shell 进程开放。因此规则是这样的:你应该把你的变量存放在 shell 空间中,除非你明确需要环境变量的行为。

    ╭─── bash ─────────────────────────╮
    │             ╭──────────────────╮ │
    │ ENVIRONMENT │ SHELL            │ │
    │             │ shell_var1=value │ │
    │             │ shell_var2=value │ │
    │             ╰──────────────────╯ │
    │ ENV_VAR1=value                   │
    │ ENV_VAR2=value                   │
    ╰──────────────────────────────────╯

当你在 shell 中运行一个新程序,bash 会为它创建新的进程,然后这个进程就会拥有它自己的环境。但是不同于 shell 进程,一般的进程没有 shell 变量,他们只有环境变量。更重要的是,当一个新的进程被创建,它的环境是通过 复制 创建它的进程的环境得到的:

    ╭─── bash ───────────────────────╮
    │             ╭────────────────╮ │
    │ ENVIRONMENT │ SHELL          │ │
    │             │ greeting=hello │ │
    │             ╰────────────────╯ │
    │ HOME=/home/lhunath             │
    │ PATH=/bin:/usr/bin             │
    ╰─┬──────────────────────────────╯
      ╎  ╭─── ls ─────────────────────────╮
      └╌╌┥                                │
         │ ENVIRONMENT                    │
         │                                │
         │ HOME=/home/lhunath             │
         │ PATH=/bin:/usr/bin             │
         ╰────────────────────────────────╯

有一种普遍存在的误解,即环境是所有进程共享的一个系统-全局性的变量池,而这种错觉通常是因为在子进程中看到了相同的变量。如果你在 bash shell 中创建一个自定义的环境变量,在此之后你新建的任何子进程都会继承到这个变量,因为它把这个变量一起从 shell 的环境变量中复制了过来。但是,因为环境是针对具体每个进程而言的,子进程改变或创建的变量不会对父进程的同名环境变量产生任何影响:

    ╭─── bash ───────────────────────╮
    │             ╭────────────────╮ │
    │ ENVIRONMENT │ SHELL          │ │
    │             │ greeting=hello │ │
    │             ╰────────────────╯ │
    │ HOME=/home/lhunath             │
    │ PATH=/bin:/usr/bin             │
    │ NAME=Bob                       │
    ╰─┬──────────────────────────────╯
      ╎  ╭─── bash ───────────────────────╮
      └╌╌┥             ╭────────────────╮ │
         │ ENVIRONMENT │ SHELL          │ │
         │             ╰────────────────╯ │
         │ HOME=/home/lhunath             │
         │ PATH=/bin:/usr/bin             │
         │ NAME=Bob                       │
         ╰────────────────────────────────╯

$ NAME=John

    ╭─── bash ───────────────────────╮
    │             ╭────────────────╮ │
    │ ENVIRONMENT │ SHELL          │ │
    │             │ greeting=hello │ │
    │             ╰────────────────╯ │
    │ HOME=/home/lhunath             │
    │ PATH=/bin:/usr/bin             │
    │ NAME=Bob                       │
    ╰─┬──────────────────────────────╯
      ╎  ╭─── bash ───────────────────────╮
      └╌╌┥             ╭────────────────╮ │
         │ ENVIRONMENT │ SHELL          │ │
         │             ╰────────────────╯ │
         │ HOME=/home/lhunath             │
         │ PATH=/bin:/usr/bin             │
         │ NAME=John                      │
         ╰────────────────────────────────╯

为什么人们会选择将特定变量存放在环境中,以上所示的区别也使得这个问题的答案更清晰了。虽然你绝大多数的变量都会是普通的 shell 变量,但还是有可能选择性输出一些 shell 变量到 shell 进程环境中。这样你就可以有效地把变量值输出到所创建的每一个子进程中,这些子进程进而又会把他们的环境变量再输出至更下一层的子进程中。你的系统会使用环境变量做各种事,其中主要是提供状态信息(state information)以及特定进程的默认配置。

例如,通常用来帮一个用户登陆进系统的 login 程序,会把用户信息输出至环境中( USER 包含你的用户名,HOME 包含你的主目录,PATH 包含标准命令搜索路径,等等)。在你登陆之后运行的所有进程,现在通过查看环境变量就会知道他们是在为哪个用户运行。

你也可以把自己创建的变量输出到环境中,这通常是用来配置你所运行的程序的行为。例如,你可以输出 LANG 并为它赋值,告诉程序他们应该使用什么语言与字符集。环境变量通常只对了解他们且明确支持他们的程序有用。有些环境变量的使用范围非常有限,例如 LSCOLORS 可以被 ls 等程序用来彩色输出你系统内的文档内容。

    ╭─── bash ───────────────────────╮
    │             ╭────────────────╮ │
    │ ENVIRONMENT │ SHELL          │ │
    │             │ greeting=hello │ │
    │             ╰────────────────╯ │
    │ HOME=/home/lhunath             │
    │ PATH=/bin:/usr/bin             │
    │ LANG=en_CA                     │
    │ PAGER=less                     │
    │ LESS=-i -R                     │
    ╰─┬──────────────────────────────╯
      ╎  ╭─── rm ─────────────────────────╮rm 只使用 LANG 
      ├╌╌┥                                │决定错误消息的语言
      ╎  │ ENVIRONMENT                    │
      ╎  │                                │
      ╎  │ HOME=/home/lhunath             │
      ╎  │ PATH=/bin:/usr/bin             │
      ╎  │ LANG=en_CA                     │
      ╎  │ PAGER=less                     │
      ╎  │ LESS=-i -R                     │
      ╎  ╰────────────────────────────────╯
      ╎  ╭─── man ────────────────────────╮除了 LANG, man 还使用 PAGER 决定
      └╌╌┥                                │使用什么程序将长指南分页
         │ ENVIRONMENT                    │
         │                                │
         │ HOME=/home/lhunath             │
         │ PATH=/bin:/usr/bin             │
         │ LANG=en_CA                     │
         │ PAGER=less                     │
         │ LESS=-i -R                     │
         ╰─┬──────────────────────────────╯
           ╎  ╭─── less ───────────────────────╮less 使用 LESS 变量
           └╌╌┥                                │为它自己提供一个初始配置
              │ ENVIRONMENT                    │
              │                                │
              │ HOME=/home/lhunath             │
              │ PATH=/bin:/usr/bin             │
              │ LANG=en_CA                     │
              │ PAGER=less                     │
              │ LESS=-i -R                     │
              ╰────────────────────────────────╯

Shell 初始化(Shell Initialization)

当你启动一个交互式 bash 时,通过读取你系统内不同文档中存储的初始化命令,bash 会自行准备好。你可以使用这些文档来告诉 bash 要如何行动。其中有一个文档就是特别用来使你输出变量到环境中的,这个文件叫做 .bash_profile,它存在于你的主目录中。很有可能你现在还没有这个文档,如果真是这样的话,你可以自己创建一个,下一次 bash 就会找到它。

在你的 ~/.bash_profile 文档末尾,应该有一条这个命令 source ~/.bashrc。这是因为当 ~/.bash_profile 文档存在的时候,bash 的表现会有些奇怪,它会停止寻找标准的 shell 初始化配置 ~/.bashrc 文档,source 命令则会修复这点反常。

注意,如果系统中没有 ~/.bash_profile 文档,bash 就会尝试从 ~/.profile 文档中读取信息,如果这个文档存在的话。这后一种文档是一种通用性的 shell 配置文件,其他 shell 也可以读取。你也可以选择将环境配置信息储存在这里,但是要注意在这个文档中,需使用POSIX sh 语法,而不能是任何具体的 bash shell 语法。POSIX sh 语法和 bash 语法非常相似,但这部分内容已超出本指南的讲解范围。

    loginlogin 程序使用户登陆至系统内
      │
      ╰─ -bashlogin 命令启动用户登陆过的shell
         │
         ╰─ screen用户从登陆后的 shell 中运行 screen 程序
              │
              ╰─ weechatscreen 程序会创建多个窗口且允许用户在窗口之间切换 
              ╰─ bash   第一个运行一个 IRC 客户端,另外两个则分别运行未登录的 bash shell 
              ╰─ bash

这个进程树描绘了一个用户,使用 bash 作为登陆的 shell,同时复用终端创建了几个独立的“屏幕(screen)”,使他可以同时与多个正在运行的程序交互。登陆后,系统( login 程序)会决定用户的登陆 shell。例如,它可能会通过查看 /etc/passwd 确定。在这个例子中,用户登陆的 shell 被设置为 bash。login 接下来会运行 bash 并将它的名字设置为 -bash。这是 login 命令的标准执行流程,它会在登陆的 shell 名称前加一个前缀 -,表示这个 shell 会作为登陆 shell 运行。

当用户有了一个正在运行且登陆过的 bash shell,接着他启动了 screen 程序。当这个程序运行时,它会接管用户的整个终端并在其中模拟出多个终端,允许用户在他们之间切换。在每一个模拟终端中,screen 程序都会运行一个新的程序。在这个例子中,用户使用 screen 程序配置了一个模拟终端运行 IRC 客户端,配置另外两个终端运行交互式(但是非登陆)bash shell。下方视频演示的是具体的操作过程:

让我们来看一下这个例子中的初始化过程具体是怎样的,以及环境变量从哪里来:

    login
      │ TERM=dumbUSER=lhunathHOME=/home/lhunathPATH=/usr/bin:/bin
      │
      ╰─ -bash
         │ TERM=dumb
         │ USER=lhunath
         │ HOME=/home/lhunath
         │ PATH=/usr/bin:/binPWD=/home/lhunathSHLVL=1
         │╭──────────────╮     ╭────────────────────────╮╭──────────────────╮
         ┝┥ login shell? ┝─yes─┥ source ~/.bash_profile ┝┥ source ~/.bashrc │
         │╰──────────────╯     ╰────────────────────────╯╰──────────────────╯
         │ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexecEDITOR=vimLANG=en_CA.UTF-8LESS=-i -M -R -W -SGREP_COLOR=31
         │
         ╰─ screen
              │ TERM=dumbTERM=screen-bce
              │ USER=lhunath
              │ HOME=/home/lhunath
              │ PATH=/usr/bin:/bin
              │ PWD=/home/lhunath
              │ SHLVL=1
              │ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec
              │ EDITOR=vim
              │ LANG=en_CA.UTF-8
              │ LESS=-i -M -R -W -S
              │ GREP_COLOR=31
              │ WINDOW=0
              │
              ╰─ weechat
              │
              ╰─ bash
              │    │╭──────────────╮
              │    ╰┥ login shell? ┝
              │     ╰──────┰───────╯
              │            no
              │     ╭──────┸───────╮     ╭──────────────────╮
              │     │ interactive? ┝─yes─┥ source ~/.bashrc │
              │     ╰──────────────╯     ╰──────────────────╯
              ╰─ bash
                   │╭──────────────╮
                   ╰┥ login shell? ┝
                    ╰──────┰───────╯
                           no
                    ╭──────┸───────╮     ╭──────────────────╮
                    │ interactive? ┝─yes─┥ source ~/.bashrc │
                    ╰──────────────╯     ╰──────────────────╯

如你所见,不同水平会输出各自的变量至环境中。每一个子进程会继承父进程的环境变量。反过来,子进程也可以覆盖一些变量的值或添加新的变量。

注意看第一个(登陆)bash 会同时读取并执行(点命令,source)~/.bash_profile~/.bashrc,而下面的两个 bash 则只读取和执行 ~/.bashrc。之所以会这样是因为只有第一个 bash 进程是作为“登陆 shell”的(它的名称前面有一个前缀 -)。下面两个 bash 进程只是普通的交互式 shell。他们之所以不需要读取并执行 ~/.bash_profile 的原因现在看来就更为明显了:~/.bash_profile 的职责在于配置 bash 的环境,而下面两个 shell 已从登陆的 shell 那里继承了它的环境。

我还可以使用参数做什么?

正如我们在前面部分提到的,参数有位置参数、特殊参数和变量三种类型。变量本质上就是有名字的参数。现在我们将会进一步看看不同类型的参数,以及他们如何允许你从 shell 中获得具体信息、或是如何改变 shell 的特定行为。

位置参数(Positional Parameters)

如果说变量是有名字的参数,位置参数就是有数值的参数(更具体地说,正整数)。我们使用标准的参数扩展语法扩展这些参数:$1$3。一定要注意,如果数字位数超过一位,bash 会要求你使用大括号将位置参数包裹:${10}${22} (实际操作中,你基本不会遇到需要明确指定如此高位置参数的情况)。

位置参数会扩展出值,然后作为参数发送至父进程创建的子进程中。例如,当你使用如下命令启动 grep 进程:

$ grep Name registrations.txt

你实际上是在运行 grep 命令,它的参数是 Nameregistrations.txt。如果 grep 是一个 bash 脚本,通过扩展位置参数 $1$2就能分别获得脚本所需的第一个和第二个参数。比 2 更高的位置参数未定义。

另外你最好知道还有一个第 0 位参数,这个位置参数会扩展为进程的 名称。进程的名称由创建它的程序选择,因此第 0 位的参数可以包含任何信息,而且完全由脚本的父进程决定。绝大多数 shell 会使用启动进程的执行文件的绝对路径作为进程名称,或是用户执行的命令名。注意这并非必须,而且你不能据此对第 0 位参数的内容做出任何可靠的推测:因此无论出于任何意图或目的最好都避免猜测。

好在且极为方便的是:关于变量参数我们目前所学的绝大多数内容也同样适用于位置参数:我们可以扩展他们,也可以应用参数扩展运算符来改变结果值:

#!/usr/bin/env bash
echo "The Name Script"
echo "usage: names 'My Full Name'"; echo

first=${1%% *} last=${1##* } middle=${1#$first} middle=${middle%$last}
echo "Your first name is: $first"
echo "Your last name is: $last"
echo "Your middle names are: $middle"

如果你把这个脚本保存在以 names 命名的文档中,然后按照用法传递一个参数给它并运行,你将会看到这个脚本分析你的名字,并告诉你其中哪些部分分别是名、姓和中间名。过程中,我们使用变量firstlastmiddle 储存这些信息片段,后面在 echo 语句中再将他们扩展使用。注意 中间名 的计算同时需要名字全称(可从第一位置参数获得)以及名字(已被计算并保存在变量 first 中)信息。

$ chmod +x names
$ ./names 'Maarten Billemont'
The Name Script
usage: names 'My Full Name'

Your first name is: Maarten
Your last name is: Billemont
Your middle names are: 
$ ./names 'James Tiberius "Jim" Kirk'
The Name Script
usage: names 'My Full Name'

Your first name is: James
Your last name is: Kirk
Your middle names are:  Tiberius "Jim"

一定要理解,和绝大数变量不同,位置参数是只读参数。仔细想想,你很可能也会认同,我们是无法从脚本内部改变传递给脚本的参数的。因此下面就是一个语法错误:

$ 1='New First Argument'
-bash: 1=New First Argument: command not found

虽然例子中返回的错误消息看起来略显混乱,它表示 bash 甚至都没有识别出这个语句是想要赋值给变量(因为参数 1 不是一个变量),相反 bash 以为你给了它一个想要运行的命令。

然而,我们可以使用一种内置命令去改变位置参数集合的值。虽然在那些缺少 bash 高级特性的古老 shell 中,这是很常用的一种操作,但是你之后在 bash 中基本不会用到。为了改变当前的位置参数集合,使用 set 命令并在 -- 参数的后面指定新的位置参数:

$ set -- 'New First Argument' Second Third 'Fourth Argument'
$ echo "1: $1, 2: $2, 4: $4"
1: New First Argument, 2: Second, 4: Fourth Argument

除了改变位置参数集合,内置命令 shift 可以用来“推动”位置参数。当我们移动位置参数时,实质上是将他们向开始的方向推动,移走前面的位置参数,为后面的腾出位置来:

New First Argument Second Third Fourth Argument
$ shift 2将位置参数移动 2 
Third Fourth Argument <----前面两个位置参数消失,原来第三个现处于第一个的位置,第四个现在第二个

最后,当我们使用 bash 命令启动一个新的 bash shell 时,有一种方法可用来传递位置参数。这是一种非常有用的方法,可以将一列参数传至内联 bash 脚本中。之后,当你把内联 bash 代码与其他功用结合使用的时候,你就会用到这种方法,但现在我们只把它作为一种实验位置参数、而无需创建独立脚本去激活和传递参数(如我们在上面 names 例子中所做的那样)的方法。。下面示例的就是如何运行一个内联 bash 命令,并传递一个参数列表扩展位置参数:

$ bash -c 'echo "1: $1, 2: $2, 4: $4"' -- 'New First Argument' Second Third 'Fourth Argument'
1: New First Argument, 2: Second, 4: Fourth Argument

我们运行 bash 命令,传递 -c 选项,后面跟着一个参数,其内含有一些 bash shell 代码。这样会告诉 bash,相比启动一个新的交互式 bash shell,你只是想让 shell 运行以上 bash 代码并结束。在 shell 代码之后,我们指明了用以扩展位置参数的参数。第一个参数是 --。虽然这个参数技术上是用来扩展第 0 位位置参数,但是为了兼容性,以及区分bash 参数和 shell 代码参数,最好总是使用 --。在这个参数之后,每一个参数像你预期的那样填充标准位置参数。

如果在上面例子中我们使用双引号,接收我们 bash 命令的shell 会将位置参数$1$2 以及 $4分别扩展,由此 -c 选项的参数就破裂掉了。

为了示例这一点,请对比我们上面完整正确的例子:

$ bash -vc 'echo "1: $1, 2: $2, 4: $4"' -- \我们将 -v 参数传递给 bash 好让它在显示结果之前先呈现要运行的代码
'New First Argument' Second Third 'Fourth Argument'我们可以在行末使用反斜杠 \ 以令起一行继续
echo "1: $1, 2: $2, 4: $4"这是即将运行的代码
1: New First Argument, 2: Second, 4: Fourth Argument这是结果

以及如果我们使用双引号而非单引号引用 -c 的参数,会发生什么:

$ bash -vc "echo "1: $1, 2: $2, 4: $4"" -- \外部的双引号与内部的双引号冲突,导致混淆
'New First Argument' Second Third 'Fourth Argument'
echo 1:结果就是,-c 选项的参数不再是完整的 bash 代码,而只包含第一个单词
1:
$ bash -vc "echo \"1: $1, 2: $2, 4: $4\"" -- \就算我们修复了引用的模糊性,问题仍在,位置参数 $1、$2 和 $4 正被我们输入命令的 shell 解释
'New First Argument' Second Third 'Fourth Argument'而不是参数被传入的 shell 
echo "1: , 2: , 4: "因为位置参数 $1、$2、$4 在你交互式的 shell 中很可能为空,扩展的结果也就相应为空,因此 -c 就没有了参数
1: , 2: , 4:

通过使用反斜杠转义所有特殊字符,包括双引号和美元符号,我们可以修复掉双引号内的全部问题。但是这会使 shell 代码看起来极为复杂且难读。维护如此被特殊转义后的 shell 代码简直就像噩梦,随时可能导致极难发现的错误:

$ bash -vc "echo \"1: \$1, 2: \$2, 4: \$4\"" -- \
'New First Argument' Second Third 'Fourth Argument'
echo "1: $1, 2: $2, 4: $4"
1: New First Argument, 2: Second, 4: Fourth Argument

特殊参数(Special Parameters)

理解位置参数使得理解特殊参数容易很多:因为他们非常相似。以单独符号字符作为参数名的参数就是特殊参数,他们通常被用来向 bash shell 请求特定状态信息。下面是不同种类的特殊参数以及他们含有的信息:

参数 示例 描述
"$*" echo "Arguments: $*" 扩展 单个字符串,将所有位置参数连接合并成一个,相互之间被 IFS 中的第一个字符分隔(通常默认的是一个空格)
注: 永远不要使用这个参数,除非你明确希望连接所用参数。你基本上总是可以使用 @ 替代。
"$@" rm "$@" 将位置参数作为一列参数分别扩展
"$#" echo "Count: $#" 扩展出一个数值,对应可用位置参数的数量
"$?" (( $? == 0 )) || echo "Error: $?" 扩展上一个(同步)命令的退出代码
退出代码 0 表示命令执行成功,其他数字分别对应执行失败的原因
"$-" [[ $- = *i* ]] 扩展出当前 shell 中活跃的选项标识集
选项标识(option flags)会配置 shell 的行为,例子是在测试 i 标识是否存在,i 表示 shell 处于交互模式(有提示符),并非在运行脚本
"$$" echo "$$" > /var/run/myscript.pid 扩展一个数值,对应正在解析代码的 shell 进程的唯一识别码
"$!" kill "$!" 扩展一个数值,对应上一个后台异步运行的进程的唯一识别码
例子表示终止后台进程
"$_" mkdir -p ~/workspace/projects/myscripts && cd "$_" 扩展上一个命令的最后一个参数

正如位置参数,特殊参数也是只读性的:你只能使用他们扩展信息,而非储存信息。

Shell 内部变量(Shell Internal Variables)

你已知道 shell 变量是什么。你是否知道 bash shell 也为你创建了一些变量?这些变量被用在各种任务中,对于从 shell 中查询状态信息或是改变 shell 行为非常方便。

虽然 bash 实际定义了许多 shell 内部变量,但绝大多数都不是非常有用。有些即使有用也只是在一些具体的情境中,其中许多变量的使用需要你理解更多高级的 bash 概念。下面我将简单介绍一些当前阶段学起来比较有趣的 shell 内部变量。使用 man bash 可以得到完整的 shell 内部变量列表。

BASH /usr/local/bin/bash
这个变量包含启动你当前 bash 的命令的完整路径名
BASH_VERSION 4.4.0(1)-release
描述当前活跃 bash 版本的版本号
BASH_VERSINFO [ 4, 4, 0, 1, release, x86_64-apple-darwin16.0.0 ]
关于当前活跃 bash 版本的一列详细版本信息
BASH_SOURCE myscript
当前正在运行的全部脚本的文件名。第一个是当前正在运行的脚本。通常这个变量或者为空(没有正在运行的脚本),或是包含你脚本的路径名。
BASHPID 5345
正在解析脚本代码的 bash 的进程 ID
UID 501
正在运行当前 bash shell 的用户的ID
HOME /Users/lhunath
正在运行当前 bash shell 的用户的主目录路径名
HOSTNAME myst.local
电脑的名字
LANG en_CA.UTF-8
你偏好的语言类别
MACHTYPE x86_64-apple-darwin16.0.0
你正在运行的系统类型的完整描述
PWD /Users/lhunath
你当前所处路径的完整路径名
OLDPWD /Users/lhunath
在你来到当前路径前上一个路径的完整路径名
RANDOM 12568
每次在0-32767之间扩展出一个新的随机值
SECONDS 338217
扩展出你 bash shell 已运行多少秒
LINES 48
你终端显示的高度(多少行)
COLUMNS 178
你终端显示单行的宽度(多少个字符空间)
IFS $' \t\n'
“Internal Field Separator” 是一个字符串,bash 用它来对数据做单词分割。默认地,bash 会在空格、tab制表键以及换行处(newlines)分割。
PATH /Users/lhunath/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec
当你运行命令时,bash 会去搜索可执行程序的路径的列表
PS1 \s-\v\$
描述交互式 bash shell 提示符会是什么样子的字符串
PS2 >
描述交互式 bash shell 二级提示符会是什么样子的字符串。二级提示符是当你输入完一行命令但是命令尚未完整时使用

如我提到的,还有许多其他的 shell 内部变量,但是他们每一个都是为某个具体的高级案例服务,这些不是我们当前关心的。如果你去查询关于当前 bash 操作的相关信息,你很有可能会在它的某个 shell 内部变量中找到答案。

练习时间!

PARAM.1. 启动一个新的 bash shell 输出它的 第一 位置参数。将 Hello World! 作为一个参数传递给它

bash -c 'echo "$1"' -- 'Hello World!'

PARAM.2. 启动一个 bash shell 输出传递给它的参数个数。将 12The Third 作为参数传递给它。

bash -c 'echo "$#"' -- 1 2 'The Third'

PARAM.3. 启动一个 bash shell 将位置参数移位 1,然后输出第一个参数。将 12The Third 作为参数传递给它。

bash -c 'shift; echo "$1"' -- 1 2 'The Third'

PARAM.4. 启动一个 bash shell 输出最后一个传入的参数。将 12The Third 作为参数传递给它。

bash -c 'echo "${@: -1}"' -- 1 2 'The Third'

数组(Arrays)

最后,但 绝非无足轻重的,我们终于抵达了可能最有趣的一种 bash 参数:数组。

什么是数组,为什么我要使用他们?

数组只是相比参数听起来更炫的一个名字,这种参数容纳的不是一个字符串,而是整个一列字符串。以列表的方式存储东西的概念并不新鲜,在这份指南中我们前面就已见识过,如变量 PATH 内就存储了一列供 bash 寻找命令程序的路径名。然而,数组的引入是为了解决当你使用简单的字符串变量存储一列事物时会面临的问题。

问题就是,当你对列表中单独的部分产生兴趣时,无法回避地要将这单个变量拆分成多个独立部分。然而,我们中的大多数人甚至都不会意识到这是一个问题:因为作为人类,我们极为擅长根据情况这样做。当看到一个名字如 Leonard Cohen,我们会立即辨认出它由两个独立的名字合构成一个人的全名。当看到一串字符如 Leonard Cohen - Adam Cohen - Lorca Cohen,也立即会看出这是由三个名字组成的一个序列:字符串中的模式就是用破折号分割姓名。事实上,我们如此擅长做这些,当看到一串名字如 Susan Q. - Mary T. - Steven S. - Anne-Marie D. - Peter E.,都不需要停下来思考。我们甚至还擅长在更大型的由行与段构成的字符串中,如诗歌,发现相关的语义单元

不幸的是,当我们开始利用计算机处理数据辅助思考的时候,就要将人类杰出的抽象能力搁置一边,穿上认知婴儿鞋了。计算机不知道 Susan Q. - Mary T. - Steven S. - Anne-Marie D. - Peter E. 这是一串人名,它不知道这些人名被破折号分隔,也绝对猜不到 Anne-Marie 是一个人的名字,而不是两个人名组成的序列。

在命令中使用参数,就是使列表中元素尽可能明晰的好方法。还记得我们学过的引用吗?事实上,现在正是重温引用相关内容的绝佳时机。

$ ls -l 05 Between Angels and Insects.ogg

在这个例子中,我们传递给 ls 命令一列参数,bash 会把每一个参数理解为一个独立文件名。很明显这不是我们想要的效果,但是 bash 并不像我们人类那样擅长从任意的数据中提取情境信息。因此,关于列表中的元素都是什么,一定要非常明确地表达出来:

$ ls -l "05 Between Angels and Insects.ogg"

现在我们非常清楚地告诉 bash,列表中只含有一个文件名,而这个文件名内包含几个单词,ls 命令终于可以正确做事了。

变量中也存在同样的问题。如果我们想要创建一个变量,其中包含一列想要删除的所有文件,那么该如何创建一个列表,既可以把其中的每个元素传递给 rm 命令以删除文件,同时还能回避 bash 错误理解文件名的风险?

答案就是使用 数组

$ files=( myscript hello.txt "05 Between Angels and Insects.ogg" )
$ rm -v "${files[@]}"

为了创建数组变量,bash 引入了一个略微不同的赋值运算符:=( )。和标准的 = 一样,我们将变量的名称放在运算符的左侧,赋给这个变量的列表的值则放在右侧 () 括号内。

你或许还记得在前面变量赋值部分,我们特别强调过不要在赋值周围使用 句法性的空格:= 后面的空格会将赋值分隔成命令名称与参数对;值内未加引用的空格则会导致 bash 将值分隔成部分赋值加命令名称。在这种新的数组赋值语法中,括号 之内 空格是可以自由使用的。实际上,他们也被用来分隔数组內不同的元素。但是和通常的变量赋值一样,当空格是数据的一部分时,必须被引用,如此 bash 才会将它按字面理解。注意看上面的例子,在myscripthello.txt 之间,我们使用 句法性 的空格,使 bash 认识到这两个词语是列表中的独立元素,但是在 05Between之间,我们使用 字面性 的空格,这里空格是文件名的一部分,不能使 bash 误将它拆为列表中的不同元素:即空格在此要被引用。

事实上,这些句法规则都不是新内容。我们已经知道如何传递不同的参数给命令,如何传递不同的元素给数组赋值运算符并无不同。

最后,创建完文件列表后,我们需要将参数扩展到 rm 命令中。如果你还记得前面参数扩展部分的内容,扩展是通过在参数名称前加前缀 $ 符号实现的。与普通参数扩展不同的是,我们感兴趣的不是扩展一个参数:而是将列表中的每一个元素作为独立的参数分别扩展到 rm 命令中。于是,我们使用 [@] 作为参数名后缀,同时为了保证 bash 将他们作为一个完整参数扩展单元理解,还需用花括号 { }将他们整个包裹起来。参数 files 使用 "${files[@]}" 语法扩展后会有如下效果:

$ rm -v myscript hello.txt "05 Between Angels and Insects.ogg"
removed 'myscript'
removed 'hello.txt'
removed '05 Between Angels and Insects.ogg'

Bash 正确地将数组列表中的每一个元素作为独立的参数传递给了 rm 命令!

祝贺你!现在你已理解 bash shell 语言中最有力的数据结构。

我还可以使用数组做什么?

除了数组赋值以及数组扩展,bash 还支持我们对数组进行一些其他操作:

$ files+=( selfie.png )使用 +=( ) 运算符我们可以在数组后面增添列表元素
$ files=( *.txt )J正像在命令参数中那样,我们也可以扩展 glob 模式
$ echo "${files[0]}"如果要扩展数组中的单独某个元素,指明元素的序列值
$ echo "$files"如果忘记了数组扩展的特殊语法,bash 就只扩展第一个元素
$ unset "files[3]"如果想从数组中移除某一个元素,使用 unset 
但是要注意:这里我们不再使用 $,因为不是在扩展值!

除了使用 [@] 后缀将数组元素分别扩展为独立参数,bash 还有办法将所有数组元素扩展到 单个 参数中去。这是通过使用 [*] 后缀做到的。Bash 如何将所有独立的元素合并进单个参数中呢?对此我们可能会想到很多种方法—:它是不是创建了一个用空格分隔的字符串?它是不是未加区隔地将全部元素挤进一个长字符串中?或者它可以创建单个字符串,其中每一个元素各居一行?事实是出于以上呈现的各种原因,实际并不存在某种策略可以将全部元素合并进一个字符串且不会产生任何问题。因此,运算符 [*]非常不可靠 的,任何情况下都应该尽力回避使用它而选用 [@]

事实上,当你使用 [*] 时,bash 允许你选择如何将元素合并为单一字符串:通过设定 shell 内部变量 IFS 当前的值。Bash 使用这个变量的 第一个 字符(默认一般是空格)来分隔字符串中的不同元素:

$ names=( "Susan Quinn" "Anne-Marie Davis" "Mary Tate" )
$ echo "Invites sent to: <${names[*]}>."产生单独一个参数,其中每个元素之间用字面性空格相连
Invites were sent to: <Susan Quinn Anne-Marie Davis Mary Tate>.
$ ( IFS=','; echo "Invites sent to: <${names[*]}>." )当我们将 IFS 改为 ,,每个元素看起来就更为清晰
Invites were sent to: <Susan Quinn,Anne-Marie Davis,Mary Tate>.

相比内部元素被清晰区隔的数组变量,包含多个独立元素的单个字符串基本总是有缺陷且不实用的,因此 [*] 后缀其实很少被用到。只有一种情况例外:当要向用户展示一列元素时,这个运算符非常有用。当我们试图向一个人呈现数组值时,就不需要特别担心输出结果的句法正确性。上面 IFS 被设为 ,的例子,就展示了一种常见的向用户呈现数组值的方式。

最后,我们前面学习过的所有特殊参数扩展运算符也都适用于数组扩展,但是下面只会挑选一些重申,因为在扩展多个独立元素的情境下,他们的效果变得极为有趣:

作为第一个,对于 ${parameter[@]/pattern/replacement} 运算符以及所有它的变式,他们的替换逻辑适用于于每一个单独被扩展的元素:

$ names=( "Susan Quinn" "Anne-Marie Davis" "Mary Tate" )
$ echo "${names[@]/ /_}"使用下划线替换每个姓名中的空格
Susan_Quinn Anne-Marie_Davis Mary_Tate
$ ( IFS=','; echo "${names[*]/#/Ms }" )更有趣的是,使用 Ms  替换每个姓名的起始,
Ms Susan Quinn,Ms Anne-Marie Davis,Ms Mary Tate成功地扩展每个元素并前缀一个字符串

运算符 ${#parameter} 结合 [@] 前缀会返回给我们元素的数量:

$ echo "${#names[@]}"
3
$ echo "${#names[1]}"我们仍然可以得到字符串的长度通过直接指定
16我们想知道的数组中的某个字符串元素

最后,运算符 ${parameter[@]:start:length} 可以用来获得数组的片段或子集:

$ echo "${names[@]:1:2}"
Anne-Marie Davis Mary Tate
$ echo "${names[@]: -2}"起始指定一个负数会使我们从末尾反向数起!
Anne-Marie Davis Mary Tate如果省略指定长度值会纳入从起始算起剩下全部的元素

注意在负数的起始值前一定要使用一个空格:如果我们遗漏了空格,bash 就会困惑并且认为你是想要使用 ${parameter:-value} 运算符,每当参数值为空时,这个运算符会用默认 value 值替换。这显然不是我们想做的事。

就是这些!你已经坚实地掌握了 bash shell 语言中绝对最重要也最有用的部分:参数与扩展,以及使用众多运算符改变扩展参数值、按我们的需求塑造他们的方法。

Fork me on GitHub