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
- Consumer Input Incorrect.
- Provider Output Incorrect.
- Semantic Ambiguity.
- Detached Parameters Incorrect.
- Undesired Side Effects.
- 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.
- Using
-
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
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
-
Misunderstanding.
- Difficult to read code and grasp the concept.
- Not reading documentation.
- Reading documentation β documentation and code mismatch.
-
Lack of awareness.
- Values are changed by "some other code".
- Behaviour changes on upgrades.
- Operating with invalid values.
-
Mental overload.
- Difficulty in expressing concept as code.
- Too many things to keep track of.
-
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 callstoString()
on all of its member fields will not inadvertently log the password.
Parse, Don't Validate
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.
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.
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 anA
or aB
. - 3: Use
Either<A, B>
to return either anA
or aB
.
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
- Bugs come in many different forms.
- Bugs happen because it is impossible to know and handle everything perfectly.
- Using types as proof of assumptions helps us write correct code.