首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

2.6  shell的语法

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

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

l 条件:shell中的布尔值。

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

l 命令列表。

l 函数。

l shell内置命令。

l 获取命令的执行结果。

l 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

环境变量

说    明

$HOME

当前用户的主目录

$PATH

以冒号分隔的用来搜索命令的目录列表

$PS1

命令提示符,通常是$字符,但在bash中,你可以使用一些更复杂的值。例如,字符串[\u@\h \W]$就是一个流行的默认值,它给出用户名、机器名和当前目录名,当然也包括一个$提示符

$PS2

二级提示符,用来提示后续的输入,通常是>字符

$IFS

输入域分隔符。当shell读取输入时,用来分隔单词的一组字符,它们通常是空格、制表符和换行符

$0

shell脚本的名字

$#

传递给脚本的参数个数

$$

shell脚本的进程号,脚本程序通常会用它来生成一个唯一的临时文件,如/tmp/tmpfile_$$

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

3.参数变量

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

参数变量见表2-3。

表  2-3

参数变量

说    明

$1, $2, ...

脚本程序的参数

$*

在一个变量中列出所有的参数,各个参数之间用环境变量IFS中的第一个字符分隔开

(续)

参数变量

说    明

$@

它是$*的一种精巧的变体,它不使用IFS环境变量,所以当IFS为空时,参数的值不会结合在一起

通过下面的例子,我们可以很容易地看出$@和$*两个参数之间的区别:

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

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

实验:参数和环境变量

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

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

实验解析

这个脚本程序创建变量salutation并显示它的内容,然后显示各种参数变量以及环境变量$HOME;$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命令可以使用的条件类型可以归为三类:字符串比较、算术比较和与文件有关的条件测试,表2-4、表2-5和表2-6描述了这三种条件类型。

表  2-4

字符串比较

结    果

string1 = string2

如果两个字符串相同则结果为真

string1 != string2

如果两个字符串不同则结果为真

-n string

如果字符串不为空则结果为真

-z string

如果字符串为空(一个空串)则结果为真

表  2-5

算术比较

结    果

expression1 -eq expression2

如果两个表达式相等则结果为真

expression1 -ne expression2

如果两个表达式不等则结果为真

expression1 -gt expression2

如果expression1大于expression2则结果为真

expression1 -ge expression2

如果expression1大于或等于expression2则结果为真

expression1 -lt expression2

如果expression1小于expression2则结果为真

expression1 -le expression2

如果expression1小于或等于expression2则结果为真

! expression

如果表达式为假则结果为真,反之亦然

表  2-6

文件条件测试

结    果

-d file

如果文件是一个目录则结果为真

-e file

如果文件存在则结果为真。要注意的是历史上-e选项不可移植,所以通常使用的是-f选项

-f file

如果文件是一个普通文件则结果为真

-g file

如果文件的SGID位被设置则结果为真

-r file

如果文件可读则结果为真

-s file

如果文件的长度不为0则结果为真

-u file

如果文件的SUID位被设置则结果为真

-w file

如果文件可写则结果为真

-x file

如果文件可执行则结果为真

读者可能想知道什么是set-group-id和set-user-id(也叫做set-gid和set-uid)位。set-uid位把程序拥有者的访问权限而不是用户的访问权限分配给程序,而set-gid位把程序所在组的访问权限分配给程序。这两个特殊位都是通过chmod命令的选项s和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上,你就可以使用如下的语法。

echo -n "Is it morning? Please answer yes or no: "

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

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.whlie语句

因为在默认情况下所有shell变量值都被认为是字符串,所以for循环特别适合于对一系列字符串进行循环处理,但在需要执行特定次数命令的场合就显得有些笨拙了。

如果我们想让循环执行二十次,请看使用for循环的脚本程序有多么冗长:

即使使用通配符扩展,你可能也会陷入不知道到底会执行多少次循环的窘境。在这种情况下,我们可以使用一个while循环,它的语法如下所示:

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

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

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

实验:循环、循环、再循环

通过将while结构和数值替换结合在一起,我们就可以让某个命令执行特定的次数。这比我们前面见过的for循环要简化多了。

注意,$(())结构最早出现在ksh中,后来被包括进X/Open规范。早期的shell用expr命令来代替它,我们会在本章的后面部分介绍这个命令,但这样做比较慢并且会占用更多的资源。所以只要有可能,你就应该使用命令的$(())格式。bash支持$(())格式,所以通常情况下我们都使用这种格式。

实验解析

这个脚本程序用[命令来测试foo的值,如果它小于或等于20,就执行循环体。在while循环的内部,语法(($(foo+1))用来对括号内的表达式进行算术赋值,所以foo的值会在每次循环中递增。

因为foo不可能变成空字符串,所以我们在对它的值进行测试时不需要把它放在双引号内加以保护。我们这样做只是因为这是一种良好的编程习惯。

6.until语句

until语句的语法如下所示:

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

until语句非常适合于应用在这样的情况:如果我们想让循环不停地执行,直到某些事件发生。请看下面的例子,我们设置一个警报,当某个特定的用户登录时,该警报就会开始工作,我们通过命令行将用户名传递给脚本程序。如下所示:

7.case语句

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

这看上去有些强制,但case结构允许我们通过一种比较复杂的方式将变量的内容和模式进行匹配,然后再根据匹配的模式去执行不同的代码。

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

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

你在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列表。虽然它们通常在一起使用,但我们将分别介绍它们的语法。

q 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部分。

q 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来编写大型程序感到恐惧,请记住,自由软件基金会FSF的autoconf程序和多个Linux软件包的安装程序就是shell脚本程序。你总是可以保证在Linux系统中有一个基本的shell。通常情况下,如果没有/bin/sh,Linux和UNIX系统根本就不能启动,更不用说允许用户登录系统了,所以你应该可以确信绝大多数的UNIX和Linux系统都会提供一个能够解释并运行你的脚本程序的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可以带一个可选的参数以表示希望继续执行的循环嵌套层数,也就是说你可以部分的跳出嵌套循环。这个参数很少使用,因为它会致使脚本程序极难理解。例如:

它的输出是:

before 1

before 2

before 3

4..命令

点(.)命令用来执行当前shell中的命令。

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

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

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

实验:点(.)命令

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

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

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

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

5.echo命令

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

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

但你也经常会遇到:

第二种方法echo -e确保启用了反斜线转义字符(如\t代表制表符,\n代表回车)的解释。它通常是默认设置的。详细情况请查看相关手册。

如果你需要一种删除结尾换行符的可移植方法,则可以使用外部命令tr来删除它,但它执行的速度比较慢。如果你需要自己的脚本兼容UNIX系统并且需要删除换行符,最好坚持使用printf命令。如果你的脚本只需要运行在Linux和bash上,echo -n是不错的选择。

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

退  出  码

说    明

126

文件不可执行

127

命令未找到

128及以上

出现一个信号

用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:

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

第一个空行的出现是因为变量foo在export2中不可用,所以$foo被赋值为空,echo一个空变量将输出一个空行。

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

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

10.expr命令

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

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

我们将在本章的后面对命令替换做进一步介绍。

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

表  2-8

表达式求值

说    明

expr1 | expr2

如果expr1非零,则等于expr1,否则等于expr2

expr1 & expr2

只要有一个表达式为零,则等于零,否则等于expr1

expr1 = expr2

等于

expr1 > expr2

大于

expr1 >= expr2

大于等于

expr1 < expr2

小于

expr1 <= expr2

小于等于

expr1 != expr2

不等于

expr1 + expr2

加法

expr1 - expr2

减法

expr1 * expr2

乘法

expr1 / expr2

整除

expr1 % expr2

取余

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

11.printf命令

只有最新版本的shell才提供printf命令。X/Open规范建议我们应该用它来代替echo命令以产生格式化的输出。

它的语法是:

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

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

表  2-9

转义序列

说    明

\\

反斜线字符

\a

报警(响铃或蜂鸣)

\b

退格字符

\f

进纸换页字符

\n

换行符

\r

回车符

\t