Assuming the Bugs

Writing code is all about making assumptions. Sometimes those assumptions are explicit, but more often we make those assumptions implicitly. We make assumptions about what kinds of parameters we will receive. We make assumptions about what kinds of values methods and functions will return. We make assumptions about the global state. The correctness of our code relies on the correctness of these assumptions.

Bad assumptions can wreck our applicatons. We assumed that method never returns null, but turns out it does. Now we have an unexpected NullPointerException crashing through our stack. We assume that we will always average at least one element. Turns out someone wants to average zero elements. Boom, division by zero, ArithmeticException. We make so many assumptions through our codebases, at least a few are going to be wrong. Really, there are many, many assumptions that are wrong. These are bugs. These bad assumptions are all of our bugs.

Usually, our assumptions are implicit, hidden to us and our fellow developers. These hidden assumptions make for hard-to-find bugs. We can attempt to find all the bugs reading through some code. For example, the following code reads a file and averages the numbers in that file. It seems like a reasonable piece of code. It is simple and direct, even using fancy Java 8 features.


public static double averageFile(final String fileName) throws IOException {
	final Path filePath = Paths.get(fileName);

	final Stream<String> numberStrings = Files.lines(filePath);

	final int[] numbers = numberStrings
        .mapToInt(Integer::parseInt)
        .toArray(int[]::new);

	final int sum = IntStream.of(numbers).sum();

	return ((double)sum) / numbers.length;
}
This code is pretty reasonable, but there are a lot of assumptions baked in. Below is the same code, annotated with many of the assumptions made on each line.

public static double averageFile(final String fileName) throws IOException {
    // We assume:
    // * fileName is a String
    // * fileName is not null
    // * fileName is not empty
    // * fileName is using a conformant path format (\ vs /, no illegal characters)
    final Path filePath = Paths.get(fileName);

    // We assume:
    // * filePath is a Path
    // * filePath is not null
    // * filePath points to a file that exists
    // * filePath points to a file that is not a directory
    // * filePath points to a file that is readable
    // * filePath points to a file organized into separate lines
    // * filePath points to a file encoded in UTF-8
    final Stream<String> numberStrings = Files.lines(filePath);

    // We assume:
    // * numberStrings is a Stream of Strings
    // * numberStrings is not null
    final int[] numbers = numberStrings
        // We assume:
        // * mapToInt will process every element in numberStrings
        // * mapToInt will return an IntStream of the parsed ints
        // * elements in numberStrings are not null
        // * elements in numberStrings are not empty
        // * elements in numberStrings are Strings
        // * elements in numberStrings contain one number per element
        // * elements in numberStrings do not contain whitespace
        // * elements in numberStrings do not contain non-number characters
        // * elements in numberStrings are not double values
        // * elements in numberStrings are not too big or small to be ints
        .mapToInt(Integer::parseInt)
        // We assume:
        // * this object is an IntStream
        // * this object is not null
        // * all of the elements in this IntStream are ints
        // * toArray will return an int array
        // * toArray will return an array of all of the ints in the IntStream
        // * there are few enough ints to fit into memory
        // * there are few enough ints to fit into an allocated array
        .toArray(int[]::new);

    // We assume:
    // * numbers is an array of ints
    // * numbers is not null
    // * IntStream.of(int[]) produces a non-null IntStream of the given int array
    // * the sum method correctly sums all of the ints in the IntStream
    // * the sum of the ints does not overflow the maximum int
    final int sum = IntStream.of(numbers).sum();

    // We assume:
    // * converting sum to a double will not lose necessary precision
    // * converting sum to a double will not alter the value in an invalid way
    // * numbers is not null
    // * numbers.length is not zero
    return ((double)sum) / numbers.length;
}

There are quite a few assumptions in there, and that's likely not every single one. Some assumptions are safer than others. Assumptions like "fileName is a String," and "filePath is a Path," are guaranteed by the language specification, and can be enforced by the compiler. When these kinds of assumptions are wrong, there is a bug, but it's in someone else's code. Other assumptions are still pretty safe without being enforced by the compiler. We know Paths.get(fileName) will not return a null value from its documentation. It will handle certain inputs, like an empty string, gracefully, and will throw an exception if it truly cannot handle a given input.

There are also plenty of assumptions that would be bugs in our code. A blank line in the given file will break this code by failing to parse as an integer. Running this on an empty file will also fail because we will divide by zero in the last line. If we know these are safe assumptions, then maybe these will never be bugs. More often, users will test these assumptions thoroughly.

This is the first in a series of posts on assumptions, bugs, debuggability, and maintainabilty.

Comments

Popular posts from this blog

Untangling Nested Code

Typing != Complexity

Magic Numbers, Semantics, and Compiler Errors