Bash 脚本编程的一些高级用法

概述

偶然间发现 man bash 上其实详细讲解了 shell 编程的语法,包括一些很少用却很实用的高级语法。就像发现了宝藏的孩子,兴奋莫名。于是参考man bash,结合自己的理解,整理出了这篇文章。

本文并不包含man bash所有的内容,也不会详细讲解shell编程,只会分享一些平时很少用,实际很实用的高级语法,或者是一些平时没太注意和总结的经验,建议有一定shell基础的同学进阶时可以看一看。

当然,这只是 Bash 上适用的语法,不确定是否所有的Shell都能用,请慎用。

shell语法

管道

有一点shell编程基础的应该都知道管道。这是一个或多个命令的序列,用字符|分隔。实际上,一个完整的管道格式是这样的:

[time [-p]] [ ! ] command [ | command2 ... ]

time单独执行某一条命令非常容易理解,统计这个命令运行的时间,但管道这种多个命令的组合,他统计的是某一个命令的时间还是管道所有命令的时间呢?如果保留字 time 作为管道前缀,管道中止后将给出执行管道耗费的用户和系统时间

如果保留字 ! 作为管道前缀,管道的退出状态将是最后一个命令的退出状态的逻辑非值。 否则,管道的退出状态就是最后一个命令的。 shell 在返回退出状态值之前,等待管道中的所有命令返回。

复合命令

我们常见的case ... in ... esac语句,if ... elif ... else语句,while .... do ... done语句,for ... in ...; do ... done,甚至函数function name() {....}都属于复合命令。

for 语句

for循环常见的完整格式是:

for name [ in word ] ;
do
	list ;
done

除此之外,其实还支持类似与C语言的for循环,

for (( expr1 ; expr2 ; expr3 )) ;
do
	list ;
done

返回值是序列 list 中被执行的最后一个命令的返回值;或者是 false,如果任何表达式非法的话。

case 语句

man bash上显示,case语句的完整格式是case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac

展开后应该是这样的:

case word in
	[(] pattern [ | pattern ])
		list
		;;
	...
esac

每一个case的分支,都是pattern,使用与路径扩展相同的匹配规则来匹配,见下面的 路径扩展 章节,且通过|支持多种匹配走同一分支。例如:

case ${val} in
	*linux* | *uboot* )
		...
		;;
	...
esac

如果找到一个匹配,相应的序列将被执行。找到一个匹配之后,不会再尝试其后的匹配。

如果没有模式可以匹配,返回值是 0。否则,返回序列中最后执行的命令的返回值。

select 语句

select语句可以说用得很少,但其实在需要交互选择的场景下非常实用。它的完整格式是:

select name [ in word ]
do
	list 
done

它可以显示出带编号的菜单,用户输入不同的编号就可以选择不同的菜单,并执行不同的功能。我们看一个例子:

#!/bin/bash
echo "What is your favourite OS?"
select name in "Linux" "Windows" "Mac OS" "UNIX" "Android"
do
    echo "You have selected $name"
done

运行结果是这样的:

What is your favourite OS?
1) Linux
2) Windows
3) Mac OS
4) UNIX
5) Android
#? 4↙
You have selected UNIX
#? 1↙
You have selected Linux
#? 9↙
You have selected
#? 2↙
You have selected Windows
#?^D

#?用来提示用户输入菜单编号,这实际是环境变量PS3的值,可以通过改这变量来改用户提示信息。^D表示按下 Ctrl+D 组合键,它的作用是结束 select 循环。

如果用户输入的菜单编号不在范围之内,例如上面我们输入的 9,那么就会给 name 赋一个空值;如果用户输入一个空值(什么也不输入,直接回车),会重新显示一遍菜单。

注意,select 是无限循环(死循环),输入空值,或者输入的值无效,都不会结束循环,只有遇到 break 语句,或者按下 Ctrl+D 组合键才能结束循环。通常和 case in 一起使用,在用户输入不同的编号时可以做出不同的反应。例如

echo "What is your favourite OS?"
select name in "Linux" "Windows" "Mac OS" "UNIX" "Android"
do
    case $name in
        "Linux")
            echo "Linux是一个类UNIX操作系统,它开源免费,运行在各种服务器设备和嵌入式设备。"
            break
            ;;
        "Windows")
            echo "Windows是微软开发的个人电脑操作系统,它是闭源收费的。"
            break
            ;;
       ......
        *)
            echo "输入错误,请重新输入"
    esac
done

( list ) 语句

( list )会让 list 序列将在一个子 shell 中执行。变量赋值和影响 shell 环境变量的内建命令在命令结束后不会再起作用。返回值是序列的返回值。

这个在需要临时切换目录或者改变环境变量的情况下非常使用。例如封装编译内核的命令,实现任何目录下都可以直接编译,我们总需要先cd到内核根目录,再make编译,最后再cd回原目录。例如:

alias mkernel='cd ~/linux ; make -j4 ; cd -'

这样会导致,在编译过程如果Ctrl + C取消返回时,你所处在的目录就变成了~/linux。这种情况下,使用( list )就能解决这问题,甚至都不需要cd -返回原目录,直接退出即可。

alias mkernel='(cd ~/linux ; make -j4)'

也例如,有某个程序比较挫,只能在程序目录执行,在其他目录,甚至上一级目录执行,都会找不到资源文件导致退出,我们可以这样解决:

alias xmind='(cd ~/软件/xmind/XMind_amd64 &>/dev/null && nohup ./XMind &>/dev/null) &'

(( expression)) 语句

表达式 expression 将被求值。如果表达式的值非零,返回值就是 0;否则返回值是 1。这种做法和 let "expression" 等价。

[[ expression ]] 语句

if 语句中,我们喜欢用 if [ expression ]; then ... fi单括号的形式,但看大神们的脚本,他们更常用if [[ expression ]]; then ... fi双括号形式。

[ ... ]等效于test命令,而[[ ... ]]是另一种命令语法,相似功能却更高级,它除了传统的条件表达式(Eg. [ ${val} -eq 0 ])外,还支持表达式的转义,就是说可以像在其他语言中一样使用出现的比较符号,例如><=&&||等。

举个例子,要判断变量val有值且大于4,用单括号需要这么写:

[ -n ${val} -a ${val} -gt 4 ]

用双括号可以这么写:

[[ -n ${val} && ${val} > 4 ]]

当使用==!=操作符时,操作符右边的字符串被认为是一个模式,根据下面 模式匹配 章节中的规则进行匹配。如果匹配则返回值是 0,否则返回1。模式的任何部分可以被引用,强制使它作为一个字符串而被匹配。

引用

这里主要讲的是$'string'特殊格式,注意的是,必须是单引号。它被扩展为string,其中的反斜杠转义字符被替换为 ANSI C 标准中规定的字符。反斜杠转义序列,如果存在的话,将做如下转换:

转义 含义
\a alert (bell) 响铃
\b backspace 回退
\e an escape character 字符 Esc
\f form feed 进纸
\n new line 新行符
\r carriage return 回车
\t horizontal tab 水平跳格
\v vertical tab 竖直跳格
\\ backslash 反斜杠
\' single quote 单引号
\nnn 一个八比特字符,它的值是八进制值 nnn (一到三个数字)
\xHH 一个八比特字符,它的值是十六进制值 HH (一到两个十六进制数字)
\cx 一个 ctrl-x 字符

例如,我希望把有换行的一段话暂存到某个变量:

$ var="第一行"$'\n'"第二行"
$ echo "${var}"
第一行
第二行

参数

数组

Bash 提供了一维数组变量。任何变量都可以作为一个数组;内建命令declare可以显式地定义数组。数组的大小没有上限,也没有限制在连续对成员引用和 赋值时有什么要求。数组以整数为下标,从 0 开始。

除了```declare``定义数组外,更常用的是以下两种方式定义数组变量:

$ array_var=(
	"mem1"
	3
	str
)
$ array_var[4]="mem4"

$ echo ${array_var[@]}
mem1 3 str mem4
$ echo ${array_var[1]}
3

数组的使用跟C语言很像,[] + 下标数字可以访问特定某一个数组成员。花括号是必须的,以避免和路径扩展冲突。

如果下标是 @ 或是 *,它扩展为数组的所有成员。 这两种下标只有在双引号中才不同。在双引号中,${name[*]},把所有成员当成一个词,用特殊变量 IFS 的第一个字符分隔;${name[@]} 将数组的每个成员扩展为一个词。 如果数组没有成员,${name[@]} 扩展为空串。这种不同类似于特殊参数 *@ 的扩展。在作为函数参数传递的时候能很明显感受到他们的差别。

#定义数组
$ array=(a b c)

# 定义函数
$ function func() {
> echo first para is $1
> echo second para is $2
> echo third para is $3
> }

# 双引号+'*'
$ func "${array[*]}"
first para is a b c
second para is
third para is

# 双引号+‘@’
$ func "${array[@]}"
first para is a
second para is b
third para is c

内建命令 unset 用于销毁数组。unset name[subscript] 将销毁下标是 subscript 的元素。 unset name, 这里name 是一个数组,或者 unset name[subscript], 这里subscript*或者是@,将销毁整个数组。

扩展

花括号扩展

什么是花括号扩展,举个例子就好理解了

mkdir /usr/local/src/bash/{old,new,dist}

等效于

mkdir /usr/local/src/bash/old /usr/local/src/bash/new /usr/local/src/bash/dist

除此之外,还支持模式匹配来批量选择,例如:

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

变量扩展

我们知道,${var}的形式可以获取变量var的值,但其实还可以有更多花式玩法。其中表示用户根目录其实属于 波浪线扩展,这比较常见,不展开介绍了。

下面的每种情况中,word 都要经过波浪线扩展,参数扩展,命令替换和 算术扩展。如果不进行子字符串扩展,bash 测试一个没有定义或值为空的 参数;忽略冒号的结果是只测试未定义的参数。

大致描述下变量扩展的功能:

扩展 功能
${var} 获取变量值
${!var} 取变量var的值做新的变量名,再次获取新变量名的值
${!prefix* 获取prefix开头的变量名
${#parameter} 获取变量长度
${parameter:-word} parameter为空时,使用wrod返回
${parameter:+word} parameter非空时,使用word返回
${parameter:=word} parameter为空时,使用word返回,同时把word赋值给parameter变量
${parameter:?word} parameter为空时,打印错误信息word
${parameter:offset} 从offset位置截取字符串
${parameter:offset:length 从offset位置截取length长度的字符串
${parameter#word} 从头开始删除最短匹配word模式的内容后返回
${parameter##word} 从头开始删除最长匹配word模式的内容后返回
${parameter%word} 从尾开始删除最短匹配word模式的内容后返回
${parameter%%word} 从尾开始删除最长匹配word模式的内容后返回
${parameter/pattern/string} 最长匹配pattern的内容替换为string
${parameter//pattern/string} 所有匹配pattern的内容替换为string

$

${!var}是间接扩展。bash 使用以 var 的其余部分为名的变量的值作为变量的名称; 接下来新的变量被扩展,它的值用在随后的替换当中,而不是使用var自身的值。

有点拗口,举个例子就懂了

$ var_name=val
$ val="Bash expansion"
$ echo ${!var_name}
Bash expansion

所以,${!var_name}等效于${val},就是取val_name的值作为变量名,再获取新变量名的值。

!有一种例外情况,那就是${!prefix*},下面再介绍。

$

${!prefix*}实现扩展为名称以 prefix 开始的变量名,以特殊变量 IFS 的第一个字符分隔。换句话说,这种用法就是用于获取变量名的。例如:

# 创建3个以VAR开头的变量
$ VAR_A=a
$ VAR_B=b
$ VAR_C=c

# 寻找以VAR开头的变量名
$ echo ${!VAR*}
VAR_A VAR_B VAR_C

$

${#parameter}用于获取变量的长度。如果 parameter* 或者是 @, 替换的值是位置参数的个数。如果 parameter 是一个数组名,下标是 * 或者是 @, 替换的值是数组中元素的个数。

$

${parameter:-word}表示使用默认值。如果 parameter 未定义或值为空,将替换为 word 的扩展。否则,将替换为 parameter 的值。

$

${parameter:=word}赋默认值。如果 parameter 未定义或值为空, word 的扩展将赋予 parameterparameter 的值将被替换。位置参数和特殊参数不能用这种方式赋值。

${parameter:=word}${parameter:-word}有什么差别?还是举个例子:

# 删除var变量
$ unset var
# 确认var变量为空
$ echo ${var}

# 当var为空时,把test赋值给var,同时返回test
$ echo ${var:=test}
test
# 可以看到,此时var已经被赋值
$ echo ${var}
test
# 再次删除var变量,继续实验
$ unset var
# 当var为空时,返回test
$ echo ${var:-test}
test
# 对比验证,此时var并没有赋值
$ echo ${var}

所以,差别在于,当parameter为空时,${parameter:=word}会比${parameter:-word}多做一步,就是把word的值赋给parameter

$

${parameter:?word}主要用于当parameter为空时,显示错误信息wordshell 如果不是交互的,则将退出。

$

如果 parameter 未定义或非空,不会进行替换;否则将替换为 word 扩展后的值。这与${parameter:-word}完全相反。简单来说,就是parameter非空时,才使用word

$

${parameter:offset:length}

$

${parameter:offset:length}可以实现字符串的截取,从offset开始,截取length个字符。如果 offset 求值结果小于 0, 值将当作从 parameter 的值的末尾算起的偏移量。如果parameter@,结果是 length 个位置参数,从 offset 开始。 如果 parameter 是一个数组名,以 @* 索引,结果是数组的 length 个成员,从 ${parameter[offset]} 开始。 子字符串的下标是从 0 开始的,除非使用位置参数时,下标从 1 开始。

$

参考 ${parameter##word}

$

word支持模式匹配,从parameter的开始位置寻找匹配,一个#的是寻找最短匹配,两个#的是寻找最长匹配,把匹配的内容删除后,把剩下的返回。例如:

$ str="we are testing, we are testing"
$ echo ${str#*are}
testing, we are testing
$ echo ${str##*are}
testing

这必须是从头开始删的,如果要删除中间的某一些字符串,可以用${parameter/pattern/string}

如果 parameter是一个数组变量,下标是@或者是*,模式删除将依次施用于数组中的每个成员,最后扩展为结果的列表。

$

参考${parameter%%word}

$

这也是在parameter中删除匹配的内容后返回。%#非常类似,前者是从头开始匹配,后者是从尾部开始匹配。同样的,一个%是寻找最短匹配,两个%%是寻找最长匹配。例如:

$ str="we are testing, we are testing"
$ echo ${str%are*}
we are testing, we
$ echo ${str%%are*}
we

这必须是从末端开始删的,如果要删除中间的某一些字符串,可以用${parameter/pattern/string}

如果 parameter是一个数组变量,下标是@或者是*,模式删除将依次施用于数组中的每个成员,最后扩展为结果的列表。

$

参考${parameter//pattern/string}

$

${parameter//pattern/string}${parameter/pattern/string},主要实现了字符串替换,当然,如果要替换的结果是空,就等效于删除。一个/,表示只有第一个匹配的被替换,两个/表示所有匹配的都替换。例如:

$ str="we are testing, we are testing"
# 替换首次匹配
$ echo ${str/we are/I am}
I am testing, we are testing
# 替换所有匹配
$ echo ${str//we are/I am}
I am testing, I am testing
# 删除首次匹配
$ echo ${str/are/}
we testing, we are testing
# 删除所有匹配
$ echo ${str//are/}
we testing, we testing

如果patten#开始,例如${str/#we are/},则必须从头开始就匹配;以%表示,例如${str/%are testing/},必须从末端就要完全匹配。

如果 parameter是一个数组变量,下标是@或者是*,模式删除将依次施用于数组中的每个成员,最后扩展为结果的列表。

路径扩展

我们经常会这样使用路径扩展,ls ~/work*,这里的*就是路径匹配的一种,表示匹配包含空串的任何字符串。除了*之外,还有?[。路径扩展其实运用了模式匹配,所以匹配规则不妨直接看模式匹配

模式匹配

任何模式中出现的字符,除了下面描述的特殊模式字符外,都匹配它本身。 模式中不能出现 NUL 字符。如果要匹配字面上的特殊模式字符,它必须被引用。

特殊模式字符有下述意义:

  • *: 匹配任何字符串包含空串。
  • ?: 匹配任何单个字符。
  • [...]: 匹配括号内的任意一个字符,与正则匹配一致。

与正则的[...]一致,[!...]或者[^...]表示不匹配括号内的字符;[a-zA-Z]表示从a到z以及从A到Z的所有字符;也支持[:alinum:]这类的特殊字符。

如果使用内建命令 shopt 启用了 shell 选项 extglob, 将识别另外几种模式匹配操作符。

  • ?(pattern-list):匹配所给模式零次或一次出现
  • *(pattern-list):匹配所给模式零次或多次出现
  • +(pattern-list):匹配所给模式一次或多次出现
  • @(pattern-list):准确匹配所给模式之一
  • !(pattern-list):任何除了匹配所给模式之一的字串

重定向

简单的重定向不累述了,讲一些高级用法。

Here Documents

here-document 的格式是:

<<[-]word
	here-document
delimiter

这种重定向使得 shell 从当前源文件读取输入,直到遇到仅包含 word 的一行 (并且没有尾部空白,trailing blanks) 为止。直到这一点的所有行被用作 命令的标准输入。

还是听拗口,咱们看例子:

$ cat <<EOF
> fist line
> second line
> third line
> EOF
fist line
second line
third line

上述的做法,把两个EOF之间的内容作为一个文件,传递给cat命令。甚至,我们还有更高级的用法,实现动态创建文件。

$ kernel=linux
$ cat > ./readme.txt <<EOF
> You are using kernel ${kernel}
> EOF
$ cat ./readme.txt
You are using kernel linux

Here Strings

here-document 的变种,形式是

<<<word

word 被扩展,提供给命令作为标准输入,例如,我希望检索变量的值,有以下两种做法:

$ echo ${var} | grep "test"
$ grep "test" <<< ${var}

Opening File Descriptors for Reading and Writing

重定向操作符,[n]<>word,使得以 word 扩展结果为名的文件被打开,通过文件描述符 n 进行读写。如果没有指定 n 那么就使用文件描述符 0。如果文件不存在,它将被创建。

这操作暂时没用过,待补充示例。

总结

本文结合man bash以及自己的一些经验,总结了Shell编程的一些高级用法。还是那句话,建议有一定基础的同学学习,毕竟在跑之前要先学会走路不是?

posted @ 2020-06-30 17:55  广漠飘羽  阅读(2408)  评论(0编辑  收藏  举报