well-known or not, bash uses dynamic scoping; this can make some fun when manipulating variables in caller and callee functions;

a brief introduction

a great introduction to dynamic scoping is right here in man bash:

The shell uses dynamic scoping to control a variable’s visibility within functions. With dynamic scoping, visible variables and their values are a result of the sequence of function calls that caused execution to reach the current function. The value of a variable that a function sees depends on its value within its caller, if any, whether that caller is the “global” scope or another shell function. This is also the value that a local variable declaration “shadows”, and the value that is restored when the function returns.

For example, if a variable var is declared as local in function func1, and func1 calls another function func2, references to var made from within func2 will resolve to the local variable var from func1, shadowing any global variable named var.

in short, dynamic scoping means the scope depends on the call stack, not the source code;

the call stack

now let us give an example showing the call stack:

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    local l=1
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    local l=0
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=1
n=0, l=0
n=0, l=0
n=1, l=1
n=2, l=2
n=3, l=3

this example defines a chain of 4 functions; when we call f3 we create a call stack of 4 frames; the frame number is shown by n; in each frame, we define a local variable l, whose value is also printed in output;

this is a pretty simple example, but serves as the beginning of our exploration;

access variable in previous scope

accessing a variable defined in a previous scope is straightforward: if we do not define a variable with the same name in current scope, then this name binds to the variable in the closest previous scope; think previous as calling or ancestor;

here is a read example:

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    local l=0
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=2
n=0, l=0
n=0, l=0
n=1, l=2
n=2, l=2
n=3, l=3

in this example, we omit the local definition of l in f1; as a result, when we access l in f1, the access is made to the l defined in its parent scope f2, the closest one that defines l;

here is a write example, which works in the same way:

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    l=x
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    local l=0
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=x
n=0, l=0
n=0, l=0
n=1, l=x
n=2, l=x
n=3, l=3

in this example, we assign a letter x to variable l in f1; because f1 does not define a variable named l, this l resolves to the l defined in f2;

unset a variable

read and write are so easy; how about unset?

again, refer to man bash:

The unset builtin also acts using the same dynamic scope: if a variable is local to the current scope, unset will unset it; otherwise the unset will refer to the variable found in any calling scope as described above. If a variable at the current local scope is unset, it will remain so until it is reset in that scope or until the function returns. Once the function returns, any instance of the variable at a previous scope will become visible. If the unset acts on a variable at a previous scope, any instance of a variable with that name that had been shadowed will become visible.

here is a brief explanation:

  • unset a variable in the current scope will “mark” it as unset;

  • unset a variable in a previous scope will “remove” it and reveal the shadowed variable defined in previous-previous scope, if any;

unset in the current scope

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    local l=1
    unset -v l
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=
n=0, l=
n=0, l=
n=1, l=
n=2, l=2
n=3, l=3

in this example, we unset l in f1 without defining l in f0; the result is, both f1 and f0 see l as unset; in specific, f0 is accessing the l in f1, which is unset;

the example above also shows that unset a variable is not the same as omit its definition; this often looks counter-intuitive; compare the example above with the one below:

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=2
n=0, l=2
n=0, l=2
n=1, l=2
n=2, l=2
n=3, l=3

in this example, both f1 and f0 are accessing the l in f2;

unset in a previous scope

unset looks more intuitive when applied to a variable in a previous scope; in this case, we can think like the variable definition has been removed in that scope;

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    unset -v l
    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=3
n=0, l=3
n=0, l=3
n=1, l=3
n=2, l=3
n=3, l=3

in this example, f1 does not define a local l, so when it unsets l it is unsetting the l defined in f2; this works like removing the l defined in f2, which reveals the l defined in f3;

revisit: access variable in previous scope

you may have found out that we cannot access a variable defined in a previous scope if we have defined it locally in the current scope; and unsetting it in the current scope does not help;

the solution is using the upvar magic:

f3() {
    local l=3
    echo "n=3, l=$l"
    f2
    echo "n=3, l=$l"
}

f2() {
    local l=2
    echo "n=2, l=$l"
    f1
    echo "n=2, l=$l"
}

f1() {
    local l=1

    ##  what to do here if we want to access `l` in `f2`? without `local l=1`,
    ##  this is as simple as `echo "$l"` (read) or `l=x` (write); the answer:
    local l && upvar l x

    echo "n=1, l=$l"
    f0
    echo "n=1, l=$l"
}

f0() {
    local l=0
    echo "n=0, l=$l"
    :
    echo "n=0, l=$l"
}

f3

output:

n=3, l=3
n=2, l=2
n=1, l=x
n=0, l=0
n=0, l=0
n=1, l=x
n=2, l=x
n=3, l=3

as we have seen, upvar assigns a new value to l defined in f2, from within the scope of f1; what is the magic of upvar?

the upvar magic

here is the definition of upvar:

upvar() {
    if unset -v "$1"; then           # Unset & validate varname
        if (( $# == 2 )); then
            eval $1=\"\$2\"          # Return single value
        else
            eval $1=\(\"\${@:2}\"\)  # Return array
        fi
    fi
}

when we call local l && upvar l x in f1, we define a local variable l in the current scope f1, then go into the upvar scope, in which l is unset; because upvar does not define a variable l, name l in the unset resolves to the one we defined in f1; because f1 is a previous scope of the upvar scope, this unset removes the l in f1 and reveals the shadowed l in the closest previous-f1 scope (f2 in this case); thus subsequent access to l in upvar occurs on the l in f2; this includes the assignment by eval; put it together, we have assigned to l in f2;

the upvar magic is, a parent makes a child to deal with the grandparent;

the upvars magic

there is also upvars that works similar to upvar but in addition supports arrays; both upvar and upvars can be found on this page; below is a comparison of their usage:

  • the upvar magic:

    f() { local b; g b; echo $b; }
    g() { local "$1" && upvar $1 bar; }
    f  # Ok: b=bar
    
  • the upvars magic:

    f() { local a b; g a b; declare -p a b; }
    g() {
        local c=( foo bar )
        local "$1" "$2" && upvars -v $1 A -a${#c[@]} $2 "${c[@]}"
    }
    f  # Ok: a=A, b=(foo bar)
    

option localvar_unset

before we end this article, we mention that bash-5.0-beta2 introduces a shopt option localvar_unset:

If set, calling unset on local variables in previous function scopes marks them so subsequent lookups find them unset until that function returns. This is identical to the behavior of unsetting local variables at the current function scope.

the upvar and upvars magics work only when this option is disabled:

shopt -u localvar_unset