opscape

Bash scripting guide · part 17 of 32

Functions

Defining and calling bash functions, arguments, local variables, return status versus output, namerefs, and sourcing a function library.

A bash function is a named compound command. You call it like any other command, it gets its own positional parameters, and it finishes with an exit status. That last part is the source of most confusion: a function does not "return a value" the way it does in other languages. It returns a status (0 to 255) and, separately, it can write to standard output, which the caller can capture.

Defining and calling

There are two syntaxes. name() { ...; } is POSIX and works in every shell; function name { ...; } is a bash/ksh form that buys you nothing in bash and breaks under sh. Use the first. The body is usually a { } group, so it needs a space after { and a ; or newline before }.

Define a function and call it

greet() {
  echo "Hello, $1"
}
greet world
greet "Ada Lovelace"
output
Hello, world
Hello, Ada Lovelace

The function is defined once and then called twice. Inside it, $1 is the function's first argument, not the script's, so the quoted "Ada Lovelace" arrives as one argument.

A function must be defined before the line that calls it runs. Bash reads a script top to bottom, so the usual layout is: functions first, then a main "$@" call at the very end. That also means the whole file has been parsed before anything executes, which matters if someone edits the script while it is running.

Arguments

Inside a function, $1, $2, $#, "$@" and shift all refer to the function's own arguments. The caller's are hidden until the function returns. $0 is the exception: it stays the name of the script (or of the shell). The function's own name is in ${FUNCNAME[0]}.

A function's own positional parameters

args.sh
#!/usr/bin/env bash
show() {
  echo "function ${FUNCNAME[0]} got $# args: $*"
  for a in "$@"; do printf '  [%s]\n' "$a"; done
}
echo "script got $# args"
show "one two" three
echo "after the call, \$1 is still: $1"
bash args.sh alpha beta
explain
output
script got 2 args
function show got 2 args: one two three
  [one two]
  [three]
after the call, $1 is still: alpha

The script was called with alpha beta, the function with "one two" three. Each sees only its own arguments, and "$@" keeps one two together as a single word. Back in the script, $1 is alpha again.

Local variables

Variables in bash are global by default, including ones first assigned inside a function. local makes a variable belong to the current function call. Use it for every variable a function assigns unless you mean to change global state.

Without local, assignments leak out of the function

count=10
bump() {
  local count=99
  total=5
}
bump
echo "count=$count total=$total"
output
count=10 total=5

count was declared local, so the global count is untouched and still 10. total was not, so the function created a global that is still there after it returns.

Bash scoping is dynamic, not lexical: a local variable is visible to every function called from the function that declared it, for as long as that call is running. It is a common surprise for people coming from other languages, and occasionally useful.

Locals are visible to the functions you call

inner() { echo "inner sees level=$level"; }
outer() { local level=outer; inner; }
level=global
outer
inner
output
inner sees level=outer
inner sees level=global

When outer calls inner, the lookup of level finds outer's local first. Called directly, inner sees the global. The value depends on who called, not where inner was written.

Return status versus output

return N sets the function's exit status, the same number $? and if look at. It is meant for success or failure, not for data. The status is taken modulo 256, so a function cannot return 300 or -1 meaningfully.

return sets the status, which wraps at 256

is_even() { return $(( $1 % 2 )); }
if is_even 4; then echo "4 is even"; fi
is_even 7; echo "is_even 7 -> $?"
big() { return 300; }
big; echo "return 300 -> $?"
output
4 is even
is_even 7 -> 1
return 300 -> 44

is_even returns 0 (success) for even numbers, so it reads naturally in an if. return 300 comes back as 44, because only the low 8 bits survive (300 - 256 = 44).

To get data out, print it and let the caller capture it with $(...). This is the idiomatic "return value". The catch is that $(...) runs the function in a subshell, so any global it sets is lost (see Subshells and command grouping). If a function must both produce a value and change state, a nameref avoids the subshell.

Return data on stdout, and what a subshell loses

calls=0
to_upper() {
  calls=$((calls + 1))
  printf '%s\n' "${1^^}"
}
name=$(to_upper "ada")
echo "name=$name calls=$calls"
to_upper "grace" >/dev/null
echo "calls=$calls"
output
name=ADA calls=0
calls=1

The captured call returned ADA, but its calls increment happened in a subshell and was thrown away, so calls is still 0. The direct call ran in the current shell and the counter reached 1.

Write into a caller's variable with a nameref

split_host() {
  local -n _host=$2 _port=$3
  _host=${1%:*}
  _port=${1##*:}
}
split_host db01:5432 h p
echo "host=$h port=$p"
output
host=db01 port=5432

local -n (bash 4.3+) makes _host an alias for whatever variable name was passed in, here h and p. No subshell is involved. Give nameref variables unusual names: if the caller passed a variable that is itself called _host, the reference would point at itself.

Inspecting and removing functions

declare -F lists function names, declare -f name prints a function's definition as bash reformatted it, and type name tells you whether a word is a function, builtin, alias or file. unset -f name removes one. Functions can also shadow commands: a function named ls wins over /usr/bin/ls, and command ls bypasses it.

See how bash stored a function

ls() { command ls -1 "$@"; }
type ls
ls data | head -n 3
output
ls is a function
ls () 
{ 
    command ls -1 "$@"
}
config.json
fruits.txt
hosts.txt

type says ls is now a function and prints the body as bash stored it, with the braces on their own lines and its own indentation. Inside, command ls calls the real binary; without command the function would call itself forever.

Libraries with source

Shared helpers go in a file that defines functions and does nothing else, which scripts load with source file (or the POSIX spelling . file). Sourcing runs the file in the current shell, so its functions and variables become part of the caller. The fixture's src/util.sh is such a file: a log that writes to stderr with a timestamp, and a die that logs and exits.

Load helper functions from another file

source src/util.sh
log "starting"
die "config not found"
echo "never printed"
outputexit 1
20:22:20 starting
20:22:20 error: config not found

log prints the time and message on stderr. die calls log and then exit 1, and because a sourced function runs in the current shell, that exit ends the whole script: the last echo never runs. Use return in library code if the caller should decide.

Recursion and the call stack

Functions can call themselves. Each call gets fresh locals, and FUNCNAME, BASH_LINENO and BASH_SOURCE hold the current call stack, which is what the caller builtin reads. Bash has no tail-call optimization and recursion is slow; it is fine for walking a small tree, not for number crunching.

A recursive function and its stack

fact() {
  local n=$1
  if (( n <= 1 )); then
    echo "depth ${#FUNCNAME[@]}: ${FUNCNAME[*]}" >&2
    echo 1
    return
  fi
  echo $(( n * $(fact $((n - 1))) ))
}
fact 5
output
depth 5: fact fact fact fact fact
120

fact 5 prints 120. The deepest call reports the stack on stderr: five nested fact calls. FUNCNAME has no main entry here because the code runs from bash -c, not from a script file.

Style that holds up

  • Declare every variable a function assigns with local, and assign the result of a command substitution on a separate line (local out; out=$(cmd)) so the status is not lost (see Strict mode and its caveats).
  • Return status for success or failure, print data on stdout, send messages to stderr.
  • Quote "$@" when passing arguments through. $* and unquoted $@ re-split them.
  • Put the script body in main() { ...; } and finish with main "$@", so every helper is defined before anything runs.

Related