05 — Function Internals, Scope Mechanics & Signal Traps
Function Definition and the Call Stack
bash
# ── Two syntaxes ──
# POSIX-compatible (preferred — works in sh too):
greet() {
echo "Hello, $1!"
}
# C-style (Bash-only, `function` keyword):
function greet() {
echo "Hello, $1!"
}
# Note: `function greet` (without ()) is Bash-only and doesn't require ().
# ── The call stack: FUNCNAME, BASH_SOURCE, BASH_LINENO ──
trace() {
local depth=${#FUNCNAME[@]} # number of frames in the call stack
for ((i = 0; i < depth - 1; i++)); do
printf '%*s→ %s() at %s:%d\n' \
"$((i * 2))" "" \
"${FUNCNAME[i]}" \
"$(basename "${BASH_SOURCE[i]}")" \
"${BASH_LINENO[i]}"
done
}
outer() { inner; }
inner() { trace; }
outer
# Output:
# → outer() at script.sh:12
# → inner() at script.sh:11
# → trace() at script.sh:7
# ── Functions get their own $1, $2, $@ (independent of the script's args) ──
show_args() {
echo "function args ($#): $*" # the function's args, NOT the script's
echo "script args: $SCRIPT_ARGS" # script args must be passed explicitly
}
SCRIPT_ARGS="$@"
show_args "alpha" "beta" # function sees: alpha beta
# The script's $@ is NOT visible inside the function as $@ — only the function's $@.
Return Values: Three Mechanisms
bash
# ── 1. Exit status (return) — 0-255, for boolean/success/failure ──
is_even() {
(( $1 % 2 == 0 )) # arithmetic: returns exit 0 if true, 1 if false
# return is implicit — uses the last command's exit status
}
if is_even 4; then echo "even"; fi # calls is_even, checks $?
# Explicit return:
is_even() {
if (( $1 % 2 == 0 )); then
return 0 # success/true
else
return 1 # failure/false
fi
}
# ⚠️ return wraps to 0-255 (modulo 256):
return 300 # actual exit status: 44 (300 % 256)
# return is for FUNCTIONS. exit is for the SCRIPT. They're different!
# ── 2. stdout capture ($(...)) — for data ──
get_date() {
printf '%s' "$(date +%Y-%m-%d)" # printf (not echo) — no trailing newline in the capture
}
today=$(get_date) # captures stdout
echo "$today" # 2024-01-15
# ⚠️ echo vs printf in function output:
get_name() { echo "Alice"; } # echo adds \n — $(...) strips trailing \n → "Alice"
get_name() { printf '%s' "Alice"; } # no \n — $(...) → "Alice" (same result, but explicit)
# ⚠️ Don't use echo for binary/special data — use printf:
get_bytes() { printf '\x01\x02'; } # raw bytes — echo can't handle \x00 or \x01 safely
# ── 3. Nameref (local -n) — for complex data (arrays, multiple values) ──
parse_version() {
local -n _result=$1 # nameref: _result is an alias for the caller's variable
local input=$2
IFS='.' read -ra _result <<< "$input" # split into array, write via nameref
}
parse_version parts "1.2.3"
echo "${parts[@]}" # 1 2 3 — the caller's `parts` array was populated by the function
# The function set a variable in the CALLER's scope by name.
Anti-Pattern: local Masks set -e Failures
bash
set -e
# ❌ NAIVE — `local` always returns 0, masking the command's failure
bad_func() {
local x=$(false) # `local x=$(false)` — local's exit status is 0 (local always succeeds)
echo "reached" # "reached" — false's failure was MASKED by local!
}
bad_func # prints "reached" — set -e didn't catch the false
# ✅ CORRECT — separate declaration and assignment
good_func() {
local x
x=$(false) # x=$(false) — false's exit status propagates (set -e catches it)
echo "reached" # NEVER reached — script exits on the x=$(false) line
}
good_func # script exits
# ── The mechanism ──
# `local` is a command that returns exit 0 (it's a variable declaration, always succeeds).
# When you write `local x=$(false)`, the $(false) runs, but local's exit status overrides it.
# With `set -e`, the masked failure doesn't trigger the exit.
# Fix: `local x; x=$(false)` — the assignment's exit status propagates.
# ── This also applies to declare ──
declare -i y=$(false) # masked! declare returns 0.
declare -i y; y=$(false) # not masked — false propagates.
Dynamic Scoping: How local Actually Works
bash
# ── Bash uses DYNAMIC scoping (not lexical) ──
# A `local` variable is visible to:
# 1. The function that declares it
# 2. ALL functions called BY that function (dynamic — based on call stack, not source code)
outer() {
local x="outer's value"
inner # inner can SEE outer's x (dynamic scope)
echo "after inner: x=$x" # may have been modified by inner!
}
inner() {
echo "inner sees: x=$x" # "outer's value" — sees caller's local
x="inner modified it" # modifies OUTER's x (because inner didn't declare its own local x)
}
outer
# inner sees: x=outer's value
# after inner: x=inner modified it ← outer's x was modified by inner!
# ── `local` shadows: if inner declares its own local, it doesn't see outer's ──
outer2() {
local x="outer2"
inner2
echo "outer2 after: x=$x" # "outer2" — inner2's local x is gone, outer2's is restored
}
inner2() {
local x="inner2" # declares its OWN x — shadows outer2's x
echo "inner2 sees: x=$x" # "inner2"
}
outer2
# inner2 sees: x=inner2
# outer2 after: x=outer2 ← outer2's x was NOT modified (inner2's local x shadowed it)
# ── Lexical scoping (Python/JS) vs Dynamic scoping (Bash) ──
# Python: inner() would NOT see x (x isn't defined in inner's lexical scope).
# Bash: inner() SEES x (x is on the call stack — outer declared it, inner is called by outer).
# This is powerful but dangerous — callees can modify caller's locals.
Anti-Pattern: Accidental Global via Dynamic Scope
bash
# ❌ NAIVE — function modifies a variable without declaring it local
process() {
count=$((count + 1)) # if count isn't local, this modifies the GLOBAL count!
echo "processed: $count"
}
count=0
process # processed: 1
process # processed: 2
echo "global: $count" # 2 — the function clobbered the global!
# ✅ CORRECT — use local for all function variables
process() {
local count=$((count + 1)) # local count — but reads the global? NO!
# ^-- `local count=$((count + 1))` — the RHS reads the GLOBAL count, then local is created.
# This is a common bug: local count copies the global, increments the copy, then discards.
echo "processed: $count"
}
count=0
process # processed: 1 (local count = global 0 + 1)
process # processed: 1 (local count = global 0 + 1 — global unchanged!)
echo "global: $count" # 0 — global was never modified
# ── If you WANT to modify a global from a function, don't use local ──
increment_global() {
count=$((count + 1)) # no local — modifies the global
}
count=0
increment_global
increment_global
echo "$count" # 2 — global incremented
# ── Or use nameref for explicit pass-by-reference ──
increment_ref() {
local -n _c=$1 # nameref to the caller's variable
((_c++))
}
count=0
increment_ref count # passes the NAME "count"
echo "$count" # 1 — modified via nameref
Namerefs in Depth
bash
# ── `local -n` creates a nameref (Bash 4.3+) ──
# A nameref is an alias for another variable. Reads and writes go to the target.
target="hello"
declare -n ref=target # ref is now a nameref to target
echo "$ref" # hello — reads target
ref="world" # writes to target
echo "$target" # world — target was modified
# ── Returning arrays from functions via nameref ──
filter_files() {
local -n _out=$1 # nameref to caller's array
shift
_out=() # clear the target array
local f
for f in "$@"; do
[[ -f "$f" ]] && _out+=("$f") # write to caller's array via nameref
done
}
existing_files=()
filter_files existing_files *.txt *.md *.json
echo "${existing_files[@]}" # only files that actually exist
# ── ⚠️ Nameref circular reference ──
# If the nameref has the SAME NAME as the target, it's a circular reference:
circular() {
local -n arr=$1 # if $1 is "arr" (same as the nameref name)...
echo "${arr[@]}"
}
arr=(1 2 3)
circular arr # ✗ "circular name reference" — arr references itself!
# Fix: use a distinct name for the nameref:
safe() {
local -n _ref=$1 # _ref is different from any reasonable caller variable name
echo "${_ref[@]}"
}
arr=(1 2 3)
safe arr # ✓ works — _ref references arr, no collision
# ── nameref to an associative array ──
set_config() {
local -n _cfg=$1
_cfg[host]="localhost"
_cfg[port]=8080
_cfg[debug]=true
}
declare -A config
set_config config
echo "${config[host]}:${config[port]} debug=${config[debug]}"
# localhost:8080 debug=true
Signal Traps: Cleanup and Graceful Shutdown
bash
# ── trap registers a handler for signals or pseudo-signals ──
# Signals: INT (Ctrl-C), TERM (kill), HUP (terminal closed), etc.
# Pseudo-signals: EXIT (any exit), ERR (command failure), DEBUG (before each command), RETURN
# ── Clean up temp files on any exit ──
tmpfile=$(mktemp)
tmpdir=$(mktemp -d)
trap 'rm -f "$tmpfile"; rm -rf "$tmpdir"' EXIT
# EXIT fires on: normal end, `exit`, `set -e` abort, and signals (if no specific trap).
# It fires in the exiting shell — so $tmpfile is still in scope.
# ── Graceful shutdown of a server ──
server_pid=""
cleanup() {
if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then
echo "shutting down server (PID $server_pid)..." >&2
kill "$server_pid" # SIGTERM — let the server clean up
wait "$server_pid" 2>/dev/null
fi
}
trap cleanup EXIT INT TERM
# EXIT: normal exit or set -e abort
# INT: Ctrl-C (user interrupt)
# TERM: kill (termination request)
# ── ERR trap: log the failing command and line ──
set -E # -E makes ERR trap inherit into functions (essential!)
on_error() {
local exit_code=$?
echo "ERROR: '$BASH_COMMAND' failed (exit $exit_code) at line $LINENO" >&2
# BASH_COMMAND: the command that triggered the error
# LINENO: current line number
# $?: the exit status (capture FIRST — any command overwrites $?)
}
trap on_error ERR
# ERR fires on any command failure (with set -e). Without set -e, ERR doesn't fire.
# ── DEBUG trap: trace every command ──
trace() {
echo "DEBUG: line $LINENO: $BASH_COMMAND" >&2
}
trap trace DEBUG # fires BEFORE every command — very verbose, but powerful for debugging
# ── Ignoring a signal (critical section) ──
trap '' INT # ignore Ctrl-C during critical section
# ... critical work that shouldn't be interrupted ...
trap - INT # restore default INT behavior (Ctrl-C kills the script)
# ── HUP for config reload ──
load_config() {
# shellcheck source=/dev/null
source "$CONFIG_FILE"
echo "config reloaded"
}
trap load_config HUP # kill -HUP $pid reloads config without restart
# ── Combining traps ──
# Multiple trap EXIT overwrite — last one wins. Combine in one function:
cleanup_all() {
local exit_code=$?
[[ -n "$server_pid" ]] && kill "$server_pid" 2>/dev/null
[[ -n "$tmpfile" ]] && rm -f "$tmpfile"
[[ -n "$tmpdir" ]] && rm -rf "$tmpdir"
if ((exit_code != 0)); then
echo "script failed (exit $exit_code)" >&2
fi
exit "$exit_code" # preserve exit code
}
trap cleanup_all EXIT INT TERM
# Don't use separate trap cleanup EXIT + trap cleanup INT — the second overwrites the first.
Production Pattern: Worker with Graceful Shutdown
bash
#!/usr/bin/env bash
set -Eeuo pipefail
# ── A worker that processes jobs and shuts down gracefully on signal ──
running=true
current_job=""
shutdown() {
echo "[$(date -Iseconds)] shutdown requested, finishing current job..." >&2
running=false
# Don't exit immediately — let the main loop finish the current job.
# The loop checks $running and exits cleanly.
}
trap shutdown INT TERM
process_job() {
local job=$1
current_job="$job"
echo "[$(date -Iseconds)] processing: $job" >&2
sleep 1 # simulate work
current_job=""
}
main() {
local jobs=("task1" "task2" "task3" "task4" "task5")
for job in "${jobs[@]}"; do
$running || break # check shutdown flag
process_job "$job"
done
echo "[$(date -Iseconds)] all jobs processed or shutdown complete" >&2
}
main "$@"
Exporting Functions to Child Processes
bash
# ── Functions are NOT inherited by child processes (only subshells) ──
my_func() { echo "hello from $0"; }
my_func # hello from script.sh (current shell)
bash -c 'my_func' # bash: my_func: command not found (child doesn't see it)
# ── `export -f` makes a function available to child processes ──
export -f my_func
bash -c 'my_func' # hello from bash (child now has the function)
# ── Use case: xargs/find -exec (they run in child processes) ──
process_file() {
local f=$1
echo "processing $f"
# ... real work ...
}
export -f process_file
# xargs runs `bash -c` in a subprocess — the function must be exported
find . -name '*.txt' -print0 | xargs -0 -P4 -I{} bash -c 'process_file "$1"' _ {}
# ── ⚠️ `export -f` serializes the function body as an environment variable ──
# The function becomes BASH_FUNC_my_func%% env var (with the function body as value).
# This is a potential injection vector if function names come from untrusted input.
# Don't export functions with names from untrusted sources.
# ── ⚠️ `export -f` doesn't work with local-scope variables ──
# If the function references global variables, those must ALSO be exported:
DATA="shared"
export DATA
use_data() { echo "$DATA"; }
export -f use_data
bash -c 'use_data' # shared — DATA was exported too
Recursion: Works But Slow
bash
# ── Each $(...) call forks a subprocess — recursion is O(2^n) subprocesses ──
fib() {
if (( $1 <= 1 )); then
echo "$1"
else
echo $(( $(fib $(($1 - 1))) + $(fib $(($1 - 2))) ))
# Each fib call forks: 2 subprocesses per call → exponential
fi
}
time fib 15 # ~2s (300+ subprocess forks)
time fib 20 # ~30s (thousands of subprocess forks)
time fib 30 # minutes — don't do this
# ── Iterative version (no subprocesses) ──
fib_iter() {
local n=$1 a=0 b=1
for ((i = 0; i < n; i++)); do
((a, b = b, a + b)) # swap: a gets b's old value, b gets a+b (parallel assignment)
done
echo "$a"
}
time fib_iter 30 # <0.01s — iterative is 1000x faster
# ── If you MUST recurse, avoid $() — use namerefs or a different language ──
# For pure computation, Bash is the wrong tool. Use Python/awk for complex algorithms.
💡 Tips & Tricks
bash
# ── Parallel assignment via arithmetic context ──
a=1; b=2
((a, b = b, a)) # swap! a=2, b=1 (comma operator in ((...)) evaluates left-to-right)
# This is the Bash idiom for swapping two variables without a temp.
# ── Default arguments for functions ──
greet() {
local name=${1:-World} # default to "World" if no arg
local greeting=${2:-Hello} # default to "Hello" if no second arg
echo "$greeting, $name!"
}
greet # Hello, World!
greet Alice # Hello, Alice!
greet Alice Hi # Hi, Alice!
# ── Function that accepts a variable number of args ──
sum() {
local total=0 n
for n in "$@"; do
((total += n))
done
echo "$total"
}
sum 1 2 3 4 5 # 15
# ── `local -A` for function-private associative arrays ──
parse_kv() {
local -A result # function-local associative array
local pair
for pair in "$@"; do
local key="${pair%%=*}" # everything before first =
local val="${pair#*=}" # everything after first =
result["$key"]="$val"
done
# Can't return an array — use nameref for the caller
# Or print as key=value pairs and parse in the caller
for key in "${!result[@]}"; do
printf '%s=%s\n' "$key" "${result[$key]}"
done
}
# ── `trap '' ''` to clear a trap ──
trap - EXIT # remove the EXIT trap (useful in subshells that shouldn't clean up parent's state)
⚠️ Edge Cases & Gotchas
bash
# ── Functions must be defined BEFORE they're called ──
# Bash reads top-to-bottom — no hoisting (unlike Python/JS).
# A function called before its definition: "command not found".
# Put all functions at the top, call main "$@" at the bottom.
# ── `$0` inside a function is the script name, not the function ──
my_func() { echo "$0"; } # prints script name, NOT "my_func"
# Use ${FUNCNAME[0]} for the current function name:
my_func() { echo "${FUNCNAME[0]}"; } # "my_func"
# ── `return` vs `exit` in a sourced script ──
# `exit` in a sourced script closes the CALLER's shell!
# `return` exits the function/sourced script and returns to the caller.
# In a sourced library: use `return`, never `exit`.
# ── `trap` in a subshell doesn't affect the parent ──
# Traps are shell-specific. `( trap 'echo sub' EXIT )` — the subshell's trap
# doesn't apply to the parent shell.
# ── `trap EXIT` with `set -e` ──
# On a `set -e` abort, EXIT still fires (good — cleanup runs).
# But the exit code in the EXIT trap is the failure's code, not 0.
# Capture it: cleanup() { local code=$?; ...; exit "$code"; }
# ── Nameref to a non-existent variable creates it ──
declare -n ref=nonexistent
ref="value" # creates `nonexistent` with value "value" (not an error)
echo "$nonexistent" # value — created on first write
# ── `local` on a nameref is Bash 4.3+ ──
# `local -n` requires Bash 4.3+. On older Bash, use `declare -n` (but not function-scoped).
# ── Functions can be overridden ──
greet() { echo "v1"; }
greet() { echo "v2"; } # redefinition — silently replaces v1
greet # v2 — last definition wins. No warning. Be careful with sourcing libraries.
🧠 Quick Quiz
What's wrong with this cleanup?
bash
set -e
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"; exit 0' EXIT
false
Answer
Two problems:
exit 0in the EXIT trap overrides the error exit code. The script fails onfalse(set -e), which should exit with code 1. But the EXIT trap runsexit 0, so the script exits with 0 (success) — the failure is masked. CI/CD would see success.- The EXIT trap fires on
set -eabort — which is good (cleanup runs), butexit 0discards the error.
Fix: preserve the exit code:
bash
set -e
tmpfile=$(mktemp)
cleanup() {
local code=$? # capture the exit code BEFORE any other command
rm -f "$tmpfile"
exit "$code" # preserve the original exit code (0 for success, non-zero for error)
}
trap cleanup EXIT
false # exits 1 → cleanup runs → exits 1 (correct)
The lesson: always capture $? as the first thing in an EXIT trap and exit "$?" to preserve the original exit code. exit 0 in cleanup masks failures.