Assuming Maintainability

Bugs are bad assumptions. Making assumptions explicit leads to more direct, easier to maintain code.

Bugs are bad assumptions. Therefore, to reduce bugs and make code more maintainable, we need to make our assumptions as explicit as we can. We make our assumptions explicit in five ways: comments, tests, conditionals, assertions, and encapsulation with the type system. Each of these ways gives us progressively more assurance that our explicit assumptions are enforced in the code base. They also give us progressively earlier feedback when our code fails to live up to our assumptions.

Comments

Comments are the simplest way to express an assumption in the code. We write out, in plain language, what our assumption is. We can then read those assumptions quickly when need to use that piece of code. For example, we can state assumptions in method-level comments that describe our parameters.


/**
 * Divide the dividend by the divisor.
 * @param dividend the number to divide into
 * @param divisor the number to divide by; must not be zero
 */
public static double divide(final double dividend, final double divisor) {
    return dividend / divisor;
}

Here we declare in our comment that passing a zero divisor is not acceptable. When we go to call this method, we can see from the documentation that we should make sure we don't pass in a zero value. The downside to a comment is that there is nothing that really enforces it. If we do pass in a zero value, the JVM will throw an ArithmeticException that will propagate out of this method, up the call stack. Other languages may be less forgiving. Instead, undefined behavior could result in an unexpected value, or a program-crashing fault. There are stronger ways to declare our above assumption, but sometimes there is logic that cannot be expressed elegantly in code. For those cases, we must rely on comments and the diligence of our fellow developers. Even when we can enforce our assumptions through stronger strategies, it is often still valuable to add the comments.

Tests

Ideally, we would like to express our assumptions in an executable format. If our explicit assumptions are executable, we can know if those assumptions are truly valid, and if those assumptions change as we change our code. This is an advantage that comments don't have. We can write a trivial test for the divide method above to express our assumption.


@Test(expected = ArithmeticException.class)
public void shouldNotAllowDivisionByZero() {
    divide(1.0, 0.0);
}

Writing tests is a good practice in general. We can consider all tests, by definition, to be an explicit expression of assumptions under given conditions. We are always testing that, given a certain set of circumstances, we assume the code to respond in a certain way. Fast, focused tests give us fast, focused feedback.

Tests are not a panacea, though. We do not get feedback until we actually run the tests. Slow, nebulous tests give us slow, nebulous feedback, reducing their utility and lengthening the feedback cycle both through their slowness and through the infrequency we will consequently run them. We also rely on a developer knowing which tests to write. We can easily miss edge cases and other implicit assumptions in our tests. Tests also cannot enforce assumptions in the code itself. A developer can still unknowingly violate an assumption.

Conditionals

Conditionals embed our assumptions directly into the code we write. With conditionals, we are able to communicate bad assumptions back to the caller by returning error values or throwing exceptions. This way, we prevent bad assumptions from compounding through invalid state. We can also communicate the exact nature of the error, letting calling code handle it in appropriate way. Say, if the input came from a user entering values, we can show an error message with details.


/**
 * Divide the dividend by the divisor.
 * @param dividend the number to divide into
 * @param divisor the number to divide by
 * @return the dividend divided by the divisor, or NaN if the divisor is zero
 */
public static double divide(final double dividend, final double divisor) {
    if (divisor == 0.0) {
    	return Double.NaN;
    }

    return dividend / divisor;
}

Here, we've changed the behavior of our divide method to return Double.NaN if the divisor is zero. Instead of allowing an unexpected fault to happen, we handle the case with code logic. The calling code can now rely on an assumption that this method will handle zero without blowing up, and will return a known value. The downside to conditionals is that they add logic to our running code. We have to maintain this code and understand the impact it has on the external behavior. Slow conditions that might only come up during development get checked on every execution in production. Conditionals also require some behavior for an unmet assumption, whether it be a thrown exception or a reasonable default value. There are cases where there is no such default value, and an exception would not be appropriate.

Assertions

At first glance, assertions look a lot like conditionals. The check a boolean condition, indicating an invalid state if that boolean condition is false. Assertions are not the same. They have several differences and advantages compared to plain conditionals. Assertions are not meant to change external behavior. While assertions indicate error, that error reporting is not considered part of the external interface of the code. This means we can make assertions about intermediate state inside a block of code without needing to change anything in the callers. For example, we may have a user interface for our divide method. That interface may use a number selector that automatically disallows a zero value for the divisor. That code could use an assertion to verify our divide method returns a value and not Double.NaN.


public void onEntryUpdated(final Form form) {
    final double dividend = form.getDoubleField("dividend");
    final double divisor = form.getDoubleField("divisor");

    final double quotient = divide(dividend, divisor);
    assert quotient != Double.NaN;

    form.setDoubleField("quotient", quotient);
}

In many languages, assertions have the added benefit of only being included or run in non-production environments. We get the security of assertions during production and testing, while avoiding the performance impact in production. The downside is that assertions not run in production don't guard against invalid assumptions in production. Ideally, assertions assert on conditions that only need verified during development and test. Otherwise, a full-blown conditional should be used instead.

Types and Encapsulation

The above methods (besides comments) attempt to enforce an assumption at the time the code is run by checking that a value meets expected criteria. However, we can leverage the type system and encapsulation to describe and enforce assumptions even earlier in the process. If we can't handle a zero value for a divisor, we can encapsulate this assumption into a type, and require that type be used. In the case of our divide method, we could create a NonZeroDobule type and use that type for the divisor parameter.


/**
 * Divide the dividend by the divisor.
 * @param dividend the number to divide into
 * @param divisor the number to divide by
 * @return the dividend divided by the divisor
 */
public static double divide(final double dividend, final NonZeroDouble divisor) {
    return dividend / divisor.doubleValue();
}

Since the NonZeroDouble type guarantees that we don't have a zero value, we don't need to check for zero. Calling code must pass in a non-zero value, or else they will get a compilation error. Constructing the NonZeroDouble instance will require its own assumption checks, most likely a conditional, but that check will be consolidated into a single place. This code will be unmistakably explicit about its assumption of a non-zero value, and an error becomes almost impossible.

In this specific case, we still need to account for a null value, meaning we don't gain a lot, but this kind of type explicitness is valuable in many places where we are dealing with potentially null values or more complex encapsulations. However, we need to make sure that these classes have enough meaning and value to carry their weight. In many cases, a NonZeroDouble value probably does not meet that standard. However, when small classes do offer enough value, they can be instrumental in making assumptions very explicit, providing immediate feedback and guaranteeing a higher level of correctness.

All of these different strategies have their place in making assumptions explicit. In many cases, we may end up using multiple strategies at the same time. We almost always want to have a comment where there is an assumption around the external interface of a piece of code. We may use a condition, then use an assertion to show that an impossible state really is impossible. By making our assumptions explicit in our code base, we know what the code expects and what the code produces, and we can proceed safe in our assumptions as we evolve the code.

Comments

Popular posts from this blog

The Timeline of Errors

Magic Numbers, Semantics, and Compiler Errors

Untangling Nested Code