Loop variable collisions in Bash
Let's have a look at this simple Bash script:
#! /bin/bash inner() { for i in {1..4}; do echo " $i" done } outer() { for i in {1..3}; do echo "$i" inner done } outer
The output is ..
1 1 2 3 4 2 1 2 3 4 3 1 2 3 4
.. as expected. Now if we turn the for
loops to C-style, we end up with this
code:
#! /bin/bash inner() { for ((i = 1; i <= 4; i++)); do echo " $i" done } outer() { for ((i = 1; i <= 3; i++)); do echo "$i" inner done } outer
Before you continue, take a moment: What output do you expect? The same? The output we get is:
1 1 2 3 4
Why? By default, in Bash variables a global unless declared local, explicitly.
That includes loop variables. The initial code had inner
modify the
global $i
already; the collision did not show, because the master loop
for i in {1..3}; do
resets $i
to the next value from list "1 2 3" rather
than adding 1 to $i
's current value. So right after inner
returns, $i
is
"corrupted" for a brief moment in the original code, already. If we were to
fix the C-style loop version, falling back to plain for
loops is addressing
symptoms more than causes. For addressing causes, I would like to propose both
a soft and a hard fix:
#! /bin/bash inner() { local i # soft fix for ((i = 1; i <= 4; i++)); do echo " $i" done } outer() { local i for ((i = 1; i <= 3; i++)); do echo "$i" ( inner ) # hard fix done } outer
The soft fix is declaring $i
as local
(to inner
) so that it does not
modify the global $i
("shadowing"). (The new local i
in outer
is for
hygiene, and not taking part in this particular fix.) The hard fix is calling
inner
from a subshell so that outer
does not have to trust inner
on
globals. To summarize:
- Loop variables are global by default, too: Better turn them local.
- Declare as many variables local as possible, in general.