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?

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

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.


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

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

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.


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

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

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

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


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

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: 嗎

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

0

Innym sposobem jest:

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

-1

Udostępniam moje rozwiązanie:

read word

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

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


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
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.