5

Could please someone explain the (from my POV) strange behavior of the COUNTER variable in the following code?

#!/bin/bash

COUNTER=0

function increment {
    ((COUNTER++))
}

function report {
    echo "COUNTER: $COUNTER ($1)"
}

function reset_counter {
    COUNTER=0
}

function increment_if_yes {
    answer=$1
    if [ "$answer" == "yes" ]
    then
        increment
    fi
}

function break_it {
    echo -e "maybe\nyes\nno" | \
    while read LL
    do
        increment_if_yes $LL
    done    
}

report start # counter should be 0
increment
report one
increment_if_yes yes
report two
increment_if_yes no
report "still two"

reset_counter
report reset

break_it
report "I'd expect one"

I would expect the COUNTER to be 1 at the end of the script, but it is 0:

$ ./broken_variable.sh 
COUNTER: 0 (start)
COUNTER: 1 (one)
COUNTER: 2 (two)
COUNTER: 2 (still two)
COUNTER: 0 (reset)
COUNTER: 0 (I'd expect one)
0

2 Answers 2

7

OP's current code works as expected in ksh, and it may also work in other shells, but not bash ...

In breakit() the echo ... | while ... causes the while loop to be fired within a subprocess, which in turn means all function calls by the while loop are also fired within the subprocess.

The subprocess (the while loop in this case) receives a copy of the COUNTER variable, so any changes the subprocess makes to COUNTER only applies to the copy of the variable. When the subprocess exits any changes to the copy of COUNTER are lost. When control is returned to the parent process the (original) COUNTER variable has the same value it had before the subprocess was initiated.

To implement the desired behavior you need to insure the while loop runs in the parent process. One approach using process substitution:

while read LL
do
    increment_if_yes "$LL"
done < <( echo -e "maybe\nyes\nno" )
1
5

This simpler example might help:

$ c=0
$ printf 'a\nb\nc\n' | while read i; do (( c++ )); echo "c is now $c"; done
c is now 1
c is now 2
c is now 3
$ echo "$c"
0

As you can see, this reproduces the behavior you observe in your script. The reason is that because you're piping data to the while, that means it will launch a subshell which inherits a copy of all the parent's variables, but then, when the loop exits, it doesn't export those copies back to the parent. In other words, you didn't increment your COUNTER variable, you incremented a copy of the variable that was destroyed as soon as your loop ended.

You can see this in action if you try this modified version of your script:

#!/bin/bash

COUNTER=0

function increment {
  echo "increment called"
    ((COUNTER++))
}

function report {
    echo "COUNTER: $COUNTER ($1)"
}

function reset_counter {
    COUNTER=0
}

function increment_if_yes {
    answer=$1
    if [ "$answer" == "yes" ]
    then
        increment
    fi
}

function break_it {
  echo "aa COUNTER at start of break_it: $COUNTER"
    echo -e "maybe\nyes\nno" | \
    while read LL
    do
        echo "bb COUNTER in loop top: $COUNTER"
        increment_if_yes $LL
        echo "bb COUNTER in loop bottom: $COUNTER"
    done
    echo "aa COUNTER at end of break_it: $COUNTER"
}

report start # counter should be 0
increment
report one
increment_if_yes yes
report two
increment_if_yes no
report "still two"

reset_counter
report reset

break_it
report "I'd expect one"

Running this prints:

COUNTER: 0 (start)
increment called
COUNTER: 1 (one)
increment called
COUNTER: 2 (two)
COUNTER: 2 (still two)
COUNTER: 0 (reset)
aa COUNTER at start of break_it: 0
bb COUNTER in loop top: 0
bb COUNTER in loop bottom: 0
bb COUNTER in loop top: 0
increment called
bb COUNTER in loop bottom: 1
bb COUNTER in loop top: 1
bb COUNTER in loop bottom: 1
aa COUNTER at end of break_it: 0
COUNTER: 0 (I'd expect one)

Note how the bb COUNTER values show that a variable named $COUNTER was incremented in your break_it function, it's just that this is actually the copy of the variable and not the one available in the rest of the script.

Finally, you might want to read through the Command Execution Environment section of the bash manual, especially (emphasis mine):

When a simple command other than a builtin or shell function is to be executed, it is invoked in a separate execution environment that consists of the following. Unless otherwise noted, the values are inherited from the shell.

  • the shell’s open files, plus any modifications and additions specified by redirections to the command
  • the current working directory
  • the file creation mode mask
  • shell variables and functions marked for export, along with variables exported for the command, passed in the environment (see Environment)
  • traps caught by the shell are reset to the values inherited from the shell’s parent, and traps ignored by the shell are ignored

A command invoked in this separate environment cannot affect the shell’s execution environment.

That last sentence is the crux of your issue: commands invoked in separate environments cannot affect the parent environment and this is why you cannot increment your variable the way you want.

You can, however, since your shell (bash) supports process substitution, change your function to this and it will work:

 function break_it {
    while read LL
    do
        increment_if_yes $LL
    done < <(printf 'maybe\nyes\nno\n')
}

If I now run your original script, but with the break_it function modified as shown above, I get:

$ foo.sh 
COUNTER: 0 (start)
COUNTER: 1 (one)
COUNTER: 2 (two)
COUNTER: 2 (still two)
COUNTER: 0 (reset)
COUNTER: 1 (I'd expect one)
1

You must log in to answer this question.

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