16

I would like to convert this Bash loop:

x="one two three"

for i in ${x}
do
    echo ${i}
done

in such a way to work with both Bash and zsh

This solution works:

x=( one two three )

for i in ${x[@]}
do
    echo ${i}
done

Anyway I am modifying x from a string to an array.

Is there a way to loop over $x in zsh when it is a string and in a way compatible with Bash?

I know about zsh setopt shwordsplit to emulate Bash, but I can't set it ad hoc for the loop, because it would not work in Bash.

`

3 Answers 3

26

Is there a way to loop over $x in zsh when it is a string and in a way compatible with Bash?

Yes!. A var expansion is not split (by default) in zsh, but command expansions are. Therefore in both Bash and zsh you can use:

 x="one two three"

 for i in $( echo "$x" )
 do
    echo "$i"
 done

In fact, the code above works the same in all Bourne shell descendants (but not in the original Bourne, change $(…) to `…` to get it working there).

The code above still have some issues with globbing and the use of echo, keep reading.



In zsh, a var expansion like $var is not split (also not glob) by default.
This code has no problems (doesn't expand to all files in the pwd) in zsh:

var="one * two"
printf "<%s> " ${var}; echo

But also doesn't split var by the value of IFS.

For zsh, splitting on IFS could be done by either using:

 1. Call splitting explicitly: `echo ${=var}`.
 2. Set SH_WORD_SPLIT option: `set -y; echo ${var}`.
 3. Using read to split to a list of vars (or an array with `-A`).

But none of those options are portable to bash (or any other shell except ksh for -A).

Going down to an older syntax that is shared by both shells: read might help.
But that can only work for one character delimiters (not IFS), and only if the delimiter exists in the input string:

 # ksh, zsh and bash(3.0+)
 t1(){  set -f;
        while read -rd "$delimiter" i; do
            echo "|$i|"
        done <<<"$x"
     }

Where $1 is a one character delimiter.

That still suffer of the expansion of globbing characters (*, ? and [), so a set -f is required. And, we can set an array variable outarr instead:

 # for either zsh or ksh
 t2(){ set -f; IFS="$delimiter" read -d $'\3' -A outarr < <(printf '%s\3' "$x"); }

And the same idea for bash:

 # bash
 t3(){ local -; set -f; mapfile -td "$1" outarr < <(printf '%s' "$x"); }

The effect of set -f is restored in the bash function by using local -.

The concept could even be extended to a limited shell like dash:

 # valid for dash and bash
 t4(){  local -; set -f;
        while read -r i; do
             printf '%s' "$i" | tr "$delimiter"'\n' '\n'"$delimiter"; echo
        done <<-EOT
$(echo "$x" | tr "$delimiter"'\n' '\n'"$delimiter")
EOT
     } 

No <<<, no <(…) and no read -A or readarray used, but it works (for one character delimiters) with spaces, newlines, and/or control characters in the input.

But it is a lot easier to simply do:

 t9(){ set -f; outarr=(   $(printf '%s' "$x")   ); }

Sadly, zsh doesn't understand the local -, so the value of set -f has to be restored as this:

 t99(){ oldset=$(set +o); set -f; outarr=( $( printf '%s' "$x" ) ); eval "$oldset"; }

Any of the functions above may be called with:

 IFS=$1 delimiter=$1 $2

Where the first argument $ is the delimiter (and IFS) and the second argument is the function to call (t1, t2, … t9, t99). That call sets the value of IFS only for the duration of the function call which gets restored to its original value when the function called exits.

1
  • This should be the accepted answer.
    – vfxdev
    Commented Aug 26, 2021 at 11:19
10
if type emulate >/dev/null 2>/dev/null; then emulate ksh; fi

In zsh, this activates options that make it more compatible with ksh and bash, including sh_word_split. In other shells, emulate doesn't exist so this does nothing.

1

If you are not afraid to use eval (= evil):

x="one two three"
eval "x=($x)"
for i in ${x[@]}; do 
    echo $i
done
4
  • the "simpler" solution is not simpler at all; at a contrary, it will fork an extra process (subshell) in bash. The only advantage is that it will be able to handle ;, |, (, etc but not * in the x variable. Your initial solution (using eval) was just better.
    – user313992
    Commented Dec 29, 2018 at 15:15
  • How is the (new) first part of this answer better than the answer I already posted?
    – user232326
    Commented Dec 29, 2018 at 15:18
  • @mosvy (1)The initial answer (as the second answer) is affected by globbing. That is easy to avoid (correctly) by doing set -f. (2) But "the original answer" (with eval) is also affected by a second expansion step which is very difficult to resolve (I would say: impossible to solve). (3) You are missing the point that the $(echo "$x") is perfectly portable to all bourne like shells. (4) If you want solutions that correctly process any input including spaces, newlines and/or control characters, then: read my answer.
    – user232326
    Commented Dec 29, 2018 at 15:30
  • @Isaac Oops, sorry Isaac, I actually realized this different method without reading your answer that has the exact same method. You have to believe me on this one. Anyway, I upvoted your answer and it has now more upvotes than me now, which I think is deserved. Also rollbacked the edit. Commented Dec 29, 2018 at 19:46

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .