Jak wykonać pętlę for na każdym znaku w ciągu w Bash?

83

Mam taką zmienną:

words="这是一条狗。"

Chcę zrobić dla pętli na każdym ze znaków, po jednym na raz, np najpierw character="这", potem character="是", character="一"itp

Jedyny znany mi sposób to umieszczanie każdego znaku w osobnej linii w pliku, a następnie użycie go while read line, ale wydaje się to bardzo nieefektywne.

  • Jak mogę przetworzyć każdy znak w ciągu za pomocą pętli for?
Wioska
źródło
3
Warto wspomnieć, że widzimy wiele pytań początkujących, w których OP uważa, że to jest to, co chcą zrobić. Bardzo często możliwe jest lepsze rozwiązanie, które nie wymaga indywidualnego przetwarzania każdego znaku. Jest to znane jako problem XY, a właściwym rozwiązaniem jest wyjaśnienie, co faktycznie chcesz osiągnąć w swoim pytaniu, a nie tylko, jak wykonać kroki, które Twoim zdaniem pomogą Ci to osiągnąć.
tripleee

Odpowiedzi:

45

Z sedna dashskorupie LANG=en_US.UTF-8, mam następne działa prawidłowo:

$ echo "你好嗎 新年好。全型句號" | sed -e 's/\(.\)/\1\n/g'
你
好
嗎

新
年
好
。
全
型
句
號

i

$ echo "Hello world" | sed -e 's/\(.\)/\1\n/g'
H
e
l
l
o

w
o
r
l
d

W ten sposób wyjście można zapętlić za pomocą while read ... ; do ... ; done

zredagowano przykładowy tekst przetłumacz na język angielski:

"你好嗎 新年好。全型句號" 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
Rony
źródło
4
Niezły wysiłek na UTF-8. Nie potrzebowałem tego, ale i tak otrzymujesz moje poparcie.
Jordan,
+1 Możesz użyć pętli for na powstałym ciągu z seda.
Tyzoid
236

Możesz użyć forpętli w stylu C :

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

${#foo}rozwija się do długości foo. ${foo:$i:1}rozwija się do podciągu zaczynając od pozycji $io długości 1.

Chepner
źródło
Dlaczego potrzebujesz dwóch zestawów nawiasów wokół instrukcji for, aby zadziałało?
tgun926
Tego bashwymaga składnia .
chepner
3
Wiem, że to jest stare, ale dwa nawiasy są wymagane, ponieważ pozwalają na operacje arytmetyczne. Zobacz tutaj => tldp.org/LDP/abs/html/dblparens.html
Hannibal
8
@Hannibal Chciałem tylko zwrócić uwagę, że to szczególne użycie podwójnych nawiasów jest w rzeczywistości konstrukcją bash: for (( _expr_ ; _expr_ ; _expr_ )) ; do _command_ ; donea nie to samo, co $ (( wyr )) ani (( wyr )). We wszystkich trzech konstrukcjach bash wyrażenie jest traktowane tak samo, a $ (( wyrażenie )) jest również zgodne z POSIX.
nabin-info
1
@codeforester To nie ma nic wspólnego z tablicami; to tylko jedno z wielu wyrażeń w programie, bashktóre jest oceniane w kontekście arytmetycznym.
chepner
36

${#var} zwraca długość var

${var:pos:N}zwraca N znaków od pospoczątku

Przykłady:

$ words="abc"
$ echo ${words:0:1}
a
$ echo ${words:1:1}
b
$ echo ${words:2:1}
c

więc jest to łatwe do iteracji.

Inny sposób:

$ grep -o . <<< "abc"
a
b
c

lub

$ 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
Tiago Peczenyj
źródło
1
a co ze spacjami?
Leandro,
A co z białymi znakami? Biały znak to znak, który zapętla wszystkie znaki. (Chociaż powinieneś uważać, aby używać podwójnych cudzysłowów wokół dowolnej zmiennej lub ciągu, który zawiera znaczące spacje. Ogólnie rzecz biorąc, zawsze
cytuj
23

Dziwię się, że nikt nie wspomniał o oczywistym bashrozwiązaniu wykorzystującym tylko whilei read.

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

Zwróć uwagę na użycie, echo -naby uniknąć dodatkowego znaku nowej linii na końcu. printfto kolejna dobra opcja, która może być bardziej odpowiednia dla Twoich szczególnych potrzeb. Jeśli chcesz ignorować spacje następnie zastąpić "$words"z "${words// /}".

Inną opcją jest fold. Należy jednak pamiętać, że nigdy nie należy go wprowadzać do pętli for. Zamiast tego użyj pętli while w następujący sposób:

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

Główną korzyścią wynikającą z używania foldpolecenia zewnętrznego (z pakietu coreutils ) byłaby zwięzłość. Możesz przekazać jego dane wyjściowe do innego polecenia, takiego jak xargs(część pakietu findutils ) w następujący sposób:

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

Będziesz chciał zastąpić echopolecenie użyte w powyższym przykładzie poleceniem, które chcesz wykonać przeciwko każdemu znakowi. Zauważ, że xargsdomyślnie odrzuca białe znaki. Możesz użyć, -d '\n'aby wyłączyć to zachowanie.


Umiędzynarodowienie

Właśnie przetestowałem foldniektóre znaki azjatyckie i zdałem sobie sprawę, że nie ma obsługi Unicode. Więc chociaż jest to dobre dla potrzeb ASCII, nie będzie działać dla wszystkich. W takim przypadku istnieje kilka alternatyw.

Prawdopodobnie zamieniłbym fold -w1na tablicę awk:

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

Lub greppolecenie wymienione w innej odpowiedzi:

grep -o .


Wydajność

Do Twojej wiadomości, porównałem 3 wyżej wymienione opcje. Pierwsze dwa były szybkie, prawie zawiązywane, a pętla zagięcia była nieco szybsza niż pętla while. Nic dziwnego, że xargsbył najwolniejszy ... 75x wolniejszy.

Oto (skrócony) kod testu:

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

Oto wyniki:

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
Sześć
źródło
characterjest puste dla białych znaków w prostym while readrozwiązaniu, co może być problematyczne, jeśli trzeba rozróżnić różne typy białych znaków.
pkfm
Niezłe rozwiązanie. Okazało się, że zmiana read -n1na read -N1była potrzebna do poprawnej obsługi znaków spacji.
nielsen
16

Uważam, że nadal nie ma idealnego rozwiązania, które poprawnie zachowałoby wszystkie białe znaki i jest wystarczająco szybkie, więc opublikuję swoją odpowiedź. Używanie ${foo:$i:1}działa, ale jest bardzo powolne, co jest szczególnie zauważalne przy dużych strunach, co pokażę poniżej.

Mój pomysł jest rozwinięciem metody zaproponowanej przez Six , która obejmuje read -n1pewne zmiany, aby zachować wszystkie znaki i działać poprawnie dla dowolnego ciągu:

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

Jak to działa:

  • IFS=''- Przedefiniowanie wewnętrznego separatora pól na pusty ciąg zapobiega usuwaniu spacji i tabulatorów. Zrobienie tego w tej samej linii readoznacza, że ​​nie wpłynie to na inne polecenia powłoki.
  • -r- Oznacza „surowy”, który zapobiega readtraktowaniu \końca wiersza jako specjalnego znaku konkatenacji wiersza.
  • -d ''- Przekazanie pustego ciągu jako separatora zapobiega readusuwaniu znaków nowej linii. Faktycznie oznacza, że ​​jako separator używany jest bajt zerowy. -d ''jest równa -d $'\0'.
  • -n 1 - Oznacza, że ​​będzie czytany jeden znak na raz.
  • printf %s "$string"- Używanie printfzamiast echo -njest bezpieczniejsze, ponieważ echotraktuje -ni-e jako opcje. Jeśli podasz "-e" jako łańcuch, echoniczego nie wydrukuje.
  • < <(...)- Przekazywanie ciągu znaków do pętli za pomocą podstawiania procesów. Jeśli zamiast tego użyjesz here-strings ( done <<< "$string"), na końcu zostanie dodany dodatkowy znak nowej linii. Ponadto przekazanie ciągu znaków przez funkcję pipe ( printf %s "$string" | while ...) spowodowałoby, że pętla działałaby w podpowłoce, co oznacza, że ​​wszystkie operacje na zmiennych są lokalne w pętli.

Teraz przetestujmy wydajność z ogromnym sznurkiem. Jako źródło użyłem następującego pliku:
https://www.kernel.org/doc/Documentation/kbuild/makefiles.txt
Następujący skrypt został wywołany timepoleceniem:

#!/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")

A wynik jest taki:

$ time ./test.sh

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

Jak widać, jest to dość szybkie.
Następnie zastąpiłem pętlę taką, która wykorzystuje rozszerzenie parametrów:

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

Dane wyjściowe pokazują dokładnie, jak duża jest utrata wydajności:

$ time ./test.sh

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

Dokładne liczby mogą być bardzo różne w różnych systemach, ale ogólny obraz powinien być podobny.

Thunderbeef
źródło
13

Testowałem to tylko z ciągami ascii, ale możesz zrobić coś takiego:

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
William Pursell
źródło
8

Pętla w stylu C w odpowiedzi @ chepner znajduje się w funkcji powłoki update_terminal_cwd, a grep -o .rozwiązanie jest sprytne, ale byłem zaskoczony, że nie widziałem rozwiązania używającego seq. To moje:

read word
for i in $(seq 1 ${#word}); do
  echo "${word:i-1:1}"
done
De Novo
źródło
6

Możliwe jest również podzielenie ciągu na tablicę znaków za pomocą, folda następnie iteracja po tej tablicy:

for char in `echo "这是一条狗。" | fold -w1`; do
    echo $char
done
sebix
źródło
1
#!/bin/bash

word=$(echo 'Your Message' |fold -w 1)

for letter in ${word} ; do echo "${letter} is a letter"; done

Oto wynik:

Y to litera o to litera u to litera r to litera M to litera e to litera s to litera s to litera a to litera g to litera e to litera

user13765771
źródło
1

Aby iterować znaki ASCII w powłoce zgodnej z POSIX, można uniknąć zewnętrznych narzędzi, używając Rozszerzeń parametrów:

#!/bin/sh

str="Hello World!"

while [ ${#str} -gt 0 ]; do
    next=${str#?}
    echo "${str%$next}"
    str=$next
done

lub

str="Hello World!"

while [ -n "$str" ]; do
    next=${str#?}
    echo "${str%$next}"
    str=$next
done
nggit
źródło
1

sed działa z Unicode

IFS=$'\n'
for z in $(sed 's/./&\n/g' <(printf '你好嗎')); do
 echo hello: "$z"
done

wyjścia

hello: 你
hello: 好
hello: 嗎
Paweł
źródło
0

Inne podejście, jeśli nie zależy ci na ignorowaniu białych znaków:

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

źródło
0

Innym sposobem jest:

Characters="TESTING"
index=1
while [ $index -le ${#Characters} ]
do
    echo ${Characters} | cut -c${index}-${index}
    index=$(expr $index + 1)
done
Javier Salas
źródło
-1

Udostępniam moje rozwiązanie:

read word

for char in $(grep -o . <<<"$word") ; do
    echo $char
done
Dani Ballesteros
źródło
Jest to bardzo błędne - spróbuj użyć łańcucha zawierającego znak *, otrzymasz pliki w bieżącym katalogu.
Charles Duffy,
-3
TEXT="hello world"
for i in {1..${#TEXT}}; do
   echo ${TEXT[i]}
done

gdzie {1..N}jest zakres obejmujący

${#TEXT} to liczba liter w ciągu

${TEXT[i]} - możesz pobrać znak ze stringa jak element z tablicy

Dmitri Emeliov
źródło
5
Shellcheck zgłasza „Bash nie obsługuje zmiennych w rozszerzaniu zakresów nawiasów klamrowych”, więc to nie zadziała w Bash
Bren
@Bren Wydaje mi się, że to błąd.
Sapphire_Brick