Lesson 5: Functions & Modularity

Goal: Write reusable, organized code with functions, return values, and script sourcing.


Table of Contents

  1. Defining Functions
  2. Arguments in Functions
  3. Return Values
  4. Local Variables
  5. Sourcing Other Scripts
  6. Common Function Patterns
  7. Exercises

Defining Functions

Two syntaxes (both work)

# Style 1 — recommended
greet() {
    echo "Hello, World!"
}

# Style 2 — also valid
function greet {
    echo "Hello, World!"
}

# Call the function
greet

Functions must be defined before use

#!/bin/bash

# WRONG — function not yet defined
greet   # Error: command not found

greet() {
    echo "Hello"
}
#!/bin/bash

# CORRECT — define first, call later
greet() {
    echo "Hello"
}

greet   # Works!

Arguments in Functions

Functions receive arguments the same way scripts do: $1, $2, $@, $#.

greet() {
    local name="$1"
    local greeting="${2:-Hello}"
    echo "$greeting, $name!"
}

greet "Benjamin"           # Hello, Benjamin!
greet "Benjamin" "Hi"      # Hi, Benjamin!

Forward all arguments

wrapper() {
    echo "Running with args: $@"
    some_command "$@"
}

wrapper --flag value --other

Check argument count

create_user() {
    if [[ $# -lt 2 ]]; then
        echo "Usage: create_user <username> <email>" >&2
        return 1
    fi

    local username="$1"
    local email="$2"
    echo "Creating user: $username ($email)"
}

create_user "alice" "alice@example.com"
create_user "bob"   # Error: Usage message

Return Values

Exit codes (0 = success, 1-255 = error)

is_even() {
    local num="$1"
    if (( num % 2 == 0 )); then
        return 0   # success = true
    else
        return 1   # failure = false
    fi
}

if is_even 42; then
    echo "42 is even"
fi

if ! is_even 7; then
    echo "7 is odd"
fi

Capture output (for returning data)

get_hostname() {
    hostname -f
}

# Capture output into a variable
MY_HOST=$(get_hostname)
echo "Host: $MY_HOST"

Return complex data

get_system_info() {
    echo "$(hostname)|$(uname -r)|$(uptime -p)"
}

INFO=$(get_system_info)
IFS='|' read -r HOST KERNEL UPTIME <<< "$INFO"
echo "Host: $HOST"
echo "Kernel: $KERNEL"
echo "Uptime: $UPTIME"

Local Variables

Without local — variables leak

set_name() {
    NAME="Alice"    # GLOBAL — modifies outer scope!
}

NAME="Benjamin"
set_name
echo "$NAME"   # Alice — outer variable was changed!

With local — variables are scoped

set_name() {
    local NAME="Alice"    # LOCAL — only inside this function
    echo "Inside: $NAME"
}

NAME="Benjamin"
set_name                  # Inside: Alice
echo "Outside: $NAME"    # Outside: Benjamin — unchanged!

Rule: Always use local for variables inside functions.

process_file() {
    local file="$1"
    local line_count
    local size

    line_count=$(wc -l < "$file")
    size=$(du -h "$file" | cut -f1)

    echo "$file: $line_count lines, $size"
}

Sourcing Other Scripts

Split large scripts into reusable modules.

Create a library file

# lib/utils.sh
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}

error() {
    echo "[ERROR] $*" >&2
}

die() {
    error "$@"
    exit 1
}

require_command() {
    command -v "$1" &>/dev/null || die "$1 is required but not installed"
}

Source it in your script

#!/bin/bash
set -euo pipefail

# Source the library (relative to script location)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/utils.sh"

# Now use the functions
log "Starting deployment"
require_command docker
require_command git

log "All checks passed"

Guard against double-sourcing

# lib/utils.sh
[[ -n "${_UTILS_LOADED:-}" ]] && return
_UTILS_LOADED=1

log() { echo "[$(date '+%H:%M:%S')] $*"; }
# ... rest of functions

Common Function Patterns

Logging functions

readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly NC='\033[0m'

log_info()  { echo -e "${GREEN}[INFO]${NC}  $*"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC}  $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }

log_info "Starting process"
log_warn "Disk space low"
log_error "Connection failed"

Cleanup with trap

cleanup() {
    local exit_code=$?
    echo "Cleaning up temporary files..."
    rm -rf "$TMP_DIR"
    exit "$exit_code"
}

TMP_DIR=$(mktemp -d)
trap cleanup EXIT

# Work with TMP_DIR... cleanup runs automatically on exit
echo "Working in $TMP_DIR"

Confirmation prompt

confirm() {
    local message="${1:-Are you sure?}"
    read -rp "$message [y/N] " response
    [[ "$response" =~ ^[Yy]$ ]]
}

if confirm "Delete all logs?"; then
    rm -f /var/log/myapp/*.log
    echo "Logs deleted"
else
    echo "Cancelled"
fi

Retry function

retry() {
    local max_attempts="${1:-3}"
    local delay="${2:-2}"
    shift 2
    local cmd=("$@")

    for ((attempt=1; attempt<=max_attempts; attempt++)); do
        if "${cmd[@]}"; then
            return 0
        fi
        echo "Attempt $attempt/$max_attempts failed. Retrying in ${delay}s..."
        sleep "$delay"
    done

    echo "All $max_attempts attempts failed" >&2
    return 1
}

# Usage
retry 5 3 curl -sf https://api.example.com/health

Exercises

Exercise 1: Calculator

cat > calc.sh << 'EOF'
#!/bin/bash
set -euo pipefail

add()      { echo $(($1 + $2)); }
subtract() { echo $(($1 - $2)); }
multiply() { echo $(($1 * $2)); }
divide()   {
    if [[ "$2" -eq 0 ]]; then
        echo "Error: division by zero" >&2
        return 1
    fi
    echo $(($1 / $2))
}

if [[ $# -ne 3 ]]; then
    echo "Usage: $0 <num1> <operator> <num2>"
    echo "Operators: add, sub, mul, div"
    exit 1
fi

case "$2" in
    add) result=$(add "$1" "$3") ;;
    sub) result=$(subtract "$1" "$3") ;;
    mul) result=$(multiply "$1" "$3") ;;
    div) result=$(divide "$1" "$3") ;;
    *)   echo "Unknown operator: $2" >&2; exit 1 ;;
esac

echo "$1 $2 $3 = $result"
EOF

chmod +x calc.sh
./calc.sh 10 add 5
./calc.sh 20 mul 3
./calc.sh 100 div 0
rm calc.sh

Exercise 2: Library pattern

# Create library
mkdir -p lib
cat > lib/colors.sh << 'EOF'
[[ -n "${_COLORS_LOADED:-}" ]] && return
_COLORS_LOADED=1

info()    { echo -e "\033[32m[INFO]\033[0m  $*"; }
warn()    { echo -e "\033[33m[WARN]\033[0m  $*"; }
error()   { echo -e "\033[31m[ERROR]\033[0m $*" >&2; }
success() { echo -e "\033[32m[OK]\033[0m    $*"; }
EOF

# Create main script
cat > deploy.sh << 'EOF'
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/lib/colors.sh"

info "Starting deployment..."
warn "This is a dry run"
success "All checks passed"
error "Just kidding, this is a demo error"
EOF

chmod +x deploy.sh && ./deploy.sh
rm -rf deploy.sh lib/

Key Takeaways

  • Define functions before calling them
  • Use local for all variables inside functions
  • return sets exit code (0/1); use $(func) to capture output
  • source file.sh loads functions into current shell
  • Common patterns: logging, cleanup with trap, retry, confirmation
  • Split large scripts into library files for reusability
  • Always pass arguments with "$@" to preserve quoting

Next Lesson: Lesson 6: Arrays & Data Structures →