Module 8 — Functions and Script Structure

Updated 3 September 2026

Module 8 — Functions and Script Structure. Real scripts grow. Functions keep a 300-line deploy script readable, testable, and safe to change.

🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start. You need Modules 1–7 — especially positional parameters (Module 7: functions reuse that exact machinery), exit codes (Module 3: functions have verdicts too), and source vs execute (Module 1 C1: it returns here with a twist). Tools: nothing new. Keep ~/bash-course.

Part A — Defining and calling

A1. A function is a named block, defined before it is called

A function gives a block of commands a name; saying the name later runs the block. The standard form:

bash
log() {
    echo "[$(date +%H:%M:%S)] $*"
}
log "deploy starting"

The parentheses are empty punctuation marking "this is a function definition" — arguments do not go there (A2 shows where they go). The body sits in { }. An alternative spelling function log { … } exists in bash; the name() form is the portable convention, and mixing both in one file is a style smell.

One rule follows from bash's nature as a top-to-bottom reader (Module 1): a function must be defined earlier in the file than its first call. There is no hoisting, no lookahead — a call above the definition is just an unknown command (exit 127, Module 3's old friend). Hence the universal layout: functions at the top, action at the bottom — C1 makes that a formal template.

Real-world analogy — the named play. A sports team drills a set-piece until it has a name — "run Falcon!" — and one shouted word triggers the whole rehearsed sequence. Functions are named plays: define once, invoke by name, and the game plan (your main script) reads as strategy instead of choreography.

Where the analogy stops working. A team remembers plays from last season. Bash remembers nothing across runs: definitions live only in the shell that read them, die with it (Module 2's jars), and must appear in every script that uses them — which is why shared functions get a file of their own that scripts source (C2), the closest bash has to a playbook binder.

🧪 Exercise 8.1 — name a play
bash
cd ~/bash-course
nano fn1.sh     # six lines:
bash
#!/bin/bash
log() {
    echo "[$(date +%H:%M:%S)] $*"
}
log "deploy starting"
log "deploy finished"
bash
chmod +x fn1.sh
./fn1.sh
Expected result — click to reveal
plain text
[09:00:24] deploy starting
[09:00:24] deploy finished

What to read out of it (your timestamps will differ): two calls, one definition — the timestamp logic exists in exactly one place, so changing the log format is a one-line edit forever after. Note the body is pure Module-2 machinery: $(date +%H:%M:%S) is command substitution with a format argument, $* is Module 7's weld — correct here, because a log line is exactly the "one display string" case. Functions add no new expansion rules; they package the ones you know.

A2. Function arguments: $1 again, wearing a different hat

Arguments go after the function's name at the call, exactly like arguments to a script — and inside the function, they arrive as $1, $2, $#, "$@": the same positional machinery, temporarily re-pointed. For the duration of the call, the function's arguments shadow the script's; when it returns, the script's own $1 is back, untouched. Everything Module 7 taught applies wholesale — quoting, $# guards, shift, even getopts (with the OPTIND reset from Module 7's E2).

🧪 Exercise 8.2 — arguments in, arguments restored
bash
greet() { echo "Hello, $1 (of $# args)"; }
greet alice
greet "Ada Lovelace" extra
Expected result — click to reveal
plain text
Hello, alice (of 1 args)
Hello, Ada Lovelace (of 2 args)

What to read out of it: each call got its own private $1/$# — one argument, then two, the quoted name arriving whole (Module 7's rules, unbroken). The one thing that does not get re-pointed: $0 still names the script, not the function (a function's own name lives in FUNCNAME, for the rare moment you need it). One habit transfers immediately: functions validate their arguments exactly like scripts do — [ "$#" -eq 1 ] || return 2 is A2 of Module 7, miniaturized.

Interview questions — Part A

🎯 "How to write a function?" — asked verbatim at Edureka; Zero To Mastery asks "How do you use functions in Bash?"; Hirist asks "How do you define and call a function in Bash?"

The direct answer: define with name() { commands; }, call by bare name with arguments after it: name arg1 arg2. Inside, arguments are $1, $2, $#, "$@" — the positional-parameter machinery, scoped to the call.

Going deeper: the definition-before-call rule (bash reads top to bottom; no hoisting), the two definition syntaxes and why the portable name() form wins, and the fact that a function call is cheap — no new process, unlike running a script (which is why cd inside a function does affect the caller, unlike cd in a child script — Module 1's distinction, inverted, and a great interview moment).

The details that separate candidates: naming that in-process property explicitly — functions can change the calling shell's state, scripts cannot — and its corollary that function-heavy code needs local discipline (Part B); plus knowing declare -f name prints a function's current definition, the fastest way to inspect what is actually loaded in a live shell.

Part B — Scope and results

B1. Variables in functions are global — unless you say local

Here is the scoping fact that surprises everyone arriving from other languages: a variable assigned inside a bash function is the same global variable as everywhere else. No wall, no privacy — a function that casually uses i, tmp, or count overwrites the caller's i, tmp, count. The fix is one keyword: local name (or local name=value) makes the variable exist only for the duration of the call, restoring whatever was there before when the function returns.

Counter-intuitive: the danger scales with innocence. A loop variable named i inside a helper function silently corrupts the i of any loop that calls the helper — the caller's loop jumps or ends early, the bug appears to be in the loop, and nothing points at the helper. This is among the hardest bash bug classes to spot after the fact and the easiest to prevent: every assignment in every function starts with local unless the function's documented job is to set a global. ShellCheck cannot fully save you here (it cannot know intent); the discipline must be yours.
Real-world analogy — the shared office whiteboard. By default, every team (function) writes on the one whiteboard in the corridor (global scope). The intern wiping a corner to compute a subtotal just erased the quarterly targets — nobody saw, everyone suffers. local is bringing your own notepad: scribble freely, toss it on the way out, corridor board untouched.

Where the analogy stops working. Bash's local is dynamic, not lexical: your notepad is visible not just to you but to any function you call while holding it — callees see (and can modify!) their caller's locals of the same name. It rarely bites, but when interviewers push on "is local like other languages' local?", the precise answer is no: it is call-stack-scoped, not block-scoped.

🧪 Exercise 8.3 — corrupt, then contain

The first function corrupts on purpose.

bash
counter=10
bump() { counter=99; }               # no local — writes the corridor board
bump
echo "after bump: $counter"
safe() { local counter=0; counter=5; echo "inside safe: $counter"; }
safe
echo "after safe: $counter"
Expected result — click to reveal (contains deliberate corruption)
plain text
after bump: 99
inside safe: 5
after safe: 99

What to read out of it, carefully: bump overwrote the script's counter — 10 became 99 with no ceremony. safe then worked on its own local counter (watch it hold 5 inside) — and the final line shows the corridor board still reads 99: safe's notepad was tossed, and bump's earlier graffiti is what remains. The exercise's sting is that last line: local protected the world from safe, but nothing un-writes what bump already did. Scope discipline works prospectively only.

B2. What a function gives back: verdicts, and output

Functions produce results through the same two channels every command uses (Modules 3–4) — and confusing them is the classic mistake. Channel one, the verdict: a function's exit status is its last command's, or whatever return N says explicitly. That makes well-named functions read as English in conditionals — is_even() { [ $(( $1 % 2 )) -eq 0 ]; } and then if is_even "$n". return is for numbers 0–255 meaning success/failure — it is not how you send a value back. Channel two, output: a function that computes something prints it, and the caller captures with command substitution: stamp=$(now_stamp) — exactly how you capture any command, because a function call is a command.

And the sharp edge that pays off a Module 3 debt: return ends a function; exit ends the whole script — from anywhere, including inside a function. A helper that "handles" an error with exit doesn't return control to its caller; it vaporizes the caller. Libraries of functions (C2) must use return exclusively and let the top level decide about exiting.

🧪 Exercise 8.4 — the two channels, and the grenade

The last function kills the script on purpose.

bash
cd ~/bash-course
nano fn2.sh     # this script:
bash
#!/bin/bash
is_even() { [ $(( $1 % 2 )) -eq 0 ]; }
now_stamp() { date +%Y%m%d; }
f() { return 3; echo "never printed"; }
g() { exit 5; }
is_even 4 && echo "4 is even"
is_even 7 || echo "7 is odd"
backup="db-$(now_stamp).sql"
echo "would create: $backup"
f; echo "after f: $?"
echo "before g"
g
echo "you will not see this"
bash
chmod +x fn2.sh
./fn2.sh ; echo "script verdict: $?"
Expected result — click to reveal (contains a deliberate script-kill)
plain text
4 is even
7 is odd
would create: db-20260903.sql
after f: 3
before g
script verdict: 5

What to read out of it (your date will differ): is_even never echoed anything — its entire interface is the verdict, which &&/|| and if consume directly. now_stamp's interface is output, captured into a filename. f shows return's two jobs at once: it stopped the function (the echo after it never ran) and set the verdict (3, read by the caller). Then the grenade: g's exit 5 ended not g but the script — the final echo is missing, and the script's own verdict became 5. In a 300-line script, an exit buried in a helper produces exactly this: a run that just… stops, with the log's last line nowhere near the real cause.

Interview questions — Part B

🎯 "How do you return a value from a bash function?" — the classic trap question; asked in many phrasings (no canonical published wording — tutorial answers to Zero To Mastery's function question cover it), and designed to catch a specific misconception

The direct answer: you don't — not the way other languages mean it. return N sets only an exit status, 0–255, meaning success/failure. Values come back as output: the function prints, the caller captures with result=$(fn args).

Going deeper: show both channels working together — fn prints its answer and returns a status, the caller does out=$(fn x) || fallback — and name the trap in the question: return "hello" errors, return 300 wraps to 44 (Module 3's one-byte fact). Also worth stating: echo-as-return means a function's diagnostics must go to stderr (>&2, Module 4), or they contaminate the captured value.

The details that separate candidates: that $(fn) runs the function in a subshell — its variable assignments don't reach the caller (Module 12's mechanics, arriving early); so the three honest value-passing options are stdout-capture, a global the function documents it sets, or (bash 4.3+) a nameref — naming all three, with stdout-capture as the default recommendation, is a complete answer.

🎯 "What is the difference between local and global variables in a bash function?" — a standard screen; published lists cover local/global for scripts (PlacementPreparation's "local vs global" question) more than for functions specifically

The direct answer: nothing is local unless declared — assignments inside functions hit the same global namespace as everywhere else. local var scopes the variable to the current function call, restoring the previous value on return.

Going deeper: give the corruption demo (a helper assigning counter overwrites the caller's) and the discipline (every function-internal variable gets local, first mention). Then the precision point: bash's local is dynamic scope — visible to callees during the call — not the lexical scope of most languages.

The details that separate candidates: the local + command substitution gotcha — local out=$(cmd) masks cmd's exit status (local's own success wins); split it: local out; out=$(cmd) || … — a real production bug with a two-line fix, and ShellCheck flags it (SC2155). Candidates who know that one have debugged real scripts.

Part C — Script structure: the shape production scripts share

C1. The template

Once functions exist, nearly every serious bash script converges on one layout — worth internalizing as a template you fill rather than a structure you invent each time:

bash
#!/bin/bash
# deploy.sh — one-line purpose statement
# usage: deploy.sh -e env [-n] target
# (strict-mode flags from Module 14 will land here)

readonly SCRIPT_NAME="${0##*/}"        # constants first, locked (Module 2)
readonly DEPLOY_ROOT="/opt/deploys"

log()   { echo "[$SCRIPT_NAME] $*" >&2; }        # helpers: small, local-disciplined
die()   { log "ERROR: $*"; exit 1; }             # the ONE place a helper may exit

usage() { echo "usage: $SCRIPT_NAME -e env [-n] target" >&2; exit 2; }

main() {
    [ "$#" -ge 1 ] || usage                      # validation (Module 7)
    local target="$1"
    log "deploying $target"
    # real work, calling helpers, checking verdicts
}

main "$@"                                        # the only top-level action line

Why this shape wins: the file reads top-down as declaration → tools → intent; the only executable line at top level is main "$@", so nothing runs while the file is being read as a library; main's local variables keep the global namespace clean; and die concentrates the exit-from-helper decision (B2's grenade) into one audited place. The main "$@" forwarding is Module 7's "$@" doing its precise job — the script's arguments arrive in main's $1, $2 untouched.

Real-world analogy — the restaurant kitchen's mise en place. Before service, a professional kitchen lays out stations: labeled containers (constants), prepped components (helper functions), and the pass where orders are assembled (main). Service itself touches only the pass. A home cook's kitchen — everything happening everywhere, ingredients prepped mid-dish — works for scrambled eggs and collapses under a wedding banquet; that is precisely a 20-line script versus a 300-line one.

Where the analogy stops working. A kitchen's stations operate in parallel; this template is still strictly sequential (bash reads top to bottom, runs main last). The template's win is not concurrency but addressability: every behavior has one named home, so the 3 a.m. fix goes into a function, not into "somewhere around line 180."

🧪 Exercise 8.5 — feel the difference structure makes

No new commands — an editing exercise. Take your opts.sh from Module 7 and restructure it into the template: constants up top, the getopts loop inside main, error text through a die helper. Then run its happy path and both failure paths again (Exercise 7.4's four runs) and confirm identical behavior.

Expected result — click to reveal
plain text
env=staging dry=1 leftover=release-42
unknown option: -x
verdict: 2
option -e needs a value
verdict: 2
usage: ./opts.sh -e environment [-n]

What to read out of it: byte-for-byte the same behavior as Module 7's flat version — restructuring is behavior-preserving, and proving that with the old test runs is the habit that matters (you just performed your first refactor with a regression test, which is what Module 15's bats testing industrializes). The payoff is invisible in output and enormous in the diff: the next feature lands as one new function plus one call line.

C2. Libraries: source, and the return-not-exit contract

When several scripts want the same helpers, the functions move to a shared file and each script loads it: source /opt/lib/common.sh (Module 1 C1's source, now with its real production purpose: sourcing a file of definitions loads them into the current shell). Two contracts make a file library-safe. First: definitions only — a library that does things at top level does them at every source, in every consumer, at load time. Second: helpers return, never exit (B2's grenade rule, now structural): sourced code runs in the consumer's shell, so a library's exit kills the consumer — and Module 3's interview answer about exit in sourced files is now something you can derive rather than memorize.

🧪 Exercise 8.6 — build a library, and prove the contract matters
bash
cd ~/bash-course
nano common.sh    # a LIBRARY — no shebang needed, nothing executes at top level:
bash
# common.sh — shared helpers (source me; do not execute)
log() { echo "[$(date +%H:%M:%S)] $*" >&2; }
require_file() {
    [ -f "$1" ] || { log "missing required file: $1"; return 1; }
}
bash
nano uses-lib.sh    # a consumer:
bash
#!/bin/bash
source ./common.sh
log "starting checks"
require_file /etc/hosts    && log "hosts: ok"
require_file /etc/nothere  || log "continuing without optional file"
log "still alive — the library returned instead of exiting"
bash
chmod +x uses-lib.sh
./uses-lib.sh
Expected result — click to reveal
plain text
[09:15:02] starting checks
[09:15:02] hosts: ok
[09:15:02] missing required file: /etc/nothere
[09:15:02] continuing without optional file
[09:15:02] still alive — the library returned instead of exiting

What to read out of it (timestamps yours): one library, one consumer, and the contract visibly holding — require_file's failure came back as a verdict (caught by ||, Module 3), not as a script-ending explosion; the consumer decided the missing file was survivable and said so. Mentally swap the library's return 1 for exit 1 and re-read the transcript: everything after line 3 vanishes. That is the whole difference between a library and a landmine. (Also note every log line went to stderr — >&2 in one place, Module 4's discipline inherited by every consumer for free.)

Now imagine this at 500 hosts. Function libraries are how fleet script quality actually scales: one common.sh — logging with hostnames and timestamps, retry wrappers, "is this host in maintenance?" checks — sourced by every operational script, versioned in git, reviewed once, improved everywhere. The two contracts become fleet law: a library that executes at load time once restarted a service on 500 hosts while being sourced; a library that exits killed a fleet-wide orchestration mid-round. Teams codify both in review checklists, and pin library versions in deploys exactly like any other dependency — because a bad common.sh ships to everything at once.

Part D — The map

D1. Where a result should travel — the decision

Note: the diagram below is a Mermaid code block. Notion does not render it automatically — click the block and switch it from "Code" to "Preview" (or "Split") to see the flowchart.
Diagram source
flowchart TD
    A["My function produces<br>something for the caller"] --> B{"What is it?"}
    B -->|"success / failure<br>(maybe which failure)"| C["exit status:<br>last command, or return N"]
    C --> C1["caller: if fn / fn || ...<br>rc=$? for the number"]
    B -->|"a computed value<br>(string, path, count)"| D["print to stdout"]
    D --> D1["caller: out=$(fn args)<br>diagnostics must go >&2"]
    B -->|"several values /<br>complex state"| E["document a global<br>the function sets"]
    E --> E1["name it loudly:<br>FN_RESULT_ARRAY etc."]
    B -->|"'stop everything'"| F{"am I a library<br>or the top level?"}
    F -->|"library"| G["never exit —<br>return nonzero, let<br>the caller decide"]
    F -->|"top level / die()"| H["exit N — once,<br>audited, logged"]

Interview questions — Parts C–D

🎯 "What is the difference between source and executing a script directly?" — asked verbatim at Hirist; Zero To Mastery's variant appeared in Module 1 — here is the function-era layer of the same question

The direct answer: executing runs in a child shell (changes die with it); sourcing runs in the current shell (changes persist). Module 1 proved it with cd; the layer that matters now: sourcing is how function libraries load — definitions are changes to the shell, so they only stick if sourced.

Going deeper: the two library contracts (definitions-only at top level; return-not-exit) and why each follows from sourcing's mechanics — top-level code runs at load, in the consumer; an exit is the consumer's exit. Mention the guard some libraries carry: refusing to run when executed directly (comparing ${BASH_SOURCE[0]} to $0 — the library-vs-script self-test).

The details that separate candidates: knowing source searches PATH for relative names (surprising!) so libraries are sourced by explicit path (source "$(dirname "$0")/common.sh" is the classic spelling); and the operational point that sourcing user-supplied files is code execution in your shell — a security boundary, not a convenience.

🎯 "Why does my script die halfway with no error — and the last log line is from a helper function?" — the production-shaped version of return-vs-exit; interviewers pose it as a debugging scenario rather than a definition

The direct answer: some helper called exit — which ends the entire script from any depth — where it should have used return. The script didn't crash; it was told to stop, by code that thought it was being tidy.

Going deeper: the audit is mechanical — grep -n 'exit' script.sh lib/*.sh and justify every hit; the fix is structural — helpers return verdicts, one die() at the top level owns exiting (C1's template), libraries never exit (C2's contract). The $? of the vanished script equals the helper's exit argument — a forensic clue worth naming.

The details that separate candidates: the subshell nuance — exit inside $( ) or a pipeline stage kills only that subshell (Module 12), so the same keyword is fatal in one position and harmless in another, and candidates who can say which positions and why have genuinely internalized the process model; plus trap ... EXIT (Module 13) as the way to make even surprise exits leave a forensic last word.

Part E — Production

E1. 🏭 Production practices

These are the practices in production — how the things this module taught are actually done on real systems:

Scripts follow the template. Header comment with purpose and usage; constants readonly at the top; functions next; a single main "$@" at the bottom. Reviewers reject top-level action lines scattered through a file the way they reject unquoted expansions.

Every function-internal variable is local, declared at first mention. And declared separately from command substitutions — local out; out=$(cmd) — so the substitution's verdict survives (SC2155 is in the CI linter for exactly this).

Functions expose one interface: verdict, stdout value, or documented global — chosen deliberately. Diagnostics always on stderr so captured values stay clean.

exit lives in exactly one helper (die) plus the top level; everything else returns. Libraries never exit, never act at load time, and are sourced by explicit path with versions pinned like any dependency.

Shared helpers live in a sourced common.sh, owned, reviewed, and changed like production code — because it is production code running inside every consumer.

Functions are the unit of testing. Because the template keeps logic in functions and action in main, a test harness (bats, Module 15) can source the script and exercise functions individually — structure now is testability later.

E2. Production-practice table

SymptomWhat is really happeningWhat to runThe fix
Caller's loop counter jumps or its variables change after calling a helperThe helper assigned without local — same global namespacedeclare -f helper and read its assignments; probe the variable before/after the calllocal on every function-internal variable, first mention
command not found (127) for a function you can see right there in the fileThe call sits above the definition — bash had not read it yetNote the line numbers of call vs definitionTemplate layout: all functions first, main "$@" last
A captured value contains log lines: out = "starting… 42"The function's diagnostics went to stdout, and $( ) captured everything printedfn >/dev/null — what disappears was on stdout; what remains was correct stderrDiagnostics to >&2; stdout reserved for the value
Script stops mid-run, no error; $? is some small number; last log line is from a helperA helper called exit instead of return — the B2 grenadegrep -n 'exit' script.sh lib/*.sh and justify each hitHelpers return; one die() owns exiting; libraries never exit
local out=$(cmd) never detects cmd's failurelocal's own success (0) overwrites the substitution's verdictReproduce: f(){ local o=$(false); echo $?; }; f → 0Split: local out; out=$(cmd) || handle (ShellCheck SC2155)
Sourcing the library ran actions — restarted something, printed outputThe library has top-level action lines; source executes them at loadRead the library for anything outside a function definitionDefinitions-only contract; move actions into functions callers invoke deliberately

E3. 🎓 Capstone — four tickets from the queue

Work each ticket yourself before opening the answer. Everything needed was taught in Modules 1–8.
🎓 Ticket 1 — "After we added a retry() helper to the deploy script, the main loop deploys to the first three hosts, then starts over from host one, forever. The loop is for ((i=0; i<${#hosts}; i++))-style with a counter i — untouched for months."

Diagnosis. B1's innocent-variable corruption: retry() uses i for its attempt counter, without local. Every retry call resets the caller's i, rewinding the host loop — the bug manifests in the loop that did not change, which is exactly why this class is so hard to see (the yellow callout's warning, live).

Work the steps: declare -f retry and read for assignments; then instrument: print i before and after one retry call — watch it change hands.

Fix and prevention: local i (and every other variable) inside retry; then sweep the whole library — corruption rarely travels alone: grep -n '=' lib/common.sh inside function bodies, demanding local on each. Team rule going forward: helpers declare all their variables local in the first lines, where review can check at a glance.

🎓 Ticket 2 — "Our get_active_region() helper returns the region — the deploy uses get_active_region; region=$?. It worked in us-east-1 for a year. This week traffic moved to eu-central-1 and the deploy started targeting region '3'."

Diagnosis. B2's channel confusion, aged into production: someone encoded regions as return numbers (us-east-1 → some index that happened to work as long as nothing changed), and $? faithfully delivered a small integer — statuses are 0–255 numbers, never strings, so the eu move surfaced as a meaningless "3". The function's interface was wrong from birth; stability hid it.

Work the steps: declare -f get_active_region — find the return $index; trace where "3" entered the target list.

Fix and prevention: flip the channel: the helper prints the region name, callers capture — region=$(get_active_region) || die "cannot determine region" — with diagnostics on stderr so the capture stays clean. Sweep for siblings: any $? being used as data rather than verdict is the same disease. Interface rule for the library README: verdict = did it work; stdout = what it found.

🎓 Ticket 3 — "We sourced the new common.sh into our maintenance script. Now, whenever a preflight check fails on ONE host, the whole orchestrator — which loops over 200 hosts — vanishes mid-run. No stack trace, nothing."

Diagnosis. C2's contract violation: the library's preflight_check() "handles" failure with exit 1. Sourced code runs in the consumer's shell, so that exit is the orchestrator's exit — one bad host vaporizes the round (and Module 6's fleet rules said a round must survive per-item failure). The missing stack trace is the fingerprint: nothing crashed; something chose to stop.

Work the steps: grep -n 'exit' common.sh — every hit inside a function is a suspect; reproduce with one host and watch echo $? after the vanish (it equals the library's exit code).

Fix and prevention: library-wide: exitreturn (with meaningful codes); orchestrator decides per-host policy: preflight_check "$host" || { log "skip $host"; continue; }. Add the C2 guard to the library so executing it directly still works for testing, and add a review checklist line: a library that can end its consumer is not a library.

🎓 Ticket 4 — "A teammate 'cleaned up' a working script into functions. Now running it does… nothing at all. No output, no error, exit 0. The functions are all there — we can see them in the file."

Diagnosis. The template's last line is missing: everything became definitions, and nothing calls them. Bash read the file top to bottom, learned some plays (A1's analogy), reached the end, and exited 0 — a flawless performance of zero requests. It is the gentlest possible failure and a rite of passage.

Work the steps: run with tracing later (Module 15's set -x would show only definitions), or simply: grep -vE '^\s*(#|$)' script.sh and look for any line that is not inside a function — there is none.

Fix and prevention: append the missing dispatcher: main "$@". Then upgrade the refactor to the full template while you are there (constants readonly, local audit, die helper) and rerun the old invocation tests (Exercise 8.5's habit) — a refactor is not done until the old behavior is demonstrated back. The one-line moral for the runbook: a script of pure functions is a library; main "$@" is what makes it a program.

E4. Documentation reference

TopicAuthoritative referenceWhat you'll find there
Function syntax and semanticsBash manual — Shell FunctionsBoth definition forms, argument handling, FUNCNAME, dynamic scoping of local
return and sourceBash manual — Bourne Shell Builtinsreturn's exact rules (functions and sourced files); source's PATH behavior
local and declareBash manual — Bash Builtinslocal's options; declare -f for inspecting loaded functions
The SC2155 gotchaShellCheck SC2155Why local out=$(cmd) masks cmd's exit status, with the split-declaration fix

E5. Self-assessment

Answer from memory, out loud or on paper. Every answer is in this module.

  1. Write the standard function definition syntax, and state the one ordering rule bash enforces about definitions and calls.
  2. Inside a function, what do $1, $#, "$@" refer to? What happens to the script's own $1 during the call, and what does $0 hold?
  3. Why is an undisciplined helper's tmp=… assignment dangerous? What is the one-keyword fix and the team rule around it?
  4. In what precise way is bash's local not like other languages' local variables?
  5. A function must hand back a computed string. Name the wrong way (and why it fails beyond 255), and write the right way — both sides, function and caller.
  6. What are return's two simultaneous effects? What does exit inside a function do instead?
  7. Why does local out=$(cmd) hide cmd's failure, and what is the two-line spelling that doesn't?
  8. Sketch the production script template's five layers, and explain why main "$@" is the only top-level action line.
  9. State the two contracts of a function library, and derive each from how source works.
  10. Why do libraries source by explicit path rather than bare name?
  11. A script stops mid-run with no error and $? = 4. Reconstruct the likely cause and the grep that finds it.
  12. Why does the captured output of a well-written function contain no log lines — which two-module combination guarantees it?

E6. Sources

Interview questions in this module were captured verbatim from:

Edureka — Top 60 Shell Scripting Interview Questions and Answers — "How to write a function?" (page updated Dec 9, 2024)

Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "How do you use functions in Bash?" (published June 18, 2026)

Hirist — Top 30+ Shell Scripting Interview Questions and Answers — "How do you define and call a function in Bash?", "What is the difference between source and executing a script directly?" (published Jul 22, 2025; last modified Dec 31, 2025)

A note on the corpus: basic function syntax is well represented; the topics this module leans hardest on — local discipline, return-vs-exit, and library contracts — are asked in real screens as scenario questions ("why did the caller's variable change?", "why did the script vanish?") rather than as published one-liners, and the two scenario questions above are labeled accordingly rather than given invented citations. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2; timestamps and dates are flagged as machine-dependent where they occur.

Next: your functions still manipulate strings with external tools and luck. Bash has a built-in string workshop — defaults, trims, replacements, substrings — living inside ${ } braces, and it eliminates whole categories of sed calls: Module 9 — Parameter Expansion and String Manipulation.
Spotted a mistake or want something added? Send me a note.