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 变量中)的形式返回这些值:true为零,false为其他值。test 命令的一般形式是
例如
test "Hello World" = "Hello World"
此测试用于检查两个字符串是否相等,返回退出状态为零。还有一个'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 之后的命令列表。
- 如果最后一个命令返回状态零,则执行第一个 then 之后的命令列表,并在该列表中的最后一个命令完成执行后终止该语句。
- 如果最后一个命令返回非零状态,则执行第一个 elif(如果有)之后的命令列表。
- 如果最后一个命令返回状态零,则执行下一个 then 之后的命令列表,并在该列表中的最后一个命令完成执行后终止该语句。
- 如果最后一个命令返回非零状态,则执行下一个 elif(如果有)之后的命令列表,依此类推。
- 如果 if 或 elif 之后的任何命令列表都没有以零状态终止,则执行 else(如果有)之后的命令列表。
- 该语句终止。如果该语句在没有错误的情况下终止,则返回状态为零。
有趣的是,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 语句之间究竟有什么区别?以及为什么要有两种如此相似的语句?嗯,技术上的区别在于: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 的值,它应该是一个食物偏好。如果该值为水果或以 veg 开头的任何东西,我们将向脚本结果添加一个断言,表明某人是一个素食主义者。如果该值恰好是肉类,那么这个人是一个肉食者。其他任何情况,他都是一个杂食动物。请注意,在最后一个 case 模式子句中,我们必须在变量替换中使用大括号;这是因为我们希望直接在句子的现有值上添加一个字母 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!
除了条件执行机制外,每种编程语言都需要一种重复机制,即重复执行一组命令。Bourne Shell 提供了几种用于此目的的机制:while 语句、until 语句和 for 语句。
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 语句也是一个重复语句,但它是 while 语句的语义相反。until 语句的通用形式为
do command-list2
do command-list2
done
until [ $lines -eq 10000 ]
do
lines=`wc -l dates | awk '{print $1}'`
sleep 5
done
while 语句主要用于建立某种效果(“重复直到完成”),而 until 语句更常用于轮询某个条件的存在或等待某个条件满足。例如,假设某个正在运行的进程将 10000 行写入某个特定文件。以下 until 语句将等待该文件增长到 10000 行。
等待 myfile.txt 增长到 10000 行。for 循环
for 语句循环遍历一组固定的、有限的值。它的通用形式为
for name in w1 w2 ...
for myval in Abel Bertha Charlie Delta Easy Fox Gumbo Henry India
do
echo $myval Company
done
该语句对“in”之后的每个命名值为命令列表执行。在命令列表中,可以通过变量name来获取“当前”值wi。值列表必须通过分号或换行符与“do”分隔。并且命令列表必须通过分号或换行符与“done”分隔。因此,例如
一个打印一些值的 for 循环
Abel Company
Bertha Company
Charlie Company
Delta Company
Easy Company
Fox Company
Gumbo Company
#!/bin/sh
for arg
do
echo $arg
done
for 语句被大量用于循环遍历命令行参数。为此,shell 甚至为此用途提供了一种简写表示法:如果您省略“in”和值部分,则该命令将假定“$*”作为值列表。例如
使用 for 循环遍历命令行参数
$ sh loop_args.sh A B C D
A
C
Dfor 的这种用法通常与 case 结合使用,以处理命令行开关。
在上一节关于控制流的内容中,我们讨论了 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 识别以下元字符
- *
- 匹配任何字符串。
- ?
- 匹配任何单个字符。
- [字符]
- 匹配尖括号中包含的任何字符。
- [!字符]
- 匹配尖括号中不包含的任何字符。
- 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' printworkingdirectory,所以这真的很容易
所以哪里出错了?嗯,单引号会禁用对所有特殊字符的解释。所以我们用于命令替换的反引号不起作用!我们能用其他方法吗?比如使用工作目录环境变量($PWD)?不,$-字符也不起作用。
这是一个典型的“金发姑娘”问题。我们想引用一些特殊字符,但不是全部。我们可以使用反斜杠,但这不足以方便(太冷)。我们可以使用单引号,但这会禁用太多特殊字符(太热)。我们需要的是恰到好处的引用。更确切地说,我们想要(而且比你想象的更频繁)禁用所有特殊字符解释,除了变量和命令替换。因为这是一种常见的需求,shell 通过一个单独的引用机制来支持它:双引号。双引号会禁用所有特殊字符解释,除了反引号(命令替换)、$(变量替换)和双引号(这样您就可以停止引用)。所以上面我们问题的解决方案是
顺便说一句,为了教学目的,我们在上面实际上有点作弊(嘿,你试试想出这些例子);我们也可以这样解决问题