From Punched Cards to Prompts
AndroidIntroduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Good code makes its context plain. At a glance, you can see what it needs to succeed, and what happens when it does. Mastering Kotlin’s common language for codifying your functions’ assumptions and promises will help you write code you can change with confidence. You will catch any bugs sooner, and you will spend less time debugging.
Kotlin has three functions for capturing execution context:
require(Boolean)
throws IllegalArgumentException
when its argument is false. Use it to test function arguments.check(Boolean)
throws IllegalStateException
when its argument is false. Use it to test object state.assert(Boolean)
throws AssertionError
when its argument is false (but only if JVM assertions are enabled with -ea
). Use it to clarify outcomes and check your work.These functions give Kotlin programmers a common language. If you do not use these functions, you will probably reinvent them.
Bare exceptions and errors are little help. So each function has another variation.That variation takes a lazy message closure as final argument. Use that message to jumpstart debugging by reporting relevant values. You will see these variations used soon.
These three functions look very similar, but each has its specific purpose. Examples of using each alone, then all together, will make that clear.
A function makes assumptions about:
Direct Inputs: These are function arguments. Maybe you need an Int
to be non-negative. Maybe you need a File
to be readable. Before you begin working with your arguments, check that they are valid with require
.
Indirect Inputs: These are often object state. Sometimes certain functions only make sense to call if other functions have been called already. A socket needs to connect to a host before it makes sense to read from or write to it. You check these conditions using check
.
To check assumptions about function arguments, use require
:
fun activate(index: Int) {
// Argument Assumption: |index| is a non-negative integer.
require(index > 0) { "Int |index| must be non-negative. index=$index" }
…
}
fun load(from: File): String {
// Argument Assumption: |from| is a readable file.
require(from.canRead()) { "File |from| must be readable. file=$from canRead=${from.canRead()}" }
…
}
To check assumptions about things that are not function arguments, use check
:
class Socket {
var isConnected: Boolean = false
var connectedHost: Host? = null
fun connect(to: Host, result: (isConnected: Boolean) -> Void) {
// Starting State Assumption: |this| is not already connected.
check(!isConnected) {
"|Socket.connect| cannot be called after a successful call to |Socket.connect|. "+
"socket=$this to=$to connectedHost=$connectedHost"
}
…
}
fun write(blocks: Blocks): Int {
// Starting State Assumption: |this| is connected.
check(isConnected) {
"|Socket.connect| must succeed before |socket.write| can be called. "+
"socket=$this blocks=$blocks"
}
…
}
}
We write code to do something. When that something is to return a value, our promise is the return type. But return types often do not tell the whole story. And when that something is to change other state, our promise is secret.
assert
verifies your function did its job:
fun activate(index: Int) {
…
// Ending State Promise: The pump at |index| is now active.
assert(pump[index].isActive) { "Failed to activate pump index=$index" }
}
Kotlin gives us tools to write clear code. Clear code says what it knows. It does not keep it secret.
You often use require
, check
and assert
in the same places in a function:
fun anyFunction(arg: Arg): Result {
// Starting State Assumption: XXX
check(internalStateIsSane) {
"Say what you expected. Log |this| and |args| as well as the failing internal state."
}
// Argument Assumption: XXX
require(arg.isSane) {
"Say what you expected. Log |arg| and the values used in the failed check."
}
…
// Ending State Promise: XXX
assert(result.isSane) {
"Say what you expected. Log |result| and the failed check's output."
}
result
}
As shown, the pattern is:
Before anything else, check the starting state with check
. If any of these checks fails, the arguments do not even matter – the function should never have been called!
Next, check the function arguments with require
. If an argument turns out to be invalid, it is best to catch that before changing anything or doing any other work, since the function call will fail anyway.
In the middle, do the actual work of your function.
Lastly, assert the function did what it was supposed to do. Sometimes that means checking that some objects have a new state. Sometimes that means checking that the return value is reasonable.
In all cases, write your failure message to jumpstart debugging. If your first question when a check fails will be, “What was the value of something
?” then the message should answer that question.
Checking things can grow tiresome. The way out is more precise types: As Yaron Minsky says, “Make illegal states unrepresentable.” For example, a require(intValue >= 0)
check can be eliminated by using a type whose values can only represent non-negative integers. But that is a topic for another day.
Curious to better know Kotlin? Stay updated with our Kotlin Programming books & bootcamps. Our two-day Kotlin Essentials course delivers in spades, while our Android Essentials with Kotlin course will set you on the right path for Android development.
Introduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Jetpack Compose is a declarative framework for building native Android UI recommended by Google. To simplify and accelerate UI development, the framework turns the...
Big Nerd Ranch is chock-full of incredibly talented people. Today, we’re starting a series, Tell Our BNR Story, where folks within our industry share...