在 shell 中如何分割字符串与合并多行文本
目录
在高级程序语言中,基本都提供了丰富的字符串操作功能函数,一般都包含名称为或类似于String 的类或标准库中。
在 shell 这款诞生于几十年前、语言特性并无跟随现代化演进的脚本语言上,要实现类似于现代高级程序语言中 String 的合并分割功能,并没有可直接使用的 split
或 join
函数。那么有没有变通和实现方法呢?
其实,在 SHELL 实现诸如字符串的 split 和 join 操作,有非常多的方法。
字符串分割 string spliting #
在 shell 中,在字符串进行分割,有两种思路。一是使用 shell 脚本的语言特性,二是使用 *nix 系统带的命令行工具。
使用字符替换来分割字符串 #
tr 或者 类似实现字符串替换的工具,如 sed。
echo "go:python:rust:js" | tr ":" "\n"
使用 tr
将分隔符 :
替换成换行符 \n
。
输出:
使用 cut 分割字符串 #
echo "go:python:rust:js" | cut -d ":" -f 1
echo "go:python:rust:js" | cut -d ":" -f 2
echo "go:python:rust:js" | cut -d ":" -f 3
echo "go:python:rust:js" | cut -d ":" -f 4
使用 cut
对输入字符串进行切割。如上所述,它有比较明显的缺点,需要指定哪一个列,如果输入多列,那么需要多次执行命令。不过,当字符串很短的情况下,这些差异可以忽略。
输出:
使用 awk 分割字符串 #
echo "go:python:rust:js" | awk -F":" 'BEGIN {OFS="\n"} {$1=$1} {print $0}'
# 或者
echo "go:python:rust:js" | awk 'BEGIN {FS=":"; OFS="\n"} {$1=$1; print $0}'
使用 awk 处理输入字符串,通过指定输入列分隔符( FS
=":" )、与输出列分隔符(OFS
="\n")来实现。特别需要指出的,在上面的示例脚本中,{$1=$1}
是必要的,它告诉awk 强行重新格式化行数据,不然不会有什么效果。
输出:
使用 IFS 环境变量 #
IFS,全称为 Internal Field Separator。
在 zsh 中,
# zsh
IFS=":" read -rA aaa <<< "go:python:rust:js"
echo "$aaa[1], $aaa[2], $aaa[3], $aaa[4]"
echo "${aaa[@]}"
在 bash 中,
# bash
IFS=":" read -ra aaa <<< "go:python:rust:js"
echo "${aaa[0]}, ${aaa[1]}, ${aaa[2]} ,${aaa[3]}"
echo "${aaa[@]}"
本例,使用 read
命令工具读入字符串,通过 shell 环境变量 IFS
来指定列分隔符,并将分割的各位列值赋给一个数组变量。通过操作数据,可以获得所要列的值。
需要指出的,由于 zsh 与 bash 在 shell 脚本实现差异,导致最终 shell 脚本存在不同的地方。zsh 内置 read 命令; read 命令参数 -A
与 -a
(可以使用绝对路径指定 /usr/bin/read
来消除这个差别);数组下标索引号。
⚠️ 上例中
- zsh 中的 read: 是 zsh 内置的 命令
- bash 3.2 中的 read: 实际使用的是
/usr/bin/read
命令工具
输出:
用纯shell 参数展开(Parameter expansions)与数组来实现分割字符串 #
# bash
string="go:python:rust:js"
# 1. read -ra
read -ra aaa <<< ${string//":"/" "}
# 2. or ${string//":"/" "}
# aaa=( ${string//":"/" "} )
echo "${aaa[0]}, ${aaa[1]}, ${aaa[2]}, ${aaa[3]}"
echo "${aaa[@]}"
使用参数展开,将字符串中的 :
替换成空格后,赋值给一个数组变量。
# zsh
string="go:python:rust:js"
# 1. ${(s/:/)string}
aaa=( ${(@s/:/)string} )
# 2. or ${string//":"/" "}
# aaa=( $(echo ${string//":"/" "}) )
# 3. or read -rA
# read -rA aaa <<< ${string//":"/" "}
echo "$aaa[1], $aaa[2], $aaa[3], $aaa[4]"
echo "$aaa"
在 zsh 中,也有类似 bash 的参数展开,但是有小小区别。如果不用 echo ${string//":"/" "}
而是 ${string//":"/" "}
,那么得到的 aaa 只有一个元素(“go python rust js”)的数组,而非四个元素。也可以使用 ( ${(s/:/)string} )
指定分隔符(":"),不过有悟的本地 bash 3.2.57 环境并不包含这个特性。
输出:
多行合并 string joining #
与字符串分割类似,在 shell 中合并多行文本为一行,也有命令行工具与 shell 脚本两种实现方法。当然,如果学习过高级程序语言,那么很自然会联想到 for
或者 while
方法逐行文本连接。不过,下面选择介绍数据处理思维的命令工具方法。
使用 tr 替换 实现多行合并 #
strings="go
python
rust
js
"
echo "$strings" | tr -s "\n" ", "
将换行符 \n
替换为分隔符 ,
,也可使用具体替换功能的 sed、awk 等。
输出:
使用 paste 命令合并多行 #
strings="go
python
rust
js
"
echo "$strings "| paste -s -d"," -
paste
命令是最接近高级程序语言中的 string.join
函数的用法。-s
表示合并成一行,-d","
表示使用 “,” 作为列分隔符,因此处从管道中读取上一个命令的输出,故结尾使用 -
表示从标准输入读取内容。若要合并的多行文本内容保存在文件中,如叫 lines.txt
,那么也可以使用 paste -s -d"," lines.txt
来合并。
至于这个功能的命令工具被叫为 “paste”,偶尔会因为不常用而联想不起这个命令名称。
⚠️ 注意: 自己动手试试 paste
的 -d
参数分别为 ","
与 ", "
时有什么不同,规律是什么?
输出:
使用 awk 合并多行 #
strings="go
python
rust
js
"
echo "$strings" | awk '{printf("%s%s", sep, $0); sep=", "} END {print}'
这个 awk 例子,至少涉及到 awk 三个知识点:
printf
函数打印的字符串不带换行符,除非格式化模板包含\n
结尾,如 “%s%s\n”- awk 中,对于未定义或无值的变量,打印时不占位。注意到,脚本中
sep
变量赋值在后,起到的作用时,当处理一行文本时,由于sep
未知,awk 不会在第一行的前面添加分隔符,这正是我们想要的。试一试将sep=", "
放至printf
前面,会有什么不同 🤔? END {print}
是在结尾打一个换行符,试试去掉后有什么变化?
输出:
strings="go
python
rust
js
"
echo "$strings" | awk '{s=(s d)$0; d=", " } END {print s}'
与上例略有不同,这个 awk 示例,没有使用 printf
逐行输出,而是每一行使用分隔符拼接起来,保存在变量 s
,在结尾一次性打印。
同样,当处理每一行文本时,s
与 d
此时均为空,故 s=(s d)$0
为 s=$0
,当处理第 N(N > 1)行时,s=(s d)$0
实际为 “前N-1行合并 + 分隔符 + 第N行”(此处 +
表示字符串连接)。
输出:
使用 printf 格式化函数合并多行 #
# bash
strings="go
python
rust
js
"
aaa=(`echo $strings`)
printf "%s, " "${aaa[@]}" # '@' not '*'
echo; printf "%s, " "${aaa[*]}" # 不起作用
printf
函数的使用方法与 awk printf 函数类似,它也与现代高级程序语言的 string.format
方法雷同。这个 printf
命令行工具,可以接收多个参数,如printf "%s," hello world
,会输出 hello,world,
。printf
并不会为输出的结尾添加一个换行结束符,所以在你的终端里执行时,结尾会提示 “EOF”。对于本例想演示的功能,这个问题并不会造成影响。在 bash 与 zsh 中都有相同的用法。需要另外说明的,
- 这个方法对于行文本中包含 空格 时,会失效,问题在于
aaa=( $(echo $strings) )
,不能按我们预期那样按换行隔断,空格会被认为是数组元素的分隔符。 printf "%s, " "${aaa[@]}"
,输入的参数为${aaa[@]}
,而不是"${aaa[*]}
,区别在于@
是将数组展开为多个参数,而*
表示数组展开后做为一个整体。你自己可以试一试这个差异如何体现。
# zsh
strings="go
python
rust
js
"
# 1. use echo
# aaa=(`echo $strings`)
# 2. use <<<
aaa=(`<<< $strings`)
printf "%s, " "${aaa[@]}" # '@' not '*'
echo; printf "%s, " "${aaa[*]}" # 不起作用
输出:
使用 IFS 数组展开合并多行 #
strings="go
python
rust
js
"
aaa=(`echo $strings`)
IFS=","; echo "${aaa[*]}"
IFS=","; echo "${aaa[@]}" # 不起作用
本例使用的技巧是数组元素展开,它受到 IFS
变量的影响。与上例使用 printf
函数正好相反。${aaa[*]}
在展开后连接各元素时,会使用 IFS
值作为分隔符,而 ${aaa[@]}
是展开为多个值,故不会使用 IFS
。另外,它同样受到 aaa=( $(echo $strings) )
的限制,当行文本中包含空格时,结果会不正确。
输出:
去除尾部的分隔符 #
在本文多行文本合并的部分例子中,zsh 例子打印的出现了 %
,这是由于 iterm2 支持显示结尾特殊符号 EOF,而在bash 例子中出现了 [ble EOF]
字样,这是因为有悟的 bash 环境安装了 命令行语法高亮包 ble.sh
的提示所致。部分例子的输出结果还带有分隔符。
问题在于如果去掉结尾令人讨厌的这个提示呢?(说的是如何使输出没有这个符号,而不是工具配置)
这个问题就比较简单了,可以使用替换工具。
strings="go
python
rust
js
"
aaa=(`echo $strings`)
bbb=$(printf "%s, " "${aaa[@]}")
echo "${bbb%, }"
把输出的结果先临时保存在一个变量中,这里用 bbb
,使用 shell parameter expansions 方法,${值%替换串}
,则会替换掉结尾匹配到的符号串。注意,上述的 EOF
没有影响实际的操作,当使用 echo
或者 printf "%s\n"
打印变量时,输出的结果会换行,这个 EOF
提示也就自然消失了。
输出:
本文中脚本的实验环境 #
- OS: MacOS 13.2.1
- zsh: 5.8.1
- bash: 3.2.57(1)-release
因为版本问题,有可能还存在其它的方法。
- bash 4.4 包含 mapfile (readarray) 命令,你可以尝试使用这个命令如何读取多行文本到数组,以及合并多行。
汇总的示例脚本 #
收集了上文各个例子的演示shell脚本:
在命令行使用 bash 脚本文本名
或者 zsh 脚本文件名
即可执行脚本。