5 minutes
Demystifying Bash Functions: Clear, Effective, and Maintainable Code
I’ve often seen Bash scripts where code sections are placed in functions, but sometimes the intent isn’t entirely clear. Here, I’d like to share my thoughts on building effective and useful Bash functions. Much of this revolves around how functions handle input and output. In “proper” programming languages, functions are designed to handle input (arguments) and output (returned values) in a structured way. While Bash doesn’t make this as straightforward, it is quite capable of achieving similar results.
I also want to explore a “clean code” approach to Bash functions without veering too deeply into complexities. While some efforts bend Bash to act more like a general-purpose programming language, it’s helpful to remember that Bash is a scripting language, and treating it as anything more can quickly become cumbersome.
Purpose of Functions (in My Opinion)
Functions should group common tasks but, more importantly, they should provide abstraction and clarity. Clean code principles encourage keeping functions simple and abstracting complexity. In the words of Uncle Bob, our code “…should read like well-written prose.” If a function gets too long, complexity escalates. I won’t set a strict limit on line count here since Bash requires space to declare parameters, but ideally, let’s aim to keep the logic within the function at around four lines after parameter declarations.
Input: Giving functions what they need
Like a Bash script itself, a function handles arguments passed into it, accessible through its own array of variables. $@, $*, $1, etc., are used within functions just as they are in scripts.
Example:
function my_task() {
local first_argument="$1"
local second_argument="$2"
}
my_task foo bar
Simple enough, right? This makes functions very versatile and means arguments should be used whenever possible.
Output: Retrieving Data from Functions
Bash functions don’t have a dedicated way to return data as other languages do. In Python, for example, you can simply use return my_array
. However, in Bash, the return
keyword is for providing an exit status of the function rather than any data. Also, because each Bash function creates a sub-shell, variables and other data created within that function aren’t accessible once it exits. While newer Bash versions support global variables, relying on these can reduce script portability. To retrieve data, we typically use echo
or printf
.
Example:
function my_task() {
local first_argument="$1"
echo "You passed '$first_argument'."
}
output="$(my_task foo)"
echo $output
# Output:
# You passed 'foo'.
However, if we want to log function actions, this method presents a challenge, as any additional echo
or printf
statements will also end up in the output variable.
Example:
function my_task() {
echo "Getting the value of the first argument..." # logging what we're doing
local first_argument="$1"
echo "You passed '$first_argument'."
}
output="$(my_task foo)"
echo $output
# Output:
# Getting first argument... You passed 'foo'.
If we want to keep logs out of the function’s output, we can redirect them to stderr
using I/O redirection.
Example:
function my_task() {
echo "Getting the value of the first argument..." 1>&2
local first_argument="$1"
echo "You passed '$first_argument'."
}
output="$(my_task foo)"
echo $output
# Output:
# Getting first argument...
# You passed 'foo'.
The 1>&2
here redirects echo output from stdout
(1) to stderr
(2), keeping logs separate from function output.
Abstraction: Organizing with Clean Code
With input and output covered, let’s clean up the code a bit. We can create a logging function, making our code more reusable and clearer.
Example:
function logger() {
echo $@ 1>&2
}
function my_task() {
logger "Getting the value of the first argument..."
local first_argument="$1"
echo "You passed '$first_argument'."
}
output="$(my_task foo)"
echo $output
# Output:
# Getting first argument...
# You passed 'foo'.
Separating logging into its own function has multiple benefits:
- Improved readability: We see right away that the line is for logging, not for output.
- Reusable logging: We don’t have to add
1>&2
each time we log. - Expandability: We can easily add more features to our logging without needing to modify each instance.
Here’s a more feature-rich logging function that categorizes logs by level.
Example:
function logger() {
# Log messages with different colors based on logging level
# Usage:
# logger info "This is an informational message."
# logger warn "This is a warning message."
# logger error "This is an error message."
local level="$1"
local message="$2"
local green='\033[0;32m'
local yellow='\033[0;33m'
local red='\033[0;31m'
local no_color='\033[0m'
local datetime="$(date '+%b %d %H:%M:%S')"
case "$level" in
info) printf "${green}${datetime} INFO: %s${no_color}\n" "$message" 1>&2 ;;
warn) printf "${yellow}${datetime} WARN: %s${no_color}\n" "$message" 1>&2 ;;
error) printf "${red}${datetime} ERROR: %s${no_color}\n" "$message" 1>&2 ;;
*) printf "${datetime} Unknown logging level: %s\n" "$level" 1>&2 ;;
esac
}
function my_task1() {
logger info "Getting the value of the first argument..."
local first_argument="$1"
if [ -z $first_argument ]; then
logger error "Error: Argument required."
return 1
fi
echo "You passed '$first_argument'."
}
function my_task2() {
logger info "Getting the value of the first argument..."
local first_argument="$1"
if [ -z $first_argument ]; then
logger error "Error: Argument required."
return 1
fi
echo "You passed '$first_argument'."
}
output1="$(my_task1 foo)"
echo $output1
output2="$(my_task2 bar)"
echo $output2
# Output:
# Oct 04 15:53:34 INFO: Getting the value of the first argument...
# you passed 'foo'.
# Oct 04 15:53:34 INFO: Getting the value of the first argument...
# you passed 'bar'.
Conclusion
While our function examples aren’t doing anything complex, the structure we’ve built here is powerful. By following these techniques, we can keep our code cleaner and easier to maintain. Thanks for reading—I hope you found this helpful!