Introduction

🐞

Bug: A difference between what is happening and what is desired.

This book illustrates 6 kinds of software bugs and patterns to reduce their occurrences.

What Are Bugs

Unexpected Behaviour

  1. Consumer Input Incorrect.
  2. Provider Output Incorrect.
  3. Semantic Ambiguity.
  4. Detached Parameters Incorrect.
  5. Undesired Side Effects.
  6. Input Restrictions Too Loose.

Consumer Input Incorrect

Input value doesn't comply with allowed specification.

  • Input format incorrect.
  • Able to enter "anything", so consumer assumes whatever fits is okay.

Provider Output Incorrect

Output value doesn't comply with output specification.

  • Invalid values not rejected.
  • Logic incorrect.

Semantic Ambiguity

  • Sentinel values:

    • Using null to mean "it's not there", or "it's there, but it's empty."
    • Using -1 to indicate something went wrong.
    • Using -1 to indicate a variable has not been given a proper value.
  • Data type represents different concepts.

    • If you want X, define fields A, B, and C.
    • If you want Y, define fields A, B, D, and E.

Detached Parameters Incorrect

%3aAcalculatecalculate(a)a->calculatebBcalculate_innerdoCalculate(a, b, c)b->calculate_innercCc->calculate_innercalculate->calculate_innerresult..calculate_inner->result

For the same input from the consumer, provider returns different output due to calculation using other state.

  • Environmental variables
  • Configuration

The bug may be outside of the source code. Configuration is deferred code.

Undesired Side Effects

Provider leaks information:

  • Sensitive information in application logs.
  • Stack traces in responses.

Input Restrictions Too Loose

Provider unintentionally does more than it should.

  • Remote code execution: Log4Shell
  • Returns too much data: Heartbleed

Sometimes what sounds like a good idea at the time, may not be a good idea later on.

Causes of Bugs

  1. Misunderstanding.

    • Difficult to read code and grasp the concept.
    • Not reading documentation.
    • Reading documentation – documentation and code mismatch.
  2. Lack of awareness.

    • Values are changed by "some other code".
    • Behaviour changes on upgrades.
    • Operating with invalid values.
  3. Mental overload.

    • Difficulty in expressing concept as code.
    • Too many things to keep track of.
  4. Changing expectations.

    • What was desired previously is now undesired.
    • Subset of code is changed to meet new expectation.

Patterns

Largely designed for statically typed languages – they rely on having a compiler.

These patterns are designed or intended to:

  • Communicate input and output expectations through API.

  • Ensure correctness through type safety.

    Only allow an instance of a type to exist, if it meets certain criteria.

  • Favour compile time errors over runtime errors.

    By making it impossible to compile incorrect code, correctness and quality is maintained, with fewer tests.

Strong Types

In constrast to Stringly typed

Instead of String username, String password, use Username username, Password password.

Switch from:

public class Profile {
    private String username;
    private String password;

    public Profile(String username, String password) { .. }
    // ..
}

to:

public class Profile {
    private Username username;
    private Password password;

    public Profile(Username username, Password password) { .. }
    // ..
}

public class Username {
    private String value;
    public boolean equals(..) { .. }
    // ..
}

public class Password {
    private String value;
    public void toString() { return "******"; }
    // ..
}

Bug Variants Addressed

  • 1: Avoids argument swap by producing a compiler error.
  • 5: Password.toString() never returns the true value, so a wrapping class that calls toString() on all of its member fields will not inadvertently log the password.

Parse, Don't Validate

%3aAbBa->bparser.parse(a)

Once you validate raw input, turn it into another type as proof that it is validated. The type safety stops the possibility of invalid values propagating far into the code.

%3cluster_stringStringcluster_passwordPasswordstring_0""string_1"weak"string_2"エむズγƒͺエル と 申します‼️"password_3"?Q9|HgU8_asio;<1z%%A@v^"string_n..password_4"vat*#2%!&ExN4F+C$KB-da3"

Parser:

  • Function that transforms an input type to a target type, iff it matches certain criteria.

  • The target type's constructor must be restricted to the parser.

    Ensures the target type can never be constructed without going through the parser.

  • Raise an error if the criteria is not met.

Example: Updating a password only if the proposed value is strong.

Switch from:

// Usage
void resetPassword(final String passwordProposed) throws PasswordTooWeakException {
    passwordChecker.check(passwordProposed);

    // password is strong

    passwordUpdater.update(profile, passwordProposed);
}

// our "validator"
public class PasswordChecker {
    public void check(String passwordProposed) throws PasswordTooWeakException {
        if (isStrong(passwordProposed)) {
            // ok!
            return;
        } else {
            throw new PasswordTooWeakException(passwordProposed);
        }
    }
}

// our provider
public class PasswordUpdater {
    public void update(Profile profile, String newPassword) { /* .. */ }
}

to:

// Usage
void resetPassword(final String passwordProposed) throws PasswordTooWeakException {
    Password password = passwordChecker.check(passwordProposed);

    passwordUpdater.update(profile, password);
}

public class Password {
    private String value;

    // package private visibility
    Password(String value) { /* .. */ }
    // ..
}

// our "parser"
public class PasswordChecker {
    public Password check(String passwordProposed) throws PasswordTooWeakException {
        if (isStrong(passwordProposed)) {
            return new Password(passwordProposed);
        } else {
            throw new PasswordTooWeakException(passwordProposed);
        }
    }
}

// our provider
public class PasswordUpdater {
    public void update(Profile profile, Password newPassword) { /* .. */ }
}
Example: Only accepting a list of one-or-more elements.

Switch from:

void displaySuggestions(final List<Suggestion> suggestions) {
    // ..
}

to:

void displaySuggestions(final ListMinOneElement<Suggestion> suggestions) {
    // ..
}

public <T> class ListMinOneElement<T> {
    private List<T> inner;

    private ListMinOneElement(List<T> inner) {
        this.inner = inner;
    }

    public static ListMinOneElement<T> tryFrom(List<T> maybeEmptyList)
            throws ListEmptyException {
        if (maybeEmptyList.isEmpty()) {
            throw new ListEmptyException(maybeEmptyList);
        } else {
            return new ListMinOneElement(maybeEmptyList);
        }
    }
}

Bug Variants Addressed

  • 1: Raises an error1 at the point of parsing.

  • 2: Reduces provider code working with invalid input, because only valid input reaches the business logic.

  • 3: Well-named restrictive types informs the consumer of constraints the input must adhere to, and parsing enforces the constraints.

  • 6: Constrains input to a range of safe values.

    If we have a list of safe commands that input is parsed into, then we protect ourselves from running unsafe logic.

1 It is not a bug to raise an error.

See Also

Builder

Never allow an object to be constructed unless it's correct.

%3aAbuilderBuildera->builder builder.with_a(a)     bBb->builder builder.with_b(b)     cCc->builder builder.with_c(c)     dDbuilder->d builder.build()

Builder:

  • Collects multiple values, then maps them into a single validated value in a build() method.
  • Avoids partial objects – don't allow yourself to have an instance of the target type unless it is correct.

Example: Data where intValue must be greater than byteValue.

Switch from:

public class Data {
    private final byte byteValue;
    private final int intValue;
    private final Optional<String> stringValue;

    public Data(byte byteValue, int intValue, Optional<String> stringValue) {
        this.byteValue = byteValue;
        this.intValue = intValue;
        this.stringValue = stringValue;
    }

    // ..
}

// Usage
Data data = new Data(0, 1, Optional.empty());

to:

public class Data {
    private final byte byteValue;
    private final int intValue;
    private final Optional<String> stringValue;

    // Package private to allow builder class access.
    Data(byte byteValue, int intValue, Optional<String> stringValue) {
        this.byteValue = byteValue;
        this.intValue = intValue;
        this.stringValue = stringValue;
    }

    // ..
}

public class DataBuilder {
    private final Optional<Byte> byteValue;
    private final Optional<Integer> intValue;
    private final Optional<String> stringValue;

    // Constructor
    // either:

    public DataBuilder() {
        this.byteValue = Optional.empty();
        this.integerValue = Optional.empty();
        this.stringValue = Optional.empty();
    }

    // or

    public DataBuilder(byte byteValue, int intValue) {
        this.byteValue = Optional.of((Byte) byteValue);
        this.integerValue = Optional.of((Integer) intValue);
        this.stringValue = Optional.empty();
    }

    // Append additional values.

    public DataBuilder withByteValue(byte byteValue) {
        this.byteValue = Optional.of(byteValue);
        return this;
    }

    public DataBuilder withIntValue(int intValue) {
        this.intValue = Optional.of((Integer) intValue);
        return this;
    }

    public DataBuilder withStringValue(String stringValue) {
        this.stringValue = Optional.of(stringValue);
        return this;
    }

    public Data build() throws IncompatibleValuesException {
        // Default the byteValue and intValue
        byte byteValue = this.byteValue
                .orElse(0)
                .byteValue();

        int intValue = this.intValue
                .orElse((int) byteValue + 1)
                .intValue();

        if (intValue < (int) byteValue) {
            throw new IncompatibleValuesException(
                    intValue,
                    byteValue,
                    "intValue must not be smaller than byteValue.");
        }

        return new Data(byteValue, intValue, this.stringValue());
    }

    // ..
}

// Usage
Data data = new DataBuilder()
    .withByteValue(123)       // allows method chaining
    .withIntValue(456)
    .withStringValue("abc")
    .build();                 // enforces constraints
                              // so any error is raised here

Bug Variants Addressed

  • 1: Raises an error1 at the point of building.
  • 2: Reduces provider code working with invalid input, because only valid input reaches the business logic.
  • 4: Only allow groupings of valid values in the constructed type.
  • 6: Constrains input to a range of safe values.

1 It is not a bug to raise an error.

Option Type

null in different clothes.

Use Optional<T> to indicate when a value may not actually exist.

Example: Max value from a list of values.

Switch from:

Integer max = null;
for (int value: values) {
    if (max == null) {
        max = value;
    } else if (max < value) {
        max = value;
    }
}
return max;

// Usage
int max = getMax(new int[] {}); // NullPointerException

to:

Optional<Integer> max = Optional.empty();
for (int value: values) {
    max = max
        .map(currentMax -> currentMax < value ? value : currentMax)
        .orElse(value);
}

return max;
Example: Returning data from storage.

Switch from:

public interface DataStorage {
    /**
     * @return the data for the given ID, or null if it doesn't exist.
     */
    public Data get(DataId dataId);
}

public class DataConsumer {
    public void doSomething(DataId dataId) {
        Data data = dataStorage.get(dataId);
        if (data != null) {
            // do something with it.
        }

        // `data` exists here, possible to use null reference.
    }
}

to:

public interface DataStorage {
    /**
     * @return the data for the given ID if exists.
     */
    public Optional<Data> get(DataId dataId);
}

public class DataConsumer {
    public void doSomething(DataId dataId) {
        dataStorage
            .get(dataId)
            .ifPresent(data -> {
                // do something with it.
            });

        // `data` doesn't exist here, impossible to use null reference.
    }
}

We communicate across the API boundary that the value may not exist.

Bug Variants Addressed

  • 1: Use Optional to indicate when it is valid to not provide a value.
  • 3: Use Optional in return types to indicate there may not be a return value.

Either Type

Buy one get one free.

Avoid writing two classes in one class, separate concepts by type.

Switch from:

public class InputOptions {
    private InputDevice inputDevice;

    // Using keyboard controls
    // Only relevant if InputDevice is Keyboard
    private KeyCode keyAttack;
    private KeyCode keyJump;
    private KeyCode keyUp;
    private KeyCode keyDown;
    private KeyCode keyLeft;
    private KeyCode keyRight;

    // Using Game Controller controls
    // Only relevant if InputDevice is GameController
    private ButtonCode buttonAttack;
    private ButtonCode buttonJump;
    private DirectionPad directionInput;

    // ..
}

public enum InputDevice {
    Keyboard,
    GameController;
}

// Usage
switch (inputOptions.getInputDevice()) {
    case InputDevice.Keyboard:
        if (inputOptions.getKeyAttack()) { /* .. */ }
        break;
    case InputDevice.GameController:
        if (inputOptions.getButtonAttack()) { /* .. */ }
        break;
}

to:

public class Keyboard {
    private KeyCode keyAttack;
    private KeyCode keyJump;
    private KeyCode keyUp;
    private KeyCode keyDown;
    private KeyCode keyLeft;
    private KeyCode keyRight;

    // ..
}

public class GameController {
    private ButtonCode buttonAttack;
    private ButtonCode buttonJump;
    private DirectionPad directionInput;

    // ..
}

// Strong type alias.
public class InputOptions extends Either<Keyboard, GameController> { /* .. */ }

// Usage
inputOptions
    // Impossible to use game controller fields in keyboard logic
    .ifKeyboard(keyboard -> {
        if (keyboard.getKeyAttack()) { /* .. */ }
    })
    // Impossible to use keyboard fields in game controller logic
    .ifGameController(gameController -> {
        if (gameController.getButtonAttack()) { /* .. */ }
    });

Doesn't exist in the JDK, but exists in functionaljava: fj.data.Either (docs)

Bug Variants Addressed

  • 1: Use Either<A, B> to allow passing in an A or a B.
  • 3: Use Either<A, B> to return either an A or a B.

Application

As with most concepts, pragmatism applies.

Applying these patterns excessively may:

  • Negatively affect compile time or runtime performance.
  • Increase the number of files needing maintenance.
  • Cause debate between colleagues, depending on each persons bias.

Conclusion

🐜
  1. Bugs come in many different forms.
  2. Bugs happen because it is impossible to know and handle everything perfectly.
  3. Using types as proof of assumptions helps us write correct code.

Questions and Comments

Thanks