bash bite: dynamic scoping
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 functionfunc1
, andfunc1
calls another functionfunc2
, references tovar
made from withinfunc2
will resolve to the local variablevar
fromfunc1
, shadowing any global variable namedvar
.
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