2021-09-10
James Diacono
james@diacono.com.au
The idea of Design by Contract was proposed by Bertrand Meyer in 1986. It states that the components of a program should be responsible for protecting their own integrity. When it is easy to reason about the correctness of the parts, it is easier to reason about the correctness of the whole. Mistakes are quicker to find and easier to correct, making the program more reliable.
When a function is called with unexpected input, what should it do? Its behaviour is unspecified. In dynamic languages, a function can be passed literally any value. This makes almost every function a partial function, that is, a function whose behaviour is defined only for a subset of possible inputs.
Static typing has been proposed as a solution, but static type systems are too clumsy to express the constraints that we actually need. Consider division. Dividing a number by zero is meaningless, yet we are unable to statically type the denominator as a non-zero number. Static typing shrinks the set of invalid inputs, but does not necessarily eliminate them.
It is common to test how a component behaves when misconfigured. For instance, we might write a test case that asserts that a division function throws an exception when it is passed zero as a denominator. But how do we know where to stop? Should we test that the division function throws an exception when it is passed a string? If so, how many strings should we test? In general, it is impractical to comprehensively test a component’s misconfigurations. In dynamic languages, the possible bad configurations vastly outnumber the good. We should instead focus on testing the good, and protect against the bad via some other mechanism.
That is where Design by Contract comes in. The contract of a divide
function can be specified precisely:
Given two finite numbers (the second of which must not be zero), the divide
function returns the first number divided by the second number. The return value is a finite number.
Notice how this contract states obligations for both the divide
function and its caller. This is just like a contract in the real world, which is a binding agreement between two parties. We can define a component’s failure as its inability to satisfy its contract, which may be due to its misuse or malfunction.
In JavaScript, exceptions are a robust mechanism for signalling failure. Execution of a function is halted immediately when a throw
statement is encountered, and control is transferred to some catch
clause that is prepared to deal with the failure.
The divide
function, together with its contract, might be codified like so:
function divide(numerator, denominator) {
if (!Number.isFinite(numerator)) {
throw new Error("Bad numerator: " + numerator);
}
if (
!Number.isFinite(denominator)
|| denominator === 0
) {
throw new Error("Bad denominator: " + denominator);
}
const quotient = numerator / denominator;
if (!Number.isFinite(quotient)) {
throw new Error("Bad quotient: " + quotient);
}
return quotient;
}
The divide
function declares two preconditions to protect itself from bad input. Arguments like NaN
and "hello"
will cause the function to fail. Being a good citizen, it also declares a postcondition to protect its caller from bad output like Infinity
. It is extremely reliable, but it is not very easy to read. There could be bugs hiding in the pre or postconditions. What we need is a way to specify such conditions more declaratively.
I wrote a library called JSValid. It lets you use functions to express value constraints. Let’s use it to express the set of valid denominators.
function valid_denominator() {
return valid.all_of([
valid.number(),
valid.not(0)
]);
}
Assertions are not built into the JavaScript language, but we are able to write an assert
function that is nearly as good. The first argument of assert
is the value to be checked. The second argument is a JSValid validator, or an expected value. If the value does not conform to the validator, an exception is thrown.
function assert(value, validator) {
if (typeof validator !== "function") {
validator = valid.literal(validator);
}
const violations = validator(value);
if (violations.length > 0) {
throw new Error(testify(value, violations));
}
}
The testify
function used above generates a human-readable report describing the problems with the value
, and has been omitted for brevity. Now we are able to make the divide
function easier to read.
function divide(numerator, denominator) {
assert(numerator, valid.number());
assert(denominator, valid_denominator());
const quotient = numerator / denominator;
assert(quotient, valid.number());
return quotient;
}
We can also declare invariants to ensure the consistency of a portion of state. Invariants can be included in the preconditions and postconditions of functions that mutate state. To demonstrate this, I will present an example that is a little more complex than the divide
function. It is a component for the web, which shows transient status messages to the user. In modern parlance, these messages are known as “toasts”.
function valid_toast() {
return valid.object({
duration: valid.integer(0, undefined),
message: valid.string()
});
}
We initially specify what kinds of values we consider to be toasts. The make_toaster
function instantiates a toaster, which is used to show toasts in sequence. A toaster holds its state in a closure, as the queue
and is_showing
variables. The assert_invariant
function asserts that the toaster’s state is consistent.
function make_toaster() {
let queue = [];
let is_showing = false;
function assert_invariant() {
assert(queue, valid.array(valid_toast()));
assert(is_showing, valid.boolean());
}
The next
function is used internally by the toaster to show the next toast in the queue. It must not be called if there is a toast currently showing. Because it modifies the state, it calls assert_invariant
before and after it does its work.
function next() {
assert(is_showing, false);
assert_invariant();
const toast = queue.shift();
if (toast !== undefined) {
is_showing = true;
const toast_element = document.createElement("div");
toast_element.innerText = toast.message;
toast_element.className = "toast";
document.body.appendChild(toast_element);
After a pause, the toast is hidden and the next toast is shown. A precondition in hide_toast
makes explicit our assumption that there is indeed a toast to hide.
setTimeout(
function hide_toast() {
assert(is_showing, true);
document.body.removeChild(toast_element);
is_showing = false;
return next();
},
toast.duration
);
}
assert_invariant();
}
The show
function requests that a toast be shown eventually.
function show(toast) {
assert(toast, valid_toast());
assert_invariant();
queue.push(toast);
if (!is_showing) {
next();
}
assert_invariant();
}
The last thing that the make_toaster
function does is check its integrity, before returning the show
function.
assert_invariant();
return show;
}
As programmers, we spend the majority of our time debugging. Our ability to reason correctly about the values and state flowing through a system is vital. Design by Contract produces software that is more reliable because it exposes mistakes sooner, when they are easier to fix.