Lesson 11 / 24
Functions and Scope
Function definition, argument passing, the distinction between returning an exit code and returning a value, local variables, and dynamic scope.
Contents
As the script grows, the same work repeats in more than one place: reporting errors, printing a section header, counting and sorting a field. In the Programming Fundamentals course, the counterpart of this was the function, and a function was a construct that took named parameters and returned a value.
A function in the shell is a different thing. It is a named command: it is called with positional parameters, it returns an exit code, and it writes its result — if it produces a value — to its standard output. That is, a shell function is a program’s counterpart inside the shell.
Definition and Invocation
section() { printf '\n== %s ==\n' "$*"; }
section "Most requested paths"
== Most requested paths ==
The body sits between braces. Because braces are recognized as words,
spaces around them are mandatory, and a ; or newline is required after
the last command. Bash also recognizes the function name { ... } form;
the form above is the POSIX one.
A function must be defined at the point it is called. The shell reads the file from top to bottom; a call before the definition gives a “command not found” error:
call_first echo "code=$?"
./f.sh: line 14: call_first: command not found code=127
This is why functions are gathered at the start of the script, above the code that does the work.
Arguments
When a function is called, positional parameters are replaced with the
function’s own: $1, $2, $#, "$@" point at the arguments given to
the function. There is one exception — $0 does not change, it keeps
pointing at the script’s name.
who() { echo "0=$0 1=$1 count=$#"; }
who a b
0=./f.sh 1=a count=2
The rule from the second lesson applies to argument passing: "$@" is
used, $* is written only to produce message text.
Shell functions have no signature; a function called with a missing
argument does not error, $1 stays empty. Required arguments must be
tested at the start of the body:
class_name() {
[ "$#" -eq 1 ] || { echo "class_name: expects one argument" >&2; return 2; }
...
}
Returning an Exit Code
return ends the function and sets the exit code. The code is subject to
the same contract as process exit codes: 0 success, nonzero failure, range
0–255.
valid_code() {
case "$1" in
[1-5][0-9][0-9]) return 0 ;;
*) return 1 ;;
esac
}
valid_code 404; echo "404 -> $?"
valid_code abc; echo "abc -> $?"
404 -> 0 abc -> 1
Out-of-range values are reduced modulo 256; a function that writes
return 300 returns 44.
return should not be confused with exit. return only exits the
function; exit terminates the entire script.
#!/usr/bin/env bash f() { echo "inside f"; return 1; } g() { echo "inside g"; exit 4; } f; echo "continuing after f, code=$?" g; echo "THIS LINE IS NOT WRITTEN"
inside f continuing after f, code=1 inside g
echo $?
4
Rule: library-like functions use return and leave the decision to the
caller; only helpers that function as “fatal error” handlers call exit.
Returning a Value
return is not a tool for returning a value; a code in the range 0–255
cannot carry a string or a large number. In the shell, the way to return a
value is to write to standard output, with the caller retrieving it
through command substitution.
class_name() {
case "$1" in
2) echo "successful" ;;
3) echo "redirect" ;;
4) echo "client error" ;;
5) echo "server error" ;;
*) echo "unknown"; return 1 ;;
esac
}
name=$(class_name 4); echo "4 -> $name"
4 -> client error
This contract has a direct consequence: a function that returns a value cannot write anything else to standard output. If it needs to print a progress message or a warning, it writes to standard error. This is the clearest example of why the stream distinction from the topic’s first lesson is needed in practice.
The second consequence is cost: command substitution creates a subshell. In scripts that make many calls inside a loop, this leads to a measurable slowdown. Writing the value to a global variable is the alternative; because it lowers readability, it is used only when needed.
Scope: Global by Default
Variables in the shell are global by default. An assignment made inside a function is also visible outside.
counter=0 increment() { counter=$((counter + 1)); } increment; increment; echo "counter=$counter"
counter=2
This is the reverse of the default in the Programming Fundamentals course; there, an assignment in a function body created a local name. The shell’s behavior is a consequence of the function being designed as a “command” — commands see shell variables too.
The local declaration binds the name to the function:
x="outside" demo() { local x="inside"; echo "inside the function: $x"; } demo; echo "outside the function: $x"
inside the function: inside outside the function: outside
local is not defined in the POSIX shell language; it exists in almost
all common shells, but using it in a script declaring #!/bin/sh removes
the portability guarantee.
Rule: every temporary variable used inside a function is declared
local. An undeclared name silently overwrites the same-named variable
in the calling context; this is the hardest class of bug to find in long
scripts.
Dynamic Scope
A name declared local is visible not only to the function it is defined
in, but to every function that function calls. The shell’s scope model
is not lexical, it is dynamic.
inner() { echo "inner sees: $secret"; }
outer() { local secret="from the outer function"; inner; }
outer
echo "main shell sees: [${secret-undefined}]"
inner sees: from the outer function main shell sees: [undefined]
The inner function saw the name secret even though it never defined
it, because that name was active along the call chain. This visibility
would not occur under the lexical scoping introduced in the Programming
Fundamentals course; there, which definition a name bound to was
determined by looking at where the code was written.
Practical consequence: your functions’ behavior can become dependent on the local variables of a caller they never see. The way to prevent this is for functions to take every value they need as an argument.
local and the Exit Code
local is a command, and it produces its own exit code. Doing the
declaration and the assignment on the same line makes the right-hand
command’s code invisible:
fail() { return 3; }
try1() { local d; d=$(fail); echo "separate assignment -> $?"; }
try2() { local d=$(fail); echo "combined with local -> $?"; }
try1; try2
separate assignment -> 3 combined with local -> 0
The 0 seen in the second function is the local command’s own code.
Under strict mode, this leads to a failed command not stopping the script
— the same trap will be taken up again in the Error Handling lesson. Rule:
declaration and value assignment are written on separate lines.
Functions with a Subshell Body
If the body is written with parentheses instead of braces, the function
runs in a subshell. Side effects such as changing directory, setting
IFS, or turning on a shell option do not leak to the caller:
temp() ( cd /tmp && pwd )
Its cost is the cost of creating a process and the loss of global variable assignments. If isolation is genuinely needed, it is the right tool.
Applying It to the Script
report.sh takes the repeated work into functions:
SCRIPT_NAME="report.sh" error() { printf '%s: error: %s\n' "$SCRIPT_NAME" "$*" >&2; } warn() { printf '%s: warning: %s\n' "$SCRIPT_NAME" "$*" >&2; } die() { error "$1"; exit "${2:-1}"; } section() { printf '\n== %s ==\n' "$*"; } class_name() { case "$1" in 2) echo "successful" ;; 3) echo "redirect" ;; 4) echo "client error" ;; 5) echo "server error" ;; *) echo "unknown"; return 1 ;; esac } field_count() { local field="$1" file="$2" cut -d' ' -f"$field" "$file" | sort | uniq -c | sort -rn }
The checks come down to a single line each, and error codes keep their meaning:
log_file=access.log [[ "$count" =~ ^[1-9][0-9]*$ ]] || die "-n expects a positive integer: $count" 2 [ -f "$log_file" ] || die "not a regular file or does not exist: $log_file" 3 [ -r "$log_file" ] || die "no read permission: $log_file" 3 [ -s "$log_file" ] || warn "log is empty: $log_file"
./report.sh -n 3
== Log: access.log == == Most requested 3 paths == 6 /api/data 4 /missing.html 4 /index.html == Status code classes == 2xx successful 18 3xx redirect 3 4xx client error 6 5xx server error 3
./report.sh missing.log; echo "code=$?"
report.sh: error: not a regular file or does not exist: missing.log code=3
Because the die function calls exit, the script stops there; error
and warn only write and leave the decision to the caller. Separating the
three functions this way will provide a single touch point when strict
mode is added in later lessons.
Summary
- A shell function is a named command: it is called with positional
parameters, returns an exit code, and writes its result to standard
output.
$0does not change inside a function. returnsets the exit code and only exits the function;exitterminates the script.- The way to return a value is to write to standard output; because of this, a function that returns a value must write its warnings to standard error.
- Variables are global by default; the
localdeclaration binds the name to the function, and should be written for every temporary variable. - The shell’s scope is dynamic: a
localname is also visible to called functions. - The form
local d=$(command)hides the command’s exit code; declaration and assignment are kept separate.
Next Step
Every count in the report reads the log from start to end once more. Five counts mean five scans. Bringing this down to a single scan requires keeping intermediate results in memory. The next lesson takes up arrays and associative arrays; from which bash version the latter is available, and what to do when it is not, will be shown there too.
To keep your progress and take notes, Log in
My notes
Log in to take notes.