Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ log() { printf '%s\n' "$*" >&2; }
$ log starting
starting

A 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { echo hi }
bash: syntax error: unexpected end of file

$ f() { echo hi; }
$ f
hi

A newline does the same job, which is why the multi-line form needs no semicolon:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ function log2 { echo hi; }
$ log2
hi

Use 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { echo "args=$#"; }
$ f one two
args=2

No 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ count() { wc -l; }
$ printf 'a\nb\nc\n' | count
       3
$ count < /etc/hosts
       9

count 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

How the shell resolves a name once you have defined a function. It checks your functions first, then its own builtins, then the directories on the search path, and takes the first match. Because functions come first, defining one with the name of an existing command replaces that command everywhere in the script.01A NAME IS ABOUT TO BE RUNthe shell has to decide what it means02YOUR FUNCTIONS ARE CHECKED FIRSTso a function wins over everything else03THEN THE SHELL'S OWN BUILTINSand only then the search path04FIRST MATCH WINS, SILENTLYwhich is why naming matters more than it looks
When the shell is about to run a name, it checks your functions before its own builtins and before any directory on the search path, and it takes the first match without comment. Defining a function therefore replaces whatever else had that name, for the rest of the script — which is a powerful tool and the reason a helper's name deserves a moment's thought.

Chapter 10.5 established that order for aliases and functions; here it is, measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo() { printf 'FN-ECHO\n'; }
$ echo x
FN-ECHO
$ builtin echo "BUILTIN"
BUILTIN
$ command echo "COMMAND"
COMMAND

A function beat the builtin echo. Not just an external program — the shell's own builtin. The same is true of cd:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cd() { echo "MY CD"; }
$ pwd
/home/engineer
$ cd /tmp
MY CD
$ pwd
/home/engineer
$ builtin cd /tmp; pwd
/tmp

cd /tmp printed a message and did not change directory, because the function replaced the builtin completely.

Two escape hatches, and they are different:

  • command name skips functions and aliases, running the builtin or the external program.
  • builtin name runs the shell builtin specifically.
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ls() { echo "MINE"; }
$ ls
MINE
$ command ls /etc/hosts
/etc/hosts
$ unset -f ls
$ ls /etc/hosts
/etc/hosts

unset -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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'later; later() { echo hi; }'
bash: later: command not found

$ bash -c 'later() { echo hi; }; later'
hi

There 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { echo one; }
$ f() { echo two; }
$ f
two

No 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ declare -F
declare -f greet
$ declare -f greet
greet ()
{
    echo "hello $1"
}
$ unset -f greet
$ type greet
bash: type: greet: not found

declare -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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if ! declare -F log > /dev/null; then
  echo "${0##*/}: lib.sh did not load" >&2
  exit 4
fi

That 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { return 3; }; f; echo "status=$?"
status=3
$ f() { true;  }; f; echo "status=$?"
status=0
$ f() { false; }; f; echo "status=$?"
status=1

With 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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=inner

The ( ) 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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 ]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 $?
2

Byte-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

  1. Define log() { printf '%s\n' "$*" >&2; } and call it. Then run type log, and compare the body Bash prints with what you typed — note what is missing.
  2. Run f() { echo hi } and then f() { echo hi; }. Explain the first error in terms of } being a reserved word.
  3. Run f() { echo "$#"; } then f("x"). What kind of error is it, and what does that tell you about what a function is?
  4. Define count() { wc -l; }. Feed it from a pipe, then from a file redirect. Why does the function not need to know which happened?
  5. Define echo() { printf 'MINE\n'; }. Call echo x, then builtin echo x, then command echo x. Write down the lookup order you just demonstrated.
  6. Run type log on your machine before defining anything. If it names a real program, say what would happen to a script that intended to define log() but whose library failed to load.
  7. Run bash -c 'f; f() { echo hi; }'. Explain the error, then state the rule it implies for script layout.
  8. Define f() ( cd /tmp; x=1; ) and g() { cd /tmp; x=1; }. Call each and print $PWD and $x. Then write a one-line rule for choosing between them.
  9. Use declare -F to 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?
  10. Take triage.sh and add a summary function that prints the final two lines. Does the script's exit status still work? Explain using §6.

Check Your Understanding

  1. Why is f("one") a syntax error rather than a wrong-arguments error?
  2. Why must there be a semicolon or newline before the closing }?
  3. You define cd(). What happens to the builtin, and how do you still get at it?
  4. What is the difference between command name and builtin name?
  5. Why can't you call a function defined further down the script?
  6. What does a function return if it has no return statement?
  7. 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. The function keyword 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 name bypasses it; builtin name reaches the builtin; unset -f removes it.
  • log is 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 -F lists names, declare -f prints bodies, type says what a name is.
  • A function has an exit status, not a return value, so it works in if, while and || 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.