协慌网

登录 贡献 社区

如何在 Bash 中将字符串拆分为数组?

在 Bash 脚本中,我想将一行分割成几部分并将它们存储在数组中。

例如,给出以下行:

Paris, France, Europe

我想让结果数组看起来像这样:

array[0] = Paris
array[1] = France
array[2] = Europe

一个简单的实现是可取的。速度没关系。我该怎么做?

答案

IFS=', ' read -r -a array <<< "$string"

注意,在字符$IFS被单独视为分离器,使得在这种情况下,字段可以由逗号或空间而不是两个字符的序列中分离出来。但是有趣的是,当逗号空间出现在输入中时,不会创建空字段,因为空格是经过特殊处理的。

要访问单个元素:

echo "${array[0]}"

要遍历元素:

for element in "${array[@]}"
do
    echo "$element"
done

要同时获取索引和值:

for index in "${!array[@]}"
do
    echo "$index ${array[index]}"
done

最后一个示例很有用,因为 Bash 数组稀疏。换句话说,您可以删除元素或添加元素,然后索引不连续。

unset "array[1]"
array[42]=Earth

获取数组中元素的数量:

echo "${#array[@]}"

如上所述,数组可以是稀疏的,因此您不应该使用长度来获取最后一个元素。这是在 Bash 4.2 及更高版本中的操作方法:

echo "${array[-1]}"

在任何版本的 Bash 中(从 2.05b 之后的某个版本开始):

echo "${array[@]: -1:1}"

较大的负偏移量选择距数组末端较远的位置。请注意在较早的形式中减号之前的空格。它是必需的。

这个问题的所有答案都以一种或另一种方式是错误的。


错误的答案#1

IFS=', ' read -r -a array <<< "$string"

1:这是对$IFS的滥用。所述的值$IFS变量采取作为一个单一的可变长度的字符串分隔符,而它被作为一单字符字符串分隔符,其中每一个该字段read从输入线分割关闭可以通过任何字符被终止在集合中(在此示例中为逗号或空格)。

实际上,对于真正的顽固分子而言, $IFS的全部含义要稍微复杂一些。从bash 手册

Shell 将IFS 的每个字符视为定界符,并使用这些字符作为字段终止符将其他扩展的结果拆分为单词。如果未设置 IFS ,或者其值恰好是默认 ,则在先前扩展结果的开头和结尾处分别 的序列会被忽略,并且任何不在开头或结尾的 IFS 字符序列都用于分隔单词。如果IFS的值不是默认值,则只要单词的开头和结尾都将忽略空格字符 的序列。 IFSIFS空格字符)。 IFS中不是IFS空格的任何字符,以及任何相邻的IFS空格字符,都会对字段进行定界。 IFS空格字符序列也被视为定界符。如果IFS 的值为 null,则不会发生词拆分。

$IFS非默认非 null 值,字段可以用(1)一个或多个字符序列进行分隔,这些字符序列均来自 “IFS 空白字符” 集(即, >,<标签 ><换行符>(“换行”,意思是换行(LF) )是在本任何地方$IFS ),或(2)任何非 “IFS 空白字符” 那存在于$IFS与任何沿着 “IFS 空格字符” 将其包围在输入行中。

对于 OP,我在上一段中描述的第二种分隔模式可能正是他为他的输入字符串所需要的,但是我们可以很确信,我描述的第一种分隔模式根本不正确。例如,如果他的输入字符串是'Los Angeles, United States, North America'怎么办?

IFS=', ' read -ra a <<<'Los Angeles, United States, North America'; declare -p a;
## declare -a a=([0]="Los" [1]="Angeles" [2]="United" [3]="States" [4]="North" [5]="America")

2:即使您将此解决方案与单字符分隔符一起使用(例如,逗号本身,也就是没有跟随空格或其他包)),如果$string变量的值恰好包含任何 LF,然后, read将在遇到第一个 LF 时停止处理。 read内置函数每次调用仅处理一行。即使您仅将输入管道传递read语句,也是如此,就像我们在此示例中使用here-string机制所做的那样,因此保证了未处理的输入会丢失。 read的代码不了解其包含的命令结构中的数据流。

您可能会争辩说,这不太可能引起问题,但是,如果可能的话,应该避免这种微妙的危害。这是由于以下事实造成的: read内置实际上执行了两个级别的输入拆分:首先拆分为行,然后拆分为字段。由于 OP 只需要一个拆分级别,因此read内置函数,我们应该避免这种情况。

3:此解决方案的一个非显而易见的潜在问题是, read总是删除尾随字段(如果为空),尽管它保留空白字段。这是一个演示:

string=', , a, , b, c, , , '; IFS=', ' read -ra a <<<"$string"; declare -p a;
## declare -a a=([0]="" [1]="" [2]="a" [3]="" [4]="b" [5]="c" [6]="" [7]="")

也许 OP 对此并不在意,但这仍然是一个值得了解的限制。它降低了解决方案的健壮性和通用性。

这个问题可以通过附加的虚拟尾随分隔符输入字符串之前,为了喂养它要解决的read ,因为我会在后面演示。


错误的答案#2

string="1:2:3:4:5"
set -f                     # avoid globbing (expansion of *).
array=(${string//:/ })

类似的想法:

t="one,two,three"
a=($(echo $t | tr ',' "\n"))

(注意:我在回答者似乎已省略的命令替换处添加了缺少的括号。)

类似的想法:

string="1,2,3,4"
array=(`echo $string | sed 's/,/\n/g'`)

这些解决方案利用数组分配中的单词拆分功能将字符串拆分为多个字段。有趣的是,就像read一样,常规单词拆分也使用$IFS特殊变量,尽管在这种情况下,它暗示了将其设置为默认值 ,因此它的任何序列都为 1 或更多的 IFS 字符(现在都是空白字符)被视为字段定界符。

由于单词拆分本身仅构成一个拆分级别,因此这解决了read但是,就像以前一样,这里的问题在于输入字符串中的各个字段已经可以包含$IFS字符,因此在单词拆分操作中会不正确地拆分它们。这些应答程序提供的任何示例输入字符串都不是这种情况(多么方便...),但是当然,这不会改变以下事实,即任何使用此惯用语的代码库都将冒着以下风险:如果这个假设在某个时候被违反,就会爆炸。再次考虑我的反例'Los Angeles, United States, North America' (或'Los Angeles:United States:North America' )。

同样,在单词拆分之后通常会进行文件名扩展也称为路径名扩展,即通配),如果这样做,则可能会破坏包含字符* ? ,或[后跟] (如果extglob ,则将其括在括号中的片段先加上?*+@! ),方法是将它们与文件系统对象进行匹配,并相应地扩展单词(“globs”)。这三个应答器中的第一个已通过set -f来禁用通配符,巧妙地解决了该问题。从技术上讲,这是可行的(尽管您可能应该在set +f来重新启用 glob,以获取可能依赖于它的后续代码),但是为了破解基本的字符串到数组的解析操作而不得不打乱全局 shell 设置是不可取的在本地代码中。

这个答案的另一个问题是所有空字段都将丢失。根据应用程序的不同,这可能是问题,也可能不是问题。

注意:如果要使用此解决方案,则最好使用${string//:/ } “模式替换” 形式的参数扩展,而不要麻烦调用命令替换(它会分叉 shell) ),启动管道并运行外部可执行文件( trsed ),因为参数扩展纯粹是外壳程序内部操作。 (此外,对于trsed解决方案,输入变量应在命令替换内用双引号引起;否则,单词拆分将在echo命令中生效,并可能使字段值混乱。此外, $(...)命令替换的形式比旧的`...`更好,因为它简化了命令替换的嵌套并允许文本编辑器更好地突出显示语法。)


错误的答案#3

str="a, b, c, d"  # assuming there is a space after ',' as in Q
arr=(${str//,/})  # delete all occurrences of ','

这个答案与#2几乎相同。不同之处在于,应答者已假设字段由两个字符分隔,其中一个以默认$IFS ,而另一个则不是。他通过使用模式替换扩展来删除非 IFS 表示的字符,然后使用单词拆分在剩余的 IFS 表示的分隔符上拆分字段,从而解决了这种相当特殊的情况。

这不是一个非常通用的解决方案。此外,可以说逗号实际上是这里的 “主要” 定界符,而去掉它然后再取决于空格符进行字段拆分是完全错误的。再次考虑我的反例: 'Los Angeles, United States, North America'

同样,文件名扩展也可能破坏扩展的单词,但是可以通过使用set -f暂时禁用 globlob 分配,然后再set +f来防止这种情况。

同样,所有空白字段都将丢失,根据应用程序的不同,这可能是问题,也可能不是问题。


错误的答案#4

string='first line
second line
third line'

oldIFS="$IFS"
IFS='
'
IFS=${IFS:0:1} # this is useful to format your code with tabs
lines=( $string )
IFS="$oldIFS"

这与#2#3相似,因为它使用分词来完成工作,只是现在代码显式地将$IFS设置为仅包含输入字符串中存在的单字符字段定界符。应当重复说明,这不适用于多字符字段定界符,例如 OP 的逗号分隔符。但是,对于本例中使用的 LF 这样的单字符定界符,它实际上接近完美。正如我们在先前的错误答案中看到的那样,不能在中间无意中拆分字段,并且根据需要只有一个拆分级别。

一个问题是文件名扩展将破坏受影响的单词,如前所述,尽管可以再次将关键语句包装在set -fset +f来解决。

另一个潜在的问题是,由于 LF 符合前面定义的 “IFS 空格字符”,因此所有空白字段都将丢失,就像#2#3 中一样。如果定界符碰巧是非 “IFS 空格字符”,那么这当然不会成为问题,并且取决于应用程序,它可能无所谓,但这确实使解决方案的通用性大打折扣。

因此,总而言之,假设您使用一个字符分隔符,并且它是非 “IFS 空格字符”,或者您不关心空字段,则将关键语句包装在set -fset +f ,则此解决方案有效,否则无效。

$'...'语法(例如IFS=$'\n'; )可以更轻松地在 bash 中为变量分配 LF。)


错误的答案#5

countries='Paris, France, Europe'
OIFS="$IFS"
IFS=', ' array=($countries)
IFS="$OIFS"

类似的想法:

IFS=', ' eval 'array=($string)'

该解决方案实际上是#1 (因为它将$IFS设置为逗号空间)和#2-4 (因为它使用单词拆分将字符串拆分为字段)之间的交叉。因此,它遭受了困扰上述所有错误答案的大多数问题,就像世界上最糟糕的错误一样。

同样,关于第二个变体,由于eval的参数是单引号的字符串文字,因此似乎完全不需要 eval 调用,因此它是静态已知的。但是,以这种方式eval实际上有一个非常明显的好处。通常,当您运行一个仅包含变量赋值的简单命令时,意味着没有紧随其后的实际命令字,该赋值将在 shell 环境中生效:

IFS=', '; ## changes $IFS in the shell environment

即使简单命令涉及多个变量分配,也是如此。同样,只要没有命令字,所有变量分配都会影响 shell 环境:

IFS=', ' array=($countries); ## changes both $IFS and $array in the shell environment

但是,如果变量赋值连接到命令名(我喜欢称之为 “前缀分配”),那么它不会影响 shell 环境,而是仅影响执行的命令的环境中,无论它是一个内置或外部:

IFS=', ' :; ## : is a builtin command, the $IFS assignment does not outlive it
IFS=', ' env; ## env is an external command, the $IFS assignment does not outlive it

bash 手册中的相关报价:

如果没有命令名称,则变量分配会影响当前的 shell 环境。否则,变量将添加到已执行命令的环境中,并且不会影响当前的 shell 环境。

可以利用变量分配的此功能$IFS ,这使我们避免了像第一个变体中$OIFS但是我们在这里面临的挑战是,我们需要运行的命令本身仅仅是一个变量分配,因此它不会涉及使$IFS分配成为临时性的命令字。您可能会想自己,为什么不直接在语句中添加无操作命令词: builtin来使$IFS临时赋值呢?这是行不通的,因为这样会使$array赋值也成为临时的:

IFS=', ' array=($countries) :; ## fails; new $array value never escapes the : command

因此,我们实际上陷入了僵局,只差了 22 个。但是,当eval运行其代码时,它将在 shell 环境中运行,就像它是正常的静态源代码一样,因此,我们可以在eval $array赋值使其在 shell 环境中生效,而前缀为eval命令的$IFS前缀分配不会超过eval命令。这正是此解决方案的第二个变体中使用的技巧:

IFS=', ' eval 'array=($string)'; ## $IFS does not outlive the eval command, but $array does

因此,正如您所看到的,这实际上是一个巧妙的技巧,它以一种相当不明显的方式准确地完成了要求的工作(至少在赋值实现方面)。 eval参与其中,但实际上我总体上并不反对这种技巧;只需小心将引号字符串单引号以防止出现安全威胁。

但是同样,由于问题的 “世界上最糟糕” 的聚集,这仍然是对 OP 要求的错误答案。


错误的答案#6

IFS=', '; array=(Paris, France, Europe)

IFS=' ';declare -a array=(Paris France Europe)

嗯什么? OP 具有一个字符串变量,需要将其解析为数组。该 “答案” 以粘贴到数组文字中的输入字符串的逐字内容开头。我想那是做到这一点的一种方法。

看来应答者可能已经假定$IFS变量会在所有上下文中影响所有 bash 解析,但事实并非如此。从 bash 手册中:

IFS内部字段分隔符,用于在扩展后进行单词拆分,并使用read Builtin 命令将行拆分为单词。默认值为

因此, $IFS特殊变量实际上仅在两个上下文中使用:(1)扩展后执行的单词拆分(意味着在解析 bash 源代码时read内置函数将输入行拆分为单词。

让我尝试使这一点更清楚。我认为最好在解析执行之间进行区分。 Bash 必须首先解析源代码,这显然是一个解析事件,然后再执行代码,这就是在图片扩展时。扩展实际上是一个执行事件。此外,我对上面刚刚引用$IFS我不是说分词是在扩展之后执行的,而是说分词是扩展过程中执行的,或者甚至更准确地说,分词是扩展过程的一部分。短语 “分词” 仅指此扩展步骤;它不应该被用来引用 bash 源代码的解析,尽管不幸的是文档似乎确实把 “split” 和 “words” 这两个词混为一谈。这是 bash 手册的 linux.die.net 版本的相关摘录:

拆分成单词后,在命令行上执行扩展。进行了七种扩展:大括号扩展代字号扩展参数和变量扩展命令替换算术扩展单词拆分路径名扩展

扩展的顺序是:大括号扩展;波浪线扩展,参数和变量扩展,算术扩展和命令替换(以从左到右的方式完成);分词和路径名扩展。

您可能会认为GNU 版本的手册做得更好,因为它在 “扩展” 部分的第一句中选择了 “令牌” 一词,而不是 “单词”:

扩展已拆分为令牌后,将在命令行上执行。

重要的一点是, $IFS不会更改 bash 解析源代码的方式。 bash 源代码的解析实际上是一个非常复杂的过程,涉及识别外壳语法的各种元素,例如命令序列,命令列表,管道,参数扩展,算术替换和命令替换。在大多数情况下,bash 解析过程无法通过用户级操作(例如变量分配)来更改(实际上,此规则有一些小例外;例如,请参见各种compatxx Shell 设置,这些设置可以更改解析行为的某些方面即时)。然后,根据上述文档摘录中分解的一般 “扩展” 过程,将由复杂的解析过程产生的上游 “单词” /“令牌” 进行扩展,其中将扩展(扩展?)文本的单词拆分为下游单词只是该过程的一个步骤。分词仅涉及上一个扩展步骤中吐出的文本;它不会影响立即从源字节流解析的原义文本。


错误的答案#7

string='first line
        second line
        third line'

while read -r line; do lines+=("$line"); done <<<"$string"

这是最好的解决方案之一。注意,我们回到了使用read 。我不是早先说过read是不合适的,因为当我们只需要一个时,它会执行两个级别的拆分?这里的窍门是,您可以read ,即它仅执行一个级别的拆分,特别是每次调用仅拆分一个字段,这需要在循环中重复调用它的开销。有点麻烦,但是可以用。

但是有问题。第一:当您提供至少一个NAME参数以read ,它将自动忽略从输入字符串中分离出的每个字段中的前导和尾随空格。不管是否将$IFS设置为其默认值,都会发生这种情况,如本文章前面所述。现在,OP 可能不在乎其特定用例,实际上,这可能是解析行为的理想功能。但是,并非每个想要将字符串解析为字段的人都希望这样做。但是,有一个解决方案: read一种不太明显的用法是传递零个NAME参数。在这种情况下, read会储存完整的输入线,它从一个名为变量输入流得到$REPLY ,并且,作为奖励,它不会删除前导和值尾随空白。 read的非常强大的用法,在我的 shell 编程生涯中经常使用它。这是行为差异的演示:

string=$'  a  b  \n  c  d  \n  e  f  '; ## input string

a=(); while read -r line; do a+=("$line"); done <<<"$string"; declare -p a;
## declare -a a=([0]="a  b" [1]="c  d" [2]="e  f") ## read trimmed surrounding whitespace

a=(); while read -r; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="  a  b  " [1]="  c  d  " [2]="  e  f  ") ## no trimming

此解决方案的第二个问题是,它实际上并未解决自定义字段分隔符(例如 OP 的逗号空间)的问题。和以前一样,不支持多字符分隔符,这是此解决方案的不幸限制。 -d选项指定分隔符来尝试至少分割逗号,但是看看会发生什么:

string='Paris, France, Europe';
a=(); while read -rd,; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France")

可以预见的是,未说明的周围空白被拉入了字段值,因此随后必须通过修整操作对此进行校正(这也可以直接在 while 循环中完成)。但是还有另一个明显的错误:欧洲不见了!这是怎么回事?答案是,如果read到达文件末尾(在这种情况下,我们可以称其为字符串末尾),而未在 final 字段上遇到 final 字段终止符,则 read 返回失败的返回码。这导致 while 循环过早中断,我们失去了最后一个字段。

从技术上讲,同样的错误也困扰着前面的例子;区别在于字段分隔符被视为 LF,这是您未指定-d选项时的默认值,并且<<< (“here-string”)机制会自动将 LF 附加到字符串在将其作为命令的输入之前。因此,在那些情况下,我们通过不经意地将附加的虚拟终结器附加到输入中,无意中解决了最终字段丢失的问题。我们将此解决方案称为 “虚拟终结者” 解决方案。我们可以在 here 字符串中实例化虚拟终结符解决方案时,将其自己与输入字符串连接起来,从而对任何自定义定界符手动应用虚拟终结符解决方案:

a=(); while read -rd,; do a+=("$REPLY"); done <<<"$string,"; declare -p a;
declare -a a=([0]="Paris" [1]=" France" [2]=" Europe")

在那里,问题解决了。另一种解决方案是仅在同时(1) read返回的失败和(2) $REPLY为空的情况read在读取文件末尾之前无法读取任何字符。演示:

a=(); while read -rd,|| [[ -n "$REPLY" ]]; do a+=("$REPLY"); done <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=$' Europe\n')

这种方法还揭示了秘密 LF,该秘密 LF 由<<<重定向运算符自动附加到此字符串。当然,可以通过前面所述的显式修整操作将其单独剥离,但是显然,手动虚拟终止符方法可以直接解决该问题,因此我们可以继续进行下去。手动虚拟终结器解决方案实际上非常方便,因为它可以一次性解决这两个问题(掉落的最终场问题和附加的 LF 问题)。

因此,总的来说,这是一个功能强大的解决方案。唯一的弱点是缺乏对多字符定界符的支持,我将在后面解决。


错误的答案#8

string='first line
        second line
        third line'

readarray -t lines <<<"$string"

(这实际上与#7来自同一帖子;回答者在同一帖子中提供了两个解决方案。)

内置的readarray mapfile的同义词,是理想的选择。这是一个内置命令,可以一次将字节流解析为数组变量。不会弄乱循环,条件,替换或其他任何东西。而且它不会从输入字符串中秘密删除任何空格。并且(如果-O )可以在分配给目标数组之前方便地清除目标数组。但是它仍然不是完美的,因此我批评它为 “错误的答案”。

首先,只是为了避免这种情况,请注意,就像执行字段解析时read行为一样,如果数组为空readarray再次,这可能不是 OP 所关心的问题,但是可能是针对某些用例的。我待会儿再讲这个。

其次,和以前一样,它不支持多字符定界符。我将在稍后对此进行修复。

第三,编写的解决方案不能解析 OP 的输入字符串,实际上,不能按原样使用它来解析它。我也会暂时对此进行详细说明。

由于上述原因,我仍然认为这是对 OP 问题的 “错误答案”。在下面,我将给出我认为是正确的答案。


正确答案

仅通过指定-d选项,就可以天真地尝试#8 的工作:

string='Paris, France, Europe';
readarray -td, a <<<"$string"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=$' Europe\n')

我们看到结果与在#7 中read解决方案的双条件方法得到的结果相同。我们几乎可以使用手动虚拟终止符来解决此问题:

readarray -td, a <<<"$string,"; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=" Europe" [3]=$'\n')

这里的问题是readarray保留了尾随字段,因为<<<重定向运算符将 LF 附加到输入字符串,因此尾随字段不为空(否则它将被丢弃)。我们可以通过事后显式取消设置最终数组元素来解决此问题:

readarray -td, a <<<"$string,"; unset 'a[-1]'; declare -p a;
## declare -a a=([0]="Paris" [1]=" France" [2]=" Europe")

剩下的仅有两个实际相关的问题是:(1)需要修剪的多余空白;(2)缺少对多字符定界符的支持。

当然也可以在之后修剪空白(例如,请参阅如何从 Bash 变量修剪空白? )。但是,如果我们可以破解一个多字符定界符,那么一口气就能解决这两个问题。

不幸的是,没有直接的方法可以使多字符定界符起作用。我想到的最好的解决方案是对输入字符串进行预处理,以用单字符定界符替换多字符定界符,这样可以确保不会与输入字符串的内容发生冲突。唯一具有此保证的字符是NUL 字节。这是因为,在 bash 中(尽管不是在 zsh 中),变量不能包含 NUL 字节。该预处理步骤可以在过程替换中内联完成。这是使用awk 的方法

readarray -td '' a < <(awk '{ gsub(/, /,"\0"); print; }' <<<"$string, "); unset 'a[-1]';
declare -p a;
## declare -a a=([0]="Paris" [1]="France" [2]="Europe")

终于到了!该解决方案不会在中间错误地分割字段,不会过早地删除字段,不会丢弃空字段,不会在文件名扩展中损坏自身,不会自动剥离前导和尾随空格,最后不会留下隐藏式 LF,不需要循环,也不需要单字符定界符。


修整解决方案

readarray -C callback选项来演示我自己相当复杂的修整解决方案。不幸的是,我已经超出了 Stack Overflow 严格的 30,000 个字符的发布限制,因此无法解释。我将其留给读者作为练习。

function mfcb { local val="$4"; "$1"; eval "$2[$3]=\$val;"; };
function val_ltrim { if [[ "$val" =~ ^[[:space:]]+ ]]; then val="${val:${#BASH_REMATCH[0]}}"; fi; };
function val_rtrim { if [[ "$val" =~ [[:space:]]+$ ]]; then val="${val:0:${#val}-${#BASH_REMATCH[0]}}"; fi; };
function val_trim { val_ltrim; val_rtrim; };
readarray -c1 -C 'mfcb val_trim a' -td, <<<"$string,"; unset 'a[-1]'; declare -p a;
## declare -a a=([0]="Paris" [1]="France" [2]="Europe")

这是一种无需设置 IFS 的方法:

string="1:2:3:4:5"
set -f                      # avoid globbing (expansion of *).
array=(${string//:/ })
for i in "${!array[@]}"
do
    echo "$i=>${array[i]}"
done

这个想法是使用字符串替换:

${string//substring/replacement}

将 $ substring 的所有匹配项替换为空格,然后使用替换的字符串初始化数组:

(element1 element2 ... elementN)

注意:此答案使用split + glob 运算符。因此,为防止扩展某些字符(例如* ),暂停此脚本的遍历是一个好主意。