Shell 骨干流程2——复合命令与控制流程 Jan 3, 2022 · shell CLI处理流程 · 分享到: shell复合命令——流程控制 循环结构 条件结构 条件运算符号与算术运算符号 组命令 协同进程 shell函数 函数的定义 函数的使用 shell参数 位置参数 特殊参数 参考内容 复合命令与控制流程 复合命令是通过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-commands为true时停止。 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-commands为false时停止。 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使用两内置命令来实现该功能:break和continue。break命令允许跳出所有循环(终止执行后面的所有循环)。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; }到底有什么区别呢? 语法层面。()是shell的操作符,因此会被shell解释器自动分割,且不用在小括号左右加空格;而{}是shell的保留字,因此需要在左大括号后面添加空格(开头不需要,后面那个右大括号也不需要),同时{ }最后一个命令要加分号分割。 执行层面。当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 ! 参考内容 https://www.gnu.org/software/bash/manual/bash.html https://www.runoob.com/linux/linux-shell-process-control.html https://www.runoob.com/w3cnote/linux-shell-brackets-features.html http://c.biancheng.net/view/807.html