You are setting the conventions for a shared bash function library used by a team's deploy scripts. How should functions hand results back to callers, and what are the tradeoffs between capturing stdout, using globals, and nameref out-parameters?
answer
- mirror the command contract
- status, stdout, stderr
- escape hatch for arrays
- bash 4.3 and a name collision
- write the contract in the header
basics
~20 sStandardise on three channels: exit status for success or failure, standard output for the value, standard error for humans. Use nameref out-parameters (bash 4.3+) or documented globals only where capturing output is impossible or too slow.
solid answer
~50 sMake the default contract the same one every Unix command follows — status says whether it worked, standard output carries the value, standard error carries diagnostics — because that is the only convention `if`, `||`, pipelines and `$( )` already understand, and it makes every function testable in isolation. Deviate deliberately, not by habit. Nameref out-parameters (`local -n out=$1`, bash 4.3 and later) are the right escape hatch when a function must return an array or fill several variables, and they avoid a capture; their sharp edge is the name collision, where a caller passing a variable with the same name as your local nameref triggers a circular-reference error, so prefix the ref (`local -n __out=$1`). Globals are fastest and worst: action at a distance, collisions, untestable functions. Whatever you choose, write the contract in a comment header on each function and hold it everywhere.
code
bash · 11 lines#!/usr/bin/env bash
# nameref out-parameters require bash 4.3 or newer
split_pair() { # split_pair "k=v" keyvar valvar
local -n __key=$2 __val=$3 # __ prefix avoids caller name collisions
__key=${1%%=*}
__val=${1#*=}
}
split_pair "region=eu-west-1" k v
printf '%s -> %s\n' "$k" "$v" # region -> eu-west-1go deeper
Know the default convention you will meet in good shell libraries: exit status for success or failure, standard output for the value, standard error for messages. Follow it rather than inventing a per-function scheme.
Be able to explain why stdout capture is the composable default and where it runs out — multiple values, arrays, values containing newlines — and what a nameref out-parameter buys you in those cases.
Show you have operated such a library: the double-underscore prefix that avoids nameref collisions, the bash 4.3 floor versus macOS 3.2, the per-function contract comment, and the rule that no library function terminates the process.
Own the convention as a team decision with a stated cost, enforce it consistently, and be willing to name the point where shell stops paying — structured returns and typed errors are the signal to move the logic into a real language.
## Start from the command contract A bash function library has one enormous advantage available to it: the shell already has a universal calling convention, and every operator in the language is built around it. - **Exit status** — did it work? Consumed by `if`, `while`, `&&`, `||`, `set -e`. - **Standard output** — the value. Consumed by `$( )`, by a pipeline, by a redirect to a file. - **Standard error** — the narration for whoever is reading the log. Adopt that as the default and functions compose with the rest of the shell for free, are trivially testable (`out=$(f x); [[ $out == expected ]]`), and behave predictably for the next person. Every alternative convention is a private protocol each caller must be taught. ## Where stdout capture stops being the right answer It is not universal, and pretending otherwise produces worse code than deviating honestly: - **Multiple values.** Returning three things means inventing an encoding — tab-separated fields, one record per line — and every caller must decode it correctly. - **Arrays and data containing newlines.** Paths, log lines and user-supplied strings can contain almost anything, so text encodings need NUL separators to be safe, and the decode side gets fiddly fast. - **Cost in hot loops.** Every capture runs the function in a separate process. Once per script is irrelevant; ten thousand times inside a loop is a measurable slowdown that profiling will point straight at. ## Nameref out-parameters Since bash 4.3, `declare -n` / `local -n` makes a variable an alias for another variable named at runtime, which gives you a real out-parameter: ```bash split_pair() { # split_pair "k=v" keyvar valvar local -n __key=$2 __val=$3 __key=${1%%=*} __val=${1#*=} } split_pair "region=eu-west-1" k v ``` This returns as many values as you like, handles arrays natively, and costs no extra process. Two things to legislate if you adopt it: 1. **Name collisions.** If the caller's variable happens to share the name of your local nameref, bash reports a circular name reference and the assignment does not do what you meant. The standard defence is an unlikely prefix — `local -n __out=$1` — and a documented rule that callers never use names beginning with a double underscore. 2. **Portability.** Namerefs need bash 4.3 or newer. macOS still ships bash 3.2 as `/bin/bash`, so a library that must run on developer laptops as well as Linux CI cannot use them unless the team installs a newer bash and the shebang points at it. The same caveat applies to `mapfile`, which is bash 4+. ## Globals A function that assigns to a well-known global (`RESULT`, `LAST_ERROR`) is the cheapest option and the hardest to live with. There is no locality — you cannot tell from the call site what changed; two functions using the same name silently interfere; nested or recursive calls clobber each other; and testing a function means inspecting shell state instead of its output. Reserve globals for genuinely process-wide configuration that is set once at start-up and read everywhere, and say so explicitly in the library's header rather than letting them accrete. ## Write the contract down Whatever you pick, the convention only pays off if it is legible. A two-line header per function is enough: ```bash # fetch_manifest <env> # stdout: manifest JSON stderr: progress # status: 0 ok, 1 not found, 2 network error ``` Pair that with library-wide rules: diagnostics always on standard error; status codes documented per function and kept inside 0-255; no function terminates the process — that decision belongs to the entry point; and a consistent prefix for both function names and any global they own, so a sourced library cannot collide with the caller's own definitions. ## Knowing when to stop The honest principal-level answer includes a limit. When functions start returning structured records, when callers need typed errors rather than a small integer, when the encode/decode helpers become the bulk of the library, the shell has stopped being the cheap glue that justified it. At that point the right move is to move the logic into a language with real data types and keep bash for what it is genuinely best at: sequencing commands and wiring their streams together.
- Why is a nameref out-parameter usually better than a documented global for returning an array?The caller chooses the variable name at the call site, so two callers — or a recursive call — never collide, and the function stays readable because the destination is visible in the call. A global hides the destination, breaks under nesting, and forces every test to inspect shell state instead of the call's own results.
- How would you keep the library usable on macOS, which ships bash 3.2?Either avoid the bash 4+ surface entirely — no namerefs, no mapfile, no associative arrays — or require a newer bash and make the shebang `#!/usr/bin/env bash` so the one on PATH wins, with a start-up check on BASH_VERSINFO that fails loudly. What you must not do is use them and hope; the failure is a syntax error at source time.
- What signals that a shell function library has outgrown bash?Encode/decode helpers dominating the code, callers needing typed errors rather than a small integer, values that must carry structure or nesting, and tests that are harder to write than the logic. When the convention costs more than the glue it enables, move the logic to a language with data types and keep bash for sequencing commands.
saying these in an interview costs you the question
- Returning values through globals because "it is simpler"
- Assuming namerefs work on any bash, including macOS 3.2
- Using the exit status to carry a computed number
- Mixing three return conventions across one library
- Never documenting which channel a function uses