伊利丹他老人家比较忙,这次就不请了。
由于本次实验的特殊性,将不按照实验指导书上的步骤分标题。请自取所需内容。
要查看 sed
和 awk
的用法,见扩展 4.1.
脚本文件的基础结构
在 *nix 下,文件的类型并不是通过扩展名,而是由文件内容决定的。这就意味着对于特定类型的文件,必须遵循特定类型的格式。顺便,要查看一个文件的格式,使用 file
命令,格式如下:
$ file <文件名...>
一个脚本文件通常使用 .sh
作为扩展名——不加也可以,加了的话编辑器在启动时就会给出代码高亮(需要 syntax on
),开头的第一行需要按 #!
方式指定解释器,如下所示:
#!/bin/bash
# 之后的内容
这里的解释器还可以根据文件内容的不同更换成不同的解释器,如 Python 脚本就可以指定为 #!/usr/bin/env python3
.
默认情况下,Bash 脚本的语句结尾不需要分号。分号仅需要在一行中包含多个语句时分隔语句使用。
基本输入输出
要输出一段文字,可以使用 echo
命令,如下:
echo "Hello, world!"
要避免输出自动换行,添加 -n
参数。
要输入一段文字,可以使用 read
命令,如下:
read <变量名>
如果希望在读入文字时不要进行反斜杠转义,而是将反斜杠视作反斜杠本身,添加 -r
参数。
变量的声明和使用
在 bash
中,变量是动态的。这意味着你不需要提前声明变量。但是变量可以被声明且指定类型。
要声明一个特定类型的变量,可以使用 declare
命令,其形式如下:
$ declare <修饰符?> <变量名<=值?>?>
与类型有关的修饰符有:
-
-a
- 普通数组(如果支持) -
-A
- 关联数组/散列表(如果支持) -
-i
- 整数 -
-n
- 指向其他变量的指针 -
-f
- 子过程
与访问性有关的修饰符有:
-
-r
- 只读 -
-g
- 如果是在子过程中,使变量声明为全局变量,否则无效果 -
-x
- 将变量导出 (export) 到环境变量中
与调试相关的修饰符有:
-
-p
- 打印这个变量的类型和值
以及一些挺好玩但似乎没啥卵用的修饰符有:
-
-u
- 将变量名转化成大写 -
-l
- 将变量名转化成小写
当不提供变量名时,则按照给定修饰符打印具有相应属性的变量;如果也没有提供修饰符,则打印当前环境的所有变量。
注意:在尝试打印变量时,如果变量不存在,则会触发一个错误。
变量在初始化时,直接使用 变量名=值
的方式即可,如:
a=1
b="hello, world!"
如果你之前使用 declare
命令声明了某个变量的类型,则在赋值时必须按照对应类型赋值,否则赋值将会失败且不会触发任何错误。而且注意这里的等号两侧不能有空格或 Tab, 否则会被视作指令调用从而触发错误或导致不可预料的结果。
变量在使用时,需要在变量名前加上 $
, 如:
echo $b # hello, world!
也可以在双引号括起的字符串内通过此方式使用变量。如果需要界定变量名,则可以在 $
后使用大括号包裹变量名,如:
echo "The script says: $b" # The script says: hello, world!
echo "The script says: ${b}!!!" # The script says: hello, world!!!!
要销毁一个变量,使用 unset
命令,其形式如下:
$ unset <-fv?> <变量名...>
由于普通变量和子过程名可以相同,要指定销毁一个普通变量或是一个函数,添加 -v
或 -f
参数;如果这个参数没有指定则默认销毁普通变量,但是如果普通变量不存在而子过程存在,则是否销毁子过程由 Shell 本身的实现决定;如果给定变量不存在,则不会做任何操作。
数学运算
在 Bash 中,数学运算是括在 ((
中的。注意到 ((
实际上是一个命令,所以其对空格十分敏感:在括号两侧以及运算符之间需要空格。
Bash 的数学运算能力仅限于整数的加减乘除、取模和位操作。除法在除以 0 时会产生非 0 退出值,在产生小数时舍弃小数。
在括号中使用的变量不需要前加 $
. 下面是一个示例:
x=1
y=2
(( ++x ))
(( y=(y * x) ))
echo "$x $y"
输出为 2 4
.
数组操作
Bash 中的数组是关联数组,即键值对。我特意去查了一下「散列表 (Hash table)」、「关联数组 (Associative array)」、「字典 (Dictonary)」和「键值对 (Key-value pair)」有什么区别,答案是:可能会在实现上有所不同,散列表的实现是明确的,其他三个作为容器其实现可能与散列表相同,但是也可能不同。对于调用者而言,它们的行为是几乎一样的,除了速度由于实现可能会略有不同。
要创建一个数组,将数组元素用小括号包裹,中间用空格分隔,如下:
arr1=(1 2 3)
arr2=("a" "b" "c")
要获取数组中的某一个元素,可以使用 [<索引>]
获取对应索引的元素,下标从 1 开始。获取所有要获取数组的所有元素,可以使用 [@]
。
要向数组中追加元素,可以用 +=
操作符或者直接向对应索引赋值。要从数组中删除元素,可以使用 ${<数组名>[<索引名>]}=()
. 注意:删除元素不会产生下标重排。要正确地遍历一个数组,见下文「for..in」。
要获取数组的索引集合,使用 ${!<数组名>[@]}
, 要获取数组的长度,使用 ${#<数组名>[@]}
.
选择结构
if-elif-else-fi
Bash 中的 if
的典型结构如下:
if 条件语句; then
# 体1
elif 另一个条件语句; then
# 体2
else
# 体3
fi
需要注意到此处的条件语句并非是简单的逻辑表达式,而是实际的一条指令,所以尾部需要使用分号。Bash 中的 if
实际上是一个语法糖,其替代语法为:
条件语句 && 使用分号分隔的体1 || (条件语句 && 使用分号分隔的体 2 || 使用分号分隔的体3)
这里的条件语句是通过返回值判定的。返回值是负逻辑的——即 0 为真,非 0 为假,和其他语言不一致。
一个常用的条件语句为 test
, 其内容见下文。
test, [
和 [[
注意:前方有坑。
一般而言,我们在 if
中配套使用的是 [[
和 ]]
. 但是实际上 [
和 [[
都是 test
命令的特殊别名。这里的特殊是指当你使用括号形式时,括号必须配对。
[
是 [[
的历史兼容产物,一般而言我们应总是使用 [[
而不是 [
以减少一些神奇行为的发生。
由于 [
和 [[
实际上是命令,所以对空格十分敏感(事实上 Bash 几乎处处对空格敏感)。不要偷懒而不打空格,否则参数将无法正确的解析。
数学比较
test
提供基本的数学比较,格式如下:
test <数字> <比较方式> <数字>
比较方式有:
-
-gt
: 大于 (Greater Than) -
-ge
: 不小于 (Greater than or Equal to) -
-eq
: 等于 (EQual to) -
-le
: 不大于 (Less than or Equal to) -
-lt
: 小于 (Less Than)
如下示例程序的输出应该是显而易见的:
if [[ 3 -gt 4 ]]; then
echo "3 > 4"
else
echo "3 < 4"
fi
字符串比较和匹配
test
没有使用我们平常使用的符号来比较数字的原因是因为这些符号被用于比较字符串了。
字符串的比较包括大于、小于、等于和不等于。这里的大于和小于的比较方式是按非字典序的,即短的字符串总是小于长的字符串。
如下示例程序的输出为 application > bed
.
if [[ "application" > "bed"]]; then
echo "application > bed"
else
echo "application < bed"
fi
除了字符串比较,我们还可以使用 =~
操作符测试一个字符串是否匹配一个正则表达式,如下所示:
addr="192.168.1.1"
if [[ "$addr" =~ ^((2[0-5]{2}|1[0-9]{2}|[1-9][0-9]|[0-9])\.){3}(2[0-5]{2}|1[0-9]{2}|[1-9][0-9]|[0-9])$ ]]; then
echo "$addr is a valid IPv4 address"
else
echo "$addr is not a valid IPv4 address"
fi
输出为 192.168.1.1 is a valid IPv4 address
. 关于正则表达式,见扩展 2.1.
我们还可以通过 -z
操作符来检查字符串是否为空,如下所示:
str=""
if [[ -z "$str" ]]; then
echo "str is empty"
else
echo "str is not empty"
fi
输出是显而易见的。
文件检查
test
命令还可以用于检查文件,格式如下:
[[ <单文件操作符> <文件名> ]];
[[ <文件名> <双文件操作符> <文件名> ]];
常用的单文件操作符有:
-
-e
- 是否存在 -
-d
- 是否是目录 -
-f
- 是否是普通文件 -
-L
- 是否是软链接 -
-r
- 是否有读取权限 -
-w
- 是否有写入权限 -
-x
- 是否有执行权限,如果文件是文件夹,则表示是否可以进入文件夹
常用的双文件操作符有:
-
-ot
- 左侧文件是否比右侧文件更旧 (Older Than) -
-nt
- 左侧文件是否比右侧文件更新 (Newer Than) -
-ef
- 左侧文件是否是右侧文件的硬链接
关于什么是软链接、什么是硬链接,见参考 1.
逻辑与或
每个测试条件之间可以使用 -a
和 -o
连接,其表示逻辑与和逻辑或,如下所示:
if [[ 3 -gt 4 -o 2 -lt 8 ]]; then
echo "3 > 4 or 2 < 8"
else
echo "how is this possible"
fi
输出是显而易见的(我的输入法已经记住这个短句了)。
如果需要更复杂的条件组合,也可以将每个测试使用小括号括住,并使用 &&
或者 ||
使用类似于 C 的语法进行组合。
case-esac
Bash 的 case
结构支持对整数、字符、字符串和通配表达式的选择,其一般形式如下:
read color
case $color in
red) echo "Roses are red" ;;
blue) echo "Violets are blue" ;;
*) echo "Unidentified color: $color" ;;
esac
注意到每个 case
对应的项目后的命令结尾为两个连续分号 ;;
. 使用这个符号结束 case
则具有和 break
一样的效果;如果希望可以 fall through, 则使用 ;&
结尾。
特殊的,还可以用 ;;&
结尾。当一个 case
以此结尾时,则表示接下来的 case
是对此 case
的更精细的区分,如下:
#!/bin/bash
# 此程序来自参考资料 [2].
read -p "请输入一个区号:" num
case $num in
*) echo -n "中国";;&
03*) echo -n "河北省";;&
??10) echo "邯郸市";;
??11) echo "石家庄";;
??17) echo "沧州市";;
07*) echo -n "江西省";;&
??91) echo "南昌市";;
??92) echo "九江市";;
??97) echo "赣州市";;
esac
当我们输入 0311
时,输出为 石家庄
.
循环结构
while
while
的作用和其他语言的作用一致,其基本形式如下:
while <条件语句>; do
# 体
done
此处条件语句和 if
中使用的条件语句是同样的。条件语句也可以是 true
或者 false
. 当然,true
和 false
也都是命令而不是关键字。
for...in
一般而言,仅建议将 for
用作数组的遍历。使用 while
代替 C 语言的 for
功能。
for
的基本形式如下:
for <变量名> in <取值列表>; do
# 体
done
这个循环会遍历循环列表中的值,一个简单的例子如下:
for i in 1 2 3 4 5; do
echo -n "$i " # 1 2 3 4 5
done
arr=("a" "b" "c")
for i in ${arr[@]}; do
echo -n "$i " # a b c
done
我们也可以使用大括号来生成序列作为取值列表,如 {1..100}
会生成从 1 到 100 的数字序列;{a..z}
会按 ASCII 顺序生成从 a
到 z
的字符序列。取值列表也可以从命令中生成,使用 $(<命令>)
即可获取命令的输出生成序列。
子过程
注意:前方巨坑
Bash 中,函数的实际形式是内嵌的脚本,它与我们之前学过的函数的行为均不一致(包括 MATLAB 等)。为了避免混淆,此处称其为子过程。
子过程在调用之前必须声明,且不能像 C 语言一样做空声明。
定义一个子过程
一个子过程的定义如下所示:
function <过程名字>() {
# 过程体
}
注意到这里的圆括号仅起到提示作用,并不能用于声明形式参数;大括号必须与过程体分离。
调用子过程
要调用已经声明的子过程,可以通过如下方式:
<子过程名> <参数...?>
参数不需要(也不能)使用括号。要在子过程中接收参数,使用 $1 .. $9
来接收对应位置的参数。
从子过程中返回值
从子过程中返回值不能使用 return
. 尽管看起来很像是返回,但是实际上这个关键字的作用是设置 $?
的值。
一个朴素的返回值方法是通过 echo
向标准输出中写出返回值,再由调用者通过 $(...)
方式捕获输出。
特殊的变量
有一些变量名是具有特殊含义的,具体而言有下列特殊变量:
-
$?
- 上一条命令的返回值 -
$<正整数>
- 发给脚本的命令行参数,数字为几即对应第几个参数(从 1 开始) -
$@
- 发给脚本的命令行参数数组 -
$#
- 发给脚本的命令行参数个数
One liners
有很多操作可以通过传说中的 One liner 解决——一行指令串联多个工具可以搞出十分强大的功能,这里展示一些可以解决指导书上给出的问题的 One liners.
将一个文件夹下所有 .c
移动到另一个文件夹
$ mv srcdir/*.c destdir/ # 最正常的方法
$ find srcdir -type f -name \*.c -exec mv {} destdir/ \; # find 的使用
将文件夹下的文件按大小排序
此处假定排序方式是大小倒序排序,并按顺序在文件名前添加编号。
$ cd destdir/ && ls -lS | awk '{print $9;}' | sed '/^[[:space:]]*$/d' | awk '{print NR " " $1}' | while read n f; do if [[ -f "$f" ]]; then mv "$f" "${n}-$f"; fi; done && cd ..
解析:
-
cd destdir/
- 切换工作目录 -
ls -lS
- 打印当前目录所有文件,按照大小降序排序 -
awk '{print $9;}'
- 过滤出文件名 -
sed '/^[[:space:]]*$/d'
- 过滤掉空白行 -
awk '{print NR " " $1;}'
- 将每一行编号 -
while read n f; do
- 将编号读入n
, 文件名读入f
-
if [[ -f "$f" ]]; then
- 确认该文件是普通文件(而不是文件夹) -
mv "$f" "${n}-$f"
- 重命名文件 -
fi; done
- 结束上面的if
和while
-
cd ..
- 返回原目录,可选
将文件夹下大于某个大小的文件存档,保留目录结构
由于这里面需要其他变量,所以要多写几行保存变量。One liner 是最长的那一行。
#!/bin/bash
echo -n "Directory: "
read dir
echo -n "Size in bytes: "
read size
echo -n "Archive name without extension: "
read out
if [[ -d "$dir" -a "$size" -gt 0 -a -n "$out" ]]; then
cd "$dir" && du -ab | awk '{ if ($1 > '"$size"') { print $2; } }' | while read f; do if [[ -f "$f" ]]; then echo "$f"; fi; done | tar -czvf "${out}.tar.gz" -T -
else
return 1
fi
解析:
-
cd "$dir"
- 切换工作目录 -
du -ab
- 遍历当前工作目录获取所有文件的大小,单位为字节 -
awk '{ if ($1 >'"$size"') { print $2; } }'
- 过滤出大于给定大小的文件名 -
while read f; do
- 将文件名读入f
-
if [[ -f "$f" ]]; then echo "$f"; fi; done
- 仅压缩普通文件,跳过文件夹等 -
tar -czvf "${out}.tar.gz" -T -
- 创建压缩文件,文件列表从标准输入读入 (-
的作用)
鉴于发行版之间的细微差别,命令片段 5 中的双方括号有时要写成单方括号。
参考文献
[1] I. Shields. Linux 技巧: Bash 测试和比较函数 [EB/OL]. https://www.ibm.com/developerworks/cn/linux/l-bash-test.html
[2] 果冻虾仁. 玩转 Bash 脚本:选择结构之 case [EB/OL]. https://blog.csdn.net/guodongxiaren/article/details/39758457
[3] E. Docile. How to use arrays in bash script [EB/OL]. https://linuxconfig.org/how-to-use-arrays-in-bash-script