2.6 shell的语法

现在你已看过一个简单的shell程序示例,是时候来深入研究shell强大的程序设计能力了。shell是一种很容易学习的程序设计语言,它可以在把各个小程序段组合为一个大程序之前就能很容易地对它们分别进行交互式的测试。你还可以用bash shell编写出相当庞大的结构化程序。在接下来的几节里,我们将学习以下内容:

❑ 变量:字符串、数字、环境和参数

❑ 条件:shell中的布尔值

❑ 程序控制:if、elif、for、while、until、case

❑ 命令列表

❑ 函数

❑ shell内置命令

❑ 获取命令的执行结果

❑ here文档

2.6.1 变量

在shell里,使用变量之前通常并不需要事先为它们做出声明。你只是通过使用它们(比如当你给它们赋初始值时)来创建它们。在默认情况下,所有变量都被看作字符串并以字符串来存储,即使它们被赋值为数值时也是如此。shell和一些工具程序会在需要时把数值型字符串转换为对应的数值以对它们进行操作。Linux是一个区分大小写的系统,因此shell认为变量foo与Foo是不同的,而这两者与FOO又是不同的。

在shell中,你可以通过在变量名前加一个$符号来访问它的内容。无论何时你想要获取变量内容,你都必须在它前面加一个$字符。当你为变量赋值时,你只需要使用变量名,该变量会根据需要被自动创建。一种检查变量内容的简单方式就是在变量名前加一个$符号,再用echo命令将它的内容输出到终端上。

在命令行上,你可以通过设置和检查变量salutation的不同值来实际查看变量的使用:

注意,如果字符串里包含空格,就必须用引号把它们括起来。此外,等号两边不能有空格。

你可以使用read命令将用户的输入赋值给一个变量。这个命令需要一个参数,即准备读入用户输入数据的变量名,然后它会等待用户输入数据。通常情况下,在用户按下回车键时,read命令结束。当从终端上读取一个变量时,你一般不需要使用引号,如下所示:

1.使用引号

在继续学习之前,你先需要弄清楚shell的一个特点:引号的使用。

一般情况下,脚本文件中的参数以空白字符分隔(例如,一个空格、一个制表符或者一个换行符)。如果你想在一个参数中包含一个或多个空白字符,你就必须给参数加上引号。

像$foo这样的变量在引号中的行为取决于你所使用的引号类型。如果你把一个$变量表达式放在双引号中,程序执行到这一行时就会把变量替换为它的值;如果你把它放在单引号中,就不会发生替换现象。你还可以通过在$字符前面加上一个\字符以取消它的特殊含义。

字符串通常都被放在双引号中,以防止变量被空白字符分开,同时又允许$扩展。

实验 变量的使用

这个例子显示了引号在变量输出中的作用:

输出结果如下:

实验解析

变量myvar在创建时被赋值字符串Hi there。你用echo命令显示该变量的内容,同时显示了在变量名前加一个$符号就能得到变量的内容。你看到使用双引号并不影响变量的替换,但使用单引号和反斜线就不进行变量的替换。你还使用read命令从用户那里读入一个字符串。

2.环境变量

当一个shell脚本程序开始执行时,一些变量会根据环境设置中的值进行初始化。这些变量通常用大写字母做名字,以便把它们和用户在脚本程序里定义的变量区分开来,后者按惯例都用小写字母做名字。具体创建的变量取决于你的个人配置。在系统的使用手册中列出了许多这样的环境变量,表2-2列出了其中一些主要的变量。

表2-2

如果想通过执行env <command>命令来查看程序在不同环境下是如何工作的,请查阅env命令的使用手册。你也将在本章的后面看到如何使用export命令在子shell中设置环境变量。

3.参数变量

如果脚本程序在调用时带有参数,一些额外的变量就会被创建。即使没有传递任何参数,环境变量$#也依然存在,只不过它的值是0罢了。

参数变量见表2-3。

表2-3

通过下面的例子,你可以很容易地看出$@和$*之间的区别:

如你所见,双引号里面的$@把各个参数扩展为彼此分开的域,而不受IFS值的影响。一般来说,如果你想访问脚本程序的参数,使用$@是明智的选择。

除了使用echo命令查看变量的内容外,你还可以使用read命令来读取它们。

实验 使用参数和环境变量

下面的脚本程序演示了一些简单的变量操作。当输入脚本程序的内容并把它保存为文件try_var后,别忘了用chmod+x try_var命令把它设置为可执行。

运行这个脚本程序,你将得到如下所示的输出结果:

实验解析

这个脚本程序创建变量salutation并显示它的内容,然后显示各种参数变量以及环境变量$HOME都已存在并有了适当的值。

我们将在后面进一步介绍参数替换。

2.6.2 条件

所有程序设计语言的基础是对条件进行测试判断,并根据测试结果采取不同行动的能力。在讨论它之前,我们先来看看在shell脚本程序里可以使用的条件结构,然后再来看看使用这些条件的控制结构。

一个shell脚本能够对任何可以从命令行上调用的命令的退出码进行测试,其中也包括你自己编写的脚本程序。这也就是为什么要在所有自己编写的脚本程序的结尾包括一条返回值的exit命令的重要原因。

test或[命令

在实际工作中,大多数脚本程序都会广泛使用shell的布尔判断命令[或test。在一些系统上,这两个命令的作用是一样的,只是为了增强可读性,当使用[命令时,我们还使用符号]来结尾。把[符号当作一条命令多少有点奇怪,但它在代码中确实会使命令的语法看起来更简单、更明确、更像其他的程序设计语言。

在一些老版本的UNIX shell中,这些命令调用的是一个外部程序,但在较新的shell版本中,它们已成为shell的内置命令。我们将在本章后面介绍各种命令时再次讨论这个问题。

因为test命令在shell脚本程序以外用得很少,所以那些很少编写shell脚本的Linux用户往往会将自己编写的简单程序命名为test。如果程序不能正常工作,很可能是因为它与shell中的test命令发生了冲突。要想查看系统中是否有一个指定名称的外部命令,你可以尝试使用which test这样的命令来检查执行的是哪一个test命令,或者使用./test这种执行方式以确保你执行的是当前目录下的脚本程序。如有疑问,你只需养成在调用脚本的前面加上./的习惯即可。

我们以一个最简单的条件为例来介绍test命令的用法:检查一个文件是否存在。用于实现这一操作的命令是test -f <filename>,所以在脚本程序里,你可以写出如下所示的代码:

你还可以写成下面这样:

test命令的退出码(表明条件是否被满足)决定是否需要执行后面的条件代码。

注意,你必须在[符号和被检查的条件之间留出空格。要记住这一点,你可以把[符号看作和test命令一样,而test命令之后总是应该有一个空格。

如果你喜欢把then和if放在同一行上,就必须要用一个分号把test语句和then分隔开。如下所示:

test命令可以使用的条件类型可以归为3类:字符串比较、算术比较和与文件有关的条件测试,表2-4、表2-5和表2-6描述了这3种条件类型。

表2-4

表2-5

表2-6

读者可能想知道什么是set-group-id和set-user-id(也叫做set-gid和set-uid)位。set-uid位授予了程序其拥有者的访问权限而不是其使用者的访问权限,而set-gid位授予了程序其所在组的访问权限。这两个特殊位是通过chmod命令的选项u和g设置的。set-gid和set-uid标志对shell脚本程序不起作用,它们只对可执行的二进制文件有用。

我们稍微超前了一些,但是接下来的测试/bin/bash文件状态的例子可以让你看出如何使用它们:

各种与文件有关的条件测试的结果为真的前提是文件必须存在。上述列表仅仅列出了test命令比较常用的选项,完整的选项清单请查阅它的使用手册。如果你使用的是bash,那么test命令是shell的内置命令,使用help test命令可以获得test命令更详细的信息。我们将在本章后面用到这里给出的部分选项。

现在你已学习了“条件”,下面你将看到使用它们的控制结构。

2.6.3 控制结构

shell有一组控制结构,它们与其他程序设计语言中的控制结构很相似。

在下面的各小节中,各语句的语法中的statements表示when、while或until测试条件满足时,将要执行的一系列命令。

1.if语句

if语句非常简单:它对某个命令的执行结果进行测试,然后根据测试结果有条件地执行一组语句。如下所示:

实验 使用if语句

if语句的一个常见用法是提一个问题,然后根据回答作出决定,如下所示:

这将给出如下所示的输出:

这个脚本程序用[命令对变量timeofday的内容进行测试,测试结果由if命令判断,由它来决定执行哪部分代码。

请注意,你用额外的空白符来缩进if结构内部的语句。这只是为了照顾人们的阅读习惯,shell会忽略这些多余的空白符。

2.elif语句

遗憾的是,上面这个非常简单的脚本程序存在几个问题。其中一个问题是,它会把所有不是yes的回答都看做是no。你可以通过使用elif结构来避免出现这样的情况,它允许你在if结构的else部分被执行时增加第二个检查条件。

实验 用elif结构做进一步检查

你可以对上面的脚本程序做些修改,让它在用户输入yes或no以外的其他任何数据时报告一条出错信息。这是通过将else替换为elif并且增加另一个测试条件的方法来完成的。

实验解析

这个脚本程序与上一个例子很相似,但新增的elif命令会在第一个if条件不满足的情况下进一步测试变量。如果两次测试的结果都不成功,就打印一条出错信息并以1为退出码结束脚本程序,调用者可以在调用程序中利用这个退出码来检查脚本程序是否执行成功。

3.一个与变量有关的问题

刚才所做的修改弥补了一个非常明显的缺陷,但这个脚本程序还潜藏着一个更隐蔽的问题。运行这个新的脚本程序,但是这次不回答问题,而是直接按下回车键(或是某些键盘上的Return键)。你将看到如下所示的出错信息;

哪里出问题了呢?问题就在第一个if语句中。在对变量timeofday进行测试的时候,它包含一个空字符串,这使得if语句成为下面这个样子:

而这不是一个合法的条件。为了避免出现这种情况,你必须给变量加上引号,如下所示:

这样,一个空变量提供的就是一个合法的测试了:

新脚本程序如下所示:

这个脚本对用户直接按下回车键来回答问题的情况也能够应付自如了。

如果你想让echo命令去掉每一行后面的换行符,可移植性最好的办法是使用printf命令(请见本章后面的printf一节)而不是echo命令。有的shell用echo-e命令来完成这一任务,但并不是所有的系统都支持该命令。bash使用echo-n命令来去除换行符,所以如果确信自己的脚本程序只运行在bash上,你就可以使用如下的语法:

请注意,你需要在结束引号前留出一个额外的空格,这使得在用户输入响应前有一个间隙,从而看起来更加整洁。

4.for语句

我们可以用for结构来循环处理一组值,这组值可以是任意字符串的集合。它们可以在程序里被列出,更常见的做法是使用shell的文件名扩展结果。

它的语法很简单:

实验 使用固定字符串的for循环

循环值通常是字符串,所以你可以这样写程序:

该程序的输出结果如下所示:

如果你把第一行由for foo in bar fud 43修改为for foo in "bar fud 43"会怎样呢?别忘了,加上引号就等于告诉shell把引号之间的一切东西都看作是一个字符串。这是在变量里保留空格的一种办法。

实验解析

这个例子创建了一个变量foo,然后在for循环里每次给它赋一个不同的值。因为shell在默认情况下认为所有变量包含的都是字符串,所以字符串43在使用中与字符串fud是一样合法有效的。

实验 使用通配符扩展的for循环

正如我们前面所提到的,for循环经常与shell的文件名扩展一起使用。这意味着在字符串的值中使用一个通配符,并由shell在程序执行时填写出所有的值。

你已经在最早的first例子中见过这种做法了。该脚本程序用shell扩展把*扩展为当前目录中所有文件的名字,然后它们依次作为for循环中的变量$file使用。

我们来快速地看看另外一个通配符扩展的例子。假设你想打印当前目录中所有以字母f开头的脚本文件,并且你知道自己的所有脚本程序都以.sh结尾,你就可以这样做:

实验解析

这个例子演示了$(command)语法的用法,我们将在后面的内容中对它做更详细地介绍(参见2.6.6节)。简单地说,for命令的参数表来自括在$( )中的命令的输出结果。

shell扩展f*.sh给出所有匹配此模式的文件的名字。

请记住,shell脚本程序中所有的变量扩展都是在脚本程序被执行时而不是在编写它时完成的。所以,变量声明中的语法错误只有在执行时才会被发现,就像前面我们给空变量加引号的例子中看到的那样。

5.while语句

因为在默认情况下,所有的shell变量值都被认为是字符串,所以for循环特别适合于对一系列字符串进行循环处理,但如果你事先并不知道循环要执行的次数,那么它就显得不是那么有用了。

如果需要重复执行一个命令序列,但事先又不知道这个命令序列应该执行的次数,你通常会使用一个while循环,它的语法如下所示:

请看下面的例子,这是一个非常简陋的密码检查程序:

这个脚本程序的一个输出示例如下所示:

很明显,这不是一种询问密码的非常安全的办法,但它确实演示了while语句的作用。do和done之间的语句将反复执行,直到条件不再为真。在这个例子中,你检查的条件是变量trythis的值是否等于secret。循环将一直执行直到$trythis等于secret。随后你将继续执行脚本程序中紧跟在done后面的语句。

6.until语句

until语句的语法如下所示:

它与while循环很相似,只是把条件测试反过来了。换句话说,循环将反复执行直到条件为真,而不是在条件为真时反复执行。

一般来说,如果需要循环至少执行一次,那么就使用while循环;如果可能根本都不需要执行循环,就使用until循环。

下面是一个until循环的例子,你设置一个警报,当某个特定的用户登录时,该警报就会启动,你通过命令行将用户名传递给脚本程序。如下所示:

如果用户已经登录,那么循环就不需要执行。所以在这种情况下,使用until语句比使用while语句更自然。

7.case语句

case结构比你目前为止见过的其他结构都要稍微复杂一些。它的语法如下所示:

这看上去有些令人生畏,但case结构允许你通过一种比较复杂的方式将变量的内容和模式进行匹配,然后再根据匹配的模式去执行不同的代码。这要比使用多条if、elif和else语句来执行多个条件检查要简单得多。


请注意,每个模式行都以双分号(; ;)结尾。因为你可以在前后模式之间放置多条语句,所以需要使用一个双分号来标记前一个语句的结束和后一个模式的开始。


因为case结构具备匹配多个模式然后执行多条相关语句的能力,这使得它非常适合于处理用户的输入。弄明白case工作原理的最好方法就是通过例子来进行说明。我们将使用3个实验例子逐步深入地对它进行介绍,每次都对模式匹配进行改进。

你在case结构的模式中使用如*这样的通配符时要小心。因为case将使用第一个匹配的模式,即使后续的模式有更加精确的匹配也是如此。

实验case示例一:用户输入

你可以用case结构编写一个新版的输入测试脚本程序,让它更具选择性并且对非预期输入也更宽容:

实验解析

当case语句被执行时,它会把变量timeofday的内容与各字符串依次进行比较。一旦某个字符串与输入匹配成功,case命令就会执行紧随右括号)后面的代码,然后就结束。

case命令会对用来做比较的字符串进行正常的通配符扩展,因此你可以指定字符串的一部分并在其后加上一个*通配符。只使用一个单独的*表示匹配任何可能的字符串,所以我们总是在其他匹配字符串之后再加上一个*以确保如果没有字符串得到匹配,case语句也会执行某个默认动作。之所以能够这样做是因为case语句是按顺序比较每一个字符串,它不会去查找最佳匹配,而仅仅是查找第一个匹配。因为默认条件通常都是些“最不可能出现”的条件,所以使用*对脚本程序的调试很有帮助。

实验case示例二:合并匹配模式

上面例子中的case结构明显比多个if语句的版本更精致,但通过合并匹配模式,你可以编写一个更加清晰的版本。如下所示:

实验解析

这个脚本程序在每个case条目中都使用了多个字符串,case将对每个条目中的多个不同的字符串进行测试,以决定是否需要执行相应的语句。这使得脚本程序不仅长度变短,而且实际上也更容易阅读。这个脚本程序同时还显示了*通配符的用法,虽然这样做有可能匹配意料之外的模式。例如,如果用户输入never,它就会匹配n*并显示出Good Afternoon,而这并不是我们希望的行为。另外需要注意的是*通配符扩展在引号中不起作用。

实验case示例三:执行多条语句

最后,为了让这个脚本程序具备可重用性,你需要在使用默认模式时给出另外一个退出码。如下所示:

实验解析

为了演示模式匹配的不同用法,这个代码改变了no情况下的匹配方法。你还看到了如何在case语句中为每个模式执行多条语句。注意,你必须很小心地把最精确的匹配放在最开始,而把最一般化的匹配放在最后。这样做很重要,因为case将执行它找到的第一个匹配而不是最佳匹配。如果你把*)放在开头,那不管用户输入的是什么,都会匹配这个模式。


请注意,esac前面的双分号(; ;)是可选的。在C语言程序设计中,即使少一个break语句都算是不好的程序设计做法,但在shell程序设计中,如果最后一个case模式是默认模式,那么省略最后一个双分号(; ;)是没有问题的,因为后面没有其他的case模式需要考虑了。

为了让case的匹配功能更强大,你可以使用如下的模式:

这限制了允许出现的字母,但它同时也允许多种多样的答案并且提供了比*通配符更多的控制。

8.命令列表

有时,你想要将几条命令连接成一个序列。例如,你可能想在执行某个语句之前同时满足好几个不同的条件,如下所示:

或者你可能希望至少在这一系列条件中有一个为真,像下面这样:

虽然这可以通过使用多个if语句来实现,但如你所见,写出来的程序非常笨拙。shell提供了一对特殊的结构,专门用于处理命令列表,它们是AND列表和OR列表。虽然它们通常在一起使用,但我们将分别介绍它们的语法。

AND列表

AND列表结构允许你按照这样的方式执行一系列命令:只有在前面所有的命令都执行成功的情况下才执行后一条命令。它的语法是:

从左开始顺序执行每条命令,如果一条命令返回的是true,它右边的下一条命令才能够执行。如此持续直到有一条命令返回false,或者列表中的所有命令都执行完毕。&&的作用是检查前一条命令的返回值。

每条语句都是独立执行,这就允许你把许多不同的命令混合在一个单独的命令列表中,就像下面的脚本程序显示的那样。AND列表作为一个整体,只有在列表中的所有命令都执行成功时,才算它执行成功,否则就算它失败。

实验AND列表

在下面的脚本程序中,你执行touch file_one命令(检查文件是否存在,如果不存在就创建它)并删除file_two文件。然后用AND列表检查每个文件是否存在并通过echo命令给出相应的指示。

执行这个脚本程序,你将看到如下所示的结果:

实验解析

touch和rm命令确保当前目录中的有关文件处于已知状态。然后&&列表执行[-f file_one]语句,这条语句肯定会执行成功,因为你已经确保该文件是存在的了。因为前一条命令执行成功,所以echo命令得以执行,它也执行成功(echo命令总是返回true)。当执行第三个测试[-f file_two]时,因为该文件并不存在,所以它执行失败了。这条命令的失败导致最后一条echo语句未被执行。而因为该命令列表中的一条命令失败了,所以&&列表的总的执行结果是false, if语句将执行它的else部分。

OR列表

OR列表结构允许我们持续执行一系列命令直到有一条命令成功为止,其后的命令将不再被执行。它的语法是:

从左开始顺序执行每条命令。如果一条命令返回的是false,它右边的下一条命令才能够被执行。如此持续直到有一条命令返回true,或者列表中的所有命令都执行完毕。

||列表和&&列表很相似,只是继续执行下一条语句的条件现在变为其前一条语句必须执行失败。

实验OR列表

沿用上一个例子,但要修改下面程序清单里阴影部分的语句:

这个脚本程序的输出是:

实验解析

头两行代码简单的为脚本程序的剩余部分设置好相应的文件。第一条命令[-f file_one]失败了,因为这个文件不存在。接下来执行echo语句,它返回true,因此||列表中的后续命令将不会被执行,因为||列表中有一条命令(echo)返回的是true,所以if语句执行成功并将执行其then部分。

这两种结构的返回结果都等于最后一条执行语句的返回结果。

这些列表类型结构的执行方式与C语言中对多个条件进行测试的执行方式很相似。只需执行最少的语句就可以确定其返回结果。不影响返回结果的语句不会被执行。这通常被称为短路求值(short circuit evaluation)。

将这两种结构结合在一起将更能体现逻辑的魅力。请看:

在上面的语句中,如果测试成功就会执行第一条命令,否则执行第二条命令。你最好用这些不寻常的命令列表来进行实验,但在通常情况下,你应该用括号来强制求值的顺序。

9.语句块

如果你想在某些只允许使用单个语句的地方(比如在AND或OR列表中)使用多条语句,你可以把它们括在花括号{}中来构造一个语句块。例如,在本章后面给出的应用程序中,你将看到如下所示的代码:

2.6.4 函数

你可以在shell中定义函数。如果你想编写大型的shell脚本程序,你会想到用它们来构造自己的代码。


作为另一种选择,你可以把一个大型的脚本程序分成许多小一点的脚本程序,让每个脚本完成一个小任务。但这种做法有几个缺点:在一个脚本程序中执行另外一个脚本程序要比执行一个函数慢得多;返回执行结果变得更加困难,而且可能存在非常多的小脚本。你应该考虑自己的脚本程序中是否有可以明显的单独存在的最小部分,并将其作为是否应将一个大型脚本程序分解为一组小脚本的衡量尺度。


要定义一个shell函数,你只需写出它的名字,然后是一对空括号,再把函数中的语句放在一对花括号中,如下所示:

实验 一个简单的函数

我们从一个非常简单的函数开始:

运行这个脚本程序会显示如下的输出信息:

实验解析

这个脚本程序还是从自己的顶部开始执行,这一点与其他脚本程序没什么分别。但当它遇见foo( ){结构时,它知道脚本正在定义一个名为foo的函数。它会记住foo代表着一个函数并从}字符之后的位置继续执行。当执行到单独的行foo时,shell就知道应该去执行刚才定义的函数了。当这个函数执行完毕以后,执行过程会返回到调用foo函数的那条语句的后面继续执行。

你必须在调用一个函数之前先对它进行定义,这有点像Pascal语言里函数必须先于调用而被定义的概念,只是在shell中不存在前向声明。但这并不会成为什么问题,因为所有脚本程序都是从顶部开始执行,所以只要把所有函数定义都放在任何一个函数调用之前,就可以保证所有的函数在被调用之前就被定义了。

当一个函数被调用时,脚本程序的位置参数($*、$@、$#、$1、$2等)会被替换为函数的参数。这也是你读取传递给函数的参数的办法。当函数执行完毕后,这些参数会恢复为它们先前的值。

一些老版本的shell在函数执行之后可能不会恢复位置参数的值。所以如果你想让自己的脚本程序具备可移植性,就最好不要依赖这一行为。

你可以通过return命令让函数返回数字值。让函数返回字符串值的常用方法是让函数将字符串保存在一个变量中,该变量然后可以在函数结束之后被使用。此外,你还可以echo一个字符串并捕获其结果,如下所示:

请注意,你可以使用local关键字在shell函数中声明局部变量,局部变量将仅在函数的作用范围内有效。此外,函数可以访问全局作用范围内的其他shell变量。如果一个局部变量和一个全局变量的名字相同,前者就会覆盖后者,但仅限于函数的作用范围之内。例如,你可以对上面的脚本程序进行如下的修改来查看执行结果:

如果在函数里没有使用return命令指定一个返回值,函数返回的就是执行的最后一条命令的退出码。

实验 从函数中返回一个值

下一个脚本程序my_name演示了函数的参数是如何传递的,以及函数如何返回一个true或false值。你使用一个参数来调用该脚本程序,该参数是你想要在问题中使用的名字。

(1)在shell头之后,我们定义了函数yes_or_no:

(2)然后是主程序部分:

这个脚本程序的典型输出如下所示:

实验解析

脚本程序开始执行时,函数yes_or_no被定义,但先不会执行。在if语句中,脚本程序执行到函数yes_or_no时,先把$1替换为脚本程序的第一个参数Rick,再把它作为参数传递给这个函数。函数将使用这些参数(它们现在被保存在$1、$2等位置参数中)并向调用者返回一个值。if结构再根据这个返回值去执行相应的语句。

如你所见,shell有着丰富的控制结构和条件语句。接下来,你需要学习一些shell的内置命令,然后你就要在不使用编译器的情况下解决一个实际的程序设计问题了!

2.6.5 命令

你可以在shell脚本程序内部执行两类命令。一类是可以在命令提示符中执行的“普通”命令,也称为外部命令(external command),一类是我们前面提到的“内置”命令,也称为内部命令(internal command)。内置命令是在shell内部实现的,它们不能作为外部程序被调用。然而,大多数的内部命令同时也提供了独立运行的程序版本——这一需求是POSIX规范的一部分。通常情况下,命令是内部的还是外部的并不重要,只是内部命令的执行效率更高。

我们在这里将只介绍那些在编写脚本程序时会用到的主要命令,不分内部还是外部。作为一个Linux用户,你可能还知道许多其他可以在命令提示符下执行的合法命令。请记住,除了我们在这里介绍的内置命令外,它们同样也可以在脚本程序中使用。

1.break命令

你可以用这个命令在控制条件未满足之前,跳出for、while或until循环。你可以为break命令提供一个额外的数值参数来表明需要跳出的循环层数,但我们并不建议读者这么做,因为它将大大降低程序的可读性。在默认情况下,break只跳出一层循环。

2.:命令

冒号(:)命令是一个空命令。它偶尔会被用于简化条件逻辑,相当于true的一个别名。由于它是内置命令,所以它运行的比true快,但它的输出可读性较差。

你可能会看到将它用作while循环的条件,while :实现了一个无限循环,代替了更常见的while true。

: 结构也会被用在变量的条件设置中,例如:

如果没有:, shell将试图把$var当作一条命令来处理。

在一些shell脚本,主要是一些旧的shell脚本中,你可能会看到冒号被用在一行的开头来表示一个注释。但现代的脚本总是用#来开始一个注释行,因为这样做执行效率更高。

3.continue命令

非常类似C语言中的同名语句,这个命令使for、while或until循环跳到下一次循环继续执行,循环变量取循环列表中的下一个值。

continue可以带一个可选的参数以表示希望继续执行的循环嵌套层数,也就是说你可以部分地跳出嵌套循环。这个参数很少使用,因为它会导致脚本程序极难理解。例如:

它的输出是:

4..命令

点(.)命令用于在当前shell中执行命令:

通常,当一个脚本执行一条外部命令或脚本程序时,它会创建一个新的环境(一个子shell),命令将在这个新环境中执行,在命令执行完毕后,这个环境被丢弃,留下退出码返回给父shell。但外部的source命令和点命令(这两个命令差不多是同义词)在执行脚本程序中列出的命令时,使用的是调用该脚本程序的同一个shell。

因为在默认情况下,shell脚本程序会在一个新创建的环境中执行,所以脚本程序对环境变量所作的任何修改都会丢失。而点命令允许执行的脚本程序改变当前环境。当你要把一个脚本当作“包裹器”来为后续执行的一些其他命令设置环境时,这个命令通常就很有用。例如,如果你正同时参与几个不同的项目,你就可能会遇到需要使用不同的参数来调用命令的情况,比如说调用一个老版本的编译器来维护一个旧程序。

在shell脚本程序中,点命令的作用有点类似于C或C++语言里的#include指令。尽管它并没有从字面意义上包含脚本,但它的确是在当前上下文中执行命令,所以你可以使用它将变量和函数定义结合进脚本程序。

实验 点(.)命令

下面的例子在命令行中使用点命令,但你完全可以把它用在一个脚本程序中。

(1)假设你有两个包含环境设置的文件,它们分别针对两个不同的开发环境。为了设置老的、经典命令的环境,你可以使用文件classic_set,它的内容如下所示:

(2)对于新命令,使用文件latest_set:

你可以通过将这些脚本程序和点命令结合来设置环境,就像下面的示例那样:

实验解析

这个脚本程序使用点命令执行,所以每个脚本程序都是在当前shell中执行。这使得脚本程序可以改变当前shell中的环境设置,即使脚本程序执行结束后,这些改变仍然有效。

5.echo命令

虽然,X/Open建议在现代shell中使用printf命令,但我们还是依照常规使用echo命令来输出结尾带有换行符的字符串。

一个常见的问题是如何去掉换行符。遗憾的是,不同版本的UNIX对这个问题有着不同的解决方法。Linux常用的解决方法如下所示:

但你也经常会遇到:

第二种方法echo -e确保启用了反斜线转义字符(如\c代表去掉换行符,\t代表制表符,\n代表回车)的解释。在老版本的bash中,对反斜线转义字符的解释通常都是默认启用的,但最新版本的bash通常在默认情况下都不对反斜线转义字符进行解释。你所使用的Linux发行版的详细行为请查看相关手册。

如果你需要一种删除结尾换行符的可移植方法,则可以使用外部命令tr来删除它,但它执行的速度比较慢。如果你需要自己的脚本兼容UNIX系统并且需要删除换行符,最好坚持使用printf命令。如果你的脚本只需要运行在Linux和bash上,那么echo-n是不错的选择,虽然你可能需要在脚本的开头加上#! /bin/bash,以明确表示你需要bash风格的行为。

6.eval命令

eval命令允许你对参数进行求值。它是shell的内置命令,通常不会以单独命令的形式存在。我们借用X/Open规范中的一个小例子来演示它的用法:

它输出$foo,而

输出10。因此,eval命令有点像一个额外的$,它给出一个变量的值的值。

eval命令十分有用,它允许代码被随时生成和运行。虽然它的确增加了脚本调试的复杂度,但它可以让你完成使用其他方法难以或者根本无法完成的事情。

7.exec命令

exec命令有两种不同的用法。它的典型用法是将当前shell替换为一个不同的程序。例如:

脚本中的这个命令会用wall命令替换当前的shell。脚本程序中exec命令后面的代码都不会执行,因为执行这个脚本的shell已经不存在了。

exec的第二种用法是修改当前文件描述符:

这使得文件描述符3被打开以便从文件afile中读取数据。这种用法非常少见。

8.exit n命令

exit命令使脚本程序以退出码n结束运行。如果你在任何一个交互式shell的命令提示符中使用这个命令,它会使你退出系统。如果你允许自己的脚本程序在退出时不指定一个退出状态,那么该脚本中最后一条被执行命令的状态将被用作为返回值。在脚本程序中提供一个退出码总是一个良好的习惯。

在shell脚本编程中,退出码0表示成功,退出码1~125是脚本程序可以使用的错误代码。其余数字具有保留含义,如表2-7所示。

表2-7

用0表示成功对于许多C/C++程序员来说可能有些不寻常。在脚本程序中,这种做法的一大优点是:它使得你可以使用多达125个用户自定义的错误代码,而不需要使用一个全局的错误代码变量。

下面是一个简单的例子,如果当前目录下存在一个名为.profile的文件,它就返回0表示成功:

如果你是个精益求精的人,或至少追求更简洁的脚本,那么你可以组合使用前面介绍过的AND和OR列表来重写这个脚本程序,只需要一行代码:

9.export命令

export命令将作为它参数的变量导出到子shell中,并使之在子shell中有效。在默认情况下,在一个shell中被创建的变量在这个shell调用的下级(子)shell中是不可用的。export命令把自己的参数创建为一个环境变量,而这个环境变量可以被当前程序调用的其他脚本和程序看见。从更技术的角度来说,被导出的变量构成从该shell衍生的任何子进程的环境变量。我们用下面两个脚本程序export1和export2来说明它的用法。

实验 导出变量

(1)我们先列出脚本程序export2:

(2)然后是脚本程序export1。在这个脚本的结尾,我们调用了export2:

如果你运行这个脚本程序,你将得到如下的输出:

实验解析

export2脚本只是回显两个变量的值。export1脚本同时设置两个变量,但只导出变量bar,所以当它其后调用export2时,变量foo的值已丢失,但变量bar的值已被导出到第二个脚本中。脚本输出中第一个空行的出现是因为$foo在export2中没有定义,回显一个null变量将输出一个空行。

一旦一个变量被shell导出,它就可以被该shell调用的任何脚本使用,也可以被后续依次调用的任何shell使用。如果脚本export2调用了另一个脚本,bar的值对新脚本来说仍然有效。

set -a或set -o allexport命令将导出它之后声明的所有变量。

10.expr命令

expr命令将它的参数当作一个表达式来求值。它的最常见用法就是进行如下形式的简单数学运算:

反引号(``)字符使x取值为命令expr $x+1的执行结果。你也可以用语法$( )替换反引号``,如下所示:

expr命令的功能十分强大,它可以完成许多表达式求值计算。表2-8列出了主要的一些求值计算。

表2-8

在较新的脚本程序中,expr命令通常被替换为更有效的$((...))语法,这个我们会在本章后面的内容中介绍。

11.printf命令

只有最新版本的shell才提供printf命令。X/Open规范建议我们应该用它来代替echo命令,以产生格式化的输出,但看来几乎没什么人接受这一建议。

它的语法是:

格式字符串与C/C++中使用的非常相似,但有一些自己的限制。主要是不支持浮点数,因为shell中所有的算术运算都是按照整数来进行计算的。格式字符串由各种可打印字符、转义序列和字符转换限定符组成。格式字符串中除了%和\之外的所有字符都将按原样输出。

表2-9列出了它支持的转义序列。

表2-9

字符转换限定符相当复杂,所以我们在这里只列出最常见的用法。更详细的介绍可以参考bash的在线手册或printf在线手册的第一部分(man 1 printf)。如果在手册的第一部分找不到,你可以尝试查找手册的第三部分。字符转换限定符由一个%和跟在后面的一个转换字符组成。主要的转换字符如表2-10所示。

表2-10

格式字符串然后被用来解释printf后续参数的含义并输出结果。例如:

注意,你必须使用双引号括住Hi There字符串,使之成为一个单独的参数。

12.return命令

return命令的作用是使函数返回。我们在前面介绍函数时已提到过它。return命令有一个数值参数,这个参数在调用该函数的脚本程序里被看做是该函数的返回值。如果没有指定参数,return命令默认返回最后一条命令的退出码。

13.set命令

set命令的作用是为shell设置参数变量。许多命令的输出结果是以空格分隔的值,如果需要使用输出结果中的某个域,这个命令就非常有用。

假设你想在一个shell脚本中使用当前月份的名字。系统本身提供了一个date命令,它的输出结果中包含了字符串形式的月份名称,但是你需要把它与其他区域分隔开。你可以将set命令和$(...)结构相结合来执行date命令,并且返回想要的结果。date命令的输出把月份字符串作为它的第二个参数:

这个程序把date命令的输出设置为参数列表,然后通过位置参数$2获得月份。

注意,我们以date命令作为一个简单的例子来说明怎么提取位置参数。由于date命令的输出受本地语言的影响较大,所以在实际工作中,你应该使用date+%B命令来提取月份名字。date命令还有许多其他格式选项,详细资料请参考它的手册页。

你还可以通过set命令和它的参数来控制shell的执行方式。其中最常用的命令格式是set -x,它让一个脚本程序跟踪显示它当前执行的命令。我们将在本章后面介绍程序调试时讨论set命令和它更多的选项。

14.shift命令

shift命令把所有参数变量左移一个位置,使$2变成$1, $3变成$2,以此类推。原来$1的值将被丢弃,而$0仍将保持不变。如果调用shift命令时指定了一个数值参数,则表示所有的参数将左移指定的次数。$*、$@和$#等其他变量也将根据参数变量的新安排做相应的变动。

在扫描处理脚本程序的参数时,经常要用到shift命令。如果你的脚本程序需要10个或10个以上的参数,你就需要用shift命令来访问第十个及其后面的参数。

例如,你可以像下面这样依次扫描所有的位置参数:

15.trap命令

trap命令用于指定在接收到信号后将要采取的行动,我们将在本书后面的内容中详细介绍信号。trap命令的一种常见用途是在脚本程序被中断时完成清理工作。历史上,shell总是用数字来代表信号,但新的脚本程序应该使用信号的名字,它们定义在头文件signal.h中,在使用信号名时需要省略SIG前缀。你可以在命令提示符下输入命令trap -l来查看信号编号及其关联的名称。

对于那些不熟悉信号的人们来说,信号是指那些被异步发送到一个程序的事件。在默认情况下,它们通常会终止一个程序的运行。

trap命令有两个参数,第一个参数是接收到指定信号时将要采取的行动,第二个参数是要处理的信号名。

请记住,脚本程序通常是以从上到下的顺序解释执行的,所以你必须在你想保护的那部分代码之前指定trap命令。

如果要重置某个信号的处理方式到其默认值,只需将command设置为-。如果要忽略某个信号,就把command设置为空字符串’'。一个不带参数的trap命令将列出当前设置的信号及其行动的清单。

表2-11列出了X/Open规范里面规定的能够被捕获的比较重要的一些信号(括号里面的数字是对应的信号编号)。更多细节请参考signal在线手册的第7部分(man 7 signal)。

表2-11

实验 信号处理

下面的脚本演示了一些简单的信号处理方法:

如果你运行这个脚本,在每次循环时按下Ctrl+C组合键(或任何你系统上设定的中断键),将得到如下所示的输出:

实验解析

在这个脚本程序中,我们先用trap命令安排它在出现一个INT(中断)信号时执行rm -f/tmp/my_tmp_file_$$命令删除临时文件。脚本程序然后进入一个while循环,只要临时文件存在,循环就一直持续下去。当用户按下Ctrl+C组合键时,脚本程序就会执行rm -f /tmp/my_tmp_file_$$语句,然后继续下一个循环。因为临时文件现在已经被删除了,所以第一个while循环将正常退出。

接下来,脚本程序再次调用trap命令,这次是指定当一个INT信号出现时不执行任何命令。脚本程序然后重新创建临时文件并进入第二个while循环。这次当用户按下Ctrl+C组合键时,没有语句被指定执行,所以采取默认处理方式,即立即终止脚本程序。因为脚本程序被立即终止了,所以最后的echo和exit语句永远都不会被执行。

16.unset命令

unset命令的作用是从环境中删除变量或函数。这个命令不能删除shell本身定义的只读变量(如IFS)。这个命令并不常用。

下面的脚本第一次输出字符串Hello World,但第二次只输出一个换行符:

使用foo=语句产生的效果与上面脚本中的unset命令产生的效果差不多,但并不等同。foo=语句将变量foo设置为空,但变量foo仍然存在,而使用unset foo语句的效果是把变量foo从环境中删除。

17.另外两个有用的命令和正则表达式

在学习如何应用shell编程中的这个新知识点之前,让我们再来看另外两个非常有用的命令,它们虽然不是shell的一部分,但在编写shell程序时经常用到。同时,我们也将介绍正则表达式,一种出现在所有Linux以及与之关联程序中的模式匹配特征。

find命令

你将看到的第一个命令是find。这是个用于搜索文件的命令,它极其有用,但Linux初学者常常觉得它不易使用,这不仅仅是因为它有选项、测试和动作类型的参数,还因为其中一个参数的处理结果可能会影响到后续参数的处理。

在深入研究这些选项、测试和参数之前,让我们首先看一个非常简单的例子,它用来在本地机器上查找名为test的文件。为了确保你具有搜索整个机器的权限,请以root用户身份来执行这个命令:

根据你所使用系统的不同,你可能还会找到其他几个名称也为test的文件。正如你可能猜测的那样,这个命令的含义是:从根目录开始查找名为test的文件,并且输出该文件的完整路径。这非常简单。

然而,这个命令的执行确实需要花费很长的时间,并且网络上的Windows机器的硬盘也会高速转动。这是因为Linux机器挂载(使用SAMBA)了一大块Windows机器的文件系统,看起来似乎是Windows文件系统也被搜索了,尽管我们知道要查找的文件应该在Linux机器上。

这就是我们要介绍的第一个选项发挥作用的时候了。如果你指定-mount选项,你就可以告诉find命令不要搜索挂载的其他文件系统的目录。

我们仍然能找到文件,但这次搜索速度会更快,同时也不必再搜索挂载的其他文件系统。

find命令的完整语法格式如下所示:

path部分很容易理解:你既可以使用绝对路径,如/bin,也可以使用相对路径,如.。如果需要,你也可以指定多个路径,如find /var /home。

find命令有许多选项可用,表2-12列出了一些主要的选项。

表2-12

下面是测试部分。可以提供给find命令的测试非常多,每种测试返回的结果有两种可能:true或false。find命令开始工作时,它按照顺序将定义的每种测试依次应用到它搜索到的每个文件上。如果一个测试返回false, find命令就停止处理它当前找到的这个文件,并继续搜索。如果一个测试返回true, find命令将继续下一个测试或对当前文件采取行动。表2-13只列出了最常用的测试,请参考find命令的手册页以了解所有可以使用的测试。

表2-13

你还可以用操作符来组合测试。大多数操作符有两种格式:短格式和长格式,如表2-14所示。

表2-14

你可以通过使用圆括号来强制测试和操作符的优先级。由于圆括号对shell来说有其特殊的含义,所以你还必须使用反斜线来引用圆括号。此外,如果你在文件名处使用的是匹配模式,你就必须在模式上使用引号以确保模式没有被shell扩展,而是直接传递给find命令。例如,如果你想写一个测试“搜索的文件比文件X要新,或者文件名以下划线开头”,你可以这样写:

我们将在下一个“实验解析”部分之后举这样一个例子。

实验 使用带测试的find命令

在当前目录下搜索比文件while2要新的文件:

这个结果看起来不错,不过在结果中还包括了当前目录,而这并不是你想要的,你只对普通文件感兴趣。所以你会增加一个额外的测试-type f:

实验解析

它是如何工作的呢?你指定find命令应该在当前目录(.)中搜索比文件while2要新的文件(-newer while2),如果这个测试通过,然后再测试这个文件是否是一个普通文件(-type f)。最后,你使用前面已经讲过的-print来确认搜索到的文件。

现在来查找以下划线开头的文件或比while2文件要新的文件,但在两种情况下都必须是普通文件。这个例子将演示如何使用圆括号来对测试进行组合:

可以看出完成这个任务并不是很困难。你必须转义圆括号使得它们不会被shell处理,而且还需要将*号用引号括起使得它被直接传递给find命令。

现在你已可以可靠地搜索文件了。下面来看看在发现匹配指定条件的文件之后,你可以执行的动作。表2-15只列出了最常见的动作,完整的动作列表请见find命令的手册页。

-exec和-ok命令将命令行上后续的参数作为它们参数的一部分,直到被\;序列终止。实际上,-exec和-ok命令执行的是一个嵌入式命令,所以嵌入式命令必须以一个转义的分号结束,使得find命令可以决定什么时候它可以继续查找用于它自己的命令行选项。魔术字符串{}是-exec或-ok命令的一个特殊类型的参数,它将被当前文件的完整路径取代。

表2-15

上面的解释可能并不容易理解,但通过一个例子可以将其解释得更清楚。我们来看一个比较简单的例子,它使用一条非常安全的命令ls:

如你所见,find命令非常有用。你只需通过一点练习就可以很好地掌握它。无论如何,这点练习是完全值得的,所以请使用find命令来进行实验。

grep命令

我们将介绍的第二个非常有用的命令是grep,这个不寻常的名字代表的是通用正则表达式解析器(General Regular Expression Parser,简写为grep)。你使用find命令在系统中搜索文件,而使用grep命令在文件中搜索字符串。事实上,一种非常常见的用法是在使用find命令时,将grep作为传递给-exec的一条命令。

grep命令使用一个选项、一个要匹配的模式和要搜索的文件,它的语法如下所示:

如果没有提供文件名,则grep命令将搜索标准输入。

我们首先来查看grep命令的一些主要选项,它们列在了表2-16中,完整的选项列表请见grep命令的手册页。

表2-16

实验 基本的grep命令用法

我们来看一些使用grep命令的简单例子:

实验解析

第一个例子未使用选项,它只是在文件words.txt中搜索字符串in,然后输出匹配的行。文件名未输出是因为你只在一个文件中进行搜索。

第二个例子在两个不同的文件中计算匹配行的数目。在这种情况下,文件名被输出。

最后一个例子使用-v选项对搜索取反,在两个文件中计算不匹配行的数目。

正则表达式

正如你所看到的,grep命令的基本用法非常容易掌握。现在是时候介绍正则表达式的基础知识了,它允许你实现更复杂的匹配。正如我们在本章前面提到的那样,正则表达式被广泛应用于Linux和许多其他开源编程语言中。你可以在vi编辑器或Perl脚本中使用它们,而且不论它们出现在哪里,其基本原理都是一样的。

在正则表达式的使用过程中,一些字符是以特定方式处理的。最常使用的特殊字符如表2-17所示。

表2-17

如果想将上述字符用作普通字符,就需要在它们前面加上\字符。例如,如果想使用$字符,你需要将它写为\$。

在方括号中还可以使用一些有用的特殊匹配模式,如表2-18所示。

表2-18

另外,如果指定了用于扩展匹配的-E选项,那些用于控制匹配完成的其他字符可能会遵循正则表达式的规则(见表2-19)。对于grep命令来说,我们还需要在这些字符之前加上\字符。

表2-19

这看上去有点复杂,但如果你实际应用它,将会发现它并不像第一眼看上去那么复杂。掌握正则表达式的最简单方法就是尝试一些实验。

(1)我们的第一个例子是查找以字母e结尾的行。你可能会猜到需要使用特殊字符$,如下所示:

如你所见,这个命令找到了以字母e结尾的行。

(2)现在假设想要查找以字母a结尾的单词。要完成这一任务,你需要使用方括号括起的特殊匹配字符。在本例中,你将使用的是[[:blank:]],它用来测试空格或制表符:

(3)下面我们来查找以Th开头的由3个字母组成的单词。在本例中,你既需要使用[[:space:]]来划定单词的结尾,还需要用字符(.)来匹配一个额外的字符:

(4)最后,我们用扩展grep模式来搜索只有10个字符长的全部由小写字母组成的单词。我们通过指定一个匹配字母a到z的字符范围和一个重复10次的匹配来完成这一任务:

我们在这里只涉及了正则表达式中最重要的内容。与Linux中大多数事物一样,系统中的大量文档可以帮助你了解更多的细节,但学习正则表达式最好的方法是实际操作。

2.6.6 命令的执行

编写脚本程序时,你经常需要捕获一条命令的执行结果,并把它用在shell脚本程序中。也就是说,你想要执行一条命令,并把该命令的输出放到一个变量中。

你可以用在本章前面set命令示例中介绍的$(command)语法来实现,也可以用一种比较老的语法形式`command`,这种用法目前依然很常见。

请注意,在脚本程序里执行命令的比较老的语法形式时,使用的是反引号(`),而不是我们在前面使用的单引号(')(单引号的作用是防止变量扩展)。只有当你需要使自己的脚本程序具备非常好的可移植性时,你才应该使用这种比较老的方法。

所有的新脚本程序都应该使用$(...)形式,引入这一形式的目的是为了避免在使用反引号执行命令时,处理其内部的$、`、\等字符所需要应用的相当复杂的规则。如果在反引号`...`结构中需要用到反引号,它就必须通过\字符进行转义。这些相对晦涩的字符往往会让程序员感到困惑,有时即使是经验丰富的shell脚本程序员也必须反复进行实验,才能确保在反引号命令中引号的使用不会出错。

$(command)的结果就是其中命令的输出。注意,这不是该命令的退出状态,而是它的字符串形式的输出结果。例如:

因为当前目录是一个shell环境变量,所以程序的第一行不需要使用这个命令执行结构。但如果我们想要在脚本程序中使用who命令的输出结果,就需要使用这个结构。

如果想要将命令的结果放到一个变量中,你可以按通常的方法来给它赋值,如下所示:

这种把命令的执行结果放到变量中的能力是非常强大的,它使得在脚本程序中使用现有命令并捕获其输出变得很容易。如果需要把一条命令在标准输出上的输出转换为一组参数,并且将它们用做为另一个程序的参数,你会发现命令xargs可以帮你完成这一工作。具体细节请参考它的手册页。

有时,当你打算调用的命令在输出你想要的内容之前先输出了一些空白字符,或者它输出的内容比你想要的要多的时候也会出现问题。此时,你可以用前面介绍的set命令来解决。

1.算术扩展

我们已经介绍过expr命令,通过它可以处理一些简单的算术命令,但这个命令执行起来相当慢,因为它需要调用一个新的shell来处理expr命令。

一种更新更好的办法是使用$((...))扩展。把你准备求值的表达式括在$((...))中能够更有效地完成简单的算术运算。如下所示:

注意,这与x=$(...)命令不同,两对圆括号用于算术替换,而我们之前见到的一对圆括号用于命令的执行和获取输出。

2.参数扩展

你已经见过形式最简单的参数赋值和扩展了,如下所示:

但当你想在变量名后附加额外的字符时就会遇到问题。假设你想编写一个简短的脚本程序,来处理名为1_tmp和2_tmp的两个文件。你可能会这样写:

但是在每次循环中,你都会看到如下所示的出错信息:

哪里出错了呢?

问题在于shell试图替换变量$i_tmp的值,而这个变量其实并不存在。shell并不会认为这是一个错误,仅仅会将它替换为一个空值,因此根本不会有参数被传递给my_secret_process。为了保护变量名中类似于$i部分的扩展,你需要把i放在花括号中,如下所示:

在每次循环中,变量i的值替换了${i},从而给出正确的文件名。也就是说,你把参数的值替换进了一个字符串。

你可以在shell中采用多种参数替换方法。对于多参数处理问题来说,这些方法通常会提供一种精巧的解决方案。表2-20列出了一些常见的参数扩展方法。

表2-20

当处理字符串时,这些替换通常是很有用的。特别是上表中对字符串进行部分删除的最后4个参数扩展方法,在处理文件名和路径时非常有用,请看下面的例子。

实验 参数的处理

下面脚本程序的各个部分分别演示了各种参数匹配操作符的用法:

它的输出结果如下:

实验解析

第一条语句${foo:-bar}给出的值是bar,这是因为在这条语句执行时foo没有值。这条语句执行后,变量foo未发生变化,它还停留在未设置状态。

如果这条语句是${foo:=bar},那么变量$foo就会被赋值。这个字符串操作符的作用是,检查变量foo是否存在且不为空。如果它不为空,就返回它的值,否则就把变量foo赋值为bar并返回这个值。

${foo:? bar}语句将在变量foo不存在或它设置为空的情况下,输出foo:bar并异常终止脚本程序。最后,${foo:+bar}语句将在变量foo存在且不为空的情况下返回bar。选择可太多了!

{foo#*/}语句仅仅匹配并删除最左边的/(记住,*匹配零个或多个字符)。{foo##*/}语句匹配并删除尽可能多的字符,所以它删除最右边的/及其前面的所有字符。

{bar%local*}语句匹配从右边起直到第一次出现local(及跟在它后面的所有字符),而{bar%%local*}语句则从右边起尽可能多地匹配字符,直到遇到最靠左边的local。

因为UNIX和Linux系统都非常依赖过滤器的思想,所以一个操作的结果常常必须手工进行重定向。假设你想使用cjpeg程序将一个GIF文件转换为一个JPEG文件:

但有时,你可能希望对大量文件执行这类操作,那么如何实现自动重定向呢?很简单,像下面这样做即可:

这个脚本名为giftojpeg,它为当前目录中的每个GIF文件创建一个对应的JPEG文件。

2.6.7 here文档

在shell脚本程序中向一条命令传递输入的一种特殊方法是使用here文档。它允许一条命令在获得输入数据时就好像是在读取一个文件或键盘一样,而实际上是从脚本程序中得到输入数据。

here文档以两个连续的小于号<<开始,紧跟着一个特殊的字符序列,该序列将在文档的结尾处再次出现。<<是shell的标签重定向符,在这里,它强制命令的输入是一个here文档。这个特殊字符序列的作用就像一个标记,它告诉shell here文档结束的位置。因为这个标记序列不能出现在传递给命令的文档内容中,所以应该尽量使它既容易记忆又相当不寻常。

实验 使用here文档

最简单的例子就是给cat命令提供输入数据,如下所示:

它的输出如下所示:

here文档功能可能看起来相当奇怪,但其实它的作用很大。因为它可以用来调用交互式的程序,比如一个编辑器,并向它提供一些事先定义好的输入。但它更常见的用途是在脚本程序中输出大量的文本,就像你在刚才的示例中看到的那样,从而可以避免用echo语句来输出每一行。你可以在标识符两端都使用感叹号(!)来确保不会引起混淆。

如果想按预定的方式处理一个文件中的几行,你可以使用ed行编辑器,并在脚本程序中通过here文档向它提供命令。

实验here文档的另一个用法

(1)我们从名为a_text_file的文件开始,它的内容如下所示:

(2)你可以通过结合使用here文档和ed编辑器来编辑这个文件:

运行这个脚本程序,现在这个文件的内容是:

实验解析

这个脚本程序只是调用ed编辑器并向它传递命令,先让它移动到第三行,然后删除该行,再把当前行(因为第三行刚刚被删除了,所以当前行现在就是原来的最后一行,即第四行)中的is替换为was。完成这些操作的ed命令来自脚本程序中的here文档——在标记!FunkyStuff!之间的那些内容。

注意,我们在here文档中用\字符来防止$字符被shell扩展。\字符的作用是对$进行转义,让shell知道不要尝试把$s/is/was/扩展为它的值,而它也确实没有值。shell把\$传递为$,再由ed编辑器对它进行解释。

2.6.8 调试脚本程序

脚本程序的调试通常都很容易,但并没有特定的工具帮助我们进行调试。在本节中,我们将简单讲述一些常用的方法。

出现错误时,shell一般都会打印出包含错误的行的行号。如果这个错误并不是非常明显,你可以添加一些额外的echo语句来显示变量的内容,也可以通过在shell中交互式地输入代码片段来对它们进行测试。

因为脚本程序是解释执行的,所以在脚本程序的修改和重试过程中没有编译方面的额外开支。跟踪脚本程序中复杂错误的主要方法是设置各种shell选项。为此,你可以在调用shell时加上命令行选项,或是使用set命令。表2-21列出了各种选项。

表2-21

你可以用-o选项启用set命令的选项标志,用+o选项取消设置,对简写版本也是一样的处理方法。你可以通过使用xtrace选项来得到一份简单的执行跟踪报告。在调试的初始阶段,你可以先使用命令行选项的方法,但如果想获得更好的调试效果,你可以将xtrace标志(用来启用或关闭执行命令的跟踪)放到脚本程序中问题代码的前后。执行跟踪功能让shell在执行每行语句之前,先输出该行并对该行中变量进行扩展。

使用下面的命令来启用xtrace选项:

再用下面的命令来关闭xtrace选项:

默认情况下,变量扩展的层次由每行代码前的+号个数指出。你可以通过对shell配置文件中的shell变量PS4进行设置,将+号修改为更有意义的字符。

在shell中,你还可以通过捕获EXIT信号,从而在脚本程序退出时查看到程序的状态。具体做法是在脚本程序的开始处添加类似下面这样的一条语句: