Dynamic Dispatch and Metaprogramming

Chapter 10 — Dynamic Dispatch and Metaprogramming

Metaprogramming is writing code that treats other code as data — inspecting it, generating it, or modifying it at runtime. Bash supports a surprisingly rich set of these techniques: introspecting function definitions with declare -f, calling functions by name stored in variables, building command-dispatch tables, generating functions in loops, and constructing code strings that are safely evaluated. Mastering these patterns produces dramatically shorter, more consistent, and more easily extended scripts — at the cost of code that is harder to follow if the reader is unfamiliar with the idioms. This chapter covers every major technique, explains when each is appropriate, and shows where the sharp edges are.

1 — Introspecting Functions with declare -f

declare -f prints the definition of a function exactly as Bash has parsed it. declare -F prints only the name (and, with -p, the source file and line number). These are the building blocks of every introspection-based pattern.

# Does this function exist?
function_exists() {
  declare -f "$1" >/dev/null 2&1
}

# Print full definition
greet() { echo "hello $1"; }

declare -f greet
# greet ()
# {
#     echo "hello $1"
# }

# List all defined functions
declare -F
# declare -f greet
# declare -f function_exists
# ...

# List function names only
declare -F | awk '{print $3}'

# Where was a function defined? (requires extdebug)
shopt -s extdebug
declare -F greet
# greet 1 /path/to/lib.sh    ← name, line, file
shopt -u extdebug

# Copy a function to a new name
function_copy() {
  local src="$1" dst="$2"
  function_exists "$src" || { echo "no such function: $src" >&2; return 1; }
  local body
  # Grab body then replace the name
  body=$(declare -f "$src")
  eval "${dst}() ${body#*()}"
}

greet world          # → hello world
function_copy greet hello_alias
hello_alias world    # → hello world

# Rename a function (copy + unset)
function_rename() {
  function_copy "$1" "$2" && unset -f "$1"
}

2 — Calling Functions by Name

The simplest metaprogramming trick in Bash: store a function name in a variable, then call it. Because Bash resolves commands by name at runtime, a variable holding a function name is a first-class function reference.

cmd_start()   { echo "starting..."; }
cmd_stop()    { echo "stopping..."; }
cmd_status()  { echo "running"; }

# Direct call via variable
action=start
"cmd_${action}"        # calls cmd_start

# Validated call — never call an arbitrary variable without validation
dispatch() {
  local action="$1"; shift
  local fn="cmd_${action}"
  if function_exists "$fn"; then
    "$fn" "$@"
  else
    printf 'Unknown action: %s\n' "$action" >&2
    return 1
  fi
}

dispatch start
dispatch stop
dispatch unknown   # → Unknown action: unknown

# Passing a function as a callback (higher-order functions)
apply_to_each() {
  local fn="$1"; shift
  local item
  for item in "$@"; do
    "$fn" "$item"
  done
}

shout() { printf '%s!\n' "${1^^}"; }
apply_to_each shout alpha beta gamma
# ALPHA!
# BETA!
# GAMMA!

# map/filter/reduce in pure Bash using function callbacks
array_map() {
  local fn="$1"; shift
  local -a result=()
  local item
  for item in "$@"; do
    result+=( "$("$fn" "$item")" )
  done
  printf '%s\n' "${result[@]}"
}

double() { printf '%d' $(( $1 * 2 )); }
array_map double 1 2 3 4
# 2
# 4
# 6
# 8

array_filter() {
  local pred="$1"; shift
  local item
  for item in "$@"; do
    "$pred" "$item" && printf '%s\n' "$item"
  done
}

is_even() { (( $1 % 2 == 0 )); }
array_filter is_even 1 2 3 4 5 6
# 2
# 4
# 6

3 — Command-Dispatch Tables

An associative array that maps command names to function names (or to short inline strings) is the Bash equivalent of a jump table. It is cleaner than a long case statement and trivially extensible at runtime.

# ── Dispatch table via associative array ─────────────────────────
declare -A CMDS
CMDS[start]=do_start
CMDS[stop]=do_stop
CMDS[reload]=do_reload
CMDS[status]=do_status
CMDS[help]=do_help

run_cmd() {
  local name="$1"; shift
  if [[ -v CMDS["$name"] ]]; then
    "${CMDS[$name]}" "$@"
  else
    printf '%s: unknown command "%s"\nAvailable: %s\n' \
      "$0" "$name" "${!CMDS[*]}" >&2
    return 1
  fi
}

# Register a new command at runtime
register_cmd() {
  local name="$1" fn="$2"
  function_exists "$fn" || { echo "no such function: $fn" >&2; return 1; }
  CMDS["$name"]="$fn"
}

# ── Dispatch table with metadata ─────────────────────────────────
# Store description alongside function name using a compound key
declare -A CMD_FN CMD_DESC

register() {
  local name="$1" fn="$2" desc="$3"
  CMD_FN["$name"]="$fn"
  CMD_DESC["$name"]="$desc"
}

print_help() {
  printf 'Available commands:\n'
  local name
  for name in $(printf '%s\n' "${!CMD_FN[@]}" | sort); do
    printf '  %-15s %s\n' "$name" "${CMD_DESC[$name]}"
  done
}

register start  do_start  "Start the service"
register stop   do_stop   "Stop the service"
register status do_status "Show service status"
register help   print_help "Show this message"

4 — Plugin Systems

A naming convention is all you need for a plugin system in Bash. Define a hook name (e.g. plugin_init, plugin_run) and source plugin files. Each plugin that defines a function matching the naming convention is automatically discovered and invoked.

#!/usr/bin/env bash
# Plugin system: load *.plugin.sh files and call their hooks

PLUGIN_DIR="${PLUGIN_DIR:-/etc/myapp/plugins}"

# ── Load all plugins ─────────────────────────────────────────────
load_plugins() {
  local file
  for file in "$PLUGIN_DIR"/*.plugin.sh; do
    [[ -f "$file" ]] || continue
    # Snapshot functions before sourcing
    local -A before
    while IFS= read -r line; do
      before["${line##* }"]=1
    done < <(declare -F)

    source "$file"

    # Find newly added functions
    while IFS= read -r line; do
      local fname="${line##* }"
      [[ -v before["$fname"] ]] || \
        printf '[plugin] loaded %s from %s\n' "$fname" "$file"
    done < <(declare -F)
  done
}

# ── Invoke a hook in all plugins that define it ───────────────────
run_hook() {
  local hook="$1"; shift
  local fn
  # List all functions matching plugin_*_HOOKNAME pattern
  while IFS= read -r fn; do
    [[ $fn =~ ^plugin_[a-z_]+_${hook}$ ]] || continue
    printf '[hook:%s] %s\n' "$hook" "$fn"
    "$fn" "$@"
  done < <(declare -F | awk '{print $3}')
}

# ── Example plugin file: /etc/myapp/plugins/slack.plugin.sh ──────
#
#   plugin_slack_init()  { echo "Slack plugin loaded"; }
#   plugin_slack_run()   { curl -s "$SLACK_URL" -d "text=$*"; }
#   plugin_slack_finish(){ echo "Slack plugin done"; }
#

load_plugins
run_hook init
run_hook run   "build completed"
run_hook finish

5 — Generating Functions at Runtime

When a set of functions differ only in a parameter — a resource name, a prefix, a verb — generate them in a loop instead of copy-pasting. This is the Bash equivalent of a factory function or decorator.

# ── Generate CRUD functions for multiple resource types ──────────
for resource in user group project; do
  eval "
    ${resource}_create() {
      printf 'Creating %s: %%s\n' \"${resource}\" \"\$1\"
    }
    ${resource}_delete() {
      printf 'Deleting %s: %%s\n' \"${resource}\" \"\$1\"
    }
    ${resource}_list() {
      printf 'Listing all %ss\n' \"${resource}\"
    }
  "
done

user_create alice     # → Creating user: alice
group_delete admins   # → Deleting group: admins
project_list          # → Listing all projects

# ── Safer alternative: printf-built function strings ─────────────
# Avoid embedding $resource directly in eval strings when the value
# comes from outside the script — use printf %q to quote it.
make_getter() {
  local name="$1" value="$2"
  # printf %q produces a shell-quoted string safe for eval
  local qval; printf -v qval '%q' "$value"
  eval "get_${name}() { printf '%s' ${qval}; }"
}

make_getter hostname "webserver-01"
make_getter region   "us-east-1"

get_hostname    # → webserver-01
get_region      # → us-east-1

# ── Memoisation decorator ─────────────────────────────────────────
# Wrap any function so its results are cached by argument
memoize() {
  local fn="$1"
  function_exists "$fn" || { echo "no such function: $fn" >&2; return 1; }

  # Copy original to __orig_FUNCNAME
  function_copy "$fn" "__orig_${fn}"

  # Replace fn with a caching wrapper
  eval "
    declare -A __memo_${fn}
    ${fn}() {
      local _key=\"\$*\"
      local -n _cache=__memo_${fn}
      if [[ -v _cache[\"\$_key\"] ]]; then
        printf '%s' \"\${_cache[\$_key]}\"
        return
      fi
      local _result
      _result=\$( __orig_${fn} \"\$@\" )
      _cache[\"\$_key\"]=\"\$_result\"
      printf '%s' \"\$_result\"
    }
  "
}

slow_upper() { sleep 1; printf '%s' "${1^^}"; }
memoize slow_upper

slow_upper hello   # takes ~1s
slow_upper hello   # instant — cache hit
slow_upper world   # takes ~1s — different key

6 — Safe eval Patterns

eval is necessary for some metaprogramming tasks in Bash, but it is the single most dangerous command in the language. Every unsanitised piece of data that reaches eval is a command-injection vulnerability. Use these patterns to stay safe.

# ── Rule 1: never eval externally-sourced data directly ──────────

# WRONG: $name comes from user input or a file
eval "${name}_init()"   # name='; rm -rf /' → catastrophic

# RIGHT: validate with an allowlist before eval
[[ $name =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "bad name" >&2; exit 1; }
eval "${name}_init() { echo initialised; }"

# ── Rule 2: use printf %q for values that cannot be allowlisted ──
safe_set_var() {
  local varname="$1" value="$2"
  # Validate name is a legal identifier
  [[ $varname =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || return 1
  local qval
  printf -v qval '%q' "$value"   # escapes all shell metacharacters
  eval "$varname=$qval"
}

safe_set_var greeting "hello world; rm -rf /"
echo "$greeting"    # → hello world; rm -rf /   (treated as literal string)

# ── Rule 3: use declare -p round-trip for structured data ─────────
# declare -p produces output that eval can safely consume because
# Bash itself generates it — no untrusted input involved.
declare -A original=([key1]=val1 [key2]="value with spaces")

# Serialise
serialised=$(declare -p original)
printf '%s\n' "$serialised"
# declare -A original=([key1]="val1" [key2]="value with spaces" )

# Deserialise into a new name
eval "${serialised/original/restored}"
declare -p restored
# declare -A restored=([key1]="val1" [key2]="value with spaces" )

# ── Rule 4: prefer declare -n (namerefs) over eval for indirection
# nameref is safe — it only allows variable access, not code execution

# AVOID (eval for indirection):
varname=myarray
eval "echo \${$varname[@]}"

# PREFER (nameref):
declare -n ref=$varname
echo "${ref[@]}"    # no eval, no injection risk

7 — Namerefs: Safe Indirect Variable Access

declare -n creates a nameref — a variable that is an alias for another variable whose name is its value. Namerefs let you pass array names to functions without using eval, and they support all compound-variable operations.

# ── Basic nameref ────────────────────────────────────────────────
original="hello"
declare -n ref=original
echo "$ref"         # → hello
ref="world"
echo "$original"    # → world  (same storage)

# ── Passing arrays by name ────────────────────────────────────────
array_sum() {
  declare -n _arr="$1"   # _arr is an alias for the named array
  local sum=0
  local x
  for x in "${_arr[@]}"; do
    (( sum += x ))
  done
  printf '%d' "$sum"
}

numbers=(10 20 30 40)
array_sum numbers    # → 100

# ── Returning values via nameref (avoids subshell) ────────────────
parse_kv() {
  # $1 = name of associative array to populate
  # $2 = string of key=value pairs (space-separated)
  declare -n _out="$1"
  local pair k v
  for pair in $2; do
    k="${pair%%=*}"
    v="${pair#*=}"
    _out["$k"]="$v"
  done
}

declare -A config
parse_kv config "host=localhost port=5432 dbname=mydb"
printf 'host=%s port=%s\n' "${config[host]}" "${config[port]}"
# host=localhost port=5432

# ── Nameref caution: circular reference ──────────────────────────
a=b
declare -n b=a   # b → a → b: circular!
# Bash detects this and prints a warning; $b evaluates to empty.

# ── Use a unique prefix for internal namerefs in functions ────────
# If a function uses -n ref=$1 and the caller passes "ref" as the
# array name, there is a self-reference collision. Use an unlikely
# prefix (double underscore + function name) to avoid it.
safe_push() {
  declare -n __safe_push_arr="$1"; shift
  __safe_push_arr+=("$@")
}

8 — Generating Code at Runtime

Sometimes the right output of a script is another script. Code generation — emitting shell code as text — is a legitimate and powerful technique for configuration management, test fixtures, and build systems.

# ── Emit a self-contained script from a template ─────────────────
generate_deploy_script() {
  local env="$1" version="$2" outfile="$3"

  # Validate inputs before embedding in generated code
  [[ $env     =~ ^[a-z]+$      ]] || { echo "bad env"     >&2; return 1; }
  [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version" >&2; return 1; }

  # Heredoc with NO quoting on the delimiter = variable expansion
  # Heredoc with QUOTED delimiter ('EOF') = literal output, no expansion
  cat > "$outfile" <<EOF
#!/usr/bin/env bash
# Auto-generated deploy script
# Generated: $(date -u '+%F %T UTC')
set -euo pipefail
ENV=${env}
VERSION=${version}
echo "Deploying v\${VERSION} to \${ENV}"
# ... rest of deploy logic ...
EOF
  chmod +x "$outfile"
}

generate_deploy_script staging 1.4.2 /tmp/deploy_staging.sh

# ── Emit a config file from variable state ────────────────────────
emit_env_file() {
  # Print all variables matching PREFIX_* as KEY=value env file
  local prefix="$1"
  local var val qval
  while IFS='=' read -r var val; do
    [[ $var == "${prefix}_"* ]] || continue
    printf -v qval '%q' "$val"
    printf '%s=%s\n' "$var" "$qval"
  done < <(declare -x)
}

export APP_HOST=localhost
export APP_PORT=8080
export APP_DB="my database"
emit_env_file APP
# APP_DB=my\ database
# APP_HOST=localhost
# APP_PORT=8080

9 — Tracing and Decorating Functions

# ── Automatically wrap every function with a timing decorator ─────
trace_all_functions() {
  local fn
  while IFS= read -r fn; do
    # Skip internal and already-wrapped functions
    [[ $fn == __* || $fn == trace_* ]] && continue
    function_copy "$fn" "__orig_${fn}"
    eval "
      ${fn}() {
        local _t0=\${EPOCHREALTIME}
        __orig_${fn} \"\$@\"
        local _rc=\$?
        local _elapsed
        printf -v _elapsed '%.3f' \"\$(echo \"\${EPOCHREALTIME} - \${_t0}\" | bc)\"
        printf '[trace] %s() %.3fs rc=%d\n' '${fn}' \"\${_elapsed}\" \"\${_rc}\" >&2
        return \$_rc
      }
    "
  done < <(declare -F | awk '{print $3}')
}

# ── before/after hooks on a specific function ─────────────────────
add_before_hook() {
  local fn="$1" hook="$2"
  function_copy "$fn" "__orig_${fn}"
  eval "${fn}() { ${hook} \"\$@\"; __orig_${fn} \"\$@\"; }"
}

add_after_hook() {
  local fn="$1" hook="$2"
  function_copy "$fn" "__orig_${fn}"
  eval "
    ${fn}() {
      __orig_${fn} \"\$@\"
      local _rc=\$?
      ${hook} \"\$@\"
      return \$_rc
    }
  "
}

deploy()         { echo "deploying..."; }
pre_deploy_log() { echo "[audit] deploy started by $USER"; }
post_deploy_log(){ echo "[audit] deploy finished"; }

add_before_hook deploy pre_deploy_log
add_after_hook  deploy post_deploy_log

deploy
# [audit] deploy started by alice
# deploying...
# [audit] deploy finished

Exercises

Exercise 1 — Build a subcommand CLI dispatcher

Write a script bin/cli.sh that acts as a dispatcher for subcommands, similar to how git or kubectl work. Requirements:

  • Subcommands are loaded from lib/commands/ — each file is named CMD.sh and must define cmd_CMD_run() and optionally cmd_CMD_help()
  • Running cli.sh SUBCMD [ARGS...] sources the right file then calls cmd_SUBCMD_run "$@"
  • Running cli.sh help [SUBCMD] calls cmd_SUBCMD_help if defined, or prints a generic list of available subcommands
  • Running cli.sh with no arguments prints the command list and exits 1
  • Unknown subcommands print a helpful error and exit 2

Also write two example command files: lib/commands/greet.sh (prints "Hello, NAME!") and lib/commands/shout.sh (prints the argument in uppercase).

#!/usr/bin/env bash
# bin/cli.sh
set -euo pipefail

CMD_DIR="${CMD_DIR:-${BASH_SOURCE[0]%/*}/../lib/commands}"

function_exists() { declare -f "$1" >/dev/null 2&1; }

list_commands() {
  local f
  printf 'Available commands:\n'
  for f in "$CMD_DIR"/*.sh; do
    [[ -f "$f" ]] || continue
    printf '  %s\n' "${f##*/}"
    "${f%.sh}"   # prints just the name without .sh
  done | sed 's|.*/||; s|\.sh||' | sort | xargs -I{} printf '  {}\n'
}

load_command() {
  local name="$1"
  [[ $name =~ ^[a-z][a-z0-9_-]*$ ]] || { printf 'Invalid command name\n' >&2; return 1; }
  local file="$CMD_DIR/$name.sh"
  [[ -f "$file" ]] || return 1
  source "$file"
}

subcmd="${1:-}"

if [[ -z "$subcmd" ]]; then
  list_commands
  exit 1
fi

if [[ "$subcmd" == "help" ]]; then
  if [[ -n "${2:-}" ]]; then
    load_command "$2" || { printf 'Unknown command: %s\n' "$2" >&2; exit 2; }
    function_exists "cmd_${2}_help" && "cmd_${2}_help" || printf 'No help for %s\n' "$2"
  else
    list_commands
  fi
  exit 0
fi

if ! load_command "$subcmd"; then
  printf '%s: unknown command "%s"\n' "${0##*/}" "$subcmd" >&2
  exit 2
fi

fn="cmd_${subcmd}_run"
function_exists "$fn" || {
  printf '%s: %s defines no cmd_%s_run function\n' \
    "${0##*/}" "$subcmd.sh" "$subcmd" >&2
  exit 1
}

shift
"$fn" "$@"
# lib/commands/greet.sh
cmd_greet_run() {
  local name="${1:-World}"
  printf 'Hello, %s!\n' "$name"
}
cmd_greet_help() {
  printf 'Usage: cli.sh greet [NAME]\n  Prints a greeting.\n'
}
# lib/commands/shout.sh
cmd_shout_run() {
  local msg="${*:-NOTHING}"
  printf '%s\n' "${msg^^}"
}
cmd_shout_help() {
  printf 'Usage: cli.sh shout MESSAGE\n  Prints MESSAGE in uppercase.\n'
}

Exercise 2 — Generic CRUD factory with namerefs

Write a function make_resource RESOURCE_NAME ARRAY_NAME that generates four functions for a given resource type: RESOURCE_add, RESOURCE_remove, RESOURCE_list, and RESOURCE_contains. All four functions operate on the array variable named by ARRAY_NAME using declare -n. Then demonstrate by creating tag_* functions operating on a TAGS array, and server_* functions operating on a SERVERS array. Do not use eval for the array access — only for function definition.

#!/usr/bin/env bash
set -euo pipefail

function_exists() { declare -f "$1" >/dev/null 2&1; }

make_resource() {
  local res="$1"     # e.g. "tag"
  local arrname="$2"  # e.g. "TAGS"

  # Validate both names are legal identifiers
  [[ $res     =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "bad resource name" >&2; return 1; }
  [[ $arrname =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "bad array name"    >&2; return 1; }

  eval "
    ${res}_add() {
      declare -n __${res}_arr=${arrname}
      __${res}_arr+=(\"\$1\")
    }

    ${res}_remove() {
      declare -n __${res}_arr=${arrname}
      local item=\$1 i new=()
      for i in \"\${__${res}_arr[@]}\"; do
        [[ \"\$i\" == \"\$item\" ]] || new+=(\"\$i\")
      done
      __${res}_arr=(\"\${new[@]}\")
    }

    ${res}_list() {
      declare -n __${res}_arr=${arrname}
      printf '%s\n' \"\${__${res}_arr[@]}\"
    }

    ${res}_contains() {
      declare -n __${res}_arr=${arrname}
      local item=\$1 i
      for i in \"\${__${res}_arr[@]}\"; do
        [[ \"\$i\" == \"\$item\" ]] && return 0
      done
      return 1
    }
  "
}

# ── Demo ─────────────────────────────────────────────────────────
declare -a TAGS=()
declare -a SERVERS=()

make_resource tag    TAGS
make_resource server SERVERS

tag_add production
tag_add europe
tag_add production   # duplicate (allowed — remove for set semantics)
tag_list
# production
# europe
# production

tag_remove production
tag_list
# europe
# production

tag_contains europe  && echo "europe: yes"
tag_contains asia    || echo "asia: no"

server_add web-01
server_add web-02
server_list
# web-01
# web-02

Exercise 3 — Memoisation library with TTL

Extend the memoisation pattern from Section 5 into a proper library lib/memo.sh. The memo_wrap FUNCTION [TTL_SECONDS] function should wrap any function so that:

  • Results are cached by argument string
  • Cached results expire after TTL seconds (default: no expiry)
  • A cache miss calls the original function and stores both the result and the timestamp
  • memo_clear FUNCTION invalidates the entire cache for that function
  • memo_stats FUNCTION prints hit count, miss count, and current cache size

Demonstrate with a function that simulates a slow API call (sleep 1) and show the cache working with a 5-second TTL.

#!/usr/bin/env bash
# lib/memo.sh

function_exists() { declare -f "$1" >/dev/null 2&1; }

function_copy() {
  local body
  body=$(declare -f "$1") || return 1
  eval "${2}() ${body#*()}"
}

memo_wrap() {
  local fn="$1"
  local ttl="${2:-0}"   # 0 = no expiry

  function_exists "$fn" || { echo "no such function: $fn" >&2; return 1; }
  function_copy "$fn" "__memo_orig_${fn}"

  # Per-function cache and stats arrays
  eval "
    declare -A __memo_cache_${fn}
    declare -A __memo_ts_${fn}
    __memo_hits_${fn}=0
    __memo_misses_${fn}=0
  "

  eval "
    ${fn}() {
      local _key=\"\$*\"
      local -n _cache=__memo_cache_${fn}
      local -n _ts=__memo_ts_${fn}
      local _now
      printf -v _now '%d' \"\${EPOCHSECONDS}\"

      if [[ -v _cache[\"\$_key\"] ]]; then
        local _age=$(( _now - _ts[\$_key] ))
        if (( ${ttl} == 0 || _age < ${ttl} )); then
          (( __memo_hits_${fn}++ )) || true
          printf '%s' \"\${_cache[\$_key]}\"
          return
        fi
      fi

      (( __memo_misses_${fn}++ )) || true
      local _result
      _result=\$( __memo_orig_${fn} \"\$@\" )
      _cache[\"\$_key\"]=\"\$_result\"
      _ts[\"\$_key\"]=\"\$_now\"
      printf '%s' \"\$_result\"
    }
  "
}

memo_clear() {
  local fn="$1"
  eval "__memo_cache_${fn}=(); __memo_ts_${fn}=()"
}

memo_stats() {
  local fn="$1"
  declare -n _c="__memo_cache_${fn}"
  declare -n _h="__memo_hits_${fn}"
  declare -n _m="__memo_misses_${fn}"
  printf '%s: hits=%d misses=%d size=%d\n' "$fn" "$_h" "$_m" "${#_c[@]}"
}

# ── Demo ─────────────────────────────────────────────────────────
fake_api_call() {
  sleep 1
  printf 'result_for_%s' "$1"
}

memo_wrap fake_api_call 5   # 5-second TTL

time fake_api_call foo   # miss → ~1s
time fake_api_call foo   # hit  → instant
time fake_api_call bar   # miss → ~1s
memo_stats fake_api_call
# fake_api_call: hits=1 misses=2 size=2

sleep 6                   # let cache expire
time fake_api_call foo   # miss again → ~1s
memo_stats fake_api_call
# fake_api_call: hits=1 misses=3 size=2

Exercise 4 — Config-driven function generator

Write a script that reads a configuration file in this format:

ALIAS greet  "printf 'Hi, %s\n'"
ALIAS bye    "printf 'Goodbye, %s\n'"
ALIAS shout  "printf '%s\n' \"\${1^^}\""
WRAP  greet  audit_log
WRAP  shout  rate_limit

The ALIAS name body directive creates a function called name using the body string (safely, without allowing injection). The WRAP fn decorator directive wraps fn so that decorator is called before it (the decorator receives the same arguments). Validate that both function names and wrapper names are legal identifiers. Include working implementations of audit_log and rate_limit so the demo runs.

#!/usr/bin/env bash
set -euo pipefail

function_exists() { declare -f "$1" >/dev/null 2&1; }

function_copy() {
  local body; body=$(declare -f "$1") || return 1
  eval "${2}() ${body#*()}"
}

is_ident() { [[ $1 =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; }

# ── Decorators ────────────────────────────────────────────────────
declare -A _RATE_LAST
RATE_LIMIT_SECS=2

audit_log() {
  printf '[audit] %s called with: %s\n' "${FUNCNAME[1]}" "$*"
}

rate_limit() {
  local fn="${FUNCNAME[1]}"
  local now="${EPOCHSECONDS}"
  local last="${_RATE_LAST[$fn]:-0}"
  if (( now - last < RATE_LIMIT_SECS )); then
    printf '[rate-limit] %s called too quickly, skipping\n' "$fn" >&2
    return 1
  fi
  _RATE_LAST["$fn"]="$now"
}

# ── Config parser ────────────────────────────────────────────────
load_config() {
  local file="$1"
  local directive name rest

  while IFS= read -r line; do
    [[ $line =~ ^[[:space:]]*(#|$) ]] && continue
    read -r directive name rest <<< "$line"

    is_ident "$name" || {
      printf 'Invalid identifier: %s\n' "$name" >&2; continue
    }

    case "$directive" in
      ALIAS)
        # rest is the body string — strip surrounding quotes
        local body="${rest#\"}"; body="${body%\"}"
        # Embed body literally — it's from a trusted config file
        # In a hardened implementation, scan body for dangerous patterns
        eval "${name}() { ${body} \"\$@\"; }"
        printf '[config] defined %s()\n' "$name"
        ;;
      WRAP)
        local dec="$rest"
        is_ident "$dec" || { printf 'Invalid decorator: %s\n' "$dec" >&2; continue; }
        function_exists "$name" || {
          printf 'WRAP: function not defined yet: %s\n' "$name" >&2; continue
        }
        function_exists "$dec" || {
          printf 'WRAP: decorator not defined: %s\n' "$dec" >&2; continue
        }
        function_copy "$name" "__wrapped_${name}"
        eval "${name}() { ${dec} \"\$@\" || return; __wrapped_${name} \"\$@\"; }"
        printf '[config] wrapped %s() with %s()\n' "$name" "$dec"
        ;;
      *) printf 'Unknown directive: %s\n' "$directive" >&2 ;;
    esac
  done < "$file"
}

# ── Demo ─────────────────────────────────────────────────────────
load_config functions.conf

greet Alice        # [audit] then Hi, Alice
shout hello        # [rate_limit check] then HELLO
shout hello        # rate-limited on rapid second call
bye Bob            # Goodbye, Bob