跳到主要内容
  1. Blog/

在 shell 中如何分割字符串与合并多行文本

··字数 2996·6 分钟
howto shell

在高级程序语言中,基本都提供了丰富的字符串操作功能函数,一般都包含名称为或类似于String 的类或标准库中。

在 shell 这款诞生于几十年前、语言特性并无跟随现代化演进的脚本语言上,要实现类似于现代高级程序语言中 String 的合并分割功能,并没有可直接使用的 splitjoin 函数。那么有没有变通和实现方法呢?

其实,在 SHELL 实现诸如字符串的 splitjoin 操作,有非常多的方法。

字符串分割 string spliting #

shell 中,在字符串进行分割,有两种思路。一是使用 shell 脚本的语言特性,二是使用 *nix 系统带的命令行工具。

使用字符替换来分割字符串 #

tr 或者 类似实现字符串替换的工具,如 sed。

echo "go:python:rust:js" | tr ":" "\n"

使用 tr 将分隔符 : 替换成换行符 \n

输出:

使用 tr 分割字符串
使用 tr 分割字符串

使用 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 对输入字符串进行切割。如上所述,它有比较明显的缺点,需要指定哪一个列,如果输入多列,那么需要多次执行命令。不过,当字符串很短的情况下,这些差异可以忽略。

输出:

使用 cut 分割字符串
使用 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 强行重新格式化行数据,不然不会有什么效果。

输出:

使用 awk 分割字符串
使用 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 命令工具

输出:

zsh 中使用 IFS 与数组
zsh 中使用 IFS 与数组
bash 中使用 IFS 与数组
bash 中使用 IFS 与数组

用纯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 环境并不包含这个特性。 输出:

zsh 中使用 parameter expansion 分割字符串
zsh 中使用 parameter expansion 分割字符串
bash 中使用 parameter expansion 分割字符串
bash 中使用 parameter expansion 分割字符串

多行合并 string joining #

与字符串分割类似,在 shell 中合并多行文本为一行,也有命令行工具与 shell 脚本两种实现方法。当然,如果学习过高级程序语言,那么很自然会联想到 for 或者 while 方法逐行文本连接。不过,下面选择介绍数据处理思维的命令工具方法。

使用 tr 替换 实现多行合并 #

strings="go
python
rust
js
"
echo "$strings" | tr -s "\n" ", "

将换行符 \n 替换为分隔符 , ,也可使用具体替换功能的 sed、awk 等。

输出:

使用 tr 合并多行
使用 tr 合并多行

使用 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 参数分别为 ","", " 时有什么不同,规律是什么?

输出:

使用 paste 合并多行
使用 paste 合并多行

使用 awk 合并多行 #

strings="go
python
rust
js
"
echo "$strings" | awk '{printf("%s%s", sep, $0); sep=", "} END {print}'

这个 awk 例子,至少涉及到 awk 三个知识点:

  1. printf 函数打印的字符串不带换行符,除非格式化模板包含 \n 结尾,如 “%s%s\n”
  2. awk 中,对于未定义或无值的变量,打印时不占位。注意到,脚本中 sep 变量赋值在后,起到的作用时,当处理一行文本时,由于 sep 未知,awk 不会在第一行的前面添加分隔符,这正是我们想要的。试一试将 sep=", " 放至 printf 前面,会有什么不同 🤔?
  3. END {print} 是在结尾打一个换行符,试试去掉后有什么变化?

输出:

使用 awk printf 函数合并多行
使用 awk printf 函数合并多行

strings="go
python
rust
js
"
echo "$strings" | awk '{s=(s d)$0; d=", "  } END {print s}'

与上例略有不同,这个 awk 示例,没有使用 printf 逐行输出,而是每一行使用分隔符拼接起来,保存在变量 s,在结尾一次性打印。 同样,当处理每一行文本时,sd 此时均为空,故 s=(s d)$0s=$0,当处理第 N(N > 1)行时,s=(s d)$0 实际为 “前N-1行合并 + 分隔符 + 第N行”(此处 + 表示字符串连接)。

输出:

使用 awk 行拼接合并多行
使用 awk 行拼接合并多行

使用 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 中都有相同的用法。需要另外说明的,

  1. 这个方法对于行文本中包含 空格 时,会失效,问题在于 aaa=( $(echo $strings) ) ,不能按我们预期那样按换行隔断,空格会被认为是数组元素的分隔符。
  2. 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[*]}" # 不起作用

输出:

在 bash 中使用 printf 合并多行
在 bash 中使用 printf 合并多行
在 zsh 中使用 printf 合并多行
在 zsh 中使用 printf 合并多行

使用 IFS 数组展开合并多行 #

strings="go
python
rust
js
"
aaa=(`echo $strings`)
IFS=","; echo "${aaa[*]}"
IFS=","; echo "${aaa[@]}" # 不起作用

本例使用的技巧是数组元素展开,它受到 IFS 变量的影响。与上例使用 printf 函数正好相反。${aaa[*]} 在展开后连接各元素时,会使用 IFS 值作为分隔符,而 ${aaa[@]} 是展开为多个值,故不会使用 IFS。另外,它同样受到 aaa=( $(echo $strings) ) 的限制,当行文本中包含空格时,结果会不正确。

输出:

使用 IFS 展开数组合并多行
使用 IFS 展开数组合并多行

去除尾部的分隔符 #

在本文多行文本合并的部分例子中,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

shell 版本
shell 版本

因为版本问题,有可能还存在其它的方法。

  • bash 4.4 包含 mapfile (readarray) 命令,你可以尝试使用这个命令如何读取多行文本到数组,以及合并多行。

汇总的示例脚本 #

收集了上文各个例子的演示shell脚本:

在命令行使用 bash 脚本文本名 或者 zsh 脚本文件名 即可执行脚本。