Linux & Bash · Part I · Module 18 · Functions and Script Decomposition
local, Scope and Accidental Globals
This is the chapter that decides whether a script with twenty functions works. Bash has no block scope and no implicit function scope — every assignment you do not explicitly declare is to one shared namespace — and on top of that it scopes dynamically, so a function can see and change the variables of whatever called it.
Intermediate21 min readLinuxPart IBashFunctionsScopelocalScripting
Part I · Module 18 · Chapter 18.5 · local, Scope and Accidental Globals
Chapter 18.2 showed that $1 is properly per-call — a function's arguments are its own and the caller's come back untouched. It is reasonable to assume ordinary variables behave the same way. They do not.
1. Every Undeclared Assignment Is Shared
$ i=CALLER
$ f() { for i in 1 2 3; do :; done; }
$ f
$ echo "caller i=$i"
caller i=3The caller's i is gone. The function did not have its own i — there is only one i, and the loop walked it to 3.
One word fixes it:
$ i=CALLER
$ f() { local i; for i in 1 2 3; do :; done; }
$ f
$ echo "caller i=$i"
caller i=CALLERNote what was unprotected there: a loop variable. Not a value anybody intended to share — the throwaway name in a for. That is the shape this bug almost always takes.
A shared namespace, and a declared one
2. The Bug You Will Actually Ship
The loop demo is too obvious to be convincing. Here is the real shape — two reasonable functions, written at different times, that both happen to use n:
#!/usr/bin/env bash
# bug.sh
count_lines() {
n=$(wc -l < "$1" | tr -d ' ') # no local
echo "$n"
}
report() {
for f in "$@"; do
n=$(count_lines "$f")
printf '%-10s %s lines\n' "$f" "$n"
done
}
process() {
n=0 # my counter
for f in "$@"; do
n=$(( n + 1 ))
count_lines "$f" > /dev/null # ... which count_lines overwrites
done
echo "processed $n files"
}
printf 'a\nb\n' > x.txt; printf 'c\n' > y.txt
report x.txt y.txt
process x.txt y.txt$ ./bug.sh
x.txt 2 lines
y.txt 1 lines
processed 1 filesTwo files, "processed 1 files". No error, no warning, nothing in a log. process incremented n to 1, called count_lines, which assigned n=2 for x.txt; then n=$(( n + 1 )) made it 3, and count_lines set it to 1 for y.txt. The loop ended with n=1 and the script confidently reported a number that is neither right nor obviously wrong.
Now notice three things about that bug:
report works perfectly. It captures count_lines' output and never uses n itself, so it is immune. The bug lives in process, which is a function you would not think to look at while debugging count_lines.
count_lines is not wrong in isolation. Every test of count_lines passes. It is wrong only in combination, which is why this survives unit testing and arrives in production.
One local in count_lines fixes it — and so does one local in process. Either function declaring its own n breaks the collision. Which is the argument for doing it everywhere: you cannot know which of the two future authors will remember.
3. Bash Is Dynamically Scoped
Here is the fact that surprises people who know C, Python or Java — and that makes local protect more than you would guess.
$ outer() { local secret=OUTERS; inner; }
$ inner() { echo " inner can see secret=$secret"; }
$ outer
inner can see secret=OUTERS
$ inner
at top level secret=[]inner can read outer's local variable. Not because they are related — inner is defined separately and knows nothing about outer — but because local in Bash means "visible to this call and everything it calls", not "visible to this function's body".
That is dynamic scope: what a name means depends on the call stack at run time, not on where the code sits. Called from outer, inner sees OUTERS; called from the top level, it sees nothing.
And it is not read-only:
$ outer() { local v=1; inner; echo " outer v is now $v"; }
$ inner() { v=99; }
$ outer
outer v is now 99inner reached into its caller and changed a local. outer declared v as local and still had it modified by a function that never mentions outer.
The protection is for inner to declare its own:
$ outer() { local v=1; inner; echo " outer v still $v"; }
$ inner() { local v=99; }
$ outer
outer v still 1So local does two jobs: it stops you clobbering your caller, and it stops your callees clobbering you. Both matter, and the second is the one people do not expect.
4. local Is Per Call, Not Per Function
Each invocation gets its own, which is what makes recursion safe:
$ rec() { local d=$1; if [ "$d" -gt 0 ]; then rec $(( d - 1 )); fi; echo " unwinding d=$d"; }
$ rec 3
unwinding d=0
unwinding d=1
unwinding d=2
unwinding d=3Four values of d alive at once, each belonging to its own call, each restored as the stack unwinds. Without local, every level would share one d and the unwinding would print 0 four times.
local is also only legal inside a function:
$ bash -c 'local x=1'
bash: local: can only be used in a functionAt the top level of a script every variable is global by definition. If you want a script-level variable that functions should not touch, there is no mechanism to enforce it — the convention is ALL_CAPS for script-level and lowercase for function-local, which is worth following precisely because nothing checks it.
5. local's Other Forms
local takes the same attributes as declare:
$ f() { local -r k=fixed; k=changed; }
$ f
bash: k: readonly variablelocal -r is readonly for the life of the call — useful for a validated argument you want to be sure nothing further down reassigns. local -i declares an integer, so assignments are evaluated arithmetically. local -a is an indexed array (Chapter 19.2) and local -A an associative one (Chapter 19.6, which needs Bash 4).
You can declare several at once, with or without values:
local n rc line
local total=0 skipped=0And unset inside a function removes the local, leaving the caller's value intact:
$ v=OUTER
$ f() { local v=INNER; unset v; echo " after unset in fn: [$v]"; }
$ f
after unset in fn: []
$ echo " caller: [$v]"
caller: [OUTER]6. The Assignment Trap, Where It Lives
Chapter 18.4 §5 measured this and it belongs here too, because local is where you write it:
$ f() { local v=$(false); echo " local v=\$(false) -> status $?"; }; f
local v=$(false) -> status 0
$ f() { local v; v=$(false); echo " split -> status $?"; }; f
split -> status 1local v=$(cmd) reports local's status, which is success. The command's status is discarded.
This is the single most common way a function-heavy script loses an error, because local v=$(...) is such a natural line to write and it is wrong in exactly the situation you care about. The fix is mechanical:
local v
v=$(cmd) || return 1Declare on one line; assign on the next, with the guard. Two extra characters, and the function can detect failure.
7. A Complete Script
The bug.sh collision, fixed, and grown into something that would survive review:
#!/usr/bin/env bash
# audit.sh — report line counts and a total across a file list
# usage: audit.sh <file> [file...]
#
# exit: 0 all readable · 1 at least one was not · 2 bad usage
#
# convention: ALL_CAPS is script level; every function declares its own.
set -o nounset
SCRIPT_NAME=${0##*/}
note() { printf '%s: %s\n' "$SCRIPT_NAME" "$*" >&2; }
die() { note "$*"; exit 2; }
# count_lines <file> -> prints the count; 1 if unreadable
count_lines() {
local f=$1
local n
n=$(wc -l < "$f") || return 1
printf '%s\n' "$(( n ))"
}
# longest_name <file>... -> prints the width of the longest basename
longest_name() {
local f base w
local max=0
for f in "$@"; do
base=${f##*/}
w=${#base}
(( w > max )) && max=$w
done
printf '%s\n' "$max"
}
# report <width> <file>... -> prints a line per file; 1 if any was unreadable
report() {
local w=$1; shift
local f n
local bad=0
for f in "$@"; do
if ! n=$(count_lines "$f"); then
note "cannot read $f"
bad=$(( bad + 1 ))
continue
fi
printf '%-*s %5s\n' "$w" "${f##*/}" "$n"
done
return "$(( bad > 0 ))"
}
# total <file>... -> prints the sum of the readable files' counts
total() {
local f n
local sum=0
for f in "$@"; do
n=$(count_lines "$f") || continue
sum=$(( sum + n ))
done
printf '%s\n' "$sum"
}
# ---- top level -----------------------------------------------------------
[ $# -ge 1 ] || die "usage: $SCRIPT_NAME <file> [file...]"
width=$(longest_name "$@")
rc=0
report "$width" "$@" || rc=1
printf -- '%-*s %5s\n' "$width" "TOTAL" "$(total "$@")"
exit "$rc"$ printf 'a\nb\n' > x.txt
$ printf 'c\n' > y.txt
$ printf 'd\ne\nf\n' > a-much-longer-name.txt
$ ./audit.sh x.txt y.txt a-much-longer-name.txt
x.txt 2
y.txt 1
a-much-longer-name.txt 3
TOTAL 6
$ echo $?
0
$ printf 'z\n' > locked.txt && chmod 000 locked.txt
$ ./audit.sh x.txt locked.txt y.txt
x.txt 2
./audit.sh: line 9: locked.txt: Permission denied
audit.sh: cannot read locked.txt
y.txt 1
./audit.sh: line 9: locked.txt: Permission denied
TOTAL 3
$ echo $?
1
$ ./audit.sh
audit.sh: usage: audit.sh <file> [file...]
$ echo $?
2Six decisions this chapter is responsible for:
Every function declares every variable it assigns. count_lines declares f and n; longest_name declares f base w max; report declares w f n bad; total declares f n sum. Three of those four use f and n, and they are called from each other — report calls count_lines while holding its own n, and total does the same. Without the declarations this is bug.sh again, with two separate collisions.
report calls count_lines while using n itself — the exact shape of §2's bug — and it is safe only because both declare n. Remove either local n and the report's numbers go wrong without an error.
local f=$1 copies the argument into a named local rather than using $1 throughout. That is a readability choice rather than a correctness one, and it pays off in report, where $1 is consumed by shift and $f is the loop item.
n=$(count_lines "$f") is guarded by if ! and by || continue, which works because n was declared separately — on its own local n line. Written as local n=$(count_lines "$f") neither guard would ever fire.
SCRIPT_NAME is ALL_CAPS and set once, following the convention the header documents. Nothing enforces it; the comment is the enforcement.
One honest blemish in that second transcript: Permission denied appears twice, because report and total each call count_lines on the same unreadable file and wc complains both times. The script's own message appears once, from report. Fixing it properly means counting once and reusing the number — which needs somewhere to keep a list of per-file counts, and that is an array. Module 19.
report returns $(( bad > 0 )), measured as 0 when bad is 0 and 1 when it is 3 — an arithmetic expression standing in for a status, which reads cleanly and is exactly Chapter 16.3's (( )) truth convention used on purpose.
Practice
- Run the §1 loop demo with and without
local i. Then do the same with awhileloop and a counter instead of afor, and confirm the behaviour is identical. - Type out
bug.shand run it. Confirm "processed 1 files". Then fix it by addinglocaltocount_linesonly, re-run, and then fix it instead by addinglocaltoprocessonly. Both work — what does that tell you about where the bug "is"? - In
bug.sh, explain in one sentence whyreportis unaffected. - Build the dynamic-scope demo:
outerwithlocal secret, and a separately-definedinnerthat prints it. Callinnerfromouterand then directly. Explain both results. - Make
innerassign to the caller'slocal vand confirmoutersees the change. Then protectouterwith one word ininner. - Write a recursive function with
local d=$1and without it. Run both at depth 3 and compare the unwinding output. - Run
bash -c 'local x=1'. What does the error tell you about wherelocalis meaningful, and what convention takes its place at script level? - Write
f() { local -r k=1; k=2; }and run it. Then find a place inaudit.shwherelocal -rwould be a genuine improvement. - Run both forms of
local v=$(false)and print$?. Then breakaudit.sh'scount_linesby collapsing the two lines,chmod 000a file, and show that the error is no longer detected. - In
audit.sh, deletelocal nfromreportonly. Run it on three files and explain the numbers you get.
Check Your Understanding
- What is the scope of a variable a function assigns without declaring it?
- In
bug.sh, two files produce "processed 1 files". Trace why. - Why does
reportescape the bug that hitsprocess? - A function declares
local secret. Can a function it calls read it? - Can that callee change it, and what prevents that?
- Why is
localper call rather than per function? - What does
local v=$(cmd)report, and what is the fix?
8. Remember
- An undeclared assignment inside a function is global. There is no block scope and no implicit function scope.
- Declare everything a function assigns, including loop variables, argument copies and one-line temporaries.
- The realistic bug is two functions sharing a short name — a count comes back wrong with no error, and the function at fault passes every test of its own.
- Either side declaring breaks the collision, which is why you declare on both.
- Bash is dynamically scoped. A callee can read its caller's locals, because
localmeans "this call and everything it calls". - And a callee can modify them.
localprotects you in both directions. - If a helper needs a value, pass it as an argument. Positional parameters are properly per-call; variables are not.
localis per call, which is what makes recursion safe.localis only legal in a function. At script level the convention isALL_CAPS, enforced by nothing but review.local -r,local -i,local -a,local -Atake the same attributes asdeclare.local v=$(cmd)discards the command's status. Declare, then assign, then guard.
Chapter 18.6 closes the module by moving these functions out of the script entirely, into a file other scripts can source — which shares everything this chapter has been careful to contain, and so needs a convention of its own.
Where this fits
Part of the Linux & Bash curriculum.
