Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.
Have you double-checked your code?
The function below displays a progress bar, accepting progress values ranging from 0 to 1.
/**
* Shows a progress bar blocking other UI interaction with a given progress
* ratio in \[0, 1\].
*/
fun showProgressBar(progress: Float)
I implemented the progress bar to only accept values between 0 and 1 as shown below. In this case, caller
refers to the code using showProgressBar
. Also, coerceAtMost
and coerceAtLeast
are functions used to constrain a number to a maximum or minimum limit, respectively (they serve the same purpose as min
and max
).
fun caller() {
... // snip
val cappedProgress = progress.coerceAtMost(1F)
showProgressBar(cappedProgress)
}
fun showProgressBar(progress: Float) {
val progressInRange = progress.coerceAtLeast(0F)
... // snip
}
What's the issue with this code?
It's fine as long as someone else checks the code
The issue with this code is the ambiguity regarding who is responsible for ensuring the values are within the [0, 1] range. The upper limit check with coerceAtMost
is handled by the caller, while the lower limit check with coerceAtLeast
is done by the callee. This not only makes the function prone to misuse but also increases the risk of bugs during specification or implementation changes.
As a fundamental principle, function (or method) calls should be foolproof. Here are two ways to achieve this.
Option 1: Rely only on yourself
The first option involves performing checks within the callee. This is particularly effective in scenarios where you can't safely manage checked and unchecked states.
Since a regular Float
isn't limited to the [0, 1] range, it's challenging to guarantee that a Float
argument will always fall within this range. The argument might be a user-entered value or one provided by an external system. In most cases, it's safer to consider the argument as unreliable.
In the implementation below, if a number outside the [0, 1] range is provided, it's treated as 0 or 1, accordingly.
fun showProgressBar(progress: Float) {
val progressInRange = progress.coerceIn(0F, 1F)
... // snip
}
If the caller wishes to handle out-of-range values differently, one approach is to inform them of the operation's success via a return value. In the code below, if an out-of-range argument is provided, it returns false
.
fun showProgressBar(progress: Float): Boolean {
if (progress !in 0F..1F) {
return false
}
... // snip
return true
}
To make the caller aware of potential errors, you could enforce return value handling (like Java's Error Prone library's @CheckReturnValue
, Android Kotlin's @CheckResult
, or typical functions in Swift or Rust) or use checked exceptions.
Option 2: Type-safe "checked certificates"
The second method is to create a type that ensures a value falls within a specific range, thereby guaranteeing that showProgressBar
only receives correct values.
For the showProgressBar
example, creating a "checked" model like the one below would be ideal.
class ProgressRatio(rawValue: Float) {
val value = rawValue.coerceIn(0F..1F)
}
fun showProgressBar(progressRatio: ProgressRatio)
If the caller wants to handle out-of-range values, creating a factory function (or a failable initializer) often works well.
class ProgressRatio private constructor(val value: Float) {
companion object {
fun of(value: Float): ProgressRatio? =
if (value in 0F..1F) ProgressRatio(value) else null
}
}
Using a type-safe value (like null
or Optional
) to indicate an error in the factory function makes the code more robust. Using unchecked exceptions (like IllegalArgumentException
) can make the class difficult to use without detailed knowledge.
In summary: Avoid code that implicitly assumes "it has been checked."
Keywords:
implicit dependency
,state check logic
,type safety