我承认,我再一次地当了标题党。但是不可否认,这一定是一篇精华随笔。在这一篇中,我将探讨 Bash 脚本语言中的美学与哲学。 这不是一篇 Bash 脚本编程的教程,但是却能让人更加深入地了解 Bash 脚本编程,更加快速地学习 Bash 脚本编程。 阅读这篇随笔,不需要你有 Bash 编程的经验,但一定要和我一样热衷于探索各种编程语言的本质,感悟它们的魅力。
其实早就想写关于 Bash 的东西了。 我们平时喜欢对编程语言进行分类,比如面向过程的编程语言、面向对象的编程语言、函数式编程语言等等。在我心中,我认为 Bash 就是一个面向字符串的编程语言。Bash 脚本语言的本质:一切皆是字符串。 Bash 脚本语言的一切哲学都围绕着字符串:它们从哪里来?到哪里去?使命是什么? Bash 脚本语言的一切美学都源自字符串: 由键盘上几乎所有的符号 “$ ~ ! # & ( ) [ ] { } | > < - . , ; * @ ' " ` \ ^” 排列组合而成的极富视觉冲击力的、功能极其复杂的字符串。
一、一切皆是字符串
Bash 是一个 Shell,Shell 出现的初衷是为了将系统中的各种工具粘合在一起,所以它最根本的功能是调用各种命令。 但是 Bash 又提供了丰富的编程功能。 我们经常对编程语言进行分类,比如面向过程的语言、面向对象的语言、面向函数的语言等等。 可以把 Bash 脚本语言看成是一个面向字符串的语言。 Bash 语言的本质就是:一切都是字符串。 看看下图中的这些变量:
上图是我在交互式的 Bash 命令行中做的一些演示。在上图中,我对变量分别赋值,不管等号右边是一个没有引号的字符串,还是带有引号的字符串,甚至数字,或者数学表达式,最终的结果,变量里面存储的都是字符串。我使用一个 for 循环显示所有的变量,可以看到数学表达式也只是以字符串的形式储存,没有被求值。
二、引用和元字符
如果一切都是没有特殊功能的平凡的字符串,那就无法构成一门编程语言。在 Bash 中,有很多符号具有特殊含义,比如“$”符号被用于字符串展开,“&”符号用于让命令在后台执行, “|”用作管道, “>” “<”用于输入输出重定向等等。所以在 Bash 中,虽然同样是字符串,但是被引号包围的字符串和不被引号包围的字符串使用起来是不一样的,被单引号包围的字符串和被双引号包围起来的字符串也是不一样的。
究竟带引号的字符串和不带引号的字符串使用起来有什么不一样呢?下图是我构建的一些比较典型的例子:
在上图中,我展示了 Bash 中生成字符串的 7 种方法:大括号展开、波浪符展开、参数展开、命令替换、算术展开、单词分割和文件路径展开。还有两种生成字符串的方式没有讲(Process substitution 和历史命令展开)。在使用 Bash 脚本编程的时候,了解以上 7 种字符串生成的方式就够了。在交互式使用 Bash 命令行的时候,还需要了解历史命令展开,熟练使用历史命令展开可以让人事半功倍。
在上面的图片中可以看到,有一些展开方式在被双引号包围的字符串中是不起作用的,比如大括号展开、波浪符展开、单词分割、文件路径展开,而只有参数展开、命令替换和算术展开是起作用的。从图片中还可以看出,字符串中的参数展开、命令替换和算术展开都是由“$”符号引导,命令替换还可以由“`”引导。所以,可以进一步总结为,在双引号包围的字符串中,只有“$、`、\”这三个字符具有特殊含义。
如果想让任何一个字符都不具有特殊含义,可以使用单引号将字符串包围。比如使用正则表达式的时候,还比如使用 sed、awk 等工具的时候,由于 sed 和 awk 自己执行的命令中往往包含有很多特殊字符,所以它们的命令最好用单引号包围。 比如使用 awk 命令显示 /etc/passwd 文件中的每个用户的用户名和全名,可以使用这个命令 awk -e '{print $1,$5}' ,其中,传递给 awk 的命令用单引号包围,说明 bash 不执行其中的任何替换或展开。
另外一个特殊的字符是“\”,它也是引用的一种。它可以解除紧跟在它后面的一个特殊字符的特殊含义(引用)。之所以需要“\”的存在,是因为在 Bash 中,有些字符称为元字符,这些字符一旦出现,就会将一个字符串分割为多个子串。如果需要在一个字符串中包含这些元字符本身,就必须对它们进行引用。如下图:
最常见的元字符就是空格。 从上面几张图片可以看出,如果要将一个含有空格的字符串赋值给一个变量,要么把这个字符串用双引号包围,要么使用“\”对空格进行引用。 从上图中可以看出,Bash 中只有9个元字符,它们分别是“| & ( ) ; < > space tab”,而在其它编程语言中经常出现的元字符“. { } [ ]”以及作为数学运算的加减乘除,在 Bash 中都不是元字符。
三、字符串从哪里来、到哪里去
介绍完字符串、介绍完引用和元字符,下一个目标就是来探讨这一个哲学问题:字符串从哪里来、到哪里去?通过该哲学问题的探讨,可以推导出 Bash 脚本语言的整个语法。字符串从哪里来?很显然,其中一个很直接的来源就是我们从键盘上敲上去的。除此之外,就是我前面提到的七八九种字符串展开的方法了。
字符串展开的流程如下:
1.先用元字符将一个字符串分割为多个子串;
2.如果字符串是用来给变量赋值,则不管它是否被双引号包围,都认为它被双引号包围;
3.如果字符串不被单引号和双引号包围,则进行大括号展开,即将 {a,b}c 展开为 ab ac;
以上三个流程可以通过下图证明:
4.如果字符串不被单引号或双引号包围,则进行波浪符展开,即将 ~/ 展开为用户的主目录,将 ~+/ 展开为当前工作目录(PWD),将 ~-/ 展开为上一个工作目录(OLDPWD);
5.如果字符串不被单引号包围,则进行参数和变量展开;这一类的展开全都以“$”开头,这是整个 Bash 字符串展开中最复杂的,其中包括用户定义的变量,包括所有的环境变量,以上两种展开方式都是“$”后跟变量名,还包括位置变量“$1、 $2、 ...、 $9、 ... ”,其它特殊变量:“$@、 $*、 $#、 $-、 $!、 $0、 $?、 $_ ”,甚至还有数组:“${var[i]}”, 还可以在展开的过程中对字符串进行各种复杂的操作,如:“ ${parameter:-word}、 ${parameter:=word}、 ${parameter:+word}、 ;${parameter:?word}、 ${parameter:offset}、 ${parameter:offset:length}、 ${!prefix*}、 ${!prefix@}、 ${name[@]}、 ${!name[*]}、 ${#parameter}、 ${parameter#word}、 ${parameter##word}、 ${parameter%word}、 ${parameter%%word}、 ${parameter/pattern/string}、 ${parameter^pattern}、 ${parameter^^pattern}、 ${parameter,pattern}、 ${parameter,,pattern}”;
6.如果字符串不被单引号包围,则进行命令替换;命令替换有两种格式,一种是 $(...),一种是 `...`;也就是将命令的输出作为字符串的内容;
7.如果字符串不被单引号包围,则进行算术展开;算术展开的格式为 $((...));
8.如果字符串不被单引号或双引号包围,则进行单词分割;
9.如果字符串不被单引号或双引号包围,则进行文件路径展开;
10.以上流程全部完成后,最后去掉字符串外面的引号(如果有的话)。以上流程只按以上顺序进行一遍。比如不会在变量展开后再进行大括号展开,更不会在第 10 步去除引用后执行前面的任何一步。如果需要将流程再走一遍,请使用 eval。
探讨完了字符串从哪里来,下面来看看字符串到哪里去。也就是怎么使用这些字符串。使用字符串有以下几种方式:
1.把它当命令执行;这是 Bash 中的最根本的用法,毕竟 Shell 的存在就是为了粘合各种命令。如果一个字符串出现在本该命令出现的地方(比如一行的开头,或者关键字 then、do 等的后面),它将会被当成命令执行,如果它不是个合法的命令,就会报错;
2.把它当成表达式;Bash 中本没有表达式,但是有了 ((...)) 和 [[...]],就有了表达式;((...)) 可以把它里面的字符串当成算术表达式,而 [[...]] 会把它里面的字符串当逻辑表达式,仅此两个特例;
3.给变量赋值;这也是一个特例,有点破坏 Bash 编程语言语法哲学的完整性。为什么这么说呢?因为“=”即不是一个元字符,也不允许两边有空格,而且只有第 1 个等号会被当成赋值运算符。
下面图片为以上观点给出证据:
四、再加上一点点的定义,就可以推导出整个 Bash 脚本语言的语法了
前面我已经展示了我对字符串从哪里来、到哪里去这个问题的理解。关于字符串的去向,除了两个表达式和一个为变量赋值这三个特例,剩下的就只有当命令来执行了。在前面,我提到了元字符和引用的概念,这里,还得再增加一点点定义:
定义1:控制操作符(Control Operator) 前面提到元字符是为了把一个字符串分割为多个子串,而控制操作符就是为了把一系列的字符串分割成多个命令。举例说明,在 Bash中,一个字符串 cat/etc/passwd 就是一个命令,第一个单词 cat 是命令,第 2 个单词 /etc/passwd 是命令的参数,而字符串 cat /etc/passwd | grep youxia 就是两个命令,这两个命令分别是 cat 和 grep,它们之间通过“|”分割,所以这里的“|”是控制操作符。熟悉 Shell 的朋友肯定知道“|”代表的是管道,所以它的作用是:1.把一个字符串分割为两个命令,2.将第一个命令的输出作为第二个命令的输入。在 Bash 中,总共只有 10 个控制操作符,它们分别是“|| & && | ; ;; ( ) |& <newline>”。只要看到这些控制操作符,就可以认为它前面的字符串是一个完整的命令。
定义2:关键字(Reserved Words) 我没有将其翻译成保留字,很显然,作为编程语言来说,它们应该叫做关键字。一门编程语言肯定必须得提供选择、循环等流程控制语句,还得提供定义函数的功能。这些功能只能通过关键字实现。在 Bash 中,只有 22 个关键字,它们是“! case coproc do done elif else esac fi for function if in select then until while { } time [[ ]]”。这其中有不少的特别之处,比如“! { } [[ ]]”等符号都是关键字,也就是说它们当关键字使用时相当于一个单词,也就是说它们和别的单词必须以元字符分开(否则无法成为独立的单词)。这也是为什么在 Bash 中使用“! { } [[ ]]”时经常要在它们周围留空格的原因。(再一次证明“=”是一个很变态的特例,因为它既不是元字符,也不是控制操作符,更加不是关键字,它到底是什么?)
下面开始推导 Bash 脚本语言的语法:
推导1:简单命令(Simple command) 就是一条简单的命令,它可以是一个以上述控制操作符结尾的字符串。比如单独放在一行的 uname -r 命令(单独放在一行的命令其实是以<newline>结尾,<newline>是控制操作符),或者虽然不单独放在一行,但是以“;”或“&”结尾,比如 uname -r; who; pwd; gvim& 其中每一个命令都是一个简单命令(当然,这四个命令放在一起的这行代码不叫简单命令),“;”就是简单地分割命令,而“&”还有让命令在后台执行的功能。这里比较特殊的是双分号“;;”,它只用在 case 语句中。
推导2:管道(Pipe Line) 管道是 Shell 中的精髓,就是让前一个命令的输出成为后一个命令的输入。管道的完整语法是这样 [time [-p]] [ ! ] command1 | command2 或这样 [time [-p]] [ ! ] command1 |& command2 的。其中 time 关键字和 ! 关键字都是可选的(使用[...]指出哪些部分是可选的),time 关键字可以计算命令运行的时间,而 ! 关键字是将命令的返回状态取反。看清楚 ! 关键字周围的空格哦。如果使用“|”,就是把第一个命令的标准输出作为第二个命令的标准输入,如果使用“|&”,则将第一个命令的标准输出和标准错误输出都当成第二个命令的输入。
推导3:命令序列(List) 如果多个简单命令或多个管道放在一起,它们之间以“; & <newline> || &&”等控制操作符分开,就称之为一个命令序列。关于“; & <newline>”前面已经讲过了,无需重复。关于“||”和“&&”,熟悉 C、C++、Java 等编程语言的朋友们肯定也不会陌生,它们遵循同样的短路求值的思想。比如 command1 || command2 只有当 command1 执行不成功的时候才执行 command2,而 command1 && command2 只有当 command1 执行成功的时候才执行 command2。
推导4:复合命令(Compound Commands) 如果将前面的简单命令、管道或者命令序列以更复杂的方式组合在一起,就可以构成复合命令。在 Bash 中,有 4 种形式的复合命令,它们分别是 (list) 、 { list; } 、 ((expression)) 、 [[ expression ]] 。请注意第 2 种形式和第 4 种形式大括号和中括号周围的空格,也请注意第 2 种形式中 list 后面的“;”,不过如果“}”另起一行,则不需要“;”,因为<newline>和“;”是起同样作用的。在以上4种复合命令中, (list) 是在一个新的Shell中执行命令序列,这些命令的执行不会影响当前Shell的环境变量,而 { list; } 只是简单地将命令序列分组。后面两种表达式求值前面已经讲过,这里就不讲了。后面可能会详细列出逻辑表达式求值的选项。
上面的4步推导是一步更进一步的,是由简单逐渐到复杂的,最简单的命令可以组合成稍复杂的管道,再组合成更复杂的命令序列,最后组成最复杂的复合命令。
下面是 Bash 脚本语言的流程控制语句,如下:
1. for name [ [ in [ word ... ] ] ; ] do list ; done ;
2. for (( expr1 ; expr2 ; expr3 )) ; do list ; done ;
3. select name [ in word ] ; do list ; done ;
4. case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac ;
5. if list; then list; [ elif list; then list; ] ... [ else list; ] fi ;
6. while list-1; do list-2; done ;
7. until list-1; do list-2; done 。
上面的公式大家看得懂吧,我相信大家肯定看得懂。其中的 [...] 代表的是可以有也可以真没有的部分。在以上公式中,请注意第 2 个公式 for 循环中的双括号,它执行的是其中的表达式的算术运算,这是和其它高级语言的 for 循环最像的,但是很遗憾,Bash 中的算术表达式目前只能计算整数。再请注意第 3 个公式,select 语法,和 for...in... 循环的语法比较类似,但是它可以在屏幕上显示一个菜单。如果我没有记错的话,Basic 语言中应该有这个功能。其它的控制结构在别的高级语言中都很常见,就不需要我在这里啰嗦了。
最后,再来展示一下如何定义函数:
name () compound-command [redirection]
或者
function name [()] compound-command [redirection]
可以看出,如果有 function 关键字,则“()”是可选的,如果没有 function 关键字,则“()”是必须的。这里需要特别指出的是:函数体只要求是 compound-command,我前面总结过 compound-command 有四种形式,所以有时候定义一个函数并不会出现“{ }”哦。如下图,这样的函数也是合法的:
That's all。这就是 Bash 脚本语言的全部语法。就这么简单。
好像忘了点什么?对了,还有输入输出重定向没有讲。输入输出重定向是 Shell 中又一个伟大的发明,它的存在有着它独特的哲学意义。这个请看下一节。
五、输入输出重定向
Unix 世界有一个伟大的哲学:一切皆是文件。(这个扯得有点远。) Unix 世界还有一个伟大的哲学:创建进程比较方便。(这个扯得也有点远。)而且,每一个进程一创建,就会自动打开三个文件,它们分别是标准输入、标准输出、标准错误输出,普通情况下,它们连接到用户的控制台。在 Shell 中,使用数字来标识一个打开的文件,称为文件描述符,而且数字 0、 1、 2 分别代表标准输入、标准输出和标准错误输出。在 Shell 中,可以通过“>”、“<”将命令的输入、输出进行重定向。结合 exec 命令,可以非常方便地打开和关闭文件。需要注意的是,当文件描述符出现在“>”、“<”右边的时候,前面要使用“&”符号,这可能是为了和数学表达式中的大于和小于进行区别吧。使用“&-”可以关闭文件描述符。
“> < & 数字 exec -”,这就是输入输出重定向的全部。下面的公式中,我使用 n 代表数字,如果是两个不同的数字,则使用 n1、n2,使用 [...] 代表可选参数。输入输出重定向的语法如下:
1 [n]> file #重定向标准输出(或 n)到file。 2 [n]>> file #重定向标准输出(或 n)到file,追加到file末尾。 3 [n]< file #将file重定向到标准输入(或 n)。 4 [n1]>&n2 #重定向标准输出(或 n1)到n2。 5 2> file >&2 #重定向标准输出和错误输出到file。 6 | command #将标准输出通过管道传递给command。 7 2>&1 | command #将标准输出和错误输出一起通过管道传递给command,等同于|&。
请注意,数字和“>”、“<”符号之间是没有空格的。结合 exec,可以非常方便地使用一个文件描述符来打开、关闭文件,如下:
1 echo Hello >file1 2 exec 3<file1 4>file2 #打开文件 3 cat <&3 >&4 #重定向标准输入到 3,标准输出到 4,相当于读取file1的内容然后写入file2 4 exec 3<&- 4>&- #关闭文件 5 cat file2 6 #显示结果为 Hello 7 8 #还可以暂存和恢复文件描述符,如下: 9 exec 5>&2 #把原来的标准错误输出保存到文件描述符5上 10 exec 2> /tmp/$0.log #重定向标准错误输出 11 ... 12 exec 2>&5 #恢复标准错误输出 13 exec 5>&- #关闭文件描述符5,因为不需要了
还可以将“<>”一起使用,表示打开一个文件进行读写。
除了 exec,输入输出重定向和 read 命令配合也很好用,read 命令每次读取文件的一行。但是要注意的是,输入输出重定向放到 for、while 等循环的循环体和循环外,效果是不一样的。如下图:
另外,输入输出重定向符号“>”、“<”还可以和“()”一起使用,表示进程替换(Process substitution),如“>(list)”、“<(list)”。结合前面提到的“<”、“>”、“(list)”的含义,进程替换的作用是很容易猜到的哦。
六、Bash 脚本语言的美学:大道至简
如果你问我 Bash 脚本语言哪里美?我会回答:简洁就是美。请看下面逐条论述:
1.使用了简洁的抽象的符号。Bash 脚本语言几乎使用到了键盘上能够找到的所有符号,比如“$”用作字符串展开,“|”用作管道,“<”、“>”用作输入输出重定向,一点都不浪费;
2.只使用了 9 个元字符、10 个控制操作符和 22 个关键字,就构建了一个完整的、面向字符串编程的语言;
3.概念上具有很好的一致性;比如 (list) 复合命令的功能是执行括号内的命令序列,而“$”用于引导字符串展开,所以 $(list) 用于命令替换(所以我前面说“$()”形式的命令替换比“``”形式的命令替换更加具有一致性)。比如 ((expresion)) 用于数学表达式求值,所以 $((expression)) 代表算术展开。再比如“{}”和“,”配合使用,且中间没有空格时,代表大括号展开,但是当需要使用“{ }”来定义复合命令时,必须把“{ }”当关键字,它们和它里面的内容必须以空格隔开,而且“}”和它前面的一条命令之间必须有一个“;”或者“<newline>”。这些概念上的一致性设计得非常精妙,使用起来自然而然可以让人体会到一种美感;
4.完美解决了一个命令执行时的输出和运行状态的分离。有其它编程语言经历的人也经常会遇到这样的问题:当我们调用一个函数的时候,函数可能会产生两个结果,一个是函数的返回值,一个是函数调用是否成功。在 C# 和 Java 等高级语言中,往往使用 try...catch 等捕获异常的方式来判断函数调用是否成功,但仍然有程序员让函数返回 null 代表失败,而 C 语言这种没有异常机制的语言,实在是难以判断一个函数的返回值究竟如何表示该函数调用是否成功(比如就有很多 API 让函数返回 -1 代表失败,而有的函数运行失败是会设置 errno 全局变量)。在 Bash 中,命令运行的状态和命令的标准输出区分很明确,如果你需要命令的标准输出,使用命令替换来生成字符串,如果你只需要命令的运行状态,直接将命令写在 if 语句之中即可,或者使用 $? 特殊变量来检查上一条命令的运行状态。如果不想在检查命令运行状态的时候让命令的标准输出影响用户,可以把它重定向到 /dev/null,比如
if cat /etc/passwd | grep youxia > /dev/null; then echo 'youxia is exist'; fi
5.使用管道和输入输出重定向让文件的读写变得简单。想一想在 C 语言中怎么读文件吧,除了麻烦的 open、close 不说,每读一个字符串还得先准备一个 buffer,准备长了怕浪费空间,准备短了怕缓冲区溢出,虐心啦。使用 Bash,那真的是太方便了。
6.它还是一门不折不扣的动态语言哦,eval 命令实在是太强大了,请看下图,模拟指针进行查表:
当然,自从 Bash 3 之后,Bash 本身就提供了间接引用的功能(使用“${!var}”)。
例外:
Bash 语言也并不是在所有的方面都是完美的,还存在几个特别的例外。比如前面说的“=”。除了“=”,“()”也有一个使用不一致的地方,那就是对数组的初始化,比如 array=(a b c d e f) ,这和前面讲的“()”用于在子Shell中执行命令序列还真的是不一致。
总结:
以上内容是我的胡言乱语,因为以上内容即无法教会大家完整的 Bash 语法,也无法教会大家用 Bash 做任何一点有意义的工作。
如果想用 Bash 干点实事,我送大家一本 O'Reilly 出的《Shell脚本学习指南》:Shell脚本学习指南.part1.rar Shell脚本学习指南.part2.rar Shell脚本学习指南.part3.rar Shell脚本学习指南.part4.rar (为什么一次只能上传10M啊,好好的一本书被拆分成4部分了。)
另外,我的主要参考资料来自于 Bash 用户手册。大家可以在自己的系统中运行 man bash 。
如果你对编程感兴趣或者想往编程方向发展,可以关注微信公众号【筑梦编程】,大家一起交流讨论!小编也会每天定时更新既有趣又有用的编程知识!