When talking about writing safe code, we are not talking about secure code.
Though safe code will often make it easier to write secure code, because it is easier to write things correctly and clearly.
Safe code expresses the idea of code which is easy to write, understand and maintain without introducing unexpected behavior.
A lot of the problems we run into when writing code is that we simply are not smart enough to maintain large amounts of code, states, etc. in our minds.
To limit the amount of code that we need to keep in our heads at any given point in time, we should strive to use the proper abstractions so that our code can be treated as a collection of small units glued together.
By doing this, we can usually focus on one of two things:
The abstractions should, of course, really be abstractions!
If changes within a unit of code lead to code changes elsewhere, then your abstraction is probably leaky!
Even within a small unit of code, mutability can lead to an incomprehensible number of possible states that must be considered.
If, on the other hand, we liberally
use final variables and immutable
data types, then this overhead is
significantly reduced.
In an immutable data structure, you know that the data will not change, so you can hold that constant in your mind.
Combined with a properly scoped abstraction, this will often mean that we have constant state and a very small number of code paths that need to be understood!
Immutable data structures are also inherently thread safe, which makes writing concurrent code much easier!
Beware, though, that sometimes you can fool yourself!
public class NotImmutable {
private final List<String> data;
// ...
public List<String> get(){
return data;
}
}
This is not immutable!
The data field itself is indeed
immutable, but the List itself is
not!
Since we expose it in the getter, other classes can modify the list.
In other words, final only affects
references, not data types!
A common cause of leaky abstractions is side effects. Essentially, you should prefer code where all mutations, computations, etc. have a clear cause.
We don’t want to call a method called
doX that actually does X and Y.
For instance, if we have a method that sums a list of integers, it should not modify the list in the process.
Accordingly, you should prefer functions that take inputs and compute an output without manipulating the input.
Even better is to use total functions.
In other words, functions where every input is guaranteed to have a valid output.
This, for instance, means we don’t return
null values!
Indeed, the Optional type can actually be
seen as a tool for turning partial functions
into total functions.
The more deterministic your code is, the easier it is to understand and the easier it is to reproduce and debug issues when they arise.
When non-deterministic behavior is required, abstract it away so that you can swap the source of non-determinism with a deterministic source for testing.
For example, instead of using Random in your
class directly, abstract it!
public class RandomStuff {
private final Supplier<Integer> randomInts;
public RandomStuff(Supplier<Integer> randomInts){
this.randomInts = randomInts;
}
}
// in production
final var reallyRandom = new RandomStuff(Random::nextInt);
// in tests
final var notRandom = new RandomStuff(()->42);
It’s good to validate data and cause your code to fail as soon as possible if it must fail.
This makes it easier to find the real source of a problem.
For instance, instead of putting null values in a class and having the null propagated through your code until someone needs it, fail in the constructor when a null is provided.
Note that the things we have been talking about all have costs in addition to benefits and you will likely find that you can not adhere to all of these rules of thumb.
This is an ideal we want to aim for, not a concrete requirement.