Contracts, Crossinline, and Noinline
Contracts, Crossinline, and Noinline
What inline Actually Does
When you mark a function inline, the compiler replaces every call site with the function’s body, and every lambda argument’s body is spliced directly into the generated code. No function call. No Function object allocation.
Consider this function:
inline fun measure(block: () -> Unit) {
val start = System.nanoTime()
block()
val elapsed = System.nanoTime() - start
println("Took ${elapsed}ns")
}
When you call it:
fun processData() {
measure {
database.query("SELECT * FROM users")
transformResults()
}
}
The compiler generates bytecode equivalent to:
fun processData() {
val start = System.nanoTime()
database.query("SELECT * FROM users")
transformResults()
val elapsed = System.nanoTime() - start
println("Took ${elapsed}ns")
}
The measure function is gone. The lambda body is pasted in place. If you decompile the bytecode, you’ll find no Function0 allocation, no invoke() call, no trace that measure ever existed.
In Java, the closest equivalent is:
void processData() {
long start = System.nanoTime();
Runnable block = () -> {
database.query("SELECT * FROM users");
transformResults();
};
block.run();
long elapsed = System.nanoTime() - start;
System.out.println("Took " + elapsed + "ns");
}
Even with JIT optimization, the JVM may inline the lambda — but it’s not guaranteed. Kotlin’s inline keyword makes it a compile-time guarantee. The cost is always zero.
Non-Local Returns
Inlining introduces a behavior that doesn’t exist in Java: non-local returns. Because the lambda body is copied into the enclosing function, a return inside the lambda returns from the enclosing function, not from the lambda.
fun findFirstNegative(numbers: List<Int>): Int? {
numbers.forEach { number -> // forEach is inline
if (number < 0) {
return number // returns from findFirstNegative, not from forEach
}
}
return null
}
After inlining, this becomes:
fun findFirstNegative(numbers: List<Int>): Int? {
for (number in numbers) {
if (number < 0) {
return number // clearly returns from findFirstNegative
}
}
return null
}
The return inside the lambda acts as if the lambda’s boundary doesn’t exist — because after inlining, it literally doesn’t. This is what “non-local” means: the return exits a scope beyond the lambda itself.
In a non-inline lambda, this is impossible:
fun process(numbers: List<Int>, action: (Int) -> Unit) { // not inline
numbers.forEach { action(it) }
}
fun caller() {
process(listOf(1, -2, 3)) { number ->
if (number < 0) return // ERROR: 'return' is not allowed here
}
}
The lambda is an actual object. Its code runs inside Function1.invoke(). A return would exit invoke(), but the compiler can’t guarantee that would meaningfully exit caller(). So it’s prohibited.
crossinline: Blocking Non-Local Returns
Sometimes you write an inline function but pass the lambda into a context where non-local returns are structurally impossible — like launching it on another thread, wrapping it in a Runnable, or storing it for later execution.
inline fun runOnUiThread(block: () -> Unit) {
val runnable = Runnable { block() } // ERROR: can't inline 'block' here
handler.post(runnable)
}
The problem: block is inlined, which means non-local returns are allowed inside it. But block() is called inside a Runnable — an object that will execute later, possibly on a different thread. A non-local return would try to return from a function that has already completed. That’s undefined behavior.
The fix is crossinline. It tells the compiler: “inline this lambda’s body, but prohibit non-local returns.”
inline fun runOnUiThread(crossinline block: () -> Unit) {
val runnable = Runnable { block() } // OK: block is inlined but can't use non-local return
handler.post(runnable)
}
fun updateUI() {
runOnUiThread {
textView.text = "Updated"
// return // ERROR: 'return' is not allowed here (crossinline)
}
}
The lambda body is still inlined — no Function object for block. But the compiler enforces that return inside block can only be a local return (using return@runOnUiThread), never a non-local one.
When do you need crossinline? Whenever an inline function passes its lambda parameter to another lambda or stores it in an object:
inline fun <T> Iterable<T>.parallelForEach(crossinline action: (T) -> Unit) {
val scope = CoroutineScope(Dispatchers.Default)
forEach { element ->
scope.launch {
action(element) // executed in a coroutine — non-local return is impossible
}
}
}
Without crossinline, the compiler rejects this because action is used inside a coroutine lambda (launch { }), which is a different execution context.
noinline: Keeping the Lambda as an Object
The opposite situation: sometimes you need the lambda to remain a Function object. Maybe you want to store it in a collection, pass it to a non-inline function, or return it from the current function.
inline fun setupCallbacks(
onSuccess: () -> Unit,
noinline onError: (Exception) -> Unit // kept as a Function object
) {
// onSuccess is inlined — its body is copied here
onSuccess()
// onError stays as a Function1 instance — can be stored, passed around
errorHandler.register(onError)
}
Without noinline, the compiler won’t let you pass onError to errorHandler.register() because inline lambdas don’t exist as objects — their body is spliced into the call site. There’s nothing to pass.
Use noinline when:
- Storing the lambda in a field or collection
- Passing it to a function that isn’t
inline - Returning it from the inline function
- Using it as an argument to a function that expects a
Functiontype
inline fun <T> buildPipeline(
noinline transform: (T) -> T,
noinline validate: (T) -> Boolean
): Pipeline<T> {
// Both lambdas must be objects because they're stored in Pipeline
return Pipeline(transform, validate)
}
A function where every lambda parameter is noinline gains nothing from being inline itself (unless it uses reified type parameters). The compiler will warn you about this.
Kotlin Contracts
Contracts are Kotlin’s mechanism for communicating information to the compiler that it can’t infer on its own. They’re declared inside a function body using the contract { } block and affect control flow analysis, smart casts, and variable initialization checks.
callsInPlace: Telling the Compiler How Often a Lambda Runs
The most common contract. Consider this code:
fun initialize() {
val name: String
run {
name = "Kotlin" // ERROR without contract: val might be reassigned
}
println(name) // ERROR without contract: val might not be initialized
}
Without a contract, the compiler can’t know if the lambda passed to run executes zero times, once, or multiple times. If it runs zero times, name is uninitialized at println. If it runs twice, name is assigned twice — illegal for a val. So the compiler rejects both lines.
Here’s how run declares its contract:
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
callsInPlace(block, InvocationKind.EXACTLY_ONCE) tells the compiler: “I guarantee this lambda runs exactly one time.” Now the compiler knows name will be assigned exactly once — valid for val — and will definitely be initialized before println.
The four invocation kinds:
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) // run, with, apply, also
callsInPlace(block, InvocationKind.AT_LEAST_ONCE) // at least once
callsInPlace(block, InvocationKind.AT_MOST_ONCE) // zero or one time
callsInPlace(block, InvocationKind.UNKNOWN) // no guarantees (default)
}
returns: Enabling Smart Casts
The returns contract tells the compiler what conditions are true when the function returns normally (without throwing):
fun requireNotNull(value: Any?): Unit {
contract {
returns() implies (value != null)
}
if (value == null) throw IllegalArgumentException("Value must not be null")
}
After calling requireNotNull(x), the compiler knows x is not null:
fun process(input: String?) {
requireNotNull(input)
// After this line, input is smart-cast to String (non-null)
println(input.length) // no ?. needed
}
This is how require(), check(), and checkNotNull() in the standard library enable smart casts. The contract bridges the gap between runtime validation and compile-time type narrowing.
In Java, there’s no way to express this. Even with Objects.requireNonNull(input), the compiler still treats input as @Nullable after the call. You need explicit casts or suppression annotations.
void process(@Nullable String input) {
Objects.requireNonNull(input);
// Java still considers input nullable here
System.out.println(input.length()); // warning or error with strict null checking
}
Writing Your Own Contract Functions
You can define contracts on your own functions. A practical example — a transaction function that guarantees exactly-once execution:
inline fun <T> transaction(block: () -> T): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
val connection = connectionPool.acquire()
try {
connection.beginTransaction()
val result = block()
connection.commit()
return result
} catch (e: Exception) {
connection.rollback()
throw e
} finally {
connectionPool.release(connection)
}
}
Usage:
fun transferFunds(from: Account, to: Account, amount: BigDecimal) {
val receipt: TransferReceipt
transaction {
from.debit(amount)
to.credit(amount)
receipt = TransferReceipt(from, to, amount, Instant.now())
}
// receipt is guaranteed to be initialized here
emailService.sendConfirmation(receipt)
}
Without the callsInPlace contract, the compiler would reject receipt as potentially uninitialized after the transaction block. The contract communicates: “the block runs exactly once, so any val assigned inside it is definitely initialized.”
Another example — a conditional execution function with a returns contract:
inline fun assertAuthorized(user: User?, permission: Permission) {
contract {
returns() implies (user != null)
}
if (user == null || !user.hasPermission(permission)) {
throw UnauthorizedException("User lacks permission: $permission")
}
}
fun deleteResource(user: User?, resourceId: String) {
assertAuthorized(user, Permission.DELETE)
// user is smart-cast to non-null User here
auditLog.record(user.id, "deleted $resourceId")
}
Contract Limitations
Contracts are currently experimental (@ExperimentalContracts) and have real restrictions:
- Only top-level functions — you can’t declare contracts on class methods or local functions
- First statement — the
contract { }block must be the first statement in the function body - Not verified — the compiler trusts your contract. If you declare
EXACTLY_ONCEbut call the block twice, you get undefined behavior. The compiler does not check your implementation against the contract - Limited expressions — only
callsInPlaceandreturns() implies (condition)are supported. You can’t express “this function never returns” or “this parameter is modified”
// This compiles but is WRONG — the contract lies
inline fun broken(block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
// Calling block twice violates the contract
block()
block() // compiler doesn't catch this
}
The contract system depends on developer honesty. Treat contracts like unsafe blocks in Rust — they bypass compiler checks and place the correctness burden on you.
Performance: When Inlining Helps vs. Hurts
Inlining eliminates lambda allocation and virtual dispatch. For small, frequently-called functions — scope functions, DSL builders, collection operations — the benefit is clear: zero overhead abstraction.
But inlining has a cost: code size. Every call site gets a copy of the function body and every lambda body. A large inline function called from 50 places generates 50 copies of its bytecode.
// Good candidate for inline: small body, takes lambda
inline fun <T> logged(tag: String, block: () -> T): T {
log.debug("[$tag] start")
val result = block()
log.debug("[$tag] end")
return result
}
// Bad candidate for inline: large body
inline fun processLargeDataset(data: List<Record>, transform: (Record) -> Output): List<Output> {
// 200 lines of processing logic
// Every call site copies all 200 lines
// JVM's own inliner may be more selective and efficient
}
Guidelines:
- Inline functions with lambda parameters where avoiding allocation matters
- Inline small utility functions (1-5 lines) used frequently
- Don’t inline large functions without lambda parameters — let the JIT handle it
- Don’t inline functions that call other large inline functions — the expansion compounds
The compiler warns if you mark a function inline but it has no lambda parameters and doesn’t use reified types. Heed that warning. Inlining for the sake of inlining can increase your APK/JAR size and hurt instruction cache performance — the opposite of what you intended.
The Full Inline Spectrum at a Glance
inline fun example(
block: () -> Unit, // inlined, non-local return allowed
crossinline restricted: () -> Unit, // inlined, non-local return prohibited
noinline kept: () -> Unit // NOT inlined, remains a Function object
) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block() // body spliced here, can return from caller
val runnable = Runnable {
restricted() // body spliced here, can't return from caller
}
someOtherFunction(kept) // passed as Function0 object
}
Each modifier addresses a specific constraint:
- Default (no modifier): maximum optimization, full non-local return capability
crossinline: inlined but used in a context where non-local returns are structurally impossiblenoinline: the lambda must exist as an object — storage, passing to non-inline code, or reflection
Java has none of these controls. Lambda optimization is entirely at the JVM’s discretion, with no way for the developer to communicate intent, guarantee inlining, or influence control flow analysis. Kotlin’s inline system gives you explicit control over the compilation strategy of higher-order functions — and the contracts system lets you feed that information back to the type checker.