Item 5: Specify your expectations for arguments and state
This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.
When you have expectations, declare them as soon as possible. In Kotlin, we mainly do this using:
require
block - a universal way to specify expectations for arguments.check
block - a universal way to specify expectations for states.assert
block - a universal way to check if something is true. Such checks in the JVM are evaluated only in the testing mode.- The Elvis operator with
return
orthrow
.
Here is an example that uses these mechanisms:
Specifying experiences this way does not free us from the necessity of specifying them in documentation, but it is really helpful anyway. Such declarative checks have many advantages:
- Expectations are visible even to programmers who have not read the documentation.
- If they are not satisfied, a function throws an exception instead of leading to unexpected behavior. It is important that these exceptions are thrown before the state is modified because this means we don’t have a situation where only some modifications are applied and others are not. Such situations are dangerous and hard to managepokemon. Thanks to assertive checks, errors are harder to miss and our state is more stable.
- Code is self-checking to some degree. There is less need for unit-testing when these conditions are checked in the code.
- All checks listed above work with smart casting, therefore less casting is required.
Let’s talk about different kinds of checks and why we need them. Starting from the most popular one: the arguments check.
Arguments
When you define a function with arguments, it is not uncommon for these arguments to have some expectations on them that cannot be expressed using the type system. Just take a look at a few examples:
- When you calculate the factorial of a number, you might require this number to be a positive integer.
- When you look for clusters, you might require a list of points to not be empty.
- When you send an email, you might require that the email address is valid.
The most universal and direct way to state these requirements in Kotlin is by using the require
function, which checks this requirement and throws an exception if it is not satisfied:
Notice that these requirements are highly visible because they are declared at the very beginning of functions, therefore they are clear for a user who is reading these functions (but requirements should also be stated in documentation because not everyone reads function bodies).
These expectations cannot be ignored because the require
function throws an IllegalArgumentException
when the predicate is not satisfied. When such a block is placed at the beginning of a function, we know that if an argument is incorrect, the function will stop immediately and the user won’t miss the fact that they are using this function incorrectly. An exception will be clear, unlike a potentially strange result that might propagate a long way until it fails. In other words, when we properly specify our expectations for arguments at the beginning of a function, we can then assume that these expectations will be satisfied.
We can also specify a lazy message for this exception in a lambda expression after the call:
The require
function is used when we specify requirements for arguments.
Another common case is when we have expectations of the current state; in such a case, we can use the check
function instead.
State
It is not uncommon that we only allow some functions to be used in concrete conditions. A few common examples:
- Some functions might need an object to be initialized first.
- Some actions might be allowed only if the user is logged in.
- Some functions might require an object to be open.
The standard way to check that these expectations of a state are satisfied is to use the check
function:
The check
function works similarly to require
, but it throws an IllegalStateException
when the stated expectation is not met. It checks if a state is correct. An exception message can be customized using a lazy message, just like with require
. When the expectation is on the whole function, we place it at the beginning, generally after the require
blocks. However, some state expectations are local, and check
can be used later.
We use such checks especially when we suspect that a user might break our contract and call a function when it should not be called. Instead of trusting that they won’t do that, it is better to check and throw an appropriate exception. We might also use explicit checks when we do not trust that our own implementation handles the state correctly. However, for such cases, when we are checking mainly for the sake of testing our own implementation, we have another function called assert
.
Assertions
There are things we know to be true when a function is implemented correctly. For instance, when a function is asked to return 10 elements, we might expect that it will return 10 elements. This is something we expect to be true, but this doesn’t mean we are always right. We all make mistakes. Maybe we made a mistake in the implementation. Maybe someone changed a function we used and our function does not work properly anymore. Maybe our function does not work correctly anymore because it was refactored. For all these problems, the most universal solution is to write unit tests that check if our expectations match reality:
Unit tests should be our most basic way to check implementation correctness, but notice here that the fact that the popped list size matches the desired one is considered universal to this function. It would be useful to add such a check in nearly every pop
call. Having only a single check for this single use is rather naive because there might be some edge cases. A better solution is to include the assertion in the function:
Such conditions are currently enabled only in Kotlin/JVM, and they are not checked unless they are enabled using the -ea
JVM option. We should instead treat them as part of unit tests as they check that our code works as expected. By default, they do not throw any errors in production. They are enabled by default only when we run tests. This is generally desired because if we make an error, we might hope that the end user won’t notice. If a possible error is serious and might have significant consequences, use check
instead. The main advantages of having assert
checks in functions instead of in unit tests are:
- Assertions make code self-checking and lead to more effective testing.
- Expectations are checked for every real use case instead of for concrete cases.
- We can use them to check something at the exact point of execution.
- We make code fail early, closer to the actual problem. Thanks to this, we can also easily find where and when the unexpected behavior started.
Just remember that we still need to write unit tests in order to use assertions. In a standard application run, assert
will not throw any exceptions.
Such assertions are a common practice in Python, but not so much in Java. In Kotlin, feel free to use them to make your code more reliable.
Nullability and smart casting
Both require
and check
have Kotlin contracts that state that when a function returns, its predicate is true after this check.
Everything that is checked in these blocks will later be treated as true in the same function. This works well with smart casting because once we have checked that something is true, the compiler will treat it as something that is certain. In the example below, we require a person’s outfit to be a Dress
. After that, assuming that the outfit property is final, it will be smart casted to Dress
.
This characteristic is especially useful when we check if something is null:
For such cases, we even have special functions: requireNotNull
and checkNotNull
. They both have the capability to smart cast variables, and they can also be used as expressions to “unpack” variables:
For nullability, it is also popular to use the Elvis operator with throw
or return
on the right side. Such a structure is highly readable and it gives us more flexibility in deciding what behavior we want to achieve. First of all, we can easily stop a function using return
instead of throwing an error:
If we need to take more than one action if a property is incorrectly null
, we can always add these actions by wrapping return
or throw
into the run
function. Such a capability might be useful if we need to log why a function was stopped:
The Elvis operator with return
or throw
is a popular and idiomatic way to specify what should happen in the case of variable nullability, and we should not hesitate to use it. Again, if possible, keep such checks at the beginning of the function to make them visible and clear.
Summary
Specify your expectations to:
- Make them more visible.
- Protect your application stability.
- Protect your code correctness.
- Smart cast variables.
The four main mechanisms we use for this are:
require
block - a universal way to specify expectations for arguments.check
block - a universal way to specify expectations for states.assert
block - a universal way to test if something is true in testing mode.- The Elvis operator with
return
orthrow
.
You might also use throw
to throw a different error.
I remember how, in a Gameboy Pokemon game, one could copy a pokemon by making a transfer and disconnecting the cable at the right moment. After that, this pokemon was present on both Gameboys. Similar hacks worked in many games and they generally involved turning off the Gameboy at the correct moment. The general solution to all such problems is to make connected transactions atomic: either all occur or none occur. For instance, when we add money to one account and subtract it from another. Atomic transactions are supported by most databases.