Skip to content
academia.sh

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. $0 does not change inside a function.
  • return sets the exit code and only exits the function; exit terminates 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 local declaration binds the name to the function, and should be written for every temporary variable.
  • The shell’s scope is dynamic: a local name 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.

Start typing to search.

↑↓ Esc navigate · open · close