06 March 2026
In the previous guide on higher-order functions, I mentioned that every lambda compiles to an anonymous class implementing one of the FunctionN interfaces. Thatâs a clean abstraction, but abstractions have costs. Each time you call a higher-order function like filter, map, or your own custom one, the JVM creates a Function1 or Function2 object, allocates it on the heap, and invokes its invoke method through a virtual dispatch. On most code paths, this is perfectly fine â the JVMâs garbage collector handles short-lived objects efficiently, and the overhead is negligible. But on hot paths â RecyclerView binding, custom drawing, tight loops processing thousands of items â those allocations add up fast. Every frame you have roughly 16 milliseconds of budget, and burning part of it on lambda object creation and GC pauses is waste you can eliminate entirely.
This is exactly what inline solves. When you mark a function as inline, the Kotlin compiler doesnât generate a function call at all. Instead, it copies the entire function body â and the lambda body â directly into the call site at compile time. No Function object, no virtual call, no heap allocation. The generated bytecode looks as if you wrote the loop and the logic yourself, right there in the calling function. The Kotlin standard library team understood this from day one, which is why let, run, apply, also, with, filter, map, forEach, flatMap, and essentially every higher-order function that takes a lambda and runs it immediately are all marked inline. Itâs not an optimization afterthought â itâs a deliberate design decision baked into the languageâs approach to functional programming on the JVM.
But inline does more than just eliminate allocation. It unlocks three capabilities that arenât possible without it: non-local returns, crossinline control, and reified type parameters. And when you combine inline with Kotlinâs contracts system, you get compiler guarantees about how lambdas execute that enable things like val initialization inside lambda blocks. These features fit together into a cohesive system, and understanding how they interact is what separates surface-level Kotlin from the kind of code youâd find in the standard library source.
The mechanics are straightforward but the implications are significant. When the compiler encounters an inline function call, it takes the function body and pastes it directly at the call site. It also takes the lambda argument and pastes that body inline too. The result is zero abstraction overhead â the generated bytecode is identical to what youâd write if you hand-coded the logic without any higher-order function.
Consider a simple timing utility:
inline fun <T> measureTime(block: () -> T): T {
val start = System.nanoTime()
val result = block()
val elapsed = System.nanoTime() - start
Log.d("Perf", "Took ${elapsed / 1_000_000}ms")
return result
}
// Call site
val users = measureTime {
userRepository.fetchActiveUsers()
}
Without inline, this creates a Function0<List<User>> object, calls measureTime as a normal function, and inside that function, invokes block.invoke(). With inline, the compiler generates something equivalent to:
// What the compiler actually produces at the call site
val start = System.nanoTime()
val users = userRepository.fetchActiveUsers()
val elapsed = System.nanoTime() - start
Log.d("Perf", "Took ${elapsed / 1_000_000}ms")
No function call. No object creation. The measureTime function body and the lambda body are flattened into the surrounding code as if they were always there. This is why the standard libraryâs measureTimeMillis and measureNanoTime are inline â a timing utility that adds its own overhead to the measurement would defeat its own purpose.
The performance difference is real and measurable. In a tight loop running 100,000 iterations, a non-inline higher-order function creates 100,000 Function objects that the GC has to collect. The inline version creates zero. On Android, where GC pauses can cause dropped frames, this matters. Iâve profiled apps where replacing a custom non-inline runIf helper with an inline version eliminated a noticeable stutter in a LazyColumn that was rebinding hundreds of items during fast scrolling.
Hereâs where inlining gets interesting beyond performance. Because an inline lambdaâs body is pasted directly into the calling functionâs bytecode, a return statement inside that lambda doesnât return from the lambda â it returns from the enclosing function. This is called a non-local return, and itâs the reason return works inside forEach the way youâd expect it to work inside a for loop.
fun findFirstAdmin(users: List<User>): User? {
users.forEach { user ->
if (user.role == Role.ADMIN) {
return user // Returns from findFirstAdmin, not just the lambda
}
}
return null
}
This works because forEach is inline. The lambda body is pasted into findFirstAdminâs bytecode, so return user is a normal return from findFirstAdmin. If forEach were not inline, this would be a compiler error â you canât return from an enclosing function through a non-inline lambda, because the lambda is a separate Function object with its own execution context.
This is a subtle but powerful feature. It means inline higher-order functions can participate in control flow the same way built-in language constructs do. You can use return inside run { } to exit the enclosing function. You can use return inside with(obj) { } to bail early. The standard library is designed so that these scope functions feel like language keywords, and non-local returns are a big part of why they feel that way.
One thing to be aware of: if you only want to return from the lambda itself (not the enclosing function), use a labeled return like return@forEach. This becomes important when youâre using forEach but want to skip an item rather than exit the whole function â return@forEach is the equivalent of continue in a for loop.
Not every lambda parameter in an inline function should be inlined. Sometimes you need to store a lambda in a property, pass it to another function that isnât inline, or use it in a way thatâs incompatible with inlining. Thatâs what noinline is for â it excludes a specific lambda parameter from the inlining process.
inline fun trackEvent(
eventName: String,
properties: () -> Map<String, String>,
noinline onComplete: () -> Unit
) {
val props = properties() // This lambda IS inlined
analyticsService.track(eventName, props, onComplete) // onComplete is passed as an object
}
In this example, properties is inlined normally â its body is pasted into the call site. But onComplete needs to be passed to analyticsService.track(), which is a regular function expecting a Function0 object. You canât paste inlined bytecode into a regular function parameter â it needs an actual object reference. Marking it noinline tells the compiler to create the Function0 object for that parameter only, while still inlining everything else. Youâd also use noinline when you need to store a lambda in a variable or property for later execution.
crossinline solves a different problem. Sometimes an inline function needs to invoke a lambda from a different execution context â inside an anonymous object, another lambda, or a nested function. In these cases, non-local returns canât work because the lambda isnât directly part of the enclosing functionâs bytecode flow anymore. crossinline marks a lambda that will be inlined but cannot use non-local returns.
inline fun launchWithRetry(
maxAttempts: Int,
crossinline block: suspend () -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
repeat(maxAttempts) { attempt ->
try {
block() // Called inside launch's lambda â different execution context
return@launch
} catch (e: Exception) {
Log.w("Retry", "Attempt $attempt failed: ${e.message}")
}
}
}
}
The block lambda is marked crossinline because itâs invoked inside the launch coroutine lambda â a different execution context. The lambda body will still be inlined (you get the allocation benefit), but the compiler prevents anyone from using return inside block to exit the enclosing function, because that control flow would be meaningless across coroutine boundaries. Without crossinline, the compiler would refuse to compile this code entirely, because it canât guarantee non-local return safety.
My mental model for these modifiers is simple: noinline means âdonât inline this lambda at all, I need it as an object.â crossinline means âdo inline this lambda, but forbid non-local returns because the execution context doesnât support them.â
This is, I think, the most practically useful feature that inline enables, and it solves a problem that every JVM developer has hit. On the JVM, generic type parameters are erased at runtime. If you write fun <T> parse(json: String): T, by the time the function runs, the JVM has no idea what T actually is. You canât write T::class, you canât do is T checks, you canât create instances of T. The type is gone. This is why Java APIs end up taking Class<T> parameters everywhere â itâs the only way to pass type information to runtime code.
reified type parameters on inline functions solve this entirely. Because the function body is copied to the call site, and the call site knows the concrete type, the compiler can substitute the actual type directly into the inlined code. The type information is preserved because the inlining happens at compile time â thereâs nothing to erase.
inline fun <reified T> fromJson(json: String): T {
val adapter = moshi.adapter(T::class.java)
return adapter.fromJson(json)
?: throw JsonDataException("Failed to parse ${T::class.simpleName}")
}
// Call site â no Class parameter needed
val user: User = fromJson("""{"name":"Mukul","role":"admin"}""")
val config: AppConfig = fromJson(configJsonString)
Without reified, youâd write fromJson<User>(json, User::class.java) â redundant and noisy. With reified, the compiler inlines the call and replaces T::class.java with User::class.java at the call site. The generated bytecode is identical to writing moshi.adapter(User::class.java) directly.
The standard library uses this pattern in filterIsInstance, which filters a collection to only elements of a specific type:
inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
val result = mutableListOf<T>()
for (element in this) {
if (element is T) { // Only possible with reified
result.add(element)
}
}
return result
}
// Usage
val mixedViews: List<View> = parentLayout.children.toList()
val buttons: List<Button> = mixedViews.filterIsInstance<Button>()
The element is T check compiles because T is reified â the compiler substitutes the actual type (Button) at the call site, turning it into element is Button. Without reified, youâd need to pass a Class<T> parameter and use clazz.isInstance(element), which is reflection and significantly slower.
One important limitation: reified only works with inline functions, and it only works with concrete types at the call site. You canât call a reified function with another generic type parameter that isnât itself reified â the compiler needs to know the actual type at compile time.
Contracts are an experimental feature that lets you tell the compiler things about your functionâs behavior that it canât figure out on its own. The compiler is smart, but it has limits. It can smart-cast inside an if block, but it canât smart-cast through a function call. It knows a for loop body runs zero or more times, but it doesnât know that your custom synchronized block runs its lambda exactly once.
The callsInPlace contract is the one I use most. It tells the compiler how many times a lambda parameter will be invoked â EXACTLY_ONCE, AT_MOST_ONCE, or AT_LEAST_ONCE. This enables val initialization inside lambda blocks, which would otherwise be a compiler error.
@OptIn(ExperimentalContracts::class)
inline fun <T> executeExactlyOnce(block: () -> T): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
fun processOrder(orderId: String) {
val timestamp: Long // Not initialized
executeExactlyOnce {
timestamp = System.currentTimeMillis() // Compiler allows this
}
println("Order $orderId processed at $timestamp") // Compiler knows timestamp is initialized
}
Without the contract, the compiler would flag timestamp as potentially uninitialized at the println line. It canât prove that block() runs exactly once just by looking at the function signature. The contract bridges that gap â youâre promising the compiler that the lambda runs exactly once, and it trusts you. The standard libraryâs run, let, with, apply, and also all have callsInPlace(block, EXACTLY_ONCE) contracts, which is why you can initialize val variables inside them.
The returns contract enables smart casts through function calls. The standard libraryâs require() and check() use this:
@OptIn(ExperimentalContracts::class)
fun requireNotBlank(value: String?) {
contract {
returns() implies (value != null)
}
if (value.isNullOrBlank()) {
throw IllegalArgumentException("Value must not be blank")
}
}
fun handleUserInput(input: String?) {
requireNotBlank(input)
// Compiler smart-casts 'input' to String (non-null) here
println("Processing: ${input.uppercase()}")
}
The contract says: âif this function returns normally (doesnât throw), then value != null is guaranteed.â The compiler uses this to smart-cast input to String after the call. Without contracts, the compiler would still treat input as String? even though the function guarantees itâs not null by throwing on null/blank values. This is exactly how requireNotNull(), checkNotNull(), and check() work in the standard library â they all have returns() implies contracts.
One honest caveat: contracts are still experimental. The API has been stable enough that the standard library relies on them heavily, and they havenât changed significantly since Kotlin 1.3. But the compiler doesnât verify that your contract is correct â if you declare callsInPlace(block, EXACTLY_ONCE) but actually call block twice, the compiler will trust your lie and youâll get undefined behavior. Write contracts carefully, and only for functions where the guarantee is genuinely true.
After all this, you might be tempted to mark everything inline. Donât. The Kotlin compiler will actually warn you if you write an inline function that has no lambda parameters and no reified type parameters â because in that case, inlining just bloats the bytecode without any benefit. The function body gets copied to every call site, increasing your APKâs dex method count and potentially hurting instruction cache performance.
The standard library follows a clear rule, and I think itâs the right one. Mark a function inline when it takes lambda parameters that are invoked immediately â the allocation savings justify the code duplication. Mark it inline when you need reified type parameters â thereâs no alternative. Donât mark it inline when the function body is large (more than 10-15 lines of logic), because the bytecode bloat at every call site outweighs the allocation savings. And definitely donât mark it inline when the function doesnât take any lambda parameters â thereâs literally no benefit, and the compiler will tell you so.
Thereâs a real-world tradeoff here that most guides donât mention. Inlining a function means its body is copied into every caller. If you change the implementation of an inline function in a library, every consumer needs to recompile â the old inlined bytecode is baked into their compiled classes. For public API functions, this means inline functions become part of your binary compatibility contract. The Kotlin team handles this with @PublishedApi for internal helpers that inline functions need to call, and you should think about the same considerations if youâre writing library code. For application code, where you recompile everything together, this isnât a concern.
My personal heuristic: if the function takes a lambda, runs it immediately, and the function body is short, make it inline. If it needs reified, make it inline. Everything else, leave it alone. The standard library got this right from the start, and following the same pattern has never steered me wrong.
Thanks for reading!
inline functions?Explanation:
inlinecopies the function and lambda bodies directly into the calling functionâs bytecode. No Function object is created, no virtual call happens. This is whylet,run,apply,filter, andmapare all inline in the standard library.
reified allow you to do?Explanation: Normally, generic type parameters are erased at runtime on the JVM â you canât check
is Tor accessT::class.reifiedon an inline function inlines the actual type at the call site, preserving type information at runtime.
Write an inline function retry with: maxAttempts: Int, a crossinline onRetry: (attempt: Int, exception: Exception) -> Unit callback, and a block: () -> T lambda. It should retry the block up to maxAttempts times, calling onRetry on failures. Also write an inline function <reified T> safeJsonParse(json: String): Result<T> that uses reified types to parse JSON safely. Include a contract that tells the compiler the block runs at least once.
@OptIn(ExperimentalContracts::class)
inline fun <T> retry(
maxAttempts: Int,
crossinline onRetry: (attempt: Int, exception: Exception) -> Unit,
block: () -> T
): T {
contract {
callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
}
require(maxAttempts > 0) { "maxAttempts must be positive" }
var lastException: Exception? = null
repeat(maxAttempts) { attempt ->
try {
return block()
} catch (e: Exception) {
lastException = e
if (attempt < maxAttempts - 1) {
onRetry(attempt + 1, e)
}
}
}
throw lastException!!
}
inline fun <reified T> safeJsonParse(json: String): Result<T> {
return try {
val adapter = moshi.adapter(T::class.java)
val parsed = adapter.fromJson(json)
if (parsed != null) {
Result.success(parsed)
} else {
Result.failure(
JsonDataException("Null result parsing ${T::class.simpleName}")
)
}
} catch (e: Exception) {
Result.failure(e)
}
}
// Usage
val config: AppConfig = retry(
maxAttempts = 3,
onRetry = { attempt, error ->
Log.w("Config", "Attempt $attempt failed: ${error.message}")
Thread.sleep(1000L * attempt)
}
) {
configService.fetchRemoteConfig()
}
val userResult: Result<User> = safeJsonParse<User>(responseBody)
userResult.onSuccess { user -> updateUI(user) }
.onFailure { error -> showError(error.message) }