Shell 骨干流程2——复合命令与控制流程

复合命令与控制流程

复合命令是通过shell保留字和简单命令组合形成的组合命令,算是shell脚本语言的基本结构。每一个复合命令结构都由每一个保留字或控制符开始,并以对应的保留字或控制符结束,这算是shell编程语言的一个特色,例如以if开头,fi结尾;case开头,esac结尾等等。对shell复合命令结构的输入、输出重定向将被应用到该结构的每一个简单命令中,除非其中有某个简单命令用显式的重定向覆盖该结构的重定向。写shell语言时,为了保证代码可读性,在复合命令结构之间通常用newline来分割,而不是用;来分割,虽然从语法上来讲,二者作用是一样的,但是过长的代码会给后续的维护造成困难。

The Bourne Again SHell(bash)提供循环、条件、组命令、协同四种复合命令,并用相应的保留字指示bash是哪一种复合命令。bash的保留字如以下表格所示:

if then elif else fi time
for in until while do done
case esac coproc select function
{ } [[ ]] !

需要注意的是,在其他编程语言中,continue, break算是关键字,而在shell中continue, break是内置命令。

我们将循环、条件、组命令、协同作为四种控制流程。此外,还有一种特殊的复合命令叫做函数,基本上有点编程基础的人对此都不陌生,shell的函数定义、使用方式和其他编程语言大同小异。

总体处理流程上,shell会先检查第一个标记,如果是可前置保留字(if, for, while, until, case, select, time, function, coproc, {, [[, !),则开启复合命令流程;如果是非可前置保留字(then, elif, fi, in, do, done, esac, }, ]]),且之前没有与之对应的可前置保留字,则报语法错误;如果不是保留字则当作简单命令,执行下一步骤。

shell复合命令——流程控制

基本上所有的变成语言流程控制都包含顺序执行、条件执行、循环执行三种流程控制,shell语言也不例外。此外,shell还有分组命令、协同处理两种特殊的流程控制方法。

循环结构

until循环: 循环执行一系列命令直至条件test-commandstrue时停止。

1# 单行
2until test-commands; do consequent-commands; done
3#多行
4until test-commands
5do
6  consequent-commands
7done

例子:输出 0 ~ 9 的数字

1#!/bin/bash
2
3a=0
4
5until [ ! $a -lt 10 ]
6do
7   echo $a
8   a=`expr $a + 1`
9done

while循环:和until循环相反,循环执行一系列命令直至条件test-commandsfalse时停止。

1# 单行
2while test-commands; do consequent-commands; done
3# 多行
4while test-commands
5do
6  consequent-commands
7done

例子:输出 0 ~ 9 的数字

1#!/bin/bash
2
3a=0
4
5while [ $a -lt 10 ] # 和until相比少了一个取反的"!"
6do
7   echo $a
8   a=`expr $a + 1`
9done

for循环:有两种模式,一种是C风格的条件模式,条件需要用shell算数表达式表示,还有一种python风格的遍历模式。

 1# 单行C风格
 2for (( expr1 ; expr2 ; expr3 )) ; do commands ; done
 3
 4# 多行C风格
 5for (( expr1 ; expr2 ; expr3 ))
 6do
 7  commands
 8done
 9
10# 单行 遍历风格
11for name in [words …] ; do commands; done
12
13# 多行 遍历风格
14for name in [words …]
15do
16  commands
17done

跳出循环:在循环过程中,有时候需要在未达到循环结束条件时强制跳出循环,Shell使用两内置命令来实现该功能:breakcontinuebreak命令允许跳出所有循环(终止执行后面的所有循环)。continue命令与break命令类似,只有一点差别,它不会跳出所有循环,仅仅跳出当前循环,有时甚至是加速循环。

条件结构

首先介绍两个各大语言常见的条件结构if和case。其中的分号都可以用newline替换,反之亦然。"[]"中的内容表示不是一定需要。

if条件结构:

1if test-commands; then
2  consequent-commands;
3[elif more-test-commands; then
4  more-consequents;]
5[else alternate-consequents;]
6fi

代码实例:

 1a=10
 2b=20
 3if [ $a == $b ]
 4then
 5   echo "a 等于 b"
 6elif [ $a -gt $b ]
 7then
 8   echo "a 大于 b"
 9elif [ $a -lt $b ]
10then
11   echo "a 小于 b"
12else
13   echo "没有符合的条件"
14fi

case条件结构:case ... esac为多选择语句,与其他语言中的switch ... case语句类似,是一种多分支选择结构,每个case分支用右圆括号开始,用两个分号;;表示 break,即执行结束,跳出整个case ... esac语句,esac(就是case反过来)作为结束标记。

1case $var in 
2  pattern1 | pattern2)
3    statements ;; 
4  pattern3 | pattern4)
5    statements ;; 
6 ... 
7  *)
8    statements ;;
9esac 

case工作方式如上所示,case后面跟需要判断的变量$var,再后面必须为单词in。该复合语句根据模式匹配case后面的$var,模式类似于正则表达式,多个模式之间用“|”分割,最后必须以右括号结束。$var可以为变量或常数,匹配发现取值符合某一模式后,其间所有命令开始执行直至 ;;。

$var将检测匹配的每一个模式。一旦模式匹配,则执行完匹配模式相应命令后不再继续其他模式。如果无一匹配模式,使用星号*捕获该值,再执行后面的命令。

举一个简单的例子

 1echo '输入 1 到 6 之间的数字:'
 2echo '你输入的数字为:'
 3read aNum
 4case $aNum in
 5    1 | 2)  echo '你选择了 1或2'
 6    ;;
 7    3 | 4)  echo '你选择了 3或4'
 8    ;;
 9    5)  echo '你选择了 5'
10    ;;
11    6)  echo '你选择了 6'
12    ;;
13    *)  echo '你没有输入 1 到 6 之间的数字'
14    ;;
15esac

另外,一些版本的shell还提供了select结构,select in结构用来增强交互性,它可以显示出带编号的菜单,用户输入不同的编号就可以选择不同的菜单,并执行不同的功能。但是个人觉得在实践中,这种结果使用较少,用起来也比较鸡肋,所以不做介绍了,有兴趣同读者可以自行搜索。

条件运算符号与算术运算符号

我们在之前的循环与条件结构中,都会遇到条件判断语句,例如在if后面的内容、while, until后面的内容以及for在C风格下的((...))表达式。条件结构会根据判断语句的返回码决定执行哪些后续内容,有趣的是,由于在shell中,返回码0表示进程正常执行完毕,其他返回码表示进程执行遇到错误。在shell中执行true的返回码为0,执行false的返回码为1;算数表达式计算结果不为0时,返回码为0,计算结果等于0时,返回码为1。还是要记住,条件结构看的是返回码,不是执行的输出。

判断语句一般有三种,一是单中括号[],二是双中括号[[]],三是双小括号(())

单中括号[ ]是bash特有的内置命令,等同于test命令。关于test命令的具体用法,可参考《shell-test命令使用》。

双中括号[[ ]]是bash程序语言的关键字,并不是一个命令,双中括号中的表达式被看作一个单独的元素,计算此元素结果并返回一个退出状态码。由于[[ ]]是关键字,因此,它们和表达式之间都需要空格分割[[ ]]结构比[ ]结构更加通用。在[[]]之间所有的字符都不会发生文件名扩展或者单词分割,但是会发生参数扩展和命令替换。使用[[ ]]条件判断结构,而不是[ ],能够防止脚本中的许多逻辑错误。比如,&&、||、<>操作符能够正常存在于[[ ]]条件判断结构中,但是如果出现在[ ]结构中的话,会报错。比如可以直接使用if [[ $a != 1 && $a != 2 ]], 如果不适用双括号, 则为if [ $a -ne 1] && [ $a != 2 ]或者if [ $a -ne 1 -a $a != 2 ]。此外,[[ ]]支持字符串的模式匹配,使用==, !=操作符时甚至支持shell的模式匹配,此时会把运算符右边的表达式作为一个匹配模式,而不仅仅是一个字符串,比如[[ hello == hell? ]][[ hello == h* ]]结果都为真。[[ ]]中匹配字符串或通配符,不需要引号。

关于双中括号[[ ]]中的匹配,如果我们使用=~操作符,支持字符串的shell模式匹配可升级为POSIX的正则匹配,提供更加丰富的匹配功能。

双小括号(( ))整数扩展。这种扩展计算是整数型的计算,不支持浮点型(( ))结构扩展并计算一个算术表达式的值,如果表达式的结果为0,那么返回的退出状态码为1,或者 是"false",而一个非零值的表达式所返回的退出状态码将为0,或者是"true"。单纯用(( )) 也可重定义变量值,比如a=5; ((a++))可将$a重定义为6。注意由于(( ))是C风格的,因此双括号中的变量可以不使用$符号前缀。括号内支持多个表达式用逗号分开,只要括号中的表达式符合C语言运算规则,比如可以直接使用for((i=0;i<5;i++))

组命令

shell提供了两种方式来将一组命令(无论是简单命令还是复合命令)做一个单元来执行,一是单个小括号( command list ),二是单个大括号{ command list; }。当组命令存在时,可以改变原有的执行流程,组内的命令看作一个小单元一起执行,就像在数学中使用括号改变运算优先级一样;同时,对组命令的重定向将会生效于组内每一条命令。

举一个组命令改变原有的执行流程的例子,。示例:echo $a被执行几次?

1# 先执行a=1;echo $a,再执行a=1 || echo $a,由于a=1成功执行,||右面的echo $a不会执行了
2# 最后再执行最后一个echo $a;共输出两次
3$ a=1 ;echo $a ; a=1 || echo $a ;echo $a
41
51
6# 先执行a=1;echo $a,后面a=1 || (echo $a ;echo $a)做为同优先级的组合命令执行,由于a=1成功执行
7# ||右面的(echo $a ;echo $a)组命令作为一个整体都不会执行,共输出一次
8$ a=1 ;echo $a ; a=1 || (echo $a ;echo $a)
91

那么,使用单个小括号( command list )和单个大括号{ command list; }到底有什么区别呢?

  1. 语法层面。()shell的操作符,因此会被shell解释器自动分割,且不用在小括号左右加空格;而{}shell的保留字,因此需要在左大括号后面添加空格(开头不需要,后面那个右大括号也不需要),同时{ }最后一个命令要加分号分割
  2. 执行层面。当shell执行( )中的命令时将再创建一个新的子shell,然后这个子shell去执行圆括弧中的命令。( )所有的改变只对子shell产生影响,而原shell不受任何干扰,比如在( )内部定义、改变的变量,外面是不受影响的;{ }是在当前shell中执行,不会衍生子shell,{ }中操作都是对当前shell有影响的。

我们据下面这个例子来说明( ){ }的区别。

 1# {} 内部定义变量。原进程可用
 2$ { b=1; } && echo $b
 31
 4# 没有空格、分号的话会将{b=2}整体当作一个命令
 5$ {b=2} && echo $b
 6-bash: {b=2}: command not found
 7# 有分号无空格会报语法错误
 8$ {b=2;} && echo $b
 9-bash: syntax error near unexpected token `}'
10# ()中的命令会在子shell执行,不影响原本的shell
11$ (b=2) && echo $b
121

协同进程

自bash4.0开始,bash引入了一个保留字coproc, 用来在后台创建一个异步执行的子协作进程(co-process)。使用coproc的效果就像在命令结尾加上&符号一样,但是coproc还会创建一个双向管道,将协作进程的输入和输出通过管道与文件句柄相连,与原进程进行通信。如果我们希望原进程和子进程交互执行,可以考虑使用coproc。其语法如下:

1coproc [NAME] command [redirections]
2# 有没有觉得它跟bash中定义函数的语法 function NAME {cmds} 很类似?

创建的协作子进程被命名为给出的参数NAME,如果没有给参数NAME则默认为“COPROC”。但是只有当command不是简单命令时,才可以给它命名,如果是简单命令,则一定不可以添加NAME参数,否则NAME会被当成简单命令的首单词。

coproc命令执行时,shell在当前进程中创建一个名为NAME的数组变量,命令的标准输出同当前进程的文件描述符NAME[0]相连,标准输入NAME[1]相连(和标准输入输出的默认文件描述符相反)。协作进程的进程号保存在变量NAME_PID中,我们可以在当前进程使用shell内建命令wait等待协作进程的结束。

coproc的用法和GO语言中的协程有点类似,感觉在shell实际应用中并不太常见。大多数时候,有类似功效的expect命令更受欢迎。

shell函数

shell中函数是另一种复合命令的方式,shell中的函数定义和使用与其他变成语言中函数大同小异。然而,shell的函数设计有一个特点:函数用起来尽量像个正常的命令。这个特点既有有点也有缺点,优点在于函数和命令使用的一致性,可以简化编程语法,二者互相替换会很方便;但是这也让我们无法直接分清哪些是命令哪些是函数,容易产生二义性。

函数的定义

shell函数的定义遵循以下两种方式:

1# 方法一
2fname () compound-command [ redirections ]
3# 方法二
4function fname [()] compound-command [ redirections ]

其中,function是shell关于函数的保留字fname是给出的函数名称,后面跟的单小括号( )也表明定义的是个函数,其中里面什么都不要添加。后面跟的是函数体,注意函数体一定要是复合命令。由于保留字function和单小括号( )都表明了是定义函数,因此二者至少有一个存在即可。比如:

1funcName () { command; } # 只存在单小括号( )
2function funcName { command; } # 只存在保留字function
3function funcName () { command; } # 单小括号( )、保留字function同时存在

最后的[ redirections ]表示整个函数的重定向。如果,我们想删除定义的函数,可以用unset -f内置命令:

1# 删除名为funcName的函数
2$ unset -f funcName

那么使用function保留字和不使用该保留字有什么区别呢?使用function保留字后,后面的单词funcName一定会被shell当成函数名,即使使用已经存在的命令作为名称也可以,举个例子:

1$ function ls () { whoami; pwd; } 
2$ ls
3lelouch # 这是我自己用户的名称
4/home/lelouch

在这个例子中,我作死把原来的ls命令名称,定义成了一个新的函数,这个函数会执行whoami; pwd;两个命令,所以执行结果并不是ls原本的结果,而是显示当前用户和路径。如果我在作死一点,定义一个另一个函数:

1# 注意这里的ls已经不是过去显示文件的命令,而是之前定义的函数
2$ function pwd () { whoami; ls; }

我在函数体中,又添加了ls,你猜猜现在执行pwd结果会怎么样~~

 1$ pwd
 2lelouch
 3lelouch
 4lelouch
 5lelouch
 6lelouch
 7lelouch
 8lelouch
 9lelouch
10lelouch
11lelouch
12lelouch
13lelouch
14lelouch
15lelouch
16lelouch
17....

结果会无限地执行whoami。因为pwd函数会调用函数ls(不是ls命令),函数ls又会调用函数pwd,……,产生循环调用,命令体中的whoami会被反复执行。这也说明了shell语言并不是一个很严谨的语言,很容易产生能让系统崩溃的错误。function保留字就是能让后面的单词强制变成函数名,覆盖原来的含义。

我们可以设置FUNCNEST环境变量来限制函数嵌套调用的次数:

1$ FUNCNEST=4
2$ pwd
3lelouch
4lelouch
5lelouch
6lelouch
7-bash: pwd: maximum function nesting level exceeded (4)
8# 让我们结束作死,释放ls,pwd两个函数
9$unset -f pwd ls

当函数被嵌套到达4次后,shell会自动停止,防止出现循环调用。

如果不使用function保留字,那么第一个单词就不能某个命令的名称,因为一个单词会被当成要执行的命令名,后面的内容会被当成命令的参数。( )显然会因为不符合参数规范而报错。

1$ ls () { whoami; pwd; }
2-bash: syntax error near unexpected token `('

关于函数名称的规范,shell的要求很松,除了使用function保留字造成的区别外,只要求是不含有$符号的单词(word)就可以(单词中默认不应含有元字符,但是非元字符的符号可以,比如func^@Name)

关于函数体,只要是复合命令都可以。shell在习惯上,会像C语言一样用大括号包裹函数体,需要注意的是由于大括号{ }是shell的保留字,所以左边的大括号后面必须要用空格或者newline分割后面的命令,同时大括号内部的命令也要用分号、&符号或newline分割。本质上这些就是使用大括号的组命令的规范啊。

如果在函数体中定义了局部变量,也是和其他函数一样,函数内定义的局部变量会覆盖外部定义的变量,举个例子:

 1$ func1()
 2>{
 3>    local var='func1 local'
 4>    func2
 5>}
 6
 7$ func2()
 8>{
 9>    echo "In func2, var = $var"
10>}
11$ var=global
12$ func1
13In func2, var = func1 local

函数的使用

shell函数的使用和命令、脚本的使用没有区别,都是命令/函数/脚本名称 参数1 参数2 ...的形式。之前说过这也是shell语言的特色。

由于shell也是解释型语言,当函数执行时,会根据控制流程依次一步步执行,如果遇到错误就自动终止执行。

函数执行完成后的返回值,当函数定义时,如果未检查到语法错误,则定义语句返回状态0。当函数执行时,和其他编程语言一样,shell也用return来返回状态值。只不过shell的return后面只能跟一个数字,而非其他东西。如果return后面什么都没加或者函数体中没有return,则返回函数体最后执行的简单命令的返回值。

我们可以使用declare -f查看当前环境所有的函数名称和定义,declare -F仅查看当前环境所有的函数名称。此外,shell的函数也支持递归,但是递归的层数也受到FUNCNEST环境变量的限制。

说到这里,很多读者会发现,我们没有提到函数最关键的功能——传参执行,即根据传入的参数变量执行函数。在Shell中,调用函数时确实可以向其传递参数。但是,传参的方式是shell语言特有的。在函数体内部,传入的参数通过位置参数$n的形式来代表,例如,$1表示第一个参数,$2表示第二个参数...

下一节,我们将具体说说shell中的参数传递。

shell参数

参数是一种存储值的实体,可以是名称、数字或是特殊字符。参数中用名称存储值的叫变量。变量由一个值和0-N个属性。变量值由赋值语句指定;属性由declare命令指定。赋值语句格式如下,如果要删除变量则用unset命令。

1# 给名为name的变量赋值为value
2$ name=[value]
3# 删除变量
4$ unset name

由于shell把空字符串也认为是合理的变量值,因此value值可以不用给出即name=,此时shell给name空字符串作为默认值。

位置参数

位置参数是由$和数字组成的参数。当一条命令、脚本或函数执行时,后面可以跟多个参数,我们使用位置参数变量来表示这些参数。也就是说在shell中位置参数承担这向函数、脚本传参的使用

其中,$0代表命令、脚本本身,注意不是函数名称,$1代表第1个参数,$2代表第2个参数,依次类推。当参数个数超过10个时,就必须要用大括号把这个数字括起来,例如,${10}代表第 10 个参数,${100}则代表第100个参数。

举个简单的例子:

1$ funWithParam(){
2>     echo "命令/脚本的名称是:$0 !"
3>     echo "第一个参数为 $1 !"
4>     echo "第二个参数为 $2 !"
5>     echo "第十个参数为 $10 !"
6>     # $10 不能获取第十个参数,获取第十个参数需要${10}。当n>=10时,需要使用${n}来获取参数。
7>     echo "第十个参数为 ${10} !"
8>     echo "第十一个参数为 ${11} !"
9> }

输出结果:

1$ funWithParam 1 2 3 4 5 6 7 8 9 34 73
2函数/命令的名称是:-bash !
3第一个参数为 1 !
4第二个参数为 2 !
5第十个参数为 10 !
6第十个参数为 34 !
7第十一个参数为 73 !

特殊参数

除了位置参数,shell为了方便编程,还提供一些特殊参数,如下表所示。

参数处理 说明
$# 传递到脚本或函数的参数个数
$* 以一个单字符串显示所有向脚本传递的参数
$$ 脚本运行的当前进程ID号
$! 后台运行的最后一个进程的ID号
$@ 与$*相同,但是使用时加引号,并在引号中返回每个参数。
$- 显示Shell使用的当前选项,与set命令功能相同。
$? 显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。

需要指出,我们不能给这些特殊参数进行赋值操作。关于$*,S#二者的区别。当$*$@不被双引号" "包围时,它们之间没有任何区别,都是将接收到的每个参数看做一份数据,彼此之间以空格来分隔。但是当它们被双引号" "包含时,就会有区别了:

  • "$*"会将所有的参数从整体上看做一份数据,而不是把每个参数都看做一份数据。
  • "$@"仍然将每个参数都看作一份数据,彼此之间是独立的。

比如传递了5个参数,那么对于$*来说,这5个参数会合并到一起形成一份数据,它们之间是无法分割的;而对于$@来说,这5个参数是相互独立的,它们是5份数据。如果使用echo直接输出$*$@做对比,是看不出区别的;但如果使用for循环来逐个输出数据,立即就能看出区别来。

我们将上一个例子增加一些功能如下,为了方便,我们新建一个test.sh文件存放函数:

 1#! /bin/bash
 2funWithParam(){
 3    echo "命令的名称是:$0 !"
 4    echo "第一个参数为 $1 !"
 5    echo "第二个参数为 $2 !"
 6    echo "第十个参数为 $10 !"
 7    # $10 不能获取第十个参数,获取第十个参数需要${10}。当n>=10时,需要使用${n}来获取参数。
 8    echo "第十个参数为 ${10} !"
 9    echo "第十一个参数为 ${11} !"
10    echo "参数总数有 $# 个!"
11    echo "作为一个字符串输出所有参数 $* !"
12    echo '$*与S@的区别:' # 这里是单引号防止参数展开
13    echo '使用for循环输出$* !' # 这里是单引号防止参数展开
14    for var in "$*"
15    do
16      echo $var
17    done
18    echo '使用for循环输出$@ !' # 这里是单引号防止参数展开
19    for var in "$@"
20    do
21      echo $var
22    done
23    echo "脚本运行的当前进程ID号 $$ !"
24    echo "显示Shell使用的当前选项 $- !"
25    echo "上一个命令的结束状态 $? !"
26}
27funWithParam 1 2 3 4 5 6 7 8 9 34 73

输出结果:

 1$ bash test.sh
 2命令的名称是:test.sh !
 3第一个参数为 1 !
 4第二个参数为 2 !
 5第十个参数为 10 !
 6第十个参数为 34 !
 7第十一个参数为 73 !
 8参数总数有 11 个!
 9作为一个字符串输出所有参数 1 2 3 4 5 6 7 8 9 34 73 !
10$*与S@的区别:
11使用for循环输出$* !
121 2 3 4 5 6 7 8 9 34 73
13使用for循环输出$@ !
141
152
163
174
185
196
207
218
229
2334
2473
25脚本运行的当前进程ID号 30483 !
26显示Shell使用的当前选项 hB !
27上一个命令的结束状态 0 !

参考内容