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.