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.
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
.
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):
- Short name of the option (e.g.
-f
) - Long name of the option (e.g.
--foo
) - Flags (
r
= required,v
= option is followed by a value) - Default value
- Description (for
--help
)
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
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.
To use the new module, none of the other files need to be modified. You can execute the script like
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.
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.
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.