Bash / Basic: syntax, values, and control / Lesson 5
Build functions with clear state boundaries
Define argument, output, status, and state contracts for small reusable Bash functions without confusing sourcing with child execution.
On this page
Lesson goals
- Forward positional parameters without joining them.
- Use local variables while understanding dynamic scope.
- Distinguish return, exit, sourcing, and executable scripts.
A function has several observable results
A Bash function can emit standard output, emit diagnostics, return a status, and modify shell state. Choose which of these forms its interface. A helper that returns a pathname through output must keep progress messages on standard error, or its caller may receive corrupted data. return accepts an exit-status value, not an arbitrary string result. An omitted return normally uses the last command’s status, which can accidentally become a successful log message after required work failed. Make important completion behavior explicit and document any intentional global modification.
Functions receive their own positional list
During a function call, positional parameters describe the function’s arguments. Forward them with "$@" when the next helper should receive the same list. shift changes that list and should follow a count check. A function can be tested with explicit arguments without rearranging the caller’s global list. Do not build a single space-separated scalar to forward several values. If a helper needs optional flags, define their grammar and reject unexpected operands rather than silently ignoring them. A small receiving function can expose exact argument count during a controlled test.
describe() {
printf 'count=%s\n' "$#"
printf '<%s>\n' "$@"
}
forward() { describe "$@"; }
forward 'red apple' ''
count=2
<red apple>
<>
Each function receives its own positional list. Quoted forwarding preserves the space and the empty second argument without changing them into a joined command string.
Local variables reduce accidental state changes
Bash local is a Bash-specific function feature rather than a POSIX sh guarantee. Its scope is dynamic: a called function can see a caller’s local variable unless another binding shadows it. This can surprise code written with lexical-scope assumptions. Use distinctive names and explicit arguments instead of hidden dependencies. local declaration and command substitution should be separated when the substitution’s status matters, because the declaration builtin can mask that status. Treat shell options, current directory, traps, and descriptors as state too; local variables alone do not make a function isolated.
Choose intentional mutation or isolation
A function normally runs in the current shell, so cd and assignments can persist. A parenthesized function body or an explicitly parenthesized invocation can provide a subshell boundary when state should not escape. That isolation also prevents useful assignments from returning to the parent. Decide which contract is required before choosing syntax. Capturing output through ordinary command substitution normally creates a child environment and removes trailing newlines, so it is not a general mechanism for returning arbitrary binary data or current-shell state. Later lessons inspect these boundary effects in depth.
value=outer
change() { local value=inner; printf 'inside=%s\n' "$value"; }
change
printf 'outside=%s\n' "$value"
inside=inner
outside=outer
The function’s local binding disappears on return and the outer value remains. This simple example does not depend on another helper reading the dynamic local scope.
Sourced code executes in the caller
Executing bash library.sh launches a shell, while source library.sh or . library.sh reads code into the current shell. Sourcing can change variables, options, working directory, and traps; it is execution of trusted code, not data loading. A library should avoid unconditional exit and surprising work at import time. Use return from functions or sourced code where appropriate and reserve exit for the standalone program’s boundary. Define what a module exports and test repeated sourcing if it is supported. Keep secrets out of diagnostic traces and do not source an untrusted configuration file.
Common errors and fixes
A log line corrupts a returned value
- Why it happens
- Data and diagnostics shared standard output.
- How to fix it
- Send diagnostics to standard error and return only data.
- Prevent it next time
- Document each function’s output contract.
local assignment hides a failed substitution
- Why it happens
- The declaration’s status replaced the operation’s status.
- How to fix it
- Declare first, then assign and handle failure explicitly.
- Prevent it next time
- Test failure paths through helper declarations.
Sourcing exits the caller
- Why it happens
- A library used unconditional exit.
- How to fix it
- Use an appropriate return and separate the CLI boundary.
- Prevent it next time
- Treat import behavior as part of the module API.
Check your understanding
1. A helper must calculate text but leave caller state unchanged. What boundary can support that?
Show worked solution
Use a deliberately isolated invocation and capture its documented text, accounting for command substitution’s trailing-newline removal and status. Keep diagnostics separate. If state changes are required, ordinary output capture is the wrong return mechanism.
2. Why should a sourced configuration not be treated as an inert key/value file?
Show worked solution
The shell executes it with the caller’s authority and state. It may run commands or change traps and options. Use a defined data parser for untrusted configuration rather than source, and document any trusted library’s permitted effects.
Key takeaways
- Separate data, status, diagnostics, and state.
- Use explicit arguments and deliberate module boundaries.