Linux & Bash · Part I · Module 18 · Functions and Script Decomposition
Defining and Calling a Function
Every complete script in the last two modules was one flat file, and the longer ones are past the point where a reader can hold them in their head. Functions fix that — but only if you start from the right idea of what one is. A Bash function is not a procedure that returns a value. It is a command you gave a name.
Intermediate20 min readLinuxPart IBashFunctionsScriptingLookup Order
Part I · Module 18 · Chapter 18.1 · Defining and Calling a Function
Module 17's triage.sh and run-testlist.sh both worked and both were at the limit of what a flat script should be. This module is about naming the pieces — and it starts by correcting the idea most people arrive with.
1. The Shape
$ log() { printf '%s\n' "$*" >&2; }
$ log starting
startingA name, an empty pair of parentheses, and a body in braces. That is the whole syntax.
Two details that trip people immediately:
The semicolon before } is required. } is a reserved word, not punctuation, so it has to be the first word of a command position — which means the command before it needs terminating:
$ f() { echo hi }
bash: syntax error: unexpected end of file
$ f() { echo hi; }
$ f
hiA newline does the same job, which is why the multi-line form needs no semicolon:
log() {
printf '%s\n' "$*" >&2
}The parentheses are empty and stay empty. They are a marker saying "this is a function definition" — they are not a parameter list, and nothing may go inside them. Arguments are Chapter 18.2.
Bash also accepts a function keyword, in which case the parentheses may be dropped:
$ function log2 { echo hi; }
$ log2
hiUse the first form. name() { ...; } is POSIX and works in every shell; function name { ...; } is a Bash and ksh extension that buys nothing.
2. You Call It Like a Command, Because It Is One
$ f() { echo "args=$#"; }
$ f one two
args=2No parentheses at the call site, no commas — the arguments are words separated by spaces, exactly as for any command. And if you write it the other way, Bash does not merely dislike it:
$ f("one")
bash: syntax error near unexpected token `"one"'A syntax error, not a type error or a wrong-arity complaint. Bash never even got to the point of calling anything; f("one") is not a valid command line. That is the clearest available proof of what a function is: an entry in the same namespace as ls and grep, invoked the same way.
Everything else in this module falls out of that one fact:
- It has an exit status, not a return value (Chapter 18.3).
- It reads positional parameters, not named ones (Chapter 18.2).
- It gets a value out by printing, like any command (Chapter 18.4).
- It can be redirected and piped like any command.
That last one is worth seeing, because it surprises people:
$ count() { wc -l; }
$ printf 'a\nb\nc\n' | count
3
$ count < /etc/hosts
9count reads standard input and has no idea whether it came from a pipe, a file or a terminal — because a function is a command and commands do not get to know that.
3. A Function Beats Everything With the Same Name
This is the consequence to internalise, and it is a real hazard rather than a curiosity.
How a name is resolved once a function exists
Chapter 10.5 established that order for aliases and functions; here it is, measured:
$ echo() { printf 'FN-ECHO\n'; }
$ echo x
FN-ECHO
$ builtin echo "BUILTIN"
BUILTIN
$ command echo "COMMAND"
COMMANDA function beat the builtin echo. Not just an external program — the shell's own builtin. The same is true of cd:
$ cd() { echo "MY CD"; }
$ pwd
/home/engineer
$ cd /tmp
MY CD
$ pwd
/home/engineer
$ builtin cd /tmp; pwd
/tmpcd /tmp printed a message and did not change directory, because the function replaced the builtin completely.
Two escape hatches, and they are different:
command nameskips functions and aliases, running the builtin or the external program.builtin nameruns the shell builtin specifically.
$ ls() { echo "MINE"; }
$ ls
MINE
$ command ls /etc/hosts
/etc/hosts
$ unset -f ls
$ ls /etc/hosts
/etc/hostsunset -f removes the function and the real command comes back.
4. Defined Before Used
A function definition is a command that runs, creating the function. It is not a declaration the parser hoists:
$ bash -c 'later; later() { echo hi; }'
bash: later: command not found
$ bash -c 'later() { echo hi; }; later'
hiThere is no forward declaration and no need for one. The practical rule: in a script, put every function definition above the first line of top-level code. That is also why Chapter 15.5's structure — shebang, comment block, functions, then main-style body — is the shape it is.
Redefinition is allowed and silent — the last definition wins:
$ f() { echo one; }
$ f() { echo two; }
$ f
twoNo warning. In a single file that is rarely an issue; across a script and two sourced libraries it is exactly how a subtle bug arrives, which Chapter 18.6 covers.
5. Inspecting What You Have
$ greet() { echo "hello $1"; }
$ type greet
greet is a function
greet ()
{
echo "hello $1"
}type tells you both that it is a function and what it contains — note that it prints Bash's reparsed version, not your original text, so your comments and formatting are gone.
$ declare -F
declare -f greet
$ declare -f greet
greet ()
{
echo "hello $1"
}
$ unset -f greet
$ type greet
bash: type: greet: not founddeclare -F lists function names only (capital F for "just the faces"), declare -f prints the full bodies, and unset -f removes one. declare -F is genuinely useful for checking a library loaded:
if ! declare -F log > /dev/null; then
echo "${0##*/}: lib.sh did not load" >&2
exit 4
fiThat is Chapter 16.7's command-as-condition, and it is a better guard than testing for the library file, because it checks the thing you actually need.
6. A Function Has an Exit Status
$ f() { return 3; }; f; echo "status=$?"
status=3
$ f() { true; }; f; echo "status=$?"
status=0
$ f() { false; }; f; echo "status=$?"
status=1With no explicit return, a function reports the status of its last command — which is the same rule as a script (Chapter 15.6) and carries the same trap, since a trailing echo masks everything before it. Chapter 18.3 is this in full.
What matters now is the consequence: because a function has a status, it works everywhere a command does. All of Module 16 applies unchanged:
if licence_free; then ...
while has_more_work; do ...
is_valid "$name" || die "bad name: $name"Naming a condition is the single biggest readability win functions offer, and Chapter 17.4's until licence_free was already using it.
7. The Parenthesised Body, and When to Want It
Swap the braces for parentheses and the body runs in a subshell:
$ f() ( cd /tmp; x=inner; )
$ g() { cd /tmp; x=inner; }
$ cd ~; x=outer; f; echo "after f: pwd=$PWD x=$x"
after f: pwd=/home/engineer x=outer
$ x=outer; g; echo "after g: pwd=$PWD x=$x"
after g: pwd=/tmp x=innerThe ( ) version changed neither the directory nor the variable; the { } version changed both. That is Chapter 11.4's distinction, and Chapter 17.7's subshell rule, applied to a function body.
It is occasionally exactly what you want — a helper that needs to cd, set options, or trap signals without affecting the caller:
build_in() (
cd "$1" || exit 1
shopt -s nullglob
make clean all
)No save-and-restore of $PWD, no shopt -u afterwards; the subshell exits and everything goes with it. The costs are that variables cannot come back out (Chapter 18.4) and that it forks a process per call, so do not use it in a loop over ten thousand files. Default to { }; reach for ( ) when isolation is the point.
8. A Complete Script
Module 17's triage.sh, decomposed. Same behaviour, read top to bottom:
#!/usr/bin/env bash
# triage.sh — classify every log in a results directory
# usage: triage.sh <resultsdir>
#
# exit: 0 all passed · 1 at least one failure · 2 unusable arguments
# 4 the environment is not set up
set -o nounset
# ---- helpers -------------------------------------------------------------
usage() {
echo "usage: ${0##*/} <resultsdir>" >&2
}
note() {
printf '%s: %s\n' "${0##*/}" "$*" >&2
}
have() {
command -v "$1" > /dev/null 2>&1
}
# classify one log. prints a verdict word; status is irrelevant.
verdict_for() {
if ! [ -s "$1" ]; then printf 'EMPTY\n'
elif grep -q 'UVM_FATAL' "$1"; then printf 'FATAL\n'
elif grep -q 'UVM_ERROR' "$1"; then printf 'ERRORS\n'
elif grep -q 'Error-\[' "$1"; then printf 'COMPILE\n'
elif ! grep -q 'TEST PASSED' "$1"; then printf 'NO-VERDICT\n'
else printf 'PASS\n'
fi
}
# ---- argument handling ---------------------------------------------------
dir=${1:-}
if [ -z "$dir" ]; then
usage
exit 2
fi
if ! [ -d "$dir" ] || ! [ -r "$dir" ]; then
note "$dir is not a readable directory"
exit 2
fi
if ! have grep; then
note "grep is not on PATH — something is very wrong"
exit 4
fi
# ---- the work ------------------------------------------------------------
failed=0
seen=0
for log in "$dir"/*.log; do
[ -e "$log" ] || continue
seen=$(( seen + 1 ))
name=${log##*/}
name=${name%.log}
v=$(verdict_for "$log")
[ "$v" = PASS ] || failed=$(( failed + 1 ))
printf '%-10s %s\n' "$v" "$name"
done
if [ "$seen" -eq 0 ]; then
note "no logs found in $dir"
exit 2
fi
printf -- '-- %d logs, %d not passing\n' "$seen" "$failed"
[ "$failed" -eq 0 ]$ mkdir results
$ printf 'UVM_FATAL : bad config\n' > results/alu_rand.log
$ printf 'UVM_ERROR : mismatch\nUVM_ERROR : again\n' > results/fifo_full.log
$ printf 'Error-[SE] Syntax error\n' > results/top_build.log
$ printf 'running\n' > results/uart_tx.log
$ : > results/spi_mode3.log
$ printf 'running\nTEST PASSED\n' > results/reset_seq.log
$ ./triage.sh results
FATAL alu_rand
ERRORS fifo_full
PASS reset_seq
EMPTY spi_mode3
COMPILE top_build
NO-VERDICT uart_tx
-- 6 logs, 5 not passing
$ echo $?
1
$ ./triage.sh /etc
triage.sh: no logs found in /etc
$ echo $?
2Byte-identical output to Chapter 17.7's flat version. What changed is readability, and four decisions are worth naming:
usage and note exist because the strings repeated. ${0##*/} appeared four times in the flat version; now it appears twice, inside two one-line functions, and every message is automatically consistent.
have names a condition. if ! have grep; then reads as a sentence where if ! command -v grep > /dev/null 2>&1; then reads as plumbing. The function is doing nothing clever — it is giving a name to a thing the reader should not have to decode.
verdict_for prints its answer rather than setting a variable, which is why the caller writes v=$(verdict_for "$log"). Chapter 18.4 is the full discussion of that choice and what it costs — and the comment above the function says the status is irrelevant precisely because a reader is entitled to wonder.
The elif ladder moved wholesale into a function and got clearer, because at the top level it was interleaved with counters and printing. Separating "decide what this log is" from "keep score" is the entire point of decomposition.
Practice
- Define
log() { printf '%s\n' "$*" >&2; }and call it. Then runtype log, and compare the body Bash prints with what you typed — note what is missing. - Run
f() { echo hi }and thenf() { echo hi; }. Explain the first error in terms of}being a reserved word. - Run
f() { echo "$#"; }thenf("x"). What kind of error is it, and what does that tell you about what a function is? - Define
count() { wc -l; }. Feed it from a pipe, then from a file redirect. Why does the function not need to know which happened? - Define
echo() { printf 'MINE\n'; }. Callecho x, thenbuiltin echo x, thencommand echo x. Write down the lookup order you just demonstrated. - Run
type logon your machine before defining anything. If it names a real program, say what would happen to a script that intended to definelog()but whose library failed to load. - Run
bash -c 'f; f() { echo hi; }'. Explain the error, then state the rule it implies for script layout. - Define
f() ( cd /tmp; x=1; )andg() { cd /tmp; x=1; }. Call each and print$PWDand$x. Then write a one-line rule for choosing between them. - Use
declare -Fto write a guard that fails with a clear message when a required function is missing. Why is that better than testing for the library file? - Take
triage.shand add asummaryfunction that prints the final two lines. Does the script's exit status still work? Explain using §6.
Check Your Understanding
- Why is
f("one")a syntax error rather than a wrong-arguments error? - Why must there be a semicolon or newline before the closing
}? - You define
cd(). What happens to the builtin, and how do you still get at it? - What is the difference between
command nameandbuiltin name? - Why can't you call a function defined further down the script?
- What does a function return if it has no
returnstatement? - When would you give a function a
( )body instead of{ }?
9. Remember
- A function is a command you named. No parentheses at the call site;
f("x")is a syntax error. name() { ...; }is the form to write. Thefunctionkeyword buys nothing and is not POSIX.- A semicolon or newline before
}is required —}is a reserved word. - The parentheses in the definition are empty and stay empty. They are a marker, not a parameter list.
- A function beats builtins and
PATH.command namebypasses it;builtin namereaches the builtin;unset -fremoves it. logis a real program on macOS. A helper whose name collides with a real command fails confusingly when its definition is missing.- Definitions must precede use. Put them all above the top-level code.
- Redefinition is silent — the last one wins.
declare -Flists names,declare -fprints bodies,typesays what a name is.- A function has an exit status, not a return value, so it works in
if,whileand||unchanged — and naming a condition is the biggest readability win on offer. f() ( ... )runs the body in a subshell. Default to{ }; use( )when isolation is the point.
Chapter 18.2 takes the thing those empty parentheses are not: arguments. A function gets its own $1, its own $# and its own $@ — but, measurably, not its own $0, which is why a usage message written inside a function can name the wrong thing.
Where this fits
Part of the Linux & Bash curriculum.
