Leafee98's Blog

Bash 语法的展开

· 8148 words · 17 minutes to read
Categories: tech
Tags: linux bash

Bash 有一种语法叫做“展开”,你可能已经在日常中经常使用了,比如常言的 rm -rf /* 等,其中的星号就用到了一种展开,本文将参照 Bash 的手册,对各种展开进行介绍(翻译),并添加示例。

本文中所使用的 shell 为 bash 5.1.16,所涉及的语法可能有相当部分在 zsh、fish 等 shell 中有着不一样的表现,请读者注意。

本文中的代码块中,命令提示符(PS)为 bash$,即以 bash$ 开头的行表示这一行的内容为输入内容,否则是命令输出内容。为了美观和可读性,代码块中可能会添加额外的空行,若按照代码块的步骤操作时,未能得到和代码块中相同的空行,请不要过分纠结。

Bash 有多种展开的形式,并存在先后顺序,汇总为以下:

  1. 花括号展开(brace expansion)
  2. 以下同时展开
    • 波浪线展开(tilde expansion)
    • 参数和变量展开(parameter and variable expansion)
    • 算术展开(arithmetic expansion)
    • 命令代换(command substitution)(从左至右)
    • 进程代换(process substitution)(如果系统支持)
  3. 单词分割(word splitting)
  4. 文件名展开(filename expansion)
  5. 引号移除(quote removal)

本文将对这些展开一一进行介绍。

花括号展开(brace expansion) 🔗

在引号之外,使用成对的花括号并在花括号中使用逗号分割多个字符串,那么字符串将按照花括号中的多个字符串分别与花括号外部进行组合,得到多个字符串。

bash$ echo a{d,c,b}e
ade ace abe

花括号展开支持字符和数字的范围表示,使用 {x..y[..incr]} 语法,其中 x 和 y 可以是整数或字母,incr 是步长,可以理解为 Python 中的 range 语法(但是结尾闭区间),步长可以省略并且默认是 1,如果 y 小于 x 则步长默认是 -1。如果使用整数,那么可以通过前缀 0 来表示每个整数都是等宽的,下面展示几个例子

bash$ echo {2..108..15}
2 17 32 47 62 77 92 107
bash$ echo {02..108..15}
002 017 032 047 062 077 092 107

bash$ echo {a..z..2}
a c e g i k m o q s u w y
bash$ 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 z
bash$ 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
bash$ echo {10..2}
10 9 8 7 6 5 4 3 2

花括号展开的另一个强大的特性是花括号支持嵌套:

bash$ echo a{1{b..e}2,3{i..l}4}z
a1b2z a1c2z a1d2z a1e2z a3i4z a3j4z a3k4z a3l4z

波浪线展开(tilde expansion) 🔗

如果未被引号包括的波浪线作为一个单词的开头时,那么将触发波浪线展开。一个单一的波浪线会被展开为 ${HOME} 的值(如果该值未被设置,那么该用户运行 shell 时的家目录会作为展开结果)。波浪线后是一个“+”号则会展开为 ${PWD},波浪线后是一个“-”号则会展开为 ${OLDPWD}。波浪线后是一个用户名,则会展开为该用户的家目录。

bash$ echo ~
/home/leafee98
bash$ echo ~root
/root

bash$ echo ~-
/home/leafee98/Desktop
bash$ echo $OLDPWD
/home/leafee98/Desktop

bash$ echo ~+
/home/leafee98/Desktop/project
bash$ echo $PWD
/home/leafee98/Desktop/project

波浪线后可以跟随一个整数,并可选在整数前放一个“+”或“-”号,它会被展开为 dirs [+-]N,但是我没能复现这个语法,这里只提一下,就不再解释了。

参数展开(shell parameter expansion) 🔗

$ 符号表示这是一个参数展开、命令代换或是算术展开。

对于参数展开 $可选使用花括号 {} 来包含参数名称,但是在某些情况下花括号是必须的。

最常见的使用方法就是 ${parameter} 会将此参数的值直接替换到该位置。

间接展开(indirect expansion) 🔗

一个十分少见的用法是间接展开,${!nameref} 会先将 nameref 展开的到一个字符串,然后将此字符串作为参数再次展开。对于间接展开,使用数组的时候如 ${!arrname[0]} 的时候,会先展开数组,再展开数组对应位置保存的值。

bash$ a=1
bash$ pn=a
bash$ arr=(pn a)

bash$ echo ${a}
1
bash$ echo you are No.${a}!
you are No.1!
bash$ echo you are No.${!pn}!
you are No.1!
bash$ echo ${!arr[0]}
a

间接展开有两个例外,${!prefix*}${!name[*]}(如果把其中的 * 换成 @ ,那么展开结果表现为多个字符串,详细探讨可以看星号和 At 符号的差异

  • ${!prefix*}${!prefix@} 会展开为目前环境中所有以 prefix 作为前缀的变量的名称。
  • ${!name[*]}${!name[@]} 会展开数组 name 可用的所有下标(indices / keys)。
bash$ p_a="prefix_a"
bash$ p_b="prefix_b"
bash$ p_c="prefix_c"

bash$ echo ${!p_*}
p_a p_b p_c
bash$ echo ${!p_@}
p_a p_b p_c
p_arr=(233 456 666)
echo ${!p_arr[*]}
0 1 2
echo ${!p_arr[@]}
0 1 2

变量为空的判断 🔗

而根据变量是否为 null (零长度字符串)和 unset,可以在变量展开时得到不同的结果。注意下面的测试都可以省略冒号(:),省略以后只判断是否为 unset 。

  1. ${parameter:+word}parameter 为 null 或 unset 时,“word” 作为展开结果,为 null 或 unset 时,得到一个空字符串
  2. ${parameter:-word}parameter 为 null 或 unset 时,将 “word” 作为展开结果
  3. ${parameter:=word}parameter 为 null 或 unset 时,将 “word” 作为展开结果,并且 parameter被赋值为 “word”
  4. ${parameter:?word}parameter 为 null 或 unset 时,将 “word” 输出到错误流,并中断非交互式的 bash

下面使用一个脚本 if-null.sh 来展示所有使用情况,其输出如下:

Pre-define: normal_v=O; null_v=; unset unset_v

${var:+X} normal_v:X  null_v:   unset_v:
${var+X}  normal_v:X  null_v:X  unset_v:

${var:-X} normal_v:O  null_v:X  unset_v:X
${var-X}  normal_v:O  null_v:   unset_v:X

${var:=X} normal_v:O  null_v:X  unset_v:X
  After   normal_v=O  null_v=X  unset_v=X
${var=X}  normal_v:O  null_v:   unset_v:X
  After   normal_v=O  null_v=   unset_v=X

${var:?X} ---
    normal_v : O
    null_v   : ${null_v:?X}  # skip, or the script will be interrupted
    unset_v  : ${unset_v:?X} # skip, or the script will be interrupted
${var?X}  ---
    normal_v : O
    null_v   :
    unset_v  : ${unset_v?X}  # skip, or the script will be interrupted

子串展开(Substring Expansion) 🔗

  • ${parameter:offset}
  • ${parameter:offset:length}

offset 表示在原始字符串的偏移,为负数时表示到字符串末尾的距离。length 表示截取字符串的长度,为负数时表示到字符串末尾的距离,省略时表示截取到字符串末尾。

注意 offsetlength 为负数时,-: 之间需要使用一个空格分隔,以此区分 :- 语法。

bash$ a=0123456789
bash$ echo ${a:4:1}
4
bash$ echo ${a: -4: -1}
678
bash$ echo ${a:4}
456789

parameter 是下标为 *@ 的数组时,则是对数组元素进行截取。

bash$ a=(1 b 3 c)
bash$ echo ${a[@]:1}
b 3 c
bash$ echo ${a[*]:2}
3 c

parameter*@ 时,则是对当前的位置参数(positional parameters)进行数组元素的截取。

bash$ set -- 1 b 3 c
bash$ echo ${@:1}
1 b 3 c
bash$ echo ${*:2}
b 3 c

字符串长度 🔗

${#parameter} 可以获取字符串的长度。花括号视情况可省略。

parameter 是下标为 *@ 的数组时,则会获取数组长度。

parameter*@ 或省略时,则会得到当前的位置参数(positional parameters)的个数。

bash$ a=abc
bash$ b=(1 2 3)
bash$ set -- p1 p2 p3
bash$ echo ${#a} ${#b[*]}
3 3
bash$ echo ${#@} ${#*} $#
3 3 3

匹配截取 🔗

${parameter#word}
${parameter##word}
${parameter%word}
${parameter%%word}

parameter 所展开的字符串中,移除第一个被 word 匹配的部分。使用 # 是从前向后匹配,使用 % 是从后向前匹配。使用单个 #% 是最短匹配(类似正则中的非贪婪模式),使用 ##%% 是最长匹配(类似正则中的贪婪模式)。

bash$ a=axbxcxd
bash$ echo ${a#a*x} ${a##a*x} ${a%x*d} ${a%%x*d}
bxcxd d axbxc a

替换 🔗

${parameter/pattern/string}
${parameter//pattern/string}
${parameter/#pattern/string}
${parameter/%pattern/string}

patameter 所展开的字符串中,被 pattern 所匹配的部分会被替换为 string。被替换的 string 会经过波浪线展开、参数和变量展开、算术展开、命令代换和进程代换、引号移除

  1. pattern 前是 / 则仅替换第一个匹配的到部分
  2. pattern 前是 // 则替换所有被匹配到的部分
  3. pattern 前是 /#pattern 只能匹配字符串的开头(类似正则中的 ^
  4. pattern 前是 /%pattern 只能匹配字符串的结尾(类似正则中的 $)。
bash$ a=axbxcxd
bash$ b=xbxcx

bash$ echo ${a/x/o} ${a//x/o}
aobxcxd aobocod

bash$ echo ${a/#x/o} ${a/%x/o}
axbxcxd axbxcxd

bash$ echo ${b/#x/o} ${b/%x/o}
obxcx xbxco

关于 shopt -s patsub_replacoement 所能启用的对 & 的特殊语法,这里不再介绍。

字母大小写转换 🔗

${parameter^pattern}
${parameter^^pattern}
${parameter,pattern}
${parameter,,pattern}

pattern 只能匹配单个字符,并把匹配到的字符转换为大写或小写。^ 会将匹配到的字符转换为大写,, 会将匹配到的字符转换为小写。^, 只能转换匹配的首字符^^,,会转换所有匹配到的字符。

pattern 可以省略,省略时效果等同于 pattern 设为 ?,即匹配任意字符。

bash$ a=aba
bash$ b=ABA

bash$ echo ${a^[ab]} ${a^^[ab]}
Aba ABA
bash$ echo ${b,[AB]} ${b,,[AB]}
aBA aba

字符串操作 🔗

${parameter@operator}

operator 可以是以下字符:

  • U 展开结果中,小写字母会被转化为大写字母
  • u 展开结果中,第一个字符如果是字母,则会被转化为大写
  • L 展开结果中,大写字母会被转化为小写字母
  • Q 展开结果中,将所展开字符串用引号包括,成为可以被 bash 复用到赋值表达式的形式,如使用 $'...' 转义的字符,也可以用此方法打印出来
  • E 展开时,会应用反斜杠的转义,展开效果如同将展开内容放到 $'...' 之中
  • P 展开时,会应用 提示控制符 的转义。
  • A 展开形式类似赋值表达式,如 x 的值为 abc,展开结果为 x='abc'
  • Kkey value key value 的形式展开数组。
  • a 展开结果是一些表示 parameter 属性的 flag,如 Associative Array 的 flag 是 A
  • k 类似 K,但是展开为多个单词(bash 5.1.16 未能复现)
bash$ x='"\t"'
bash$ echo ${x}
"\t"
bash$ echo ${x@Q}               # 展开结果被引号包括,若有转义则会是 $`..` 的形式
'"\t"'
bash$ echo ${x@A}               # 展开结果为赋值形式
x='"\t"'
bash$ echo ${x@P}               # \t 在提示控制符转义下为时间
"21:45:29"
bash$ echo -n "${x@E}" | xxd    # \t 在反斜杠转义下为制表符
00000000: 2209 22                                  "."


bash$ declare -A a
bash$ a["abc"]=1
bash$ a["bcd"]=2

bash$ echo ${a[@]@K}
bcd "2" abc "1"


bash$ declare -r a
bash$ echo ${a@a}
Ar

命令代换(command substitution) 🔗

命令代换有两种形式

$(command)
`command`

bash 会直接运行其中的command并将标准输出流的内容在移除所有末尾换行符之后将字符串作为代换结果,字符串中间的换行符不会被移除(但是可能会被单词分割移除)。

特别的,$(cat file) 可以替换为 $(< file) 以得到更快的速度。

算术展开(arithmetic expansion) 🔗

算术展开的语法如下,其中 expression 可以执行引号中允许的展开,如参数和变量展开、命令代换、引号移除。算术展开的结果就是其中 expression 的运算结果。算术展开可以嵌套。

$(( expression ))

如果算术展开时 expression 不是一个合法的表达式,那么 bash 会打印一条错误消息到错误流,并且这个结构不会被代换。但是下面的实践中,bash 的表现更像是停止了该命令的执行。

Original doc:

If the expression is invalid, Bash prints a message indicating failure to the standard error and no substitution occurs.

bash$ echo "$(( 1 + ))"
bash: 1 + : syntax error: operand expected (error token is "+ ")

下面的例子使用了算术展开支持命令代换、参数和变量展开、引号移除的特性:

bash$ a=1 ; b=2
bash$ echo $(( "$(echo $a + $b | bc) * $b" + $a ))
7

此语法几乎支持所有 C 语言风格的运算符,包括自增、自减、移位、赋值甚至三元运算符。详细可以见 文档

算术运算时,所有数字作为定宽整数(fixed-width integer)参与运算,除了除零检测以外,不会有溢出检测。

在算术运算时,变量可以不使用变量展开的语法进行引用。下面展示了自增运算符,并且直接引用了变量 a

bash$ a=12
bash$ echo $(( ++a ))
13

关于进制,0 开头的数字认为是 8 进制,0x 开头的数字认为是 16 进制。其他进制可以通过 [base#]n 的语法来指定进制,省略 base 时为 10 进制,其中 n 中大于 9 的数位可以用小写字母、大写字母、@_ 来按顺序表示,但是如果 base 小于等于 36,那么大小写字母可以同时用来表示 10 到 35 的数字。

在其他进制的表示方式中,按照数字、小写字母、大写字母、@_ 的计数方式,最大支持到 64 进制。

下面的例子展示了不同进制的表示方法:

  • 9 = 1 + 8 * 1
  • 17 = 1 + 16 * 1
  • 14 = 1 + 13 * 1
  • 701 = 61 + 64 * 10 (‘a’=10, ‘Z’=61)
  • 63 = 63 (’_’=63)
bash$ echo $(( 011 )) $(( 0x11 )) $(( 13#11 )) $(( 64#aZ )) $(( 64#_ ))
9 17 14 701 63

bash 的算术展开曾经还有一个 $[ expression ] 的语法,但是此语法已经在文档中移除,未来可能会移除此语法的支持,日常中应当使用 $(( expression)) 语法。

另外下面的链接中有人提到在 bash 5.0 中旧语法已经被移除,但是在 ArchLinux 目前的 5.1.16 版本中 $[ expression ] 的语法仍然可用。

来源见 Stack Overflow

进程代换(process substitution) 🔗

进程的输入/输出流可以被抽象成一个文件,并被展开为一个可读/写的一个文件名,此文件名可以作为参数传递给其他程序。语法如下:

<(process)  # 展开为一个可读的文件
>(process)  # 展开为一个可写的文件

使用场景如一个程序需要同时从两个或更多个程序的输出流中读取信息,而使用管道重定向至多只能使该程序从一个程序的输出流中读取信息,使用进程代换则可以很方便地处理这种情况。

下面的例子中,script.sh 从第 2 个以及以后的参数所指定的文件中读取内容,并将内容输出到第 1 个参数的文件中去,在执行时,第 1 个参数 >(cat) 被展开为一个可写文件,第 2 和 3 个参数两个 echo 命令各自被展开为一个可读文件。

bash$ cat script.sh
#!/usr/bin/env bash
# Usage: "script.sh outfile infile [infile...]"
while [ "$#" -gt "1" ]
do
    printf "file %s's content\n" "$2"
    cat "$2"
    shift
done > "$1"

bash$ bash script.sh >(cat) <(echo "hello, world") <(echo "hi, program")
file /dev/fd/62's content
hello, world
file /dev/fd/61's content
hi, program

进程代换的展开结果为一个文件的路径

$ echo <(echo hello)
/dev/fd/63

本节部分参考: Stack Overflow

单词分割(word splitting) 🔗

所有双引号外的参数展开、命令代换、算术展开,都会经过单词分割的处理。

单词分割以 $IFS 变量中的每一个字符作为分隔符,将其他 展开(如参数展开) 的结果分割成多个单词。

如果 $IFS 包含 <space> <tab> <newline> 中的一个或多个,那么每个分割后的单词将会移除前后的所包含的这些空白字符。

除前面三个空白字符以外,其他定义的分隔符如果相邻出现两个以上,则会出现零长度字符串作为一个单词。

$IFS 为 unset 时,将会取 $IFS 默认值,即 <space> <tab> <newline> ;$IFS 为 null 时,单词分割不再生效。

下面的例子中,单词 “a” 前缀的 <newline>、<tab>、<space> 都被移除,<tab> 分割了 “a” 和 “b”,<space> 分割了 “b” 和 “c”:

bash$ a="$(printf "\n\t a\tb c")"
bash$ IFS=$' \t\n'
bash$ for i in $a ; do echo "($i)" ; done
(a)
(b)
(c)

下一个例子中空格和换行被移除,将 <newline> 从 $IFS 中移除后,换行符不再被移除,但是空格被保留了下来:

bash$ a=$'a \n'
bash$ IFS=$' \n'
bash$ echo -n $a | xxd
00000000: 61                                       a
bash$ IFS=$'\n'
bash$ echo -n $a | xxd
00000000: 6120                                     a 

下一个例子展示了使用空格和逗号作为分隔符的例子,展示空白字符的移除和出现相邻分隔符的情况,可以看到第 1 个逗号和空格并没能分割一个额外的零长度字符串,空格被移除了,但是第 2 和 3 个逗号分割了一个额外的零长度字符串。

bash$ a='a, b,,c'
bash$ IFS=' ,'
bash$ for i in $a ; do echo "($i)" ; done
(a)
(b)
()
(c)

文件名展开(filename expansion) 🔗

文件名展开发生在单词分割之后,所以每文件名展开的对象是一个个的单词

只有在括号外的 *?[ 三个符号会被视为 pattern (指匹配的“模式”,如 a?b 能够匹配字符串 abca?b 就称为 pattern),并被替换为匹配到的字母序文件名列表。如果没有匹配到任何文件名,则单词会保留原样。

文件名展开默认启用,使用 set -o noglobset -f 可以禁用文件名展开。

文件名开头的点号默认必须在 pattern 中完全匹配,除非使用了 shopt -s dotglob。但是 ... 这两个文件名开头的点号必须完全匹配,即使使用了 shopt -s dotglob 也要完全匹配,如 .?

  • 如果使用 shopt -s nullglob,在没有匹配到任何文件名时,单词会被移除。
  • 如果使用 shopt -s failglob,在没有匹配到任何文件名时,会打印一条错误信息,而命令将不会被执行。
  • 如果使用 shopt -s nocaseglob,在匹配文件名时,将会忽略大小写。
  • 如果使用 shopt -s globskipdots,以点号(.)开头的文件无论如何都不会被匹配到。
  • 如果使用 shopt -s dotglob,则文件名开头的点号可以使用通配符匹配。

文件名匹配中,表示子目录/子文件的斜杠必须在 pattern 中完全匹配。

pattern 匹配的语法见 模式匹配(Pattern Matching)

引号移除(quota removal) 🔗

在上述所有的展开结束后,所有未被引号括起来并且不来自展开结果的 \'" 都会被移除。

其他内容 🔗

模式匹配(Pattern Matching) 🔗

在 pattern 进行匹配时,除以下特殊字符之外,每个字符都会匹配它自己,反斜杠可以转义以下特殊字符使之匹配它自己。

  • *

    匹配任意长度字符串,包括零长度字符串(null string)。

  • ?

    匹配任何单个字符。

  • [...]

    匹配被括起来的任何字符。可以使用减号 - 来表示范围,减号位于两个字符之间表示范围,在方括号最开始或最末尾则不具有此含义。可以使用 !^ 来表示取反,但是这两个字符必须在方括号的最开始才有这个效果。

    需要注意的是减号所表示的范围是在当前字符编码中的范围,与当前的 locale 有关,比如默认的 C locale 中,[a-d] 表示 [abcd],但是在一些按照字典序的 locale 中则表示 [aBbCcDd]

    可以使用 [:class:] 的语法来表示字符类别(character classes),其中 class 可以是下面的一个:

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

    其中 word 匹配字母、数字和下划线。

环境变量 GLOBIGNORE 可以限制进行模式匹配的文件名的集合,如果此环境变量被设置,那么文件名展开结果中匹配 GLOBIGNORE 中的任一 pattern 的条目都会被移除。此外只要 GLOBIGNORE 被设置并且不为零长度字符串(null),那么文件名 ... 将永远被忽略,同时 dotglob 的特性会被开启。

趣闻:以点号开头的文件作为隐藏文件起源于一个 bug,随后作为传统保留了下来,而 shell 为了兼容这个传统,要求文件名开头的点号完全匹配。

Stack Overflow

本节有一些十分少用的特性没有描述,如 GLOBIGNOREnocaseglobdotglob 之间的交互,shopt -s extglob 所能启用的拓展匹配等。

下面的例子中可以验证 dotglob 的特性,以及 ... 无论如何需要匹配其第一个 . 的特性。

bash$ ls -a
.  ..  file_a  file_b  .hide_a  .hide_b

bash$ echo *
file_a file_b
bash$ echo .*
. .. .hide_a .hide_b

bash$ shopt -s dotglob

bash$ echo *
file_a file_b .hide_a .hide_b
bash$ echo .*
. .. .hide_a .hide_b

下面的例子可以验证斜杠需要完全匹配。

bash$ mkdir d1 d2 d3
bash$ touch {d1,d2,d3}/{f1,f2,f3}
bash$ ls -RF
.:
d1/  d2/  d3/

./d1:
f1  f2  f3

./d2:
f1  f2  f3

./d3:
f1  f2  f3


bash$ echo *
a b c
bash$ echo */*
a/a a/b a/c b/a b/b b/c c/a c/b c/c

和上面类似的目录结构,下面的例子展示了 GLOBIGNORE 的效果。

GLOBIGNORE

A colon-separated list of patterns defining the set of filenames to be ignored by pathname expansion. If a filename matched by a pathname expansion pattern also matches one of the patterns in GLOBIGNORE, it is removed from the list of matches.

bash$ mkdir d1 d2 d3
bash$ touch {d1,d2,d3}/{f1,f2,f3} .hidden_file

bash$ echo *
d1 d2 d3
bash$ echo */*
d1/f1 d1/f2 d1/f3 d2/f1 d2/f2 d2/f3 d3/f1 d3/f2 d3/f3

bash$ GLOBIGNORE="d1/*:d2"

bash$ echo *
d1 d3 .hidden_file
bash$ echo */*
d2/f1 d2/f2 d2/f3 d3/f1 d3/f2 d3/f3

Tips 🔗

星号和 At 符号的差异 🔗

在展开数组等变量时,*@ 都可以表示展开全部内容,但是 * 会将所有展开结果合并成为一个字符串,而 @ 则会把展开结果分割成多个字符串(即便是在双引号包含的字符串中展开也是多个字符串)。

@ 具有分裂字符串程度的能力

区分一个包含空白字符的字符串多个字符串有些麻烦:bash 脚本作为运行参数传递,脚本可以判断参数的个数和内容;for var in ... 语法可以逐个字符串进行遍历。

bash$ a=("one" "two" "three")
bash$ for i in "${a[*]}" ; do echo "'$i'\[WIP]
: " ; done
'one two three'
bash$ for i in "${a[@]}" ; do echo "'$i'" ; done
'one'
'two'
'three'
bash$ for i in "${!a[*]}" ; do echo "'$i'" ; done
'0 1 2'
bash$ for i in "${!a[@]}" ; do echo "'$i'" ; done
'0'
'1'
'2'

但是需要注意的是,展开时若不被双引号包含, * 可能和 @ 有着同样的行为,即视为多个字符串,原因请参见单词分割

字符串的个数与 echo 命令的误区 🔗

很容易想当然地认为 echo 命令是直接把此命令后面的字符串原封不动地输出,但实际上并不是原封不动,它只是把所有的参数(非控制参数)两两之间加个空格输出罢了,比如下面的例子

bash$ echo a  b c
a b c
bash$ echo "-e" a "b"
a b

第一条命令,echo 并没有保持 “a” 和 “b” 之间额外的一个空格原样输出,因为 bash 在将这些字符串传递给 echo 时,并没有传递字符串之间的空格信息,echo 只能知道共有 3 个参数,分别是 “a”、“b”、“c”。

第二条命令中,echo 并不能区分在调用时是否有引号,以及 “-e” 和 “b” 之间的差异,因为 bash 传递参数时并没有保留引号,它只能判断认为 “-e” 是一个有效的 Option。

奇思妙想: echo 不能在不启用转义的情况下单纯打印一个 “-e” 字符串

创建末尾是换行符的字符串变量 🔗

下面这种方法不能使 IFS 的值为 <space> <tab> <newline>,因为在命令代换中,末尾的所有换行符都会被移除掉。

IFS="$(echo -en ' \t\n')"

零长度字符串和单词分割 🔗

如果一个变量的值为 null (零长度字符串),那么在双引号外展开时,此结果会被移除,如果在双引号内展开,那么展开结果会作为零长度字符串保留。

下面前两个测试命令展示了一个常见的测试变量为零长度字符串的错误用法,由于变量 a 是在双引号外展开的,展开结果作为 null 最后被移除了,所以 [ -z $a] 被解释为 [ -z ],继而由于其默认行为导致测试永远为真,最后一个测试命令则是正确用法:

bash$ a=''
bash$ [ -z $a ] && echo "empty string"
empty string
bash$ [ -n $a ] && echo "non-empty string"
non-empty string
bash$ [ -z "$a" ] && echo "empty string"
empty string

这种情况的正确用法应该是 [ -z "$a" ][ -n "$a" ]

借助 xtraceverbose 选项打印脚本命令和详细运行细节,可以更加直观地看到在测试条件中 a 最终被移除了:

bash$ cat v.sh
#!/usr/bin/env bash

set -o xtrace
set -o verbose

a=""
[ -n $a ] && echo "non-empty string"
bash$ bash v.sh
+ set -o verbose

a=""
+ a=
[ -n $a ] && echo "non-empty string"
+ '[' -n ']'
+ echo 'non-empty string'
non-empty string

按顺序展开的例子 🔗

bash$ touch a_file
bash$ IFS='='

bash$ echo "a*=z"
a*=z

bash$ echo $(echo "a*=z")
a_file z

bash$ for i in $(echo "a*=z") ; do printf "word: %s\n" $i ; done
word: a_file
word: z

在上面的例子中,依次使用了命令代换、单词分割、文件名补全:

  1. 首先是 echo "a*=z" 得到了字符串 a*=z 作为命令代换的结果(不包含引号,下同)
  2. 然后由于 IFS=,所以字符穿被单词分割为 a*z 两个字符串
  3. 最后文件名补全 a* 得到了 a_file

set 和 shopt 🔗

set 可以设置 bash 一些的行为,而 shopt 也有同样的作用,那为什么有这样两个功能高度重合的命令呢?

原因就是这是历史遗留问题,set 来自 POSIX,而 shopt 来自另一个组织,而且两个命令操作的环境变量分别是 $SHELLOPTS$BASHOPTS

参见: Stack Overflow

位置参数(positional parameters) 🔗

位置参数即程序启动时的参数,如 ls -l 中的 -l 就是 ls 的位置参数。

位置参数索引从 1 开始,但是 $0 保存进程启动时可执行文件的文件名。使用 $1 $2 可以获取各个位置参数,使用 ${@}${*} 来一次性获取所有位置参数(不包括 $0),使用 ${#} 来获取位置参数的个数,花括号可省略,@* 的区别见星号和 At 符号的差异

在 bash 中,使用 set -- p1 p2 可以在运行中设置自己的位置参数。

bash$ set -- p1 p2 p3
bash$ echo $0 $1 $2 $3 $#
bash p1 p2 p3 3
bash$ echo $@
p1 p2 p3

参考 🔗


Categories