简洁的Bash Programming技巧(一)
1. bash中alias的使用
alias其实是给常用的命令定一个别名,比如很多人会定义一下的一个别名:
1 | alias ll='ls -l' |
以后就可以使用ll,实际展开后执行的是ls -l。现在很多发行版都会带几个默认的别名,比如:
1 | alias grep='grep --color=auto' # 带颜色显示 |
alias在某些方面确实提高了很大的效率,但是也是有隐患的,这点可以看我以前的一篇文章终端下肉眼看不见的东西。那么如何不要展开alias,而是用本来的意思呢?答案是使用转义:
1 | \ls |
在命令前面加一个反斜杠后就可以了。 这里要插一段故事,前两天我在shell脚本中定义了下面的一个alias,假设位于文件util.sh:
1 | #!/bin/bash |
后面这串ssh选项是为了去掉一些warning的信息,不提示输入密码等等。具体可以看ssh的文档说明。我自己测试的时候好好的,当时我同事跑得时候却依然有报Warning。我对比了下我们两个人的用法
1 | sh util.sh # 方式一 |
大家应该知道,直接./util.sh执行,shell会去找脚本第一行的shebang中给定的解释器去执行改脚本,所以第二种用法相当于直接用bash来执行。那想必是bash/sh对alias是否默认展开这一点上是有区别的了。翻阅了下Bash的man手册,发现可以通过设置expand_aliases选项来打开alias展开的功能,默认在非交互式Shell下是关闭的(什么是交互式登录Shell)
修改下util.sh,打开这个选项就Ok了:
1 | #!/bin/bash |
2. awk打印除第一列之外的其他列
awk用来截取输入行中的某几列很有用,当时如果要排除某几列呢? 例如有如下的一个文件:
1 | $ cat /tmp/test.txt |
可以用下面的代码解决:
1 | $ awk '{$1="";print $0}' /tmp/test.txt |
但是前面多了一个空格,可以用cut命令稍微调整下:
1 | $ awk '{$1="";print $0}' /tmp/test.txt | cut -c2- |
3. 巧用bash的命令展开功能备份文件
假设要备份文件/your/path/to/file.list为/your/path/to/file.list.20121106,常规的方法是:
1 | cp /your/path/to/file.list /your/path/to/file.list.20121106 |
这样重复写上一长串的路径,是不是很麻烦,这里利用bash的展开特性可以这样做:
1 | cp /your/path/to/file.list{,.20180406} |
/your/path/to/file.list{,.20121106}这一部分会展开为/your/path/to/file.list /your/path/to/file.list.20121106,再将此传给cp命令,就达到了与前面同样的效果。(思路同ls *)。具体可以man bash中的Brace Expansion这一段。
4. 你知道sed的这个特性吗?
假设一个文件的每一行为一个路径:
1 | $ cat /tmp/test.txt |
现在要把/home/kodango/good替换成/home/kodango/bad,普通的作法是:
1 | $ sed -n 's/\/home\/kodango\/good/\/home\/kodango\/bye/p' /tmp/test.txt |
因为路径中的分隔符与sed的替换命令的分隔符都是’/‘,所以需要转义,非常麻烦。幸运的是,sed可以更改分隔符,例如使用#:
1 | sed -n 's#/home/kodango/good#/home/kodango/bad#p' /tmp/test.txt |
这样就清爽多了。 补充,如果是在地址对中使用,首个分隔符前面要加反斜杠:
1 | $ sed -n '\#/home/kodango/#p' /tmp/test.txt |
5. 合并连续重复的字符(即squeeze操作)
例如要合并一个字符串中连续的多个空格,假设字符串为’print hello, world’。 第一种方法,使用sed命令,扫描整个字符串,替换2个以上的空格为1格:
1 | $ echo 'print hello, world ' | sed -r 's/ {2,}/ /g' |
第二种方法,使用tr命令的-s选项,专门就是为了合并连续重复的字符:
1 | $ echo 'print hello, world ' | tr -s ' ' |
第三种方法,使用awk的域赋值来完成该目的:
1 | $ echo 'print hello, world ' | awk '$1=$1' |
6. 将文本中某列相同的行输出到不同的文件中
标题有点绕口,我们以实际例子来讲解,假设我们有以下的一个文件:
1 | $ cat /tmp/test.txt |
我们的目标是将该文本中的行按第二列的值归类,并且输出到相应的文件中,文件名为第二列的名称。例如第2行、第3行会输出到int.txt文件中,而第1行、第4行则输出到char.txt,以此类推。
我没有找到其它简单的方法,只找到一种用awk来处理的方法:
1 | awk '{print $1 > $2 ".txt"}' /tmp/test.txt |
我们来检查结果:
1 | grep -nH . * |
7. 用exec命令来完成重定向
以一个简单的例子开始,现在需要一个脚本,它可以接受一个文件名作为参数,然后按行读取该文件的内容并打印到标准输出。如果不指定文件名,则默认从标准输入读。首先按上面的功能需求写出一个可以完成功能的脚本:
1 | $ cat test.sh |
如果换exec来实现重定向,可以把脚本写得更优雅:
1 | $ cat test1.sh |
这里的关键在第5行代码,exec命令不仅可以用于执行命令,还可以用于打开、关闭或者复制文件描述符,这里就是利用exec将指定的文件名打开重定向到标准输入。类似地可以用exec >$filename将文件重定向到标准输出。我们可以在命令行上做一个试验:
1 | $ exec 3>&1 # 首先将fd 3重定向到标准输出,作为标准输出的一个备份 |
这一点在while read; do xxx; done < file内部仍需要从标准输入读取内容时非常有用,此时必须要将循环外部的重定向和内部的剥离开来。
8. 引号之间的区别
Shell中比较让人抓狂的是各种引号的处理,其中,反引号(cmd)是最容易掌握的,它其实和$(cmd)是差不多的。
1 | 引号的作用有几点,一个是为了将多个因为空格或者回车等分隔符隔开的字符串合在一起,避免被命令行解析分开,例如"one two three"就是一整个字符串,而不是像one two three会被解析成三个单独的字符串;另外一方面,引号可以让一些特殊符号保持原义。 |
到此为止,其实双引号和单引号的区别已经说得差不多了。不过还可以再说几个特殊的用法,前面说过可以在双引号内部使用单引号,你有想过在单引号里面使用单引号吗?
1 | $ echo '\'' |
是不是发现不能用,因为单引号中反斜杠是没有转义的效果的,任何字符都没有特殊的含义。那就没有办法了吗?方法总是有的,可以在第一个单引号前面加个$符号:
1 | $ echo $'\'' |
9. 特殊用法$’string’
前面一点中已经介绍了 $’string’这种用法,比如 $’’’,之所以可以这样用,通俗地讲,就是在这种语法里一些转义字符串是被认可的,事实上有效地的转义底字符串列表可以看这里,例如b,’,n,f,nnn,xhh等等,是不是很熟悉。 $’string’的这个特性,其实为我们提供了一种很有用的技巧:
$ echo $’\x41’
A
他可以将ASCII对应的字符赋值给某个变量或者输出。
10. 用双引号比不用更加安全
双引号除了前面第10点讲到的去除特殊涵义的作用外,还可以避免字符串被分隔解析,例如:
1 | $ echo `ls -l` |
前者没有加双引号,ls -l输出行之间的回车就被吃掉了。原因是,当ls -l返回的结果传递给echo之前,会先被shell进行参数解析,而shell是用IFS定义的分隔符来分隔字符串的,一般包括n,所以它把解析后的结果再传递给echo,就成为echo “line 1….” “line 2…”这种形式了,结果就像上面一样。
1 | 而用双引号包括起来可以避开字符串被拆开解析,因为shell认为它是一个单独的字符串。所以一般情况下,多用引号包括变量是好的,"$var"比$var更安全。 |
11. 显示一个文件并且在每行开头添加行号
有两种做法,第一种借助cat和nl命令来完成:
1 | $ cat test.txt | nl |
另外一种做法是用sed命令:
1 | $ sed '=' test.txt | sed 'N;s/\n/\t/' |
还有一种方法是通过cat -n或者cat -b命令,两者的区别是后者不会给空行增加行号,nl命令特别方便,而且空行没有行号;
12. 分别输出两个文件相同的行和不同的行
假设我们有以下两个文件:
1 | $ echo test{,2}.txt;paste test{,2}.txt |
如果要输出两个文件之间相同的行,只有test.txt拥有的行以及只有test2.txt拥有的行,怎么做?首先可以使用grep -f:
1 | $ grep -f test{,2}.txt |
还有一种选择是comm命令,这个命令就是专门用于比较文件的: comm - compare two sorted files line by line。 使用方法也很简单,comm比较两个排序好的文件返回的结果有三列,第一列是只有在文件A中有的行,第二列是只有在文件B中有的行,第三列则是两个文件共有的行:
1 | $ comm test.txt test2.txt |
要得到最初要求的结果,则只需要取相应的列就可以了。comm命令非常人性化地考虑到这个需求:
1 | $ comm test.txt test2.txt -1 -2 |
其中,=1, -2与-3这个参数分别表示不输出第1、2或者3列。
13. 获取被source的脚本的名称
1 | 一般的情况下,脚本的名称可以通过$0获取,但是这在被source导入的脚本中却不成立。假设A脚本中source了B脚本,那么它是把B的代码导入到A的环境中直接执行的,因此A和B的代码其实是在同一个执行环境下分不开的,B的代码中访问到的$0,甚至$1, $2等位置参数都是与A脚本是一致的。 |
因此$0并非是被导入的脚本的名称,实际上,Bash将被source的脚本名称保存在一个叫BASH_SOURCE的数组中,该数组的第一个元素正是当前被source的脚本的名称。该变量与我在bash获取当前函数名中介绍的FUNCNAME是类似的,当一个脚本被source时,它的名称就被压入到这个数组的第一个位置上,举个实际的例子,假设有三个脚本a.sh,b.sh,c.sh,它们的内容如下所示:
1 | $ cat a.sh |
现在执行a.sh这个脚本,实际的输出是(为了方便理解,我在实际的输出中加了一些注释和空行):
1 | $ bash a.sh |
此外,我们还可以利用BASH_SOURCE的值,在脚本中判断是被直接执行还是被导入:
1 | if [ -n "$BASH_SOURCE" -a "$BASH_SOURCE" != "$0" ] |
14. ${}参数展开
1 | 我们知道${parameter}是展开变量parameter这个值,在上一篇简洁的bash编程技巧中也曾经介绍过${parameter:-word}这种用法,用于给变量赋一个默认值。 事实上除此之外,参数展开还有许多形式,在此之前,首先要说明一下变量的几种值的形式: |
unset: 变量未设置,即变量从未声明,或者被unset命令重置;
null: 变量声明但未被赋值(var=)或者被赋值成空(var=””);
not null: 变量被赋值;
unset和null在参数展开的时候还是有很大的区别的,以下是参数展开的各种形式:
${parameter:-word}:假如parameter为unset或者null,则展开后返回word的值;
${parameter-word}:假如parameter为unset时,则展开后返回word的值;
${parameter:=word}:假如parameter为unset或者null,将word赋值给parameter;
${parameter=word}:假如parameter为unset,将word赋值给parameter;
${parameter:?word}:假如parameter为unset或者null,则将word作为错误输出到标准输出;
${parameter?word}:假如parameter为unset,则将word作为错误输出到标准输出;
${parameter:+word}:假如parameter为unset或者null,则不做展开,返回为空;(刚好与:-相反)
${parameter:word}:假如parameter为unset,则不做展开,返回为空;(刚好与-相反)
上面其实准确地应该是分成2组,一组带:,一组不带:,不带:的这组更加严格,只检查unset这种情况。以:+为例子, unset的情况均无返回:
1 | $ unset var && echo ${var:+hello} |
当var为空时:
1 | $ var= && echo "${var:+hello}" |
当var为非空时:
1 | $ var=1 && echo "${var:+hello}" |
15. 冒号的多种使用场景
冒号是一个比较奇怪的符号,它的用途有很多,这里介绍几种常用的:
- 内置命令null command:nop,表示什么都不做,也可以被当作true值使用;它也可以在循环中当作true值,例如:
1
2$ :
$ echo $? # return 01
2
3
4
5
6
7
8
9while :; do # 等价于 while true; do
take-some-action
done
if condition
then :
else
take-some-action
fi - 占位符 冒号可以在很多场景下充当占位符,例如之前介绍的${parameter=var},如果直接执行会报错,表示找不到命令;这时可以借用冒号来完成赋值:同样地,可以来判断变量是否赋值:
1
: ${parameter=var}
1
: ${parameter1?} ${parameter2?}
16. 扩展的括号展开功能
这个功能不能说鸡肋,也可以了解下:1
2
3
4
5
6$ echo {0..3}
0 1 2 3
$ echo {z..a}
z y x w v u t s r q p o n m l k j i h g f e d c b a
$ echo {a..z}
a b c d e f g h i j k l m n o p q r s t u v w x y z17. 安全的中括号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[[]]的功能比[]更加多,使用起来也更加安全。
1. 首先[[]]内部不会发生文件名展开和单词分隔。 例如:
$ touch hello\ world
$ [[ -f $file ]] && echo yes
yes
$ [ -f $file ] && echo yes
-bash: [: hello: binary operator expected
2. 进制之间自动转化 当一个十进制与八进制做比较时,会自动计算两个数的值,统一后做比较:
$ o=017
$ h=0x0f
$ [[ $o -eq $h ]] && echo yes
yes
$ [[ $o -eq 15 ]] && echo yes
yes
3. [[]]支持&&,||等运算符
$ a=1;b=3
$ [[ $a > 0 && $b < 4 ]] && echo yes
yes18. 获取Bash脚本的最后一个参数
首先,你可能想到用遍历地方法(这里为了方便,我们使用set命令来设置位置参数):1
我们都知道可以用$0,$1等来获取传递给脚本或者函数的参数,也可以用$*或者$@获取所有的参数,但是如果我只想要获取最后一个参数呢?
这里的循环什么事情都没做,我用冒号(:)完成这个任务;循环结束后, $i就是保存着最后一个参数的值。 下面是两种更加简单的方法的:1
2
3
4$ set -- arg1 arg2 arg3
$ for i in $@; do :; done
$ echo $i
arg31
2$ echo ${@: -1}
$ echo ${!#}1
2
3上面的第一种方法事实上就是Parameter Expansion中的${parameter:offset:length}这种形式,只不过offset为-1表示最后一个元素,忽略length表明是从offset开始往后直到最后一个元素,即只取最后一个元素。这里要注意的一点是,在冒号和短横之间的空格不能少,否则就变成15. ${}参数展开中介绍的${parameter:-var}这种用法了。
而第二种方法则是indirect referencing的一种表现,#这个特殊的变量存放参数的个数,!#则是对最后一个变量的引用。
19. Bash中的引用(indirect referencing)
有没有想法在Bash中也可以达到C++引用的效果?你可能不知道,但是你可能曾经有这种需求,我就有过:
有时候,我想要一个变量存放另外一个变量的名称,然后在后面我想通过这个变量的名称引用它的值
例子是这样的:
1 | $ a=b |
但是利用indirect referencing的用法,你可以这样获取b的值:
1 | $ echo ${!a} |