Bourne Shell 脚本/控制流
到目前为止,我们已经讨论了基础知识和理论。我们已经涵盖了可用的不同 shell 以及如何在 Bourne Shell 中运行 shell 脚本。我们已经讨论了 Unix 环境,并且我们已经看到你拥有控制环境的变量,以及可以用来存储供自己使用的值的变量。然而,我们还没有真正做任何事情。我们还没有让系统行动,跳过障碍,去取报纸或洗碗。
在本章中,是时候认真对待了。在本章中,我们将讨论编程 - 如何编写能够做出决策并执行命令的程序。在本章中,我们将讨论控制流和命令执行。
程序启动器和命令 shell 之间有什么区别?为什么 Bourne Shell 是一款在全球范围内拥有强大力量和尊重的工具,而不仅仅是一个用来启动“真正”程序的愚蠢小工具?因为 Bourne Shell 不仅仅是一个启动程序的环境:Bourne Shell 是一个完全可编程的环境,拥有完整编程语言的力量。我们在环境中已经看到 Bourne Shell 在内存中拥有变量。但 Bourne Shell 能做的不止这些:它可以做出决策并重复命令。像任何真正的编程语言一样,Bourne Shell 拥有控制流,可以控制计算机。
在我们能够在 shell 脚本中做出决策之前,我们需要一种评估条件的方法。我们必须能够检查某些情况的状态,以便能够根据我们的发现做出决策。
奇怪的是,实际的 shell 不包含任何此类机制。有一个专门用于此目的的工具称为 test(它确实是为 shell 脚本创建的),但它严格来说并不是 shell 的一部分。'test' 工具评估条件并返回 true 或 false,具体取决于它发现了什么。它以退出状态的形式返回这些值(在 $? shell 变量中):0 表示 true,其他值表示 false。test 命令的一般形式是
例如
test "Hello World" = "Hello World"
此字符串相等性测试返回退出状态 0。'test' 还有一种简写形式,它在脚本中通常更具可读性,即方括号
请注意括号和实际条件之间的空格 - 不要忘记在自己的脚本中添加空格。上面示例的简写形式是
[ "Hello World" = "Hello World" ]
'Test' 可以评估许多不同类型的条件,以适应你可能想要在 shell 脚本中进行的不同类型的测试。大多数特定 shell 都在基本条件集的基础上进行了扩展,但 Bourne Shell 识别以下条件
文件条件
- -b file
- file 存在且是块特殊文件
- -c file
- file 存在且是字符特殊文件
- -d file
- file 存在且是目录
- -f file
- file 存在且是常规数据文件
- -g file
- file 存在且其设置组 ID 位已设置
- -k file
- file 存在且其粘滞位已设置
- -p file
- file 存在且是命名管道
- -r file
- file 存在且可读
- -s file
- file 存在且其大小大于零
- -t [n]
- 具有编号 n 的打开文件描述符是终端设备;n 是可选的,默认值为 1
- -u file
- file 存在且其设置用户 ID 位已设置
- -w file
- file 存在且可写
- -x file
- file 存在且可执行
字符串条件
- -n s
- s 长度非零
- -z s
- s 长度为零
- s0 = s1
- s0 和 s1 相同
- s0 != s1
- s0 和 s1 不同
- s
- s 不为空(通常用于检查环境变量是否有值)
整数条件
- n0 -eq n1
- n0 等于 n1
- n0 -ge n1
- n0 大于或等于 n1
- n0 -gt n1
- n0 严格大于 n1
- n0 -le n1
- n0 小于或等于 n1
- n0 -lt n1
- n0 严格小于 n1
- n0 -ne n1
- n0 不等于 n1
最后,条件可以组合和分组
- \( B \)
- 括号用于分组条件(不要忘记反斜杠)。如果 B 为真,则分组条件 (B) 为真。
- ! B
- 否定;如果 B 为假,则为真。
- B0 -a B1
- 并且;如果 B0 和 B1 都为真,则为真。
- B0 -o B1
- 或者;如果 B0 或 B1 为真,则为真。
好的,现在我们知道如何评估一些条件。让我们看看如何利用这种能力进行一些编程。
所有编程语言都需要两件事:决策或条件执行形式,以及重复或循环形式。我们将在后面讨论循环,现在让我们关注条件执行。Bourne Shell 支持两种条件执行形式,即 if 语句和 case 语句。
if 语句是两者中最通用的。它的通用形式是
then command-list
elif command-list
then command-list
... else command-list
此命令应解释如下
- 执行 if 后的命令列表。
- 如果最后一个命令返回状态 0,则执行第一个 then 后的命令列表,并且语句在该列表中最后一个命令完成后终止。
- 如果最后一个命令返回非零状态,则执行第一个 elif(如果有)后的命令列表。
- 如果最后一个命令返回状态 0,则执行下一个 then 后的命令列表,并且语句在该列表中最后一个命令完成后终止。
- 如果最后一个命令返回非零状态,则执行下一个 elif(如果有)后的命令列表,依此类推。
- 如果没有 if 或 elif 后的命令列表以零状态终止,则执行 else(如果有)后的命令列表。
- 语句终止。如果语句在没有错误的情况下终止,则返回状态为 0。
值得注意的是,if 语句允许在任何地方使用命令列表,包括在评估条件的地方。这意味着你可以在到达决策点之前执行任意数量的复合命令。影响决策结果的唯一命令是列表中执行的最后一个命令。
但是,在大多数情况下,为了提高可读性和可维护性,你应该将自己限制为一个用于条件的命令。在大多数情况下,此命令将是 'test' 工具的使用。
rank=captain
if [ "$rank" = colonel ]
then
echo Hannibal Smith
elif [ "$rank" = captain ]
then
echo Howling Mad Murdock
elif [ "$rank" = lieutenant ]
then
echo Templeton Peck
else
echo B.A. Baracus
fi
case 语句类似于 if 语句的一种特殊形式,专门用于上一个示例中展示的测试类型:获取一个值并将其与一组固定的预期值或模式进行比较。case 语句经常用于评估脚本的命令行参数。例如,如果您编写了一个使用开关来识别命令行参数的脚本,您就会知道合法的开关数量有限。在这种情况下,case 语句是 if 语句的一个优雅的替代方案。
case 语句的一般形式是
pattern0 ) command-list-0 ;;
pattern1 ) command-list-1 ;;
...
该值可以是任何值,包括环境变量。每个模式都是一个正则表达式,执行的命令列表是第一个与该值匹配的模式的命令列表(所以确保你的模式没有重叠)。每个命令列表必须以双分号结束。如果语句在没有语法错误的情况下终止,则返回状态为零。
rank=captain
case $rank in
colonel) echo Hannibal Smith;;
captain) echo Howling Mad Murdock;;
lieutenant) echo Templeton Peck;;
sergeant) echo B.A. Baracus;;
*) echo OOPS;;
esac
If 与 case:有什么区别?
[edit | edit source]那么 if 和 case 语句到底有什么区别?为什么要有这两个如此相似的语句呢?嗯,技术上的区别是这样的:case 语句基于 shell 可用的数据(比如环境变量),而 if 语句基于程序或命令的退出状态。由于固定值和环境变量依赖于 shell,而退出状态是 Unix 系统通用的概念,这意味着 if 语句比 case 语句更通用。
让我们来看一个稍微大一点的例子,把这两个语句放在一起比较一下
#!/bin/sh
if [ "$2" ]
then
sentence="$1 is a"
else
echo Not enough command line arguments! >&2
exit 1
fi
case $2 in
fruit|veg*) sentence="$sentence vegetarian!";;
meat) sentence="$sentence meat eater!";;
*) sentence="${sentence}n omnivore!";;
esac
echo $sentence
请注意,这是一个 shell 脚本,它使用位置变量来捕获命令行参数。脚本以 if 语句开头,检查我们是否拥有正确的参数数量——请注意使用 'test' 来查看变量 $2 的值是否为空,以及 'test' 的退出状态来确定 if 语句如何继续。如果有足够多的参数,我们假设第一个参数是一个名字,并开始构建脚本结果的句子。否则,我们写一条错误信息(到 stderr,这是写入错误的地方;在 文件和流 中阅读更多内容),并以非零返回值退出脚本。请注意,这个 else 语句有一个包含多个命令的命令列表。
假设我们顺利通过了 if 语句,我们就来到了 case 语句。在这里,我们检查变量 $2 的值,它应该是一个食物偏好。如果该值为 fruit 或以 veg 开头的任何东西,我们向脚本结果添加一个断言,声称某人是素食主义者。如果该值为 exactly meat,该人是肉食主义者。其他任何东西,他都是杂食动物。请注意,在最后一个 case 模式子句中,我们必须在变量替换中使用花括号;这是因为我们想直接在 sentence 的现有值上添加一个字母 n,两者之间没有空格。
让我们把脚本放到一个名为 'preferences.sh' 的文件中,看看对这个脚本进行一些调用会产生什么效果
$ sh preferences.sh
Not enough command line arguments!
$ sh preferences.sh Joe
Not enough command line arguments!
$ sh preferences.sh Joe fruit
Joe is a vegetarian!
$ sh preferences.sh Joe veg
Joe is a vegetarian!
$ sh preferences.sh Joe vegetables
Joe is a vegetarian!
$ sh preferences.sh Joe meat
Joe is a meat eater!
$ sh preferences.sh Joe meat potatoes
Joe is a meat eater!
$ sh preferences.sh Joe potatoes
Joe is an omnivore!
重复
[edit | edit source]除了条件执行机制外,每种编程语言都需要一种重复机制,即重复执行一组命令。Bourne Shell 为此提供了多种机制:while 语句、until 语句和 for 语句。
while 循环
[edit | edit source]while 语句是 Bourne shell 中最简单、最直接的重复语句形式。它也是最通用的。其一般形式如下
do command-list2
done
while 语句的解释如下
- 执行命令列表 1 中的命令。
- 如果最后一个命令的退出状态为非零,则语句终止。
- 否则,执行命令列表 2 中的命令,并返回步骤 1。
- 如果语句不包含语法错误,并且它最终终止,则它将以退出状态零终止。
与 if 语句非常相似,您可以使用完整的命令列表来控制 while 语句,并且只有该列表中的最后一个命令才能真正控制该语句。但在现实中,您可能希望将自己限制在一个命令,并且像 if 语句一样,您通常会使用 'test' 程序来执行该命令。
counter=0
while [ $counter -lt 10 ]
do
echo $counter
counter=`expr $counter + 1`
done
1
2
3
4
5
6
7
8
9
while 语句通常用于处理脚本可以有不定数量的命令行参数的情况,方法是使用 shift 命令和指示命令行参数数量的特殊变量 '$#'
#!/bin/sh
while [ $# -gt 0 ]
do
echo $1
shift
done
until 循环
[edit | edit source]until 语句也是一种重复语句,但它在语义上与 while 语句相反。until 语句的一般形式是
do command-list2
对该语句的解释几乎与 while 语句相同。唯一的区别是,只要命令列表 1 中的最后一个命令返回非零状态,就执行命令列表 2 中的命令。或者,更简单地说:只要循环条件没有满足,就执行命令列表 2。
虽然 while 语句主要用于建立某种效果(“重复执行直到完成”),但 until 语句更常用于轮询某个条件的存在或等待某个条件满足。例如,假设某个进程正在运行,该进程将把 10000 行写入某个文件。下面的 until 语句等待该文件增长到 10000 行
until [ $lines -eq 10000 ]
do
lines=`wc -l dates | awk '{print $1}'`
sleep 5
done
for 循环
[edit | edit source]在关于 控制流 的部分,我们讨论了 if 和 case 之间的区别,前者依赖于命令退出状态,而后者与 shell 中可用的数据密切相关。这种配对也存在于重复语句中:while 和 until 使用命令退出状态,而 for 使用 shell 中明确可用的数据。
for 语句遍历一组固定的、有限的值。其一般形式是
do command-list
该语句为 'in' 之后命名的每个值执行命令列表。在命令列表中,"当前"值 wi 通过变量 name 可用。值列表必须用分号或换行符与 'do' 分隔。命令列表必须用分号或换行符与 'done' 分隔。例如
for myval in Abel Bertha Charlie Delta Easy Fox Gumbo Henry India
do
echo $myval Company
done
伯莎公司
查理公司
德尔塔公司
易公司
福克斯公司
甘博公司
亨利公司
印度公司
for 语句经常用于遍历命令行参数。出于这个原因,shell 甚至为此用途提供了一种简写符号:如果您省略了 'in' 和值部分,该命令会将 $* 视为值列表。例如
#!/bin/sh
for arg
do
echo $arg
done
A
B
C
D
命令执行
[edit | edit source]在上一节关于 控制流 的内容中,我们讨论了 Bourne Shell 提供的主要编程结构和控制流语句。然而,shell 中还有很多其他语法结构,允许您控制命令的执行方式,并将命令嵌入到其他命令中。在本节中,我们将讨论其中一些更重要的结构。
命令连接
[edit | edit source]之前,我们已经了解了 if 语句作为条件执行的一种方法。除了这个扩展的语句外,Bourne Shell 还提供了一种将两个命令直接连接起来的方法,使其中一个命令的执行取决于另一个命令的结果(退出状态)。这对于对命令执行进行快速、内联的决策非常有用。但是您可能不想在 shell 脚本或更长的命令序列中使用这些结构,因为它们的可读性不是很好。
您可以使用 && 和 || 运算符将命令连接在一起。这些运算符(您可能会认识到它们是从 C 编程语言借来的)是短路运算符:它们使第二个命令的执行依赖于第一个命令的退出状态,因此您可以避免不必要的命令执行。
&& 运算符将两个命令连接在一起,只有当第一个命令的退出状态为零(即第一个命令“成功”)时,才会执行第二个命令。请看以下示例
echo Hello World > tempfile.txt && rm tempfile.txt
在这个例子中,如果文件创建失败(例如,因为文件系统是只读的),那么删除将毫无意义。使用&& 运算符可以防止在文件创建失败的情况下尝试删除。一个类似的,可能更有用的例子是这个
test -f my_important_file && cp my_important_file backup
与&& 运算符相反,|| 运算符仅当第一个命令的退出状态不为零(即失败)时,才会执行第二个命令。请看以下示例
test -f my_file || echo Hello World > my_file
对于这两个运算符,连接的命令的退出状态是实际执行的最后一个命令的退出状态。
您可以使用; 运算符将多个命令连接到一个命令列表中,如下所示
mkdir newdir;cd newdir
这里没有条件执行;所有命令都会执行,即使其中一个命令失败。
将命令连接到命令列表时,可以将命令分组在一起以提高清晰度和一些特殊处理。有两种方法可以对命令进行分组:使用大括号和使用圆括号。
使用大括号进行分组可以增强清晰度。使用它们不会为使用分号或换行符连接添加任何语义,但是您必须在命令列表后插入一个额外的分号或换行符。大括号和命令列表之间的空格对于 shell 识别分组是必需的。以下是一个示例
{ mkdir newdir;cd newdir; }
或
{
mkdir newdir
cd newdir
}
大括号还可以用来将命令分组在一起,以将它们集成到管道中并重定向它们的输入或输出。这与在相同位置使用函数完全相同。
stderr
。首先使用函数,然后使用组。dappend() {
date
cat
}
echo "Hello, today's world" | dappend 1>&2
或
echo "Hello, today's world" | { date;cat; } 1>&2
圆括号更有趣。当您使用圆括号对命令列表进行分组时,它将在一个单独的进程中执行。这意味着您在命令列表中所做的任何事情都不会影响您发出命令的环境。再次考虑上面的例子,使用大括号和圆括号
再举一个例子
$ VAR0=A
$ (VAR1=B)
$ echo \"$VAR0\" \"$VAR1\"
在关于环境的章节中,我们讨论了变量替换。Bourne Shell 还支持命令替换。这有点像变量替换,但不是用变量的值替换变量,而是用命令的输出替换命令。我们在之前讨论while语句时看到了一个例子,我们用算术表达式计算的结果来分配环境变量。
命令替换是使用两种表示法中的任何一种完成的。原始 Bourne Shell 使用重音符 (`command`),它通常仍受大多数 shell 支持。后来 POSIX 1003.1 标准添加了$( command ) 表示法。请看以下示例
cp myfile backup/myfile-`date`
cp myfile backup/myfile-$(date)
通常,在您使用 shell 执行的日常任务中,您希望明确准确地说明要操作哪些文件。毕竟,您希望删除一个特定文件,而不是随机文件。您希望将网络通信发送到网络设备文件,而不是发送到键盘。
但是,有时,尤其是在脚本中,您需要能够一次操作多个文件。例如,如果您编写了一个脚本,定期备份主目录中所有以“.dat”结尾的文件。如果这些文件很多,或者每天都会创建更多新的文件,每次都有新的名称,那么您不想在备份脚本中显式地命名所有这些文件。
我们还看到了另一个不想过于明确的例子:在关于case语句的部分中,有一个例子表明,如果有人喜欢水果或以“veg”开头的任何东西,那么这个人就是一个素食主义者。我们可以在那里包含各种各样的选项,并且明确(尽管您可以用“veg”开头制作无限多个单词)。但是我们使用了一个模式,省去了很多时间。
对于这些确切的情况,shell 支持正则表达式的(有限)形式:允许您说类似于“我的意思是每个字符串,每个字符序列,看起来有点像这样”之类的东西。shell 允许您在任何地方使用这些正则表达式(尽管它们并不总是合理 - 例如,使用正则表达式来指定要复制文件的位置是没有意义的)。这意味着在 shell 脚本中,在交互式 shell 中,作为case语句的一部分,用于选择文件、通用字符串、任何东西。
为了创建正则表达式,您将使用一个或多个元字符。元字符是 shell 有特殊意义的字符,并自动被识别为正则表达式的一部分。Bourne shell 识别以下元字符
- *
- 匹配任何字符串。
- ?
- 匹配任何单个字符。
- [characters]
- 匹配尖括号中包含的任何字符。
- [!characters]
- 匹配尖括号中未包含的任何字符。
- pat0|pat1
- 匹配与pat0 或 pat1 匹配的任何字符串(仅在case语句模式中!)。
以下是一些示例,说明您如何在 shell 中使用正则表达式
ls *.dat
ls file-??.txt
for i in *.txt; do cp $i backup/$i-`date +%Y%m%d`; done
ls Backup[01]
ls Backup[!01]
myscript*.sh
在选择文件时,元字符匹配所有文件,除了名称以句点(“.”)开头的文件。以句点开头的文件要么是特殊文件,要么被认为是配置文件。出于这个原因,这些文件是半受保护的,因为您不能仅仅使用元字符来选择它们。为了在使用正则表达式选择时包含这些文件,您必须显式地包含开头的句点。例如
上面的示例显示了句点文件的列表。在这个示例中,列表包括“.profile”,它是 Bourne Shell 的用户配置文件。它还包括特殊目录“.”(表示“当前目录”)和“..”(表示当前目录的父目录)。您可以像其他任何目录一样访问这些特殊目录。所以例如
在语义上与“ls”相同,而
将您的工作目录更改为之前作为工作目录的目录的父目录。
当您引入像上一节中讨论的元字符这样的特殊字符时,您会自动进入真正不希望这些特殊字符被评估的情况。例如,假设您有一个文件,其名称包含一个星号('*')。您将如何访问该文件?例如
echo Test0 > asterisk*.file
echo Test1 > asteriskSTAR.file
cat asterisk*.file
Test1
显然,需要一种方法来临时关闭元字符。Bourne Shell 内置的引用机制可以做到这一点。事实上,它们的功能远不止于此。例如,如果您有一个文件名中包含空格的文件(因此 shell 无法判断文件名中的不同单词属于一起),引用机制将帮助您解决这个问题。
Bourne Shell 中有三种引用机制
- \
- 反斜杠,用于单字符引用。
- ''
- 单引号,用于引用整个字符串。
- ""
- 双引号,用于引用整个字符串,但仍允许某些特殊字符。
其中最简单的是反斜杠,它引用紧随其后的字符。所以,例如
echo *
ional1.sh~ conditional.sh conditional.sh~ dates error_test.sh error_test.sh~ fil
e with spaces.txt looping0.sh looping1.sh out_nok out_ok preferences.sh pre
ferences.sh~ test.txt
因此反斜杠基本上在单个字符的持续时间内禁用特殊字符解释。有趣的是,换行符在此上下文中也被认为是一个特殊字符,因此您可以使用反斜杠将命令拆分为多行,以便解释器理解。就像这样
反斜杠转义符也适用于包含空格的文件名
ls file with spaces.txt
ls: 无法访问 with: 没有此文件或目录
ls: 无法访问 spaces.txt: 没有此文件或目录
但是,如果你想将反斜杠传递给 shell 呢?想想看,反斜杠会禁用对单个字符的解释,所以如果你想将反斜杠用于其他用途... 那么 '\\' 可以做到!
所以我们看到,反斜杠允许你通过引用来禁用对单个字符的特殊字符解释。但是,如果你想一次引用很多特殊字符呢?正如你在上面带空格的文件名中看到的,你可以分别引用每个特殊字符,但这很快就会变得很麻烦。通常,直接引用整个字符字符串更快、更简单,并且更容易避免错误。要做到这一点,你需要使用单引号。两个单引号引用它们包围的整个字符串,禁用对该字符串中所有特殊字符的解释 - 除了单引号本身(这样你就可以停止引用)。例如
让我们尝试一下。假设出于某种奇怪的原因,我们想打印三个星号("***"),然后是一个空格,然后是当前工作目录,再是一个空格,最后是三个星号。我们知道可以用单引号禁用元字符的解释,所以这应该没什么大不了的,对吧?为了方便起见,内置命令 'pwd' 会print working directory,所以这真的很容易
到底哪里错了?嗯,单引号会禁用对所有特殊字符的解释。所以我们用于命令替换的反引号不起作用!我们可以用其他方法来实现吗?例如使用工作目录环境变量($PWD)?不行,$-字符也不起作用。
这是一个典型的“金发姑娘”问题。我们想要引用一些特殊字符,但不是全部。我们可以使用反斜杠,但这不够方便(太冷了)。我们可以使用单引号,但这会禁用太多特殊字符(太热了)。我们需要的是恰到好处的引用。更确切地说,我们想要(而且比你想象的更频繁)的是禁用所有特殊字符解释,除了变量和命令替换。由于这是一个常见的需求,shell 通过一个单独的引用机制来支持它:双引号。双引号会禁用所有特殊字符解释,除了反引号(命令替换)、$(变量替换)和双引号本身(这样你就可以停止引用)。所以我们上面问题的解决方案是
顺便说一句,为了教学目的,我们在上面实际上稍微作弊了一下(嘿,你试试想出这些例子);我们也可以这样解决这个问题