From f57937cd64db027159028ac306b6be3421641093 Mon Sep 17 00:00:00 2001 From: leafee98 Date: Fri, 3 Feb 2023 22:24:18 +0800 Subject: [PATCH] new post: shell-expansions --- content/posts/shell-expansions/if-null.sh | 28 + content/posts/shell-expansions/index.md | 796 +++++++++++++++++++ content/posts/shell-expansions/word-split.sh | 18 + 3 files changed, 842 insertions(+) create mode 100644 content/posts/shell-expansions/if-null.sh create mode 100644 content/posts/shell-expansions/index.md create mode 100644 content/posts/shell-expansions/word-split.sh diff --git a/content/posts/shell-expansions/if-null.sh b/content/posts/shell-expansions/if-null.sh new file mode 100644 index 0000000..1a743aa --- /dev/null +++ b/content/posts/shell-expansions/if-null.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +normal_v="O"; null_v=""; unset unset_v +echo -e "Pre-define: normal_v="O"; null_v=""; unset unset_v\n" + +printf "\${var:+X} normal_v:%1s null_v:%1s unset_v:%1s\n" "${normal_v:+X}" "${null_v:+X}" "${unset_v:+X}" +printf "\${var+X} normal_v:%1s null_v:%1s unset_v:%1s\n\n" "${normal_v+X}" "${null_v+X}" "${unset_v+X}" + +printf "\${var:-X} normal_v:%1s null_v:%1s unset_v:%1s\n" "${normal_v:-X}" "${null_v:-X}" "${unset_v:-X}" +printf "\${var-X} normal_v:%1s null_v:%1s unset_v:%1s\n\n" "${normal_v-X}" "${null_v-X}" "${unset_v-X}" + +printf "\${var:=X} normal_v:%1s null_v:%1s unset_v:%1s\n" "${normal_v:=X}" "${null_v:=X}" "${unset_v:=X}" +printf " After normal_v=%1s null_v=%1s unset_v=%1s\n" "${normal_v}" "${null_v}" "${unset_v}" +normal_v="O"; null_v=""; unset unset_v # reset variables + +printf "\${var=X} normal_v:%1s null_v:%1s unset_v:%1s\n" "${normal_v=X}" "${null_v=X}" "${unset_v=X}" +printf " After normal_v=%1s null_v=%1s unset_v=%1s\n\n" "${normal_v}" "${null_v}" "${unset_v}" +normal_v="O"; null_v=""; unset unset_v # reset variables + +printf "\${var:?X} ---\n" +printf " normal_v : ${normal_v:?X}\n" +printf " null_v : \${null_v:?X} # skip, or the script will be interrupted\n" +printf " unset_v : \${unset_v:?X} # skip, or the script will be interrupted\n" + +printf "\${var?X} ---\n" +printf " normal_v : ${normal_v?X}\n" +printf " null_v : ${null_v?X}\n" +printf " unset_v : \${unset_v?X} # skip, or the script will be interrupted\n" diff --git a/content/posts/shell-expansions/index.md b/content/posts/shell-expansions/index.md new file mode 100644 index 0000000..c3b4abf --- /dev/null +++ b/content/posts/shell-expansions/index.md @@ -0,0 +1,796 @@ +--- +title: "Bash 语法的展开" +date: 2023-02-03T22:23:20+08:00 +tags: [ linux, bash ] +categories: [ tech ] +weight: 50 +show_comments: true +draft: false +--- + +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](./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* 表示截取字符串的长度,为负数时表示到字符串末尾的距离,省略时表示截取到字符串末尾。 + +注意 *offset* 和 *length* 为负数时,`-` 与 `:` 之间需要使用一个空格分隔,以此区分 `:-` 语法。 + +``` +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` 展开时,会应用 [提示控制符][5] 的转义。 +- `A` 展开形式类似赋值表达式,如 `x` 的值为 `abc`,展开结果为 `x='abc'` +- `K` 以 `key 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 语言风格的运算符,包括自增、自减、移位、赋值甚至三元运算符。详细可以见 [文档](https://www.gnu.org/software/bash/manual/html_node/Shell-Arithmetic.html)。 + +算术运算时,所有数字作为定宽整数(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][1] + +## 进程代换(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][3] + +## 单词分割(word splitting) + +所有**双引号外**的参数展开、命令代换、算术展开,都会经过单词分割的处理。 + +单词分割以 `$IFS` 变量中的每一个字符作为分隔符,将其他 *展开(如参数展开)* 的结果分割成多个单词。 + +如果 `$IFS` 包含 \ \ \ 中的一个或多个,那么每个分割后的单词将会移除前后的**所包含**的这些空白字符。 + +除前面三个空白字符以外,其他定义的分隔符如果相邻出现两个以上,则会出现零长度字符串作为一个单词。 + +`$IFS` 为 unset 时,将会取 `$IFS` 默认值,即 \ \ \ ;`$IFS` 为 null 时,单词分割不再生效。 + +下面的例子中,单词 "a" 前缀的 \、\、\ 都被移除,\ 分割了 "a" 和 "b",\ 分割了 "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) +``` + +下一个例子中空格和换行被移除,将 \ 从 `$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` 能够匹配字符串 `abc`,`a?b` 就称为 pattern),并被替换为匹配到的字母序文件名列表。如果没有匹配到任何文件名,则单词会保留原样。 + +文件名展开默认启用,使用 `set -o noglob` 或 `set -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][2] + +> 本节有一些十分少用的特性没有描述,如 `GLOBIGNORE` 和 `nocaseglob`、`dotglob` 之间的交互,`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` 的值为 \ \ \,因为在命令代换中,末尾的所有换行符都会被移除掉。 + +``` +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" ]`。 + +借助 `xtrace` 和 `verbose` 选项打印脚本命令和详细运行细节,可以更加直观地看到在测试条件中 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][4] + +### 位置参数(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 +``` + +## 参考 + +- [Bash Reference Manual -- GNU.org][0] +- [What does a dollar sign followed by a square bracket $[...] mean in bash? -- Stack Overflow][1] +- [How does GLOBIGNORE work? -- Stack Overflow][2] +- [Process substitution -- Stack Overflow][3] +- [Set and Shopt - Why Two? -- Stack Overflow][4] +- [Controlling the Prompt -- GNU.org][5] + +[0]: https://www.gnu.org/software/bash/manual/html_node/Shell-Expansions.html "Shell Expansions (Bash Reference Manual)" +[1]: https://unix.stackexchange.com/questions/209833/what-does-a-dollar-sign-followed-by-a-square-bracket-mean-in-bash/209846#209846 "What does a dollar sign followed by a square bracket $[...] mean in bash?" +[2]: https://unix.stackexchange.com/questions/105465/how-does-globignore-work/105467#105467 "How does GLOBIGNORE work?" +[3]: https://unix.stackexchange.com/questions/17107/process-substitution-and-pipe/17117#17117 "Process substitution and pipe" +[4]: https://unix.stackexchange.com/questions/32409/set-and-shopt-why-two/208244#208244 "Set and Shopt - Why Two?" +[5]: https://www.gnu.org/software/bash/manual/html_node/Controlling-the-Prompt.html "Controlling the Prompt" diff --git a/content/posts/shell-expansions/word-split.sh b/content/posts/shell-expansions/word-split.sh new file mode 100644 index 0000000..8b2bb4a --- /dev/null +++ b/content/posts/shell-expansions/word-split.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +IFS=$' \t\n' +a="$(printf " a b , c d\n\t e f ")" +printf "a = " +printf "$a" | xxd + +for i in $a ; do printf "$i" | xxd ; done + +IFS=$'\t\n' +for i in $a ; do printf "$i" | xxd ; done + +IFS=$', \t\n' +for i in $a ; do printf "$i" | xxd ; done + +IFS=$', \n' +for i in $a ; do printf "$i" | xxd ; done +