Kotlin contracts
Motivation
The Kotlin smartcast feature allows to write cleaner code and reduce boilerplate. However, it does not handle all possible situations properly. For example, suppose that we define a function that returns a distinct value if its parameter is not null. When we execute that function under the condition that the distinct value has been returned, then the Kotlin compiler does not automatically cast the parameter to a non-nullable type.
The following snippet illustrates this situation. It is a program that does not compile because the parameter s
has not been casted to a non-null String.
fun checkNotNull(s: String?) = s != null
fun main(){
//Not handeled by smartcast
if (checkNotNull(s)) print(s.length)
}
It is clear for a human that if checkNotNull
returns true
, this implies that s
is not null
. We can thus use the return value of checkNotNull
as a hint for casting s
to a String
instead of a String?
. However, the Kotlin compiler, by itself, doesn’t make this guess.
In order to solve this problem, contracts
were introduced in Kotlin 1.3. Their goal is improve smartcast analysis as well as variable initialization analysis in higher order function. We will give example of both cases in remainder of this article. Another benefit is that contracts are only used at compile time.
The next section shows how to use contracts in order to improve smartcast analysis.
Improve smartcast analysis
Defining a contract in checkNotNull
allows the compiler to understand that when the functions returns true
, then the parameter s
will inevitably be not null. Thanks to that, in any code that is executed if checkNotNull
returns true
, then, its parameter s
can be safely casted to a non-null string.
The following code snippet shows how to define such a contract
:
contract {
returns(true) implies(s != null)
}
This contract means that when the function returns true
, then s is not null. In other words, if checkNotNull(s)
is true, thus, s
is also not null.
Here is the final code. Please note that we need to use the @ExperimentalContracts
annotation to avoid the experimental feature warnings.
@ExperimentalContracts
fun checkNotNull(s: String?):Boolean {
contract {
returns(true) implies(s != null)
}
return s != null
}
@ExperimentalContracts
fun main(){
val s: String? = null
if(checkNotNull(s)) print(s.length)
}
This new code compiles just fine because s
is automatically casted to a String
thanks to the information provided by the contract.
Another neat example is the definition of a require
function. Let’s define myRequire
function that takes a condition as parameter. This function throws an exception when the condition is false
. We can add a contract to myRequire
that allows the caller to assume that the condition is true and apply it to smartcasting.
@ExperimentalContracts
fun myRequire(condition: Boolean) {
contract {
returns() implies condition
}
if(condition == false) throw IllegalArgumentException()
}
@ExperimentalContracts
fun main(){
myRequire(s is String)
//s smart casted because the app returns only if condition is true
print(s.length)
}
In this example, the contract calls returns()
without parameters. This means that the implies
part is valid when the function just returns without causing an exception or crash.
The condition implied in the contract is passed as a parameter. Here, the condition is about the type of s
. This means that when the function returns or exits, the compiler smartcasts s taking into account that s is String
is true. This means that s
will be casted to a non-nullable String. That’s a pretty powerful feature when we think of it .
Contracts are not just used to improve smartcasting. The next section shows how they can improve variable initialization analysis.
Improve variable initialization analysis in higher-order functions
Quick reminder: a higher order function is a function that takes a function as parameter. Some famous higher order functions are: map, filter and reduce.
We have seen in the previous section that require
enables smartcasting based on the return value of a function. In addition to that, the require
function has allows to help the compiler know many times a function is called inside a higher order one. This is possible thanks to the callsInPlace
contract.
Let’s understand that with an example. Suppose that we have a function that calls another one passed as a parameter as follows:
fun executeOnceWithoutContracts(functionToRun: () -> Unit) {
functionToRun()
}
The functionToRun
parameter is called only once. This detail is important for the next part of the code. In fact, the next part calls the previous function as follows:
fun startGameV1(): Int {
val lives: Int
executeOnceWithoutContracts {
lives = 5
}
//Compile error here !
return lives
}
Inside the startGameV1
function, we call the executeOnceWithoutContracts
.
From a human perspective, executeOnceWithoutContracts
should initialize lives
exactly once because we know that its parameter is called only once. Thus, since the initialization lives = 5
is executed only once, then lives
remains a valid constant. However, the compiler doesn’t know how many times the function passed as a parameter to executeOnceWithoutContracts
gets called. This means that the compiler assumes any number of calls and thus forbids declaring lives
as val
because it may be reassigned, once, many times, or not at all.
Thanks to contracts, we can give a helping hand to the compiler by telling it that the function passed to executeOnceWithoutContracts
is executed only once. This is possible thanks to the callsInPlace
function that takes the function to run and the number of times the latter is called as parameters. The second parameter is expressed as an enumeration with 4 possible values:
- UNKNOWN: equivalent to not using a contract at all
- AT_LEAST_ONCE
- AT_MOST_ONCE
- EXACTLY_ONCE: This correspond to our current case
The new definition of the executeOnce
function is as follows:
@ExperimentalContracts
fun executeOnce(functionToRun: () -> Unit) {
contract {
// The compiler understands that functionToRun() will be called precisely one time
callsInPlace(functionToRun, kotlin.contracts.InvocationKind.EXACTLY_ONCE)
}
functionToRun()
}
Please note that for kotlin < 1.3.20, an inline
qualifier must be added to executeOnce
. Kotlin 1.3.20 will not require the inline qualifier
The startGame
function now compiles fine:
@ExperimentalContracts
fun startGameV2(): Int {
val lives: Int
executeOnce {
lives = 5
}
// no compile error!
return lives
}
Thank to the callsInPlace
contract, the lambda passed to executeOnce
is recognized by the compiler as a block that runs once and only once. Thus, the lives
constant is correctly initialized and the code compiles and runs correctly.
The next section illustrates how we can already benefit from contract in stdlib.
Contracts are already used in Kotlin stdlib
Even though contracts are still experimental, the Kotlin stdlib already makes use of them and benefits from its improvements. The cherry on the cake is that this part is stable and does require additional annotations or flags to activate it.
The following code snippet shows some Kotlin stdlib functions that use contracts.
fun main(){
var s: String? = null
s = readLine()
if(s == "null") s = null
//isNullOrEmpty uses returns contract for smarcasting
if( ! s.isNullOrEmpty() ){
println("s is not null. Length ${s.length}")
}else{
println("s is null")
}
//require uses returns contact on the condition
require(s is String)
println("s is a String. Length: ${s.length}")
val lock = "lock"
val message: String
//callsInPlaceContract once for the block
synchronized(lock) {
message = "initialized"
}
println("message $message")
}
In the above code, the standard functions isNullOrEmpty
and require
both use the returns
contract to improve smartcasting. While the synchronized
function uses callsInPlace
to inform the compiler that the passed function is executed exactly once.
The following section concludes this post.
Conclusion
This article presented the contracts feature introduced in Kotlin 1.3. It allows to provide hints to the compiler to make it much smarter in some situations.
We have seen two use cases where contracts prove to be a useful approach. Firstly, they improve smartcast analysis (first example). Secondly, they allow to improve variable initialization analysis in higher-order functions (second example).
While contracts are still experimental, they are already used in the stdlib and are stable in that case.