shell-骨干流程3——命令展开

总体流程图镇楼:

shell执行流程

命令展开

命令展开是shell变成语言灵活性的最佳体现,如图中绿色部分所示,包含5-11步。展开是对每一个标记(token)分别进行的,共有以下7步,对应总体流程的5-11步。

第5-7步都是各式各样的命令展开。基本的命令展开包括3种,分别为:大括号展开、波浪符号展开、参数与变量展开。这三种命令展开本质上是shell语法糖的展开。

第8,9两步实际上是子命令执行,并非语法糖,原始命令将启动子进程(子shell)来执行子命令,执行的结果作为标记,嵌入到原命令中。5-7步和8-9步的区别在于,5-7步本质是查找语法糖对应的内容进行展开,无需使用子进程;8-9步本质是采纳子进程执行的结果,而非简单的查找替换。

经过5-9步的处理,原始命令已经能够被shell直接执行,因此我们需要第10步将这些替换过后的内容重组起来,根据系统分隔符(Internal Field Seperator,IFS)再次分割(因为命令展开过程中会带来新的内容)。

最后我们还需要第11步,展开路径和文件名,这一步和之前5-7步展开又是不同的,之前是语法糖替换,而这次是使用shell模式匹配方式(通配符)替换。第11步的shell模式匹配是正则表达式简化版,主要是利用通配符,并非完整的正则表达式规则。

当命令展开完成后,造成展开的引号将会被全部移除。作为单词整体引用的引号保持原样。例如,hello "$USER"的引号在变量展开后会移除,而"hello"的引号则不会。(这里理解存疑

shell的模式匹配

在介绍shell中各种各样的展开之前,先说下shell的模式匹配。它是标准正则匹配的简化版,尽量保证了正则匹配中的精华,兼顾了匹配的速度。可用于shell流程中的大括号展开和路径与文件名展开。为了方便说明,我们在/tmp/文件夹下新建patternMatching文件夹,并在其中新建a.txt aaa.txt abc.txt a.md b.md c.html cd.html ddd.html 001.txt这9个文件,作为演示示例。

注意,shell中默认使用自带的模式匹配,而非正则匹配。模式匹配将在命令展开的各方面的到应用

1$ cd /tmp/ && mkdir patternMatching && cd patternMatching
2$ touch a.txt aaa.txt abc.txt a.md b.md c.html cd.html ddd.html 001.txt
3$ ls
4001.txt  aaa.txt  abc.txt  a.md  a.txt  b.md  cd.html  c.html  ddd.html

shell的模式匹配主要是使用了通配符,通用的有3种,当启用“extglob” shell选项时,则可以使用另外5个扩展模式匹配运算符。除了通配符,模式匹配中的其他内容都保留其字面意思,如果要匹配通配符的字面意思,则需要使用反斜杠\进行转义。另外,NULL字符不允许出现在模式匹配中。

通用的三种通配符有*, ?, [ ]

  • “*”:匹配任意字符串,包括空字符。
  • “?”:匹配任何单个字符,必须是一个字符,不匹配空字符。
  • ”:匹配中括号中给定的一个字符。例如[abdgAD134]就是匹配中括号中的六个字母和三个数字。如果是连续的字符,可以使用连字符分隔的一对字符,来表示匹配范围内的任一字符。在默认环境中,[a-dx-z]相当于[abcdxyz]。如果是不想匹配中括号中的内容,可以在开头加上!^,表示取反,如[!a-z][^a-z]则只匹配表示不匹配字母'a-z'。
    • 在'['和']'中,可以使用语法指定字符类[[:class:]], 这里的class是下列类型之一,在 POSIX 标准中定义的类型:

      alnum alpha ascii blank cntrl
      digit graph lower print punct
      space upper word xdigit

用法示例:

 1$ ls * # 显示任意内容
 2001.txt  aaa.txt  abc.txt  a.md  a.txt  b.md  cd.html  c.html  ddd.html
 3$ ls a* # a开头的所有文件
 4aaa.txt  abc.txt  a.md  a.txt
 5$ ls a? # ?只匹配一个字符,因此那个都无法匹配上
 6ls: cannot access 'a?': No such file or directory
 7$ ls c?.html # ?匹配一位,所以c.html没有匹配 
 8cd.html
 9$ ls [a-z].* # 匹配所有单个字母为名的文件
10a.md  a.txt  b.md  c.html
11$ $ ls [[:digit:]]* # 匹配文件名第一位是数字的文件
12001.txt
13$ ls [[:alpha:]]* # # 匹配文件名第一位是字母的文件
14aaa.txt  abc.txt  a.md  a.txt  b.md  cd.html  c.html  ddd.html

如果在shell选项中开启了extglob选项(shopt -s extglob),那么shell在模式匹配中可以使用五种扩展模式。

  • ?(pattern-list):匹配零个或一个的给定模式。
  • *(pattern-list):匹配零个或多个的给定模式。
  • +(pattern-list):匹配一个或多个的给定模式。
  • @(pattern-list):匹配一个给定的模式。
  • !(pattern-list):匹配除了给定的模式的其他模式。

根据上述给出的文件,例子如下:

 1$ ls c?([a-z]).html # c后面匹配0或1个字母
 2cd.html  c.html
 3$ ls a*([a-z]).* # 匹配以a开头的任意文件(文件后缀由.*匹配)
 4aaa.txt  abc.txt  a.md  a.txt
 5$ ls a+([a-z]).* # 匹配以a开头,并且不是单个a的文件,也就是说a后面必须有别的字母(文件后缀由.*匹配)
 6aaa.txt  abc.txt
 7$ ls c@([a-z]).html # 严格仅匹配一次
 8cd.html
 9$ ls !([a-z]).* # 不匹配任何单个字母为名的文件(文件后缀由.*匹配)
10001.txt  aaa.txt  abc.txt  cd.html  ddd.html
11$ ls +([[:digit:]]).* # 匹配名称全是数字的文件(文件后缀由.*匹配)
12001.txt

大括号展开

大括号是shell所有展开中的第一步,这意味着在执行大括号展开的时候,其他展开的标识符都存在与其中,这导致大括号展开相对严格且固定的格式,任何不正确的格式都会让shell无法识别大括号展开而原样输出。大括号扩展的语法有两种形式:

1preamble{pattern1,pattern2,...}postscript
2或者
3preamble{start..end[..incr]}postscript

其中,前导preamble和后缀postscript都是可选的,表示大括号左右的内容。

大括号展开本质上是一种生成多个字符串的机制(由一变多),可用于生成路径和文件名称的字符串、参数的字符串甚至用户自定义输入的字符串。先举几个例子:

 1$ echo a{a,b}b # {a,b}生成两个
 2aab abb
 3$ echo a{xy,yx,yz} # 生成3个字符串,无后缀
 4axy ayx ayz
 5$ echo a{a..d}b # 正向序列,生成a,b,c,d四个
 6aab abb acb adb
 7$ echo a{d..a}b # 反向序列,生成d,c,b,a四个
 8adb acb abb aab
 9$ echo a{d..a..2}b # 反向序列,步长为2的方式生成,d,b两个
10adb abb

可以看出,大括号的实际效果是将大括号外的内容和大括号内的每一个内容(用逗号或序列区分)进行组合,生成字符串。在使用逗号进行分割的语法中,大括号内至少只要有一个逗号。若没有逗号,shell将不认为是大括号展开,原样输出内容。这同时意味大括号至少会生成两个字符串,如果为大括号内有空内容,生成的时候也是将空内容和大括号左右组合起来。

1$ echo a{b} # 没有逗号则原样输出
2a{b}
3$ $ echo a{a,,c} # 大括号展开对空内容的处理
4aa a ac

而使用序列进行大括号展开,一定要使用..符号,其左边是起始字符,右边是终止字符,默认包括起始和终止字符。如果需要指定步长(INCR),则使用两次..符号,步长必须是一个整数。。如果不给定步长,shell会根据起始、终止内容自动判断步长为+1或-1。

大括号展开可以复合,即大括号展开可以内嵌到另一个大括号开展中,并且展开顺序是由外向内。《Bash参考手册》上有一个很有借鉴意义的例子,如下:

1chown root /usr/{ucb/{ex,edit},lib/{ex?.?*,how_ex}}

首先将最外层的大括号开展,结果如下:

1chown root /usr/ucb/{ex,edit} /usr/lib/{ex?.?*,how_ex}

然后再次执行大括号展开,最终结果如下:

1chown root /usr/ucb/ex /usr/usb/edit /usr/lib/ex?.?* /usr/lib/how_ex

最后需要强调一点:大括号展开又是优先级最高的展开,因此语法结构固定且严格。单、双引号内的大括号不进行展开(参考《Shell 骨干流程1——形成初步命令》的引号章节);为避免与参数扩展冲突,字符串'${'亦不被认为是大括号扩展。

波浪符号展开

如果一个单词(word)以不带引号的波浪字符(~)开始,那么所有直到第一个非加引号的斜杠(或所有字符,如果没有未加引号的斜杠出现)的字符都被认为是波浪前缀。一般情况下,波浪字符与shell的环境变量有关(如$HOME,$PWD,$OLDPWD),如果波浪字符与数字组合,那么会涉及到shell的dirs内置命令,我们将会单独说明。

  • 波浪字符最常用方法是表示当前用户的HOME目录,即~等同于$HOME。如果波浪字符后面加特定用户名,则表示该用户名的HOME目录。

    1$ echo ~ # 波浪线单独表示当前用户HOME目录
    2/home/lelouch
    3$ echo ~root # 波浪线后面跟用户名表示该用户名的HOME目录
    4/root
    5$ echo ~test
    6/home/test
    
  • 波浪字符与'+,-'组合,与当前路径有关,~+表示当前路径($PWD),~-表示上个路径。'-'用在路径名中通常表示上次所在目录。

    1$ pwd # 原始目录
    2/home/lelouch
    3$ cd /tmp # 切换到/tmp目录
    4$ echo ~+ # 显示当前目录
    5/tmp
    6$ echo ~- # 显示前一个目录
    7/home/lelouch
    
  • 波浪字符、数字N以及可选的'+,-'组合,则与目录堆栈有关。目录堆栈可用dirs命令查看。~N等同于~+N表示从目录堆栈顶部开始数,~-N表示从目录堆栈底部开始数。我们单开一个小节来说明。

shell文件目录堆栈

shell为了方便用户在多个目录之间直接切换,提供了目录堆栈功能。目录堆栈是将若干个目录放到一起,目录的增删遵循后入先出的规则(和堆栈一样),并且用户可以直接访问到目录堆栈中的任一目录,与目录堆栈相关的命令有:pushd, popd, dirs。使用dirs命令可以看到目录堆栈的内容。使用pushd命令切换到目录同时,会将该目录添加到堆栈顶部,而使用popd命令会删除堆栈顶部目录。默认情况下,目录堆栈底部总是保留当前目录位置。

 1$ dirs
 2~
 3# dirs 使用指南
 4dirs [-clpv] [+n] [-n]
 5      当命令不带任何参数时,显示当前目录堆栈的内容,默认情况下,所有内容显示在一行,并以空格分隔。我们使用 pushd 命令向目录堆栈压入新的目录项,使用 popd 删除目录项。当前目录项始终放在目录堆栈的底部。
 6      -c     清除目录堆栈所有条目。
 7      -l     生成一个带有全路径名的列表;默认情况下用波浪线代表用户HOME目录。
 8      -p     一个目录一行的方式显示。
 9      -v     每行一个目录来显示目录栈的内容,每个目录前加上的编号。
10      +n     显示从左到右的第n个目录,数字从0开始。
11      -n     显示从右到左的第n个日录,数字从0开始。

当前所在目录作为目录堆栈的底部常驻目录,无法将其popd出来。

shell通过psuhdpopd两个命令操作目录堆栈,不加任何参数的情况下,使用pushd命令切换到目录同时,会将该目录添加到堆栈顶部,而使用popd命令会删除堆栈顶部目录。若pushd命令不加任何参数,则会将位于记录栈最上面的2个目录对换位置。当然,psuhdpopd有带参数的使用方式。

 1# 注意下面的N是数字,n是字母n参数
 2
 3popd +N  # 删除栈中(从左边数)第N个元素,由0开始计。
 4popd -N  # 删除栈中(从右边数)第N个元素,由0开始计。
 5pop -n # 不改变当前目录(也就是不改变栈顶元素,操作除了栈顶外栈内其他元素)
 6
 7# usage: pushd [-n] [+N | -N | dir]  
 8pushd  # 不加参数时,交换栈顶前两个元素
 9pushd +N  # 将栈内元素循环左移,直到将(从左边数)第N个元素移动到栈顶,由0开始计。
10pushd -N  # 将栈内元素循环左移,直到将(从右边数)第N个元素移动到栈顶,由0开始计。
11pushd -n dir # 将目录入栈,但不改变当前元素,即将目录插入栈中作为第二个元素。 注意-n参数要在目录之前。

借由pushdpopd的特性,可以用shell实现目录间的快速定位。

那么目录堆栈和波浪符号有什么关系呢?答案是波浪符号与加减号、数字的组合是dirs与加减号、数字的组合的简写

1〜N
2等同于'dirs +N'显示的字符串
3
4〜+N
5等同于'dirs +N'显示的字符串
6
7〜-N
8等同于'dirs -N'显示的字符串

举个例子,我们先将/home /usr /etc /tmp ~反向压入目录堆栈。

 1$ dirs
 2/home /usr /etc /tmp ~
 3$ dirs +1
 4/usr
 5$ echo ~1 ~+1 # 二者效果等同于dirs +1
 6/usr /usr
 7$ dirs -1
 8/tmp
 9$ echo ~-1 # 效果等同于dirs -1
10/tmp

参数与变量展开

$在shell中是一个非常重要的符号,参数与变量展开、命令替换、算术表达式计算都与$相关。接下来三个小结,我们将逐个进行介绍。

首先,$最常见的功能是组成参数/变量名称,标准模式为${...},在不会产生误解的情形下,大括号可以省略。在大于等于10的位置参数、特殊模式变量以及名称中带有特殊符号导致变量名称有二义性时,必须要有大括号包裹。shell中的变量和一般编程语言中变量作用类似,但是在设置变量是不需要使用$符合,只有在引用变量时才需要,例子如下:

1$ a=hello
2$ echo $a
3hello
4$ echo ${a}
5hello
6$ a=world
7$ echo $a
8world

除了常规的使用方法,shell还为变量添加了各种各样便利的功能。这些拓展功能都需要使用大括号包裹。

参数间接扩展

如果shell变量中,$符号后面的第一个字符是感叹号!,且后面的内容是一个变量的名称,那么引用的参数并不是“名称”而是该名称对应的实际的值。这就构成了变量间的间接引用。举个例子:

1$ name=linux
2$ paramter=name
3$ echo ${!parameter} # 这里实际等同于${name}
4linux

在上面例子中,${}中第一个字符是感叹号,而且parameter是一个变量的名称,其值为name,因此在执行时shell把!parameter转换成其变量的值,即为name,因此扩展后的结果为${name},最终显示name变量的值linux。当然,如果感叹号后面跟的内容不是变量的名称,那么就会报-bash: parameters: invalid indirect expansion错误。

参数匹配扩展

参数的匹配扩展与参数间接扩展外表相似,但是用法完全不同。参数匹配扩展的更像是查找变量名前缀相同的变量,因此还引入了通配符*,@,所以匹配扩展不会涉及变量实际的值。我们在前一个例子基础上增加几个变量:

1$ name1=ubuntu
2$ name2=redhat
3$ name3=slackware
4$ echo ${!name*}
5name name1 name2 name3
6$ echo ${!name@}
7name name1 name2 name3

可以看出,参数间接扩展获取的是参数,参数匹配扩展获取的符合前缀的参数。如果我们将二者组合起来,就可以获取有相同前缀变量名的值:

1$ for var in ${!name*};do echo "$var--${!var}";done;
2name--linux
3name1--ubuntu
4name2--redhat
5name3--slackware

这个例子中,${!name*}使用的是参数匹配扩展,匹配所有以name为前缀的变量,共找到name, name1, name2, name3四个,然后在for循环遍历中,再用参数间接扩展${!var}var所指向的变量名称的实际值打印出来。

我们在上文中提到参数匹配扩展可以用*,@两种符合,那么使用这两种符号有什么区别吗?区别在于:当表达式被双引号包裹时,@会扩展成独立的几个变量,而*则会扩展成变量组合而成的字符串。还是以上面的几个两边为例:

1$ for var in "${!name@}";do echo "$var--${!var}";done;
2name--linux
3name1--ubuntu
4name2--redhat
5name3--slackware
6$ for var in "${!name*}";do echo "$var--${!var}";done;
7-bash: name name1 name2 name3: invalid variable name

在双引号中使用@符号生成的是一个序列,可以通过for循环遍历;而通过*符号生成的是一个字符串,for循环会直接读取整个字符串name name1 name2 name3作为$var的值,因此无法找到对应的变量,从而报invalid variable name错误。

空参数处理

由于shell是一个弱类型且语法较为宽松的编程语言,因此不会对变量是否存在、变量是否为NULL以及变量类型进行检测。如果使用了一个未设置的变量,shell直接返回空。这让我们在写程序时很容易携程难以查找的bug,因此shell提供了空参数处理的扩展。

空参数处理的格式为:${变量名[:][-+=?]变量值}。其中起不起用冒号有区别,后面[-+=?]四选一。

我们首先来解释有没有冒号:的区别。当没有冒号的时候,即${变量名[-+=?]变量值}时,shell只检测变量是否存在,相当于test -v 变量名;而使用冒号的时候,即${变量名:[-+=?]变量值}时,shell不仅检测变量时候存在,而且检测变量是否为空,相当于test -v 变量名 && test -z 变量名

test -v用于检测变量是否存在,变量存在返回结果0,不存在返回结果1;test -z用于检测变量长度是否为0,如果为0返回0,否则返回1。

  • ${parameter:-word},如果变量parameter未设置或者值为空,那么给出word的值;否则给出$parameter的值。
  • ${parameter:-word},如果变量parameter未设置或者值为空,那么给出word的值同时把word的值赋给$parameter,否则给出$parameter的值;注意无法给位置参数和特殊参数赋值;
  • ${parameter:?word}用于交互式shell,如果变量parameter未设置或者值为空,那么将word内容作为标准错误返回给shell界面,否则给出$parameter的值。
  • ${parameter:+word}${parameter:-word}的相反操作。如果parameter为空或未设置,则不进行任何替换,否则给出word的值。

下面举几个简单的例子来帮助理解空参数处理。

 1$ name=linux
 2$ name1=
 3$ echo ${name:-windows} --- ${name1:-windows} --- ${name-windows} --- ${name1-windows}
 4linux --- windows --- linux ---
 5# name 存在且不为空,因此${name:-windows},${name-windows}给出$name的值,
 6# name1存在但是为空,因此${name1:-windows}给出替代值windows,
 7# 而${name1-windows}只检测到$name1存在,因此直接输出$name1,结果为空值
 8$ echo name1
 9
10# 无论是${name1:-windows}还是${name1-windows}都不会给$name1赋值。
11$ echo ${name:=windows} --- ${name1:=windows}
12linux --- windows
13$ echo $name1
14windows
15# 可以看出使用:=会给原变量赋值。
16$ name1=
17$ echo ${name=windows} --- ${name1=windows}
18linux ---
19$ echo $name1
20
21# 这里是空值,因为$name1存在,因此${name1=windows}不进行赋值操作

子串扩展

shell的子串扩展是从shell变量中切割出一部分(字串)的快捷方法,功能和python的切片类似。格式为:

1${parameter:offset}
2${parameter:offset:length}

其中,offset代表偏移值,为开始切割字符的序数,从0开始算length为切割的长度,如果不指定,默认截取到最后。例子如下

1$ string=01234567890abcdefgh
2$ echo ${string:7} # 不指定length默认到末尾,第一位索引序数为0
37890abcdefgh
4$ echo ${string:7:0} # length长度为0,截取长度也为0
5
6$ echo ${string:7:2} # length长度为2
778
  • 如果offset值为负数,那么子串是从最后开始计算起始点,-1表示最后一位,-2表示倒数第二位。
  • 如果length值大于剩余的字符数,那么子串只截取到末尾。
  • 如果length值为负数,那么length表示的是原字符串截取到所在位序数,不包括length所在的那一位。

需要指出,冒号:和负号-之间至少要用一个空格隔开,否在会被当成是空参数处理:-

例子如下所示:

1$ string=01234567890abcdefgh
2$ echo ${string:7:100} # 当length大于剩余字符串长度时,只截取到末尾
37890abcdefgh
4$ echo ${string: -7:2} # 注意中间有个空格
5bc
6$ echo ${string:0: -5} # -5(d)是原变量倒数第5个索引,但是不包括-5(d)
701234567890abc
8$ echo ${string: -2: -1} # 这个例子更明显的表现出只包含开头,不包含结尾
9g

一般情况下,子串扩展都是从0开头,-1结尾,但是有一种例外,位置参数是按1开始从头计算偏移值

1# 设置位置参数
2$ set -- 0 1 2 3 4 5 6 7 8 9 0 a b c d e f g h
3$ echo ${@:7} # 位置参数开头从1开始计算
46 7 8 9 0 a b c d e f g h
5$ echo ${string:7} # 其他参数都是从0开始计算
67890abcdefgh

参数长度计算

bash shell在参数扩展中还提供了一种计算参数长度的便捷方式,即为

1${#parameter}

对于一般变量,${#parameter}会计算字符串长度,对于位置参数,它会计算位置参数的个数,如果parameter是一个数组名,并且下标为*或者@,表达式扩展为数组的元素个数。例子如下:

1$ set -- a b c d
2$ var=123456789
3$ arr=(1 2 3)
4$ echo ${#@} ${#*}
54 4
6$ echo ${#var}
79
8$ echo ${#arr[@]} ${#arr[*]}
93 3

参数匹配删除

shell可以通过自带的模式匹配来识别特定的“关键字”,并对识别出来的内容进行操作,例如删除、替换等。这一小节,我们先说参数匹配删除,它可以实现一些很常见的操作,如获取文件名、文件后缀、文件路径、数字等。

匹配删除共有四种模式,分别是从头最短检索、从头最长检索、从尾最短检索、从尾最长检索。从头、从尾比较容易理解,就是匹配的时候是从前往后找还是从后往前找。最长、最短通常是对*通配符而言,如果遇到连续多个合适的匹配内容,最短匹配是匹配尽量少的字符、最长匹配是尽量多的匹配字符。按照这个分类方式,我们可以列出参数匹配删除的用法表格:

从头 从尾
最短 ${parameter#word} ${parameter%word}
最长 ${parameter##word} ${parameter%%word}

最短最长比较好记,使用一个符号#,%比较短,是最短匹配;使用两个符号##,%%比较长,是最长匹配。至于从头从尾,我的记忆方法是通常#符号在bash shell中放在开头比较多(注释),因此是从头匹配。

下面还是通过几个例子来方便理解参数匹配删除:

 1# 先设置一些位置参数
 2$ set -- ab-cd-ef== uv-wx-yz==
 3# 使用从头最短匹配,*-匹配到的是ab- ,然后将其删除
 4$ echo ${1#*-}
 5cd-ef==
 6# 使用从头最长匹配,*-匹配到的是ab-cd- ,然后将其删除
 7$ echo ${1##*-}
 8ef==
 9# 使用从尾最短匹配,-*=匹配到的是-ef== ,然后删除
10$ echo ${1%-*=}
11ab-cd
12# 使用从尾最长匹配,-*=匹配到的是-cd-ef== ,然后删除
13echo ${1%%-*=}
14ab

此外,和计算参数长度的扩展一样,如果是使用@,*位置参数或数组,那么参数匹配删除将对其中的每一个元素进行操作。类似于for ... in...的效果。

 1# 在以下几个例子中,表示位置参数或数组的#,@符合可以互换。
 2# 对于使用@,*的位置参数,将会对其中每一个元素进行匹配删除操作
 3$ echo ${@#*-}
 4cd-ef== wx-yz==
 5$ echo ${*%%-*=}
 6ab uv
 7$ arr=(--a --b --c)
 8# 对数组中每一个元素进行从头最短匹配
 9$ echo ${arr[*]#-?(-)} # ?(-)需要启用“extglob” shell选项
10-a -b -c
11# 对数组中每一个元素进行从头最长匹配
12$ echo ${arr[@]##-?(-)} # ?(-)需要启用“extglob” shell选项
13a b c
14$ arr=(a-- b-- c--)
15# 对数组中每一个元素进行从尾最短匹配
16$ echo ${arr[*]%-?(-)}
17a- b- c-
18# 对数组中每一个元素进行从尾最长匹配
19$ echo ${arr[@]%%-?(-)}
20a b c
21
22# 几个实用的例子
23# 获取文件名,文件后缀
24$ FILENAME=linux_bash.sh
25$ echo ${FILENAME%.*}
26linux_bash
27$ echo ${FILENAME##*.}
28sh
29# 判断某字符串是否以某字符开头
30$ OPT='-option'
31$ if [ ${OPT#-} != ${OPT} ];
32> then
33>     echo "start with -"
34> else
35>     echo "not start with -"
36> fi
37start with -

参数匹配替换

前面介绍了参数的匹配删除,bash还提供了匹配替换的功能,也是通过自带的模式匹配来识别特定的“关键字”再进行替换。其格式为:

1${parameter/[/#%]pattern/string}

其中,parameter是待替换的原变量,pattern是匹配的模式,即需要匹配的内容,string是替换的内容。如果pattern匹配到了符合的字符子串,那么就用string替换匹配到的内容;如果pattern未匹配到相关内容,则不做任何操作。需要指出的是,参数匹配替换都是用的是最长匹配。参数匹配替换的详细用法有以下6点:

  1. 默认情况下,即${parameter/pattern/string},是从头匹配一个pattern内容并替换。
  2. pattern/符号开头时,即${parameter//pattern/string},将会匹配所有符合pattern的内容。
  3. pattern#符号开头时,即${parameter/#pattern/string},将会匹配以pattern开头的内容。
  4. pattern%符号开头时,即${parameter/%pattern/string},将会匹配以pattern结尾的内容。
  5. 如果启用了nocasematch shell选项,则不考虑字母的大小写。
  6. 和参数长度计算、参数匹配删除一样,如果是使用@,*位置参数或数组,那么参数匹配删除将对其中的每一个元素进行操作。类似于for ... in...的效果。

我们以字符串string=abceddabceddabcedd为例说明参数匹配替换的用法。

 1$ string=abceddabceddabcedd
 2$ echo ${string/d?a/+} # 匹配到abce "dda" bceddabcedd
 3abce+bceddabcedd
 4# 匹配替换都是使用最长匹配
 5$ echo ${string/d*a/+} # 匹配到abce "ddabcedda" bcedd
 6abce+bcedd
 7# 替换所有d?a
 8$ echo ${string//d?a/f} #  匹配到abce "dda" bce "dda" bcedd
 9abce+bce+bcedd
10# 替换以a开头的内容
11$ echo ${string/#a??/+} # 匹配到 "abc" eddabceddabcedd
12+eddabceddabcedd
13$ echo ${string/#b??/+} # 没有b开头的内容,因此未匹配到任何内容
14abceddabceddabcedd
15# 替换以b*d结尾的内容
16$ echo ${string/%b*d/+} # 匹配到a "bceddabceddabcedd"
17a+
18# 对于位置参数的处理
19$ set -- abc abd abe
20$ echo ${@/a/+}
21+bc +bd +be
22# 对于数组的处理
23$ arr=(abc abd abe)
24$ echo ${arr[@]/a/+}
25+bc +bd +be

大小写修改

bash shell在匹配的基础上还提供了一个小功能,就是将匹配出来的内容进行大小写修改,实际上用的不多,可以作为了解。注:此操作仅适用于bash4.0往上版本。

其基本格式为:

1# 小写转大写: ^会把开头的小写字母转换成大写,^^会转换所有小写成大写
2${parameter^pattern} 
3${parameter^^pattern}
4
5# 大写转小写: ,会把开头的大写转换成小写,,,会把所以大写转换成小写
6${parameter,pattern}
7${parameter,,pattern

举几个简单的例子:

 1# 小写转大写
 2$ par='abc'
 3$ echo ${par^} # 首字母
 4Abc
 5$ echo ${par^^} # 全部
 6ABC
 7# 大写转小写
 8$ par='ABC'
 9$ echo ${par,}
10aBC
11$ echo ${par,,}
12abc

此外,如果是使用@,*位置参数或数组,那么大小写修改将对其中的每一个元素进行操作。类似于for ... in...的效果。

变量操作

在bash4.0 版本以后,添加了一些方便使用的小功能,这些被统合为变量操作,格式为${parameter@operator},作用为根据操作符(operator)执行参数转换或者,操作符如下:

  • Q:将字符串使用引号包裹。
  • E:对于使用反斜线\后的字符一律按转义处理。
  • P:如果parameter含有prompt string时,按照prompt解释。
  • A:拓展成参数赋值的语句。
  • a:由参数属性值组成的字符串。

参数扩展做为bash中最多样,最灵活的扩展为shell提供了多样和简洁的处理方式,其本质上是多种小工具的聚合体,有的是为了弥补bash shell本身的弱点,有的是为了综合了使用率最高的小工具。同时这也是bash shell中非常容易出错的部分,需要我们谨慎地使用。

命令替换

shell通过命令替换能够完成一些运行时的动态变化。命令替换,就是在执行bash shell是用命令的输出替换命令本身。有两种格式:

1# 旧版使用反引号
2`command`
3# 新版使用$()
4$(command)

Bash通过在子shell环境中执行命令替换的内容来进行扩展,并将命令替换为命令执行后的标准输出,并删除任何结尾的换行符。嵌入的换行符不会被删除,但在后面再次分词过程中可能会被删除。简单的例子如下:

1$ echo `whoami`
2lelouch
3$ echo $(whoami)
4lelouch
5$ echo "hello,`whoami`"
6hello,lelouch
7$ echo "hello,$(whoami)"
8hello,lelouch

那为什么用新版的$()替代旧版的反引号和呢?第一个原因就是反引号"`"太容易和单引号"'"搞混了,二者长得太像不利于排错。第二个原因就是反引号不容易复合,需要使用转义符号\而使用$()则直接嵌套就能使用。举个例子,我们要查看当前用户home目录中的一个test.sh文件:

 1# 使用反引号命令替代,反引号需要转义
 2$ cat `ls /home/\`whoami\`/test.sh`
 3#!/bin/bash
 4read filename
 5read url
 6if test -w $filename && test -n $url
 7then
 8    echo $url > $filename
 9    echo "写入成功"
10else
11    echo "写入失败"
12fi
13# 使用$()命令替代
14$ cat $(ls /home/$(whoami)/test.sh)
15#!/bin/bash
16read filename
17read url
18if test -w $filename && test -n $url
19then
20    echo $url > $filename
21    echo "写入成功"
22else
23    echo "写入失败"
24fi

命令替代在shell自适应编程里非常重要。因为面向不同用户编程时,各个用户的环境变量,配置文件路径都有差异,bash shell可以通过命令替代了解当前用户的上下文,为shell脚本提供灵活处理不同环境的能力。

算术表达式扩展

算术表达式扩展允许计算算术表达式和替换结果,广义上也属于一种命令替换。算术表达式扩展使用$和两个小括号,格式是:

1$(( expression ))

特别地,bash shell中的算术表达式扩展只支持固定长度的证书运算,任何浮点数运算都会被认为是一个错误而不做任何替换操作。此外,算术表达式扩展也是可以复合的。

 1$ echo $((1+1))
 22
 3$ echo $((10/4))
 42
 5$ echo $((6%5))
 61
 7$ echo $((6-15))
 8-9
 9# 不支持浮点数运算
10echo $((1.1+3))
11-bash: 1.1+3: syntax error: invalid arithmetic operator (error token is ".1+3")
12# 复合的算法表达式
13$ echo $((10+$((3*2)) ))
1416

bash shell的计算能力只是辅助作用,虽然除0错误被捕获并标记为错误,但是bash shell没有溢出检查,不支持浮点运算,也没有强大的数学公式库,因此并不推荐使用bash shell做复杂的数学运算。

再次单词分割

在《shell骨干流程1——形成初步命令》一文中,我们了解到,shell命令处理流程的第一步就是使用元字符把输入内容分割为标记(token),以便bash shell进行保留字识别和流程控制。但是经过大括号展开、波浪符号展开、命令替换、算数运算扩展这些步骤,会带来新的内容。因此,作为shell处理流程的第10步。我们需要将这些替换过后的内容重新整肃,根据系统分隔符(Internal Field Seperator,IFS)再次将命令分割成一个个便于处理的标记(token,包括控制符和单词)。也是说,如果没有前面几步的扩展,也就不需要再次进行分割。

默认的IFS有space, tab, newline以及他们的组合,例如连续的空格,tab或者多个连续的空行。

在这次处理中,显式的空参数("" 或 '')被保留并作为空字符串传递给命令,未加引号的隐式空参数将被删除。

路径与文件名展开

如果bash中没有设置-f选项,就会支持路径与文件名扩展,其扩展模式也是通过bash shell自带的模式匹配。比如,显示/etc目录下的所有配置文件:

1ls /etc/*.conf

或是列出所有以字母ab开头的配置文件:

1ls /etc/[ab]*.conf

或者是显示所有的以image开头的后面跟着一个字符格式为.jpg的文件:

1ls image?.jpg

在路径与文件名扩展中,字符.在文件名开头或斜线后必须显式匹配,除非设置了shell选项dotglob。匹配文件名时,斜杠字符必须始终显示匹配。更多文件匹配的例子可以看文章开头shell的模式匹配小节。

shell命令展开作为四大步骤中最复杂的步骤,是我花时间精力最多的部分,其多样性和灵活性为shell脚本提供了强大的生命力。经过上述11步的操作,shell命令终于可以真正进入执行阶段了。

参考内容

  1. https://www.gnu.org/software/bash/manual/bash.html
  2. https://zhuanlan.zhihu.com/p/65599187