Functions · about 40 min

Defining and calling functions

Why this matters

As scripts grow past a screenful, the same few lines start appearing in several places: print an error and exit, check that a file exists, log a timestamped message. Copying them means fixing every copy when something changes. A function gives a block of code a name, so you write it once and call it like a command. Functions also make scripts readable: a main section that says check_args; backup_database; rotate_old_backups documents itself.

Defining a function

greet() {
    echo "Hello, $1!"
}

That is the whole syntax: a name, (), and a body in braces. The parentheses are always empty; they are not where arguments go. There is an alternative form, function greet { ... }, which is bash-only; the name() { } form works in every POSIX shell, so prefer it.

Defining a function runs nothing. bash stores the body under that name, and from then on the name works like a command:

$ greet() { echo "Hello, $1!"; }
$ greet World
Hello, World!
$ type greet
greet is a function
greet ()
{
    echo "Hello, $1!"
}

On one line, the body needs a ; (or newline) before the closing }, and a space after {: braces are reserved words, just like then and fi.

Remember the lookup order from module 1: aliases, then functions, then builtins, then PATH. A function can therefore shadow a real command, which is occasionally useful and often confusing. (Inside such a function, command ls calls the real ls and skips the function.)

Functions must be defined before they are called

bash executes a script top to bottom. A call runs whatever is stored under that name at that moment:

#!/bin/bash
hello            # error: hello: command not found
hello() { echo hi; }
hello            # works

So scripts put their functions near the top and the code that uses them at the bottom (lesson 3 shows the common main layout).

Arguments: $1, $2, "$@" inside the function

A function receives arguments exactly like a script does, and inside the function $1, $2, $#, "$@" and $* refer to the function's own arguments:

#!/bin/bash
show_args() {
    echo "function got $# args: $*"
    for a in "$@"; do echo "  [$a]"; done
}
show_args one "two words" three
echo "script got $# args: $*"
$ bash args.sh x y
function got 3 args: one two words three
  [one]
  [two words]
  [three]
script got 2 args: x y

bash saves the caller's positional parameters at the call and restores them on return. $0 stays the script's name; the function's name is in the variable FUNCNAME.

Exit status and return

A function call is a command, so it has an exit status: by default, the status of the last command run in the body. return N ends the function early and sets the status to N (0-255); plain return keeps the status of the last command.

has_user() {
    grep -q "^$1:" /etc/passwd
}

if has_user learner; then echo "learner exists"; fi
has_user nobody123 || echo "no such user"

Functions whose status means yes/no make conditions read like English. Name them like questions: is_number, has_user, file_is_empty.

return is not how you return data. return "text" is an error (return: text: numeric argument required). To hand back a value, print it and let the caller capture it with $(...); that is the subject of the next lesson.

exit vs return

That makes exit exactly right for a "fatal error" helper, the most common function in real scripts:

die() {
    echo "$(basename "$0"): error: $*" >&2
    exit 1
}

[[ -r $config ]] || die "cannot read config file $config"

>&2 sends the message to standard error, so it is not mixed into the script's real output and is still visible when stdout is redirected.

What happens when you call a function

A function call does not fork. bash runs the body in the current shell process, in the current directory, with the same variables. That is why a function can cd and the change sticks, and why a function can change global variables (more on scope next lesson). The only things bash saves and restores around the call are the positional parameters and a few internal details like FUNCNAME:

 script: $1=x $2=y                 call show_args one two
   ├── push: save $1=x $2=y        (a "call frame" on bash's internal stack)
   │     function body runs with $1=one $2=two, same process, same variables
   └── pop:  restore $1=x $2=y     status of the body becomes $?

Compare that with running a separate script (bash other.sh), which forks a new process with its own variables and directory: nothing it does to its variables can affect the caller.

Managing functions interactively

$ declare -F            # list the names of defined functions
declare -f greet
$ declare -f greet      # print a definition
$ unset -f greet        # remove it

Interactive shells usually have many functions defined by bash completion; declare -F | wc -l in the sandbox shows how many.

Common mistakes

Exercises

die builds the two helpers almost every script needs; a test checks that errors go to stderr. is-even is a predicate function used directly as a condition, checked against invalid input, an empty argument and the exit status of the whole script.

Recap

Going further: help return, help declare. Run declare -f | head -40 in the sandbox shell to read some of the completion functions bash has loaded; real-world function style in the wild.

Exercises

Exercise 1 · quiz

Whose $1?

A script is run as bash greet.sh World. What does it print?

greet() {
    echo "Hello, $1"
}
greet Alice
echo "Script got: $1"
Answer
  • Hello, Alice / Script got: World

While a function runs, $1, $2, ..., $# and "$@" refer to the function's arguments. bash saves the script's positional parameters when the call starts and restores them when it returns, so after the call $1 is World again. If a function needs the script's arguments, pass them explicitly: main "$@".

Exercise 2 · quiz

return is not output

Which statements about return in a bash function are true? Select all that apply.

Answer
  • return 3 sets the function's exit status to 3, which callers see in $?.

  • Without a return, a function's status is that of the last command it ran.

  • To give a caller a piece of text, the function prints it and the caller uses $(f).

A function behaves like a small command: it has an exit status (0-255) and it can write output. return N only sets the status; return hello fails with numeric argument required. Data is returned by printing it and capturing the output with command substitution, as you will practice in the next lesson.

Exercise 3 · write code

Helpers: log and die

Write two functions and use them:

The script itself takes a file name as its argument. It must:

  1. call die no file given if there is no argument,
  2. call die "not found: FILE" if the file does not exist,
  3. otherwise call log "FILE has N lines" where N is the number of lines (use wc -l < "$1").

Only standard output is compared, so for the error cases stdout must be empty and the exit status 1. A test also checks that the error message really goes to stderr.

File: script.sh

Starting point

#!/bin/bash
log() {
    :
}
die() {
    :
}

Example with arguments data.txt

expected output

[info] data.txt has 3 lines

Example with arguments missing.txt

expected output

(nothing)
Hint 1

$* inside double quotes joins all the function's arguments with spaces: echo "[info] $*".

Hint 2

In die: echo "[error] $*" >&2 then exit 1. exit in a function ends the whole script; return would only end the function.

Reference solution
#!/bin/bash
log() {
    echo "[info] $*"
}
die() {
    echo "[error] $*" >&2
    exit 1
}
[[ $# -ge 1 ]] || die no file given
[[ -f $1 ]] || die "not found: $1"
log "$1 has $(wc -l < "$1") lines"

Exercise 4 · write code

A function as a condition

Write a function is_valid_port that takes one argument and returns status 0 if it is a whole number from 1 to 65535 and status 1 otherwise. It must print nothing.

Then the script checks every one of its own arguments with it, printing PORT ok or PORT invalid for each, and exits with status 0 if all were ok, or 1 if any was invalid.

Use the function directly as an if condition: if is_valid_port "$p"; then.

File: script.sh

Starting point

#!/bin/bash
is_valid_port() {
    :
}

Example with arguments 80 443 65535

expected output

80 ok
443 ok
65535 ok

Example with arguments 22 0 70000 http 8080

expected output

22 ok
0 invalid
70000 invalid
http invalid
8080 ok
Hint 1

The function's status is the status of its last command, so it can end with a test: (( $1 >= 1 && $1 <= 65535 )).

Hint 2

Reject non-numbers first, or arithmetic on them misbehaves: [[ -n $1 && $1 != *[!0-9]* ]] || return 1.

Hint 3

In the main loop, set status=1 when you see an invalid port, and exit $status at the end.

Reference solution
#!/bin/bash
is_valid_port() {
    [[ -n $1 && $1 != *[!0-9]* ]] || return 1
    (( 10#$1 >= 1 && 10#$1 <= 65535 ))
}
status=0
for p in "$@"; do
    if is_valid_port "$p"; then
        echo "$p ok"
    else
        echo "$p invalid"
        status=1
    fi
done
exit $status