Summary

We can use declare, eval and text manipulation tools like awk and sed to dynamically redefine shell functions based on their content or name. This allows us to add lines or generate entirely new functions.

Introduction

Bash and other shell languages provide syntax for defining functions. Just like other languages, functions reduce repetition and make understanding the program easier by breaking things down into component parts.

greeting() {
  local out="Hello there ${1:-nobody}!"
  echo "$out"
}

greeting "$@"
$ bash greeting.sh Internet
Hello there Internet!

declare

Bash provides a couple utilities for working with functions. We can get the names, or names and content, of all currently defined functions with declare. You can try this in Bash right now:

$ example() { echo "hello"; }
$ declare -F
declare -f example

If you have other functions defined (perhaps from your .bashrc or if you run this in a script), you'll see all the other functions named as well. We can restrict the output to just a single name with -p.With a bit of awk we can pull out just the interesting part, the name:

$ declare -F -p example | awk '{print $3}'
example

We can also get the full function definition with -f:

$ declare -f -p example
example ()
{
  echo "hello"
}

Note that Bash reformatted the function for us. This means that that the output of declare -f will always have the same structure. Try this with a couple more functions if you're not convinced. This will be important later!

Another useful behavior is that redefining a function isn't an error, and overwrites the pre-existing definition. We can check this out for ourselves:

$ apple() { echo 'a'; }
$ apple() { echo 'b'; }
$ declare -f -p apple
apple ()
{
  echo 'b'
}

The last piece of the puzzle is eval, which takes a string and evaluates it in the current shell. This is different than using a subshell $( "..." ), because modifications we make to variables and functions persist in the current shell. Changes made in subshells don't make it back out to the parent shell. Note that the third definition of apple doesn't persist.

$ apple() { echo 'a'; }                 # 1, in our shell
$ eval "apple() { echo 'b'; }"          # 2, in our shell, with eval
$ bash -c "apple() { echo 'c'; }"       # 3, in a subshell
$ declare -f -p apple
apple ()
{
  echo 'b'      # the 2nd definition
}

Putting it all together

We can use these tools together to redefine functions on the fly. For example, we could use this to add echo starting and echo ending to a function. We could apply this to every function in a longer script to get a better idea of how it's running. Let's look at an example:

$ important() {
@>   # complete some task
@>   read -r data
@>   echo "(date) data" >> log.txt
@> }

$ declare -f -p important
important ()
{
    read -r data;
    echo "(date) data" >> log.txt
}

The output of declare -f is just text, we can manipulate it any way we like using all the usual tools like sed or awk. To start, let's add a line to our important function.

$ declare -f -p important | sed -e 's/^}$/    echo "done";\n}/'
important ()
{
    read -r data;
    echo "(date) data" >> log.txt
    echo "done";        # added by sed
}

We can now use eval to overwrite the original definition.

$ eval "$(declare -f -p important | sed -e 's/^}$/    echo "done";\n}/')"
$ declare -f -p important
important ()
{
    read -r data;
    echo "(date) data" >> log.txt;
    echo "done"
}

We've dynamically redefined our function. How might this be useful?

  • create tools that generate shell code, like autocli.sh
  • add tracing to all functions in a script
  • add cleanup calls to the ends of functions
  • whatever else you can think of!

Tracing example

The $FUNCNAME variable tells us the name the current function. We can use this with the function redefinition above to add tracing to all the functions defined in a script.

#!/bin/bash

add-tracing-to-all-functions() {
  while read -r name; do
    eval "$(
      declare -f -p "$name" \
        | sed \
          -e 's/^{ $/{ echo "starting \$FUNCNAME"/' \
          -e 's/^}$/   echo "ending   \$FUNCNAME";}/'
    )"
  done < <(declare -F | awk '{print $3}')
}

apple() {
  echo "apple says $*"
}

blueberry() {
  echo "blueberry says $*"
}

add-tracing-to-all-functions

apple hello             # call apple
echo
blueberry there         # call blueberry
$ bash example.sh
starting apple
apple says hello
ending apple

starting blueberry
blueberry says there
ending blueberry