Interfaces and inheritance in toolbox modules

When I first wrote toolbox, my goal was to remove boilerplate code from my scripts and improve code-reuse. Because my scripts became more robust and maintainable, I felt more comfortable writing long-running processes like daemons in bash, which led to the development of the ipc module for message-based communication. Because the module uses GPG for message signing, it can be tricky to set up, so I added the uipc module, which does mostly the same thing, but without message signing.

This put me in the situation where I had two modules in toolbox that did the same thing and even had mostly the same functions, but that still could not be used interchangeably because function names started with ipc_ in the one module, and with uipc_ in the other, meaning that you could write your scripts to use one or the other, but not both. In object-oriented programming, this could be easily solved with inheritance or interfaces, both concepts that do not exist in Bash. So I set out to write a small proof-of-concept that is now part of toolbox.

Declaring and implementing interfaces

A module can use the interface() function to declare a set of functions that may be provided (or overridden) by a module that is implementing the interface. Let’s assume the following is the code of a module called greeting.

# Module "greeting"
__init() {
    interface "hello" \
              "bye"
}

When the module is loaded, it declares an interface with the two methods hello and bye. The interface will be called greeting, since that is the name of the module that declares it. And the two methods of the interface will thus be called greeting_hello() and greeting_bye().

What happens internally is that toolbox declares a vtable (very simply said, a table with function references) for the module, and creates the function stubs greeting_hello() and greeting_bye(). When a function stub is called, it looks at the vtable and calls whatever function is referenced there. By default, vtable entries reference the function method_not_implemented(), which lets the caller know that they called an unimplemented method.

A module that wants to implement an interface can do so with the implements() function, which expects exactly one argument: the name of the interface. The following snippet shows the module german, which implements greeting.

# Module "german"
__init() {
    implements "greeting"
}

german_hello() {
    local name="$1"

    echo "Hallo $name"
}

german_bye() {
    local name="$1"

    echo "Tschüss, $name"
}

When the module german is initialized, toolbox checks if the module overrides any of the interface’s methods. The greeting interface has the two methods hello and bye, so toolbox checks if the functions german_hello() or german_bye() exist, and updates the vtable accordingly.

To see why you would want something like this, let’s have a look at a script that uses these modules.

Note: The opt module used below is a command line parser. The opt_parse() function parses the command line according to the options that were declared with opt_add_arg(). The arguments to opt_add_arg() have the following meaning (in order):

  1. Short name of the option (e.g. -f)
  2. Long name of the option (e.g. --foo)
  3. Flags (r = required, v = option is followed by a value)
  4. Default value
  5. Description (for --help)
#!/bin/bash

main() {
    local lang
    local name

    opt_add_arg "l" "lang" "v"  "german" "Language to use"
    opt_add_arg "n" "name" "rv" ""       "Name of the user"

    if ! opt_parse "$@"; then
        return 1
    fi

    lang=$(opt_get "lang")
    name=$(opt_get "name")

    include "$lang"

    greeting_hello "$name"
}

{
    if ! . toolbox.sh ||
       ! include "opt"; then
        exit 1
    fi

    main "$@"
    exit "$?"
}

Let’s assume this script is called greet.sh and the modules have been placed in a directory called include next to it. When you now execute the script with

$ ./greet.sh --name Peter

it will write the message Hallo Peter to standard output. This is because the value of the --lang option defaults to german, causing the module german to be included (which causes greeting to be included).

Now if you wanted to add another language, all you have to do is add a new module. The following is the code of the module japanese.

# Module "japanese"
__init() {
    implements "greeting"
}

japanese_hello() {
    local name="$1"

    echo "$nameさん、こんにちは"
}

japanese_bye() {
    echo "じゃ、また"
}

To use the new module, none of the other files need to be modified. You can execute the script like

$ ./greet.sh --name Peter --lang japanese

and it would greet you with the output Peterさん、こんにちは.

Extending interfaces

The example above declares a pure interface, which is an interface that does not implement any methods itself. Let’s assume we wanted to modify the example, so that it falls back to English if no language was specified.

The first thing we’d do is modify the greeting module so that it provides functions for English like the following.

# Module "greeting"
__init() {
    interface "hello" \
              "bye"
}

greeting_hello() {
    local name="$1"

    echo "Hello $name"
}

greeting_bye() {
    local name="$1"

    echo "Bye $name"
}

What’s special about this case is that the module now has functions that collide with the interface’s call stubs. When this happens, toolbox will first rename the colliding functions, so the greeting_hello() above becomes ___greeting_hello(), and greeting_bye() becomes ___greeting_bye(). It will then generate the call stubs and initialize the vtable so that the entry for hello “points” to ___greeting_hello() and bye to ___greeting_bye(). This way, the interface-implements mechanism can be used to create modules that build on other modules.

Since the greeting module now has a default behavior, we also need to change greet.sh to account for that. This is as simple as changing the default language to greeting, by changing the first option declaration like this.

    opt_add_arg "l" "lang" "v"  "greeting" "Language to use"

This is not the best solution though, since the help text that is printed when opt_parse() encounters -h or --help on the command line will say that the default value for --lang is greeting. It would be nicer to include greeting unconditionally and load one of the other modules only if --lang was passed on the command line. There are many roads that lead to Rome.

And?

Because the uipc module could now inherit code from the ipc module, I was able to remove about 80% of the former. What’s more, I could also modify foundry to support both IPC flavors with very little code changes.

Of course, this mechanism can’t compare with its counterparts in other languages, but it makes writing Bash scripts a lot more comfortable. I had concerns regarding the stability of this mechanism, but since I have been using it for half a year now and had literally zero problems with it, I merged it into stable and released toolbox 0.3.6 today.