如何在bash中对字符串中的每个字符执行for循环?

How to perform a for loop on each character in a string in Bash?

我有一个这样的变量:

1
words="这是一条狗。"

我想在每个字符上做一个for循环,一次一个,例如,首先是character="这",然后是character="是"character="一"等。

我知道的唯一方法是将每个字符输出到一个文件中的单独行,然后使用while read line,但这似乎非常低效。

  • 如何通过for循环处理字符串中的每个字符?


您可以使用C型for循环:

1
2
3
4
foo=string
for (( i=0; i<${#foo}; i++ )); do
  echo"${foo:$i:1}"
done

${#foo}扩展到foo的长度。${foo:$i:1}扩展到从长度1的$i位置开始的子串。


我把sed放在LANG=en_US.UTF-8dash壳上,就得到了如下的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ echo"你好嗎 新年好。全型句號" | sed -e 's/\(.\)/\1
/g'












1
2
3
4
5
6
7
8
9
10
11
12
13
$ echo"Hello world" | sed -e 's/\(.\)/\1
/g'

H
e
l
l
o

w
o
r
l
d

因此,输出可以与while read ... ; do ... ; done循环。

为示例文本编辑翻译为英语:

1
2
3
4
5
"你好嗎 新年好。全型句號" is zh_TW.UTF-8 encoding for:
"你好嗎"     = How are you[ doing]
""         = a normal space character
"新年好"     = Happy new year
"。全型空格" = a double-byte-sized full-stop followed by text description


${#var}返回var的长度。

${var:pos:N}返回从pos起的n个字符

实例:

1
2
3
4
5
6
7
$ words="abc"
$ echo ${words:0:1}
a
$ echo ${words:1:1}
b
$ echo ${words:2:1}
c

所以很容易迭代。

另一种方式:

1
2
3
4
$ grep -o . <<<"abc"
a
b
c

1
2
3
4
5
$ grep -o . <<<"abc" | while read letter;  do echo"my letter is $letter" ; done

my letter is a
my letter is b
my letter is c


我很惊讶没有人提到明显的bash解决方案,只使用whileread

1
2
3
while read -n1 character; do
    echo"$character"
done < <(echo -n"$words")

注意使用echo -n以避免末尾出现无关的换行符。printf是另一个很好的选择,可能更适合您的特殊需求。如果您想忽略空白,那么用"${words// /}"替换"$words"

另一个选择是fold。但是请注意,它不应该被送入for循环。相反,使用while循环,如下所示:

1
2
3
while read char; do
    echo"$char"
done < <(fold -w1 <<<"$words")

使用外部fold命令(coreutils包)的主要好处是简洁。您可以将它的输出输入另一个命令,如xargs(findutils包的一部分),如下所示:

1
fold -w1 <<<"$words" | xargs -I% -- echo %

您需要将上面示例中使用的echo命令替换为要针对每个字符运行的命令。注意,默认情况下,xargs将丢弃空白。您可以使用-d '
'
来禁用该行为。

国际化

我刚刚用一些亚洲字符测试了fold,发现它不支持Unicode。因此,虽然它可以满足ASCII的需求,但它不会适用于所有人。在这种情况下,还有一些选择。

我可能会用一个awk数组替换fold -w1

1
awk 'BEGIN{FS=""} {for (i=1;i<=NF;i++) print $i}'

或者在另一个答案中提到的grep命令:

1
grep -o .

性能

仅供参考,我将上述3个选项作为基准。前两个是快速的,几乎是平手,折叠环略快于while环。不出所料,xargs是最慢的……速度慢了75倍。

以下是(缩写)测试代码:

1
2
3
4
5
6
7
8
9
10
11
words=$(python -c 'from string import ascii_letters as l; print(l * 100)')

testrunner(){
    for test in test_while_loop test_fold_loop test_fold_xargs test_awk_loop test_grep_loop; do
        echo"$test"
        (time for (( i=1; i<$((${1:-100} + 1)); i++ )); do"$test"; done >/dev/null) 2>&1 | sed '/^$/d'
        echo
    done
}

testrunner 100

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
test_while_loop
real    0m5.821s
user    0m5.322s
sys     0m0.526s

test_fold_loop
real    0m6.051s
user    0m5.260s
sys     0m0.822s

test_fold_xargs
real    7m13.444s
user    0m24.531s
sys     6m44.704s

test_awk_loop
real    0m6.507s
user    0m5.858s
sys     0m0.788s

test_grep_loop
real    0m6.179s
user    0m5.409s
sys     0m0.921s

我相信仍然没有一个理想的解决方案能够正确地保留所有的空白字符,并且速度足够快,所以我会发布我的答案。使用${foo:$i:1}是可行的,但速度非常慢,这在使用大字符串时尤其明显,如下所示。

我的想法是对six提出的方法的扩展,该方法涉及read -n1,并进行了一些更改以保留所有字符并对任何字符串正确工作:

1
2
3
while IFS='' read -r -d '' -n 1 char; do
        # do something with $char
done < <(printf %s"$string")

它是如何工作的:

  • IFS=''—将内部字段分隔符重新定义为空字符串,可防止空格和制表符被剥离。与read在同一行上执行该命令意味着它不会影响其他shell命令。
  • -r表示"原始",它阻止read将行尾的\作为特殊的行连接字符。
  • -d ''—将空字符串作为分隔符传递,可防止read剥离换行符。实际上意味着空字节用作分隔符。-d ''等于-d $'\0'
  • -n 1—表示一次读取一个字符。
  • printf %s"$string"—使用printf而不是echo -n更安全,因为echo-n-e视为选项。如果将"-e"作为字符串传递,echo将不会打印任何内容。
  • < <(...)—使用进程替换将字符串传递给循环。如果使用这里的字符串(done <<<"$string"),则在末尾附加一个额外的换行符。此外,将字符串通过管道(printf %s"$string" | while ...将使循环在子shell中运行,这意味着所有变量操作都是循环中的局部操作。

现在,让我们用一个巨大的字符串来测试性能。我使用以下文件作为源:https://www.kernel.org/doc/documentation/kbuild/makefiles.txt以下脚本是通过time命令调用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash

# Saving contents of the file into a variable named `string'.
# This is for test purposes only. In real code, you should use
# `done <"filename"' construct if you wish to read from a file.
# Using `string="$(cat makefiles.txt)"' would strip trailing newlines.
IFS='' read -r -d '' string < makefiles.txt

while IFS='' read -r -d '' -n 1 char; do
        # remake the string by adding one character at a time
        new_string+="$char"
done < <(printf %s"$string")

# confirm that new string is identical to the original
diff -u makefiles.txt <(printf %s"$new_string")

结果是:

1
2
3
4
5
$ time ./test.sh

real    0m1.161s
user    0m1.036s
sys     0m0.116s

如我们所见,速度相当快。接下来,我将循环替换为使用参数扩展的循环:

1
2
3
for (( i=0 ; i<${#string}; i++ )); do
    new_string+="${string:$i:1}"
done

输出准确显示了性能损失有多严重:

1
2
3
4
5
$ time ./test.sh

real    2m38.540s
user    2m34.916s
sys     0m3.576s

确切的数字可能在不同的系统上非常多,但总体情况应该是相似的。


我只用ASCII字符串测试过这个,但是您可以做如下操作:

1
2
3
4
5
while test -n"$words"; do
   c=${words:0:1}     # Get the first character
   echo character is"'$c'"
   words=${words:1}   # trim the first character
done


也可以使用fold将字符串拆分为字符数组,然后在此数组上迭代:

1
2
3
for char in `echo"这是一条狗。" | fold -w1`; do
    echo $char
done

@chepner的答案中的C型循环在shell函数update_terminal_cwd中,grep -o .解决方案很聪明,但是我很惊讶没有看到使用seq的解决方案。这是我的:

1
2
3
4
read word
for i in $(seq 1 ${#word}); do
  echo"${word:i-1:1}"
done

另一种方法是:

1
2
3
4
5
6
7
Characters="TESTING"
index=1
while [ $index -le ${#Characters} ]
do
    echo ${Characters} | cut -c${index}-${index}
    index=$(expr $index + 1)
done

另一种方法,如果您不关心空白被忽略:

1
2
3
for char in $(sed -E s/'(.)'/'\1 '/g <<<"$your_string"); do
    # Handle $char here
done

我分享我的解决方案:

1
2
3
4
5
read word

for char in $(grep -o . <<<"$word") ; do
    echo $char
done


1
2
3
4
TEXT="hello world"
for i in {1..${#TEXT}}; do
   echo ${TEXT[i]}
done

其中,{1..N}是一个包含范围

${#TEXT}是字符串中的若干字母。

${TEXT[i]}—可以从字符串中获取char,就像从数组中获取项一样。