An important building block of any programming language, including bash, is to have the ability to use functions to group a set of commands and reduce code repetition. This post covers the use of bash functions in shell scripting which includes how to define, call, and debug functions.
What is a bash function?
Like in most programming languages, a function is a way to group commands for later execution to reduce code repetition. Functions are sometimes called routine, subroutine, method, procedure, etc. In a POSIX shell the commands in a function are executed in the current shell context. Bash provides a bit more flexibility where any compound command can be used for a function definition.
The syntax of a POSIX shell function is fn_name () compound-command [ redirections ]
, and an alternative syntax in bash is function fn_name [()] compound-command [ redirections ]
.
A bash compound command is any of the
bash if statement and other conditional constructs,
bash loops constructs, or more traditionally a grouping command using parentheses ()
, which creates a subshell for the function, or braces {}
, which use the current shell context.
👉 The recommended notation is to use the first one with
fn_name ()
as it is often more portable. Also, the second notation will require the use of braces{}
when the function is defined with thefunction
keyword and does not use parentheses()
after the function name.
The exit status of a function definition is zero (success) unless another read-only function with a similar name already exists or a syntax error occurs.
How to define and use functions in Bash?
You can define functions in your .bashrc
file with your other
bash alias. In a shell script, the function can be defined anywhere before being called for execution and you can source your function definitions by using the
source or dot command. A function is executed when it’s called by its name, it is equivalent to calling any other shell command.
👉 Don’t forget to document your functions with bash comments to ensure the maintainability of your code.
# Basic bash function definition and execution from a shell terminal
[me@linux ~]$ fn() { echo "Hello World!"; }
[me@linux ~]$ fn
Hello World!
⚠️ When using the curly braces
{}
notation, make sure to separate the content of the function and the braces with blanks or newlines, otherwise, asyntax error near unexpected token
will be raised. This is due to historical reasons as the braces are reserved words and can only be recognized as such when they are separated from the command list by whitespace or another shell metacharacter. Also when using the braces notation, you must terminate the last command by a semi-colon;
, an ampersand&
, or a newline.
[me@linux ~]$ type {
{ is a shell keyword
# INCORRECT - Missing space around braces
[me@linux ~]$ fn() {echo "Hello World!"}
-bash: syntax error near unexpected token `{echo\'
The examples below show how to define a function with the two shell grouping commands, parentheses ()
and braces {}
. The last example shows how the global variable used in the function is unchanged after being executed in a subshell via the parentheses ()
command grouping notation.
# Example of using function with braces (most common use)
[me@linux ~]$ TEST_VAR=1; fn() {
echo "TEST_VAR in fn() is $TEST_VAR"
TEST_VAR=0
}
[me@linux ~]$ fn; echo "TEST_VAR after fn() is $TEST_VAR"
TEST_VAR in fn() is 1
TEST_VAR after fn() is 0
# Example of using function with parentheses (less common)
[me@linux ~]$ TEST_VAR=1; fn() (
echo "TEST_VAR in fn() is $TEST_VAR"
TEST_VAR=0
)
[me@linux ~]$ fn; echo "TEST_VAR after fn() is $TEST_VAR"
TEST_VAR in fn() is 1
TEST_VAR after fn() is 1
Since the function definition call for any compound command, you can directly create function that use a loop or conditional construct without using the grouping commands.
[me@linux ~]$ counter() for ((i=0; i<5; i++)); do echo "\$i=$i"; done
[me@linux ~]$ counter
$i=0
$i=1
$i=2
$i=3
$i=4
💡 When a shell function is executed, you can access the function name inside the function with the
FUNCNAME
variable. It is often used when debugging a script in conjunction with the bash environment variables$BASH_LINENO
and$BASH_SOURCE
.
If you define a function with a name similar to an existing builtin or command, you will need to use the builtin
or command
keyword to call the original command within the function. The example below shows an echo
function that ensures the use of the builtin echo
command and prefixes the output with a
linux date command.
The command
builtin will look first for a shell builtin, then for an on-disk command found in the $PATH environment variable. You can use the enable
builtin to disable a builtin using the -n
option with the syntax enable -n <command>
. This will force the command
builtin to look for the on-disk command only. You can re-enable the builtin by using the syntax enable <command>
.
[me@linux ~]$ echo "my test msg"
my test msg
[me@linux ~]$ echo() { builtin echo -e "$(date) |> $@"; }
[me@linux ~]$ echo "my test msg"
Sun Jul 12 13:43:31 PDT 2020 |> my test msg
Function Variables
There is two variables scope in bash, the global and the local scopes. Bash variables are by default global and accessible anywhere in your shell script. Though, in a function, you can limit the scope of a variable by using the local
builtin which support all the option from the declare
builtin. The syntax for the local
keyword is local [option] name[=value]
. The local builtin makes a variable name visible only to the function and its children.
[me@linux ~]$ v="global"
[me@linux ~]$ fn() { echo $v; }
[me@linux ~]$ fn
global
[me@linux ~]$ fn() { local v="local"; echo $v; }
[me@linux ~]$ fn
local
The shell also uses dynamic scoping within functions. It refers to the way the shell controls the variables’ visibility within functions. How a function sees a variable depends on its definition within the function or the caller/parent. The benefit of dynamic scoping is often to reduce the risk of variable conflicts in the global scope. A shadow variable is one that is defined locally in a function with the same name as a global variable. The local variable shadows the global one.
[me@linux ~]$ v="global"
[me@linux ~]$ fn1() { local v="fn1 local"; fn2; }
[me@linux ~]$ fn2() { echo "fn2 v=$v"; }
[me@linux ~]$ echo "v=$v"; fn1
v=global
fn2 v=fn1 local
Function Arguments
Similar to a shell script, bash functions can take arguments. The arguments are accessible inside a function by using the shell positional parameters notation like $1
, $2
, $#
, $@
, and so on. When a function is executed, the shell script positional parameters are temporarily replaced inside a function for the function’s arguments and the special parameter #
is updated to expand to the number of positional arguments for the function.
The special parameters *
and @
hold all the arguments passed to the function. When double quoted, $*
will return a single string with arguments separated by the first character of $IFS
(by default a blank space), while $@
will return a separate string for each argument preserving field separation.
#!/usr/bin/bash
# example.sh
fn() { echo "My function first argument is ${1}"; }
echo "My script first argument is ${1}"
fn ${2}
# Example output
[me@linux ~]$ ./example.sh A B
My script first argument is A
My function first argument is B
By using parameter expansions, you can easily extend the previous counter()
function example to support for an optional argument with a default value of zero.
[me@linux ~]$ counter() for ((i=0; i<${1:-0}; i++)); do echo "\$i=$i"; done
[me@linux ~]$ counter
[me@linux ~]$ counter 1
$i=0
[me@linux ~]$ counter 5
$i=0
$i=1
$i=2
$i=3
$i=4
A counter loop as shown above is not necessary helpful, but it shows some of the exciting possibility, for example to create simple one-liner bash calculator.
Function Return
A bash function can return a value via its exit
status after execution. By default, a function returns the exit code from the last executed command inside the function. It will stop the function execution once it is called. You can use the return
builtin command to return an arbitrary number instead. Syntax: return [n]
where n
is a number. If n
is not supplied, then it will return the exit code of the last command run.
Though, the possible value range for the return
builtin is limited to the least significant 8 bits which are 0 to 255. Any number above 255 will just return a zero value. If a non-number is used, an error bash: return: string: numeric argument required
will occur and the return
builtin will return a non-zero exit code. Because of those limitations, the return
builtin should be limited to returning error code related to the function execution and not to return actual data.
[me@linux ~]$ fn() { return $1; }
[me@linux ~]$ fn; echo $?
0
[me@linux ~]$ fn 3; echo $?
3
[me@linux ~]$ fn 1024 ; echo $?
0
[me@linux ~]$ fn "string" ; echo $?
bash: return: string: numeric argument required
2
To return actual data, including returning strings or large numbers, you can either use the standard output or a global variable.
# Example using the standard output
[me@linux ~]$ fn() { echo $1; }
[me@linux ~]$ fn 2; A=$(fn 3); echo "A=$A"
2
A=3
# Example using a global variable
[me@linux ~]$ fn() { A=$1; }
[me@linux ~]$ fn 2; echo "A=$A"
A=2
How to delete a function?
Bash functions can be deleted using the unset
builtin with the syntax unset <function_name>
. The unset
builtin also follow the variable dynamic scoping rules.
[me@linux ~]$ A="global"
[me@linux ~]$ fn() {
local A="local"
echo "before unset A=$A"
unset A
echo "after unset A=$A"
}
[me@linux ~]$ fn; echo "global A=$A"
before unset A=local
after unset A=
global A=global
[me@linux ~]$ unset fn; fn
bash: fn: command not found
Nested Functions
A function can be recursive, which means that it can call itself. There is no limit placed on the number of recursive calls. Though, you can use the FUNCNEST
variable to limit the depth of the function call stack and restrict the number of function invocations. If you reach the FUNCNEST
limit, bash will throw the error maximum function nesting level exceeded
.
[me@linux ~]$ FUNCNEST=10
[me@linux ~]$ f() { echo "Invocation #${1}"; f $((${1} + 1)); }; f 0
Invocation #0
Invocation #1
Invocation #2
Invocation #3
Invocation #4
Invocation #5
Invocation #6
Invocation #7
Invocation #8
Invocation #9
bash: f: maximum function nesting level exceeded (10)
How to debug a bash function?
It can be difficult to
debug a shell script that depends on common libraries sourced into your script or when loaded from your .bashrc
in interactive mode. Functions with a conflicting name can quickly become an issue and override each other. Though, there is some way to trace, debug, and troubleshoot how your functions are defined and where to find them.
Get an existing function definition
You can use the declare
builtin with the -f
and -F
options to know whether a function already exists or get its current definition. Syntax: declare [-f|-F] <function_name>
. You can also use the
bash type command with the -t
option.
[me@linux ~]$ fn() { echo "fn context"; }
[me@linux ~]$ declare -f fn
fn ()
{
echo "fn context"
}
[me@linux ~]$ declare -F fn &>/dev/null && echo 'fn() already defined'
fn() already defined
[me@linux ~]$ [[ $(type -t fn) == function ]] && echo 'fn() already defined'
fn() already defined
Tracing code with traps
There are two differences to the shell execution environment between a function and its caller, both related to how traps are being handled.
First, the DEBUG
and RETURN
traps are not inherited unless the trace attribute is set by using the declare -t <function_name>
command, or the set -o functrace
or shopt -s extdebug
command. The declare
builtin will set the trace attribute for a specific function only when executed within the
source or dot (.) context while the set
or shopt
builtins will set the attribute for all functions being executed.
⚠️ The bash shell option
extdebug
will enable more than just function tracing, see the next section.
⚠️ Be careful when enabling trap on functions’ RETURN as it may change how your script behaves. Especially when you expect a certain string from the function standard output to be passed back to a variable. The trap standard output will take precedence over your normal function standard output.
#!/usr/bin/bash
# example.sh
set -o functrace
trap 'echo "RETURN trap from ${FUNCNAME:-MAIN} context."' RETURN
fn() { false; }
fn
echo "fn exit code is $?"
[me@linux ~]$ ./example.sh
RETURN trap from fn context.
fn exit code is 1
The second difference is with the ERR
trap which is not inherited unless the errtrace
shell option is set with set -o errtrace
. This option is also set when using shopt -s extdebug
.
[me@linux ~]$ trap 'echo "ERR trap from ${FUNCNAME:-MAIN} context."' ERR
[me@linux ~]$ false
ERR trap from MAIN context.
[me@linux ~]$ fn() { false; }; fn ; echo "fn exit code is $?"
ERR trap from MAIN context.
fn exit code is 1
[me@linux ~]$ fn() { false; true; }; fn ; echo "fn exit code is $?"
fn exit code is 0
[me@linux ~]$ set -o errtrace
[me@linux ~]$ fn() { false; true; }; fn ; echo "fn exit code is $?"
ERR trap from fn context.
fn exit code is 0
👉 It’s often conveninent to define your debugging functions and trap in a separate source file and invoke it only when debugging using the bash environment variable
$BASH_ENV
.
Find where a bash function is defined
In many cases, it may be useful to find out where a function has been defined so you can either fix or update it. Though, either in the interactive or non-interactive mode, you can’t easily trace a specific function. In bash, you will need to enable the extdebug
shell option with shopt -s extdebug
. This option enables a large set of debugging features, including the ability to show the source file name and line number corresponding to a given function when using declare -F
.
[me@linux ~]$ shopt -s extdebug
[me@linux ~]$ declare -F wttr
wttr 202 /home/me/.bash_profile