Story Opening

Friday, 9:40. Kabir had the failing audit test from Thursday open in the debugger and a breakpoint inside the forEach lambda.

// Fragment of story/RuleAudit.kt
class PriceRule(val name: String, val minPaise: Long, val percentOff: Int)
// Kabir's version: 'return' meant "skip this rule", as it would inside a Java lambda.
fun auditBroken(pricePaise: Long, rules: List<PriceRule>, log: MutableList<String>) {
rules.forEach { rule ->
if (pricePaise < rule.minPaise) return // returns from auditBroken, not from the lambda
log += "${rule.name}: -${rule.percentOff}%"
}
log += "audited ${rules.size} rules"
}

He stepped over the return. In Java he would have landed on the next element of the loop. Instead the debugger came out of auditBroken altogether, without running the line after the loop. He opened the stack view, looking for the frame of the lambda, and found none. The lambda’s code had run inside auditBroken, as if he had written it there.

“There’s no lambda,” he said to Lena, half a question.

“Not at runtime,” she said. “forEach is inline. The compiler copies your block into the function that calls it. So return means what it would mean in that function: leave it.” She pointed at the label syntax in the IDE’s quick-fix. “return@forEach is the one you wanted.”

The fix was eight characters. Kabir spent the rest of the morning working out what else he had assumed about lambdas.


Java → Kotlin: The Quick Map

JavaKotlinNote
Function<Long, Long>, LongUnaryOperator, BiFunction, Supplier…(Long) -> Long, (A, B) -> R, () -> TOne notation for every arity; no interface zoo
x -> x * 2{ x -> x * 2 } or { it * 2 }Always braces; it names a single parameter
list.forEach(x -> { if (…) return; … })forEach { if (…) return@forEach … }A bare return leaves the enclosing function
Captured locals must be effectively finalLambdas capture and mutate varsShared through a Ref box when the lambda is stored
Foo::bar, Foo::new, obj::bar::bar, ::Foo, obj::bar, Foo::propTop-level functions and properties too
@FunctionalInterfacefun interfaceJava interfaces convert automatically; Kotlin ones need fun
(JIT inlining, maybe)inline funThe compiler copies the body and the lambda into the caller
Recursion, then a manual rewrite to a looptailrec funThe compiler does the rewrite
Consumer<Builder>Builder.() -> UnitLambda with receiver: this is the builder

Conceptual Deep-Dive

Java fakes function types; Kotlin has them

Java 8 added lambdas without adding function types. A lambda has no type of its own: it becomes an instance of whatever single-method interface the context expects. So java.util.function needs 43 interfaces to cover the common shapes, plus primitive specialisations such as LongUnaryOperator to avoid boxing, and your own @FunctionalInterface for anything else.

Kotlin has real function types. (Long) -> Long is a type you can declare, store, return and make nullable, written the same way at every arity. On the JVM each one compiles to a generic interface, kotlin.jvm.functions.Function1<P1, R> for one parameter, so the type system is richer but the runtime representation is the same kind of object Java uses. One consequence follows from “generic”: a non-inline function type carries Long as an object, so values box on the way in and out. Java’s LongUnaryOperator doesn’t, and Kotlin has no primitive function type. Its answers are inline, which removes the object altogether, and the fun interface (later in this part), whose Long parameters compile to primitive long.

An inline lambda is code, not an object

This is the shift that explains Kabir’s bug. In Java, a lambda is always a separate method behind an object, and calling it is always a call. In Kotlin, there are two kinds of lambda:

  • Passed to an ordinary function, it is an object, like Java’s. It can be stored and called later, on another thread, after the caller has returned.
  • Passed to an inline function, it is not an object at all. The compiler pastes the inline function’s body into the caller and the lambda’s body into that, so at runtime the lambda’s statements are part of the caller.

The collection functions on List, Set and Map (forEach, map, filter…) are inline, and so are the scope functions such as let. The operations on Sequence, Kotlin’s lazy streams, are not, because their lambdas run later. Once you know which kind you have, the rules that surprise Java developers follow from it:

Inside a lambda passed to…returnbreak / continue of an outer loopCaptured varsRuntime cost
An inline function (forEach)Leaves the enclosing functionAllowed (Stable since 2.2)Plain localsNo lambda object, no boxing
An ordinary function (Sequence.map, your own)Prohibited; only return@labelProhibitedMoved into a heap Ref boxOne object (often cached), boxing of primitives

A non-local return makes sense only when the lambda’s code really is inside the caller. That is why the compiler forbids it everywhere else: a stored lambda may run after the function it would “return from” has finished. One more form changes only the return column: an anonymous function, fun(x) { … }, always returns from itself, like a Java lambda.


Technical Explanation

Function types, lambdas and higher-order functions

// A function type is a real type: (Long) -> Long, not an interface you had to pick from java.util.function.
val festive: (Long) -> Long = { pricePaise -> pricePaise * 90 / 100 }
// A higher-order function takes (or returns) a function.
fun applyRule(pricePaise: Long, rule: (Long) -> Long): Long = rule(pricePaise)
// Returning a function: each call builds a new rule that remembers its 'percent'.
fun discountOf(percent: Int): (Long) -> Long = { it * (100 - percent) / 100 }
// Parameter names in a function type are documentation only.
fun describe(pricePaise: Long, format: (paise: Long, currency: String) -> String): String =
format(pricePaise, "INR")
fun main() {
println(festive(16_500)) // -> 14850
println(applyRule(16_500, festive)) // -> 14850
// Trailing lambda: the last function argument moves outside the parentheses.
println(applyRule(16_500) { it - 1_000 }) // -> 15500
// 'it' is the implicit name of a single parameter; name it when the lambda isn't tiny.
println(applyRule(16_500) { price -> if (price > 10_000) price - 500 else price }) // -> 16000
val clearance = discountOf(40)
println(clearance(16_500)) // -> 9900
// The last expression is the lambda's value. No 'return'.
println(describe(16_550) { paise, currency -> "$currency ${paise / 100}.${paise % 100}" }) // -> INR 165.50
// A nullable function type is called with ?.invoke()
val optionalRule: ((Long) -> Long)? = null
println(optionalRule?.invoke(16_500) ?: 16_500) // -> 16500
}

When the last parameter is a function, the lambda moves outside the parentheses, which is why forEach { … } looks like a control structure.

Under the hood, a lambda passed to a non-inline function compiles to a private static method plus an invokedynamic call site, the same LambdaMetafactory mechanism Java uses. That has been the default since Kotlin 2.0 (-Xlambdas=indy; -Xlambdas=class restores the old one-class-per-lambda scheme). The inline section below shows the bytecode.

Closures capture variables, not values

class Promotion(val name: String, val minPaise: Long)
fun main() {
val rules = listOf(Promotion("BULK", 50_000), Promotion("FESTIVE", 10_000), Promotion("STAFF", 0))
// Java would reject this: 'applicable' is not effectively final. Kotlin captures the variable itself.
var applicable = 0
rules.forEach { if (16_500 >= it.minPaise) applicable++ }
println(applicable) // -> 2
// The lambda sees the variable, not a snapshot of its value.
var percent = 10
val discount = { pricePaise: Long -> pricePaise * (100 - percent) / 100 }
percent = 50
println(discount(16_500)) // -> 8250
}

Java requires captured locals to be effectively final because a Java lambda copies the value into its object, and a copy that could drift from the original would be confusing. Kotlin captures the variable, so the lambda and the enclosing function share it. The two halves of main show the two implementations:

// javap -c com.shelfwise.part05.closures.ClosuresKt, main() (simplified)
// applicable++ inside the inline forEach: an ordinary local
111: iload_1
112: iconst_1
113: iadd
114: istore_1
// var percent, captured by a stored lambda: moved into a heap box
129: new kotlin/jvm/internal/Ref$IntRef
140: putfield kotlin/jvm/internal/Ref$IntRef.element:I // percent = 10
144: invokedynamic invoke:(Lkotlin/jvm/internal/Ref$IntRef;)Lkotlin/jvm/functions/Function1;
153: putfield kotlin/jvm/internal/Ref$IntRef.element:I // percent = 50
private static final long main$lambda$1(kotlin.jvm.internal.Ref$IntRef, long);
4: getfield kotlin/jvm/internal/Ref$IntRef.element:I // read at call time

The inline forEach needs no box, because the lambda’s code is in main. The stored discount lambda gets a Ref$IntRef, which it reads when it runs, after percent became 50. That is why the result is 8250, not the 14850 that capturing the value would give.

Function and property references

data class Product(val sku: String, val name: String, val pricePaise: Long)
fun isPremium(product: Product): Boolean = product.pricePaise >= 50_000
class PriceBook(private val overrides: Map<String, Long>) {
fun priceOf(sku: String): Long = overrides[sku] ?: 0
}
fun main() {
val products = listOf(
Product("SHW-1001", "Toor Dal 1kg", 16_500),
Product("SHW-2040", "Basmati 5kg", 72_000),
)
// Top-level function reference: Java's Foo::isPremium without needing a class.
println(products.filter(::isPremium).map { it.name }) // -> [Basmati 5kg]
// Property reference: Product::name is a (Product) -> String.
println(products.map(Product::name)) // -> [Toor Dal 1kg, Basmati 5kg]
// Bound reference: the receiver is fixed when the reference is created.
val festiveBook = PriceBook(mapOf("SHW-1001" to 14_900))
val festivePrice: (String) -> Long = festiveBook::priceOf
println(festivePrice("SHW-1001")) // -> 14900
// Constructor reference: ::Product is a (String, String, Long) -> Product.
val make: (String, String, Long) -> Product = ::Product
println(make("SHW-3001", "Ghee 500ml", 34_000)) // -> Product(sku=SHW-3001, name=Ghee 500ml, pricePaise=34000)
// Unbound member reference: the receiver becomes the first parameter.
val length: (String) -> Int = String::length
println(length("SHW-1001")) // -> 8
}

::isPremium works because Kotlin has top-level functions, and Product::name works because properties are first-class. A bound reference (festiveBook::priceOf) fixes its receiver when it is created, like Java’s obj::method. An unbound one (String::length) takes the receiver as its first parameter, like Java’s String::length. The only real trap is overloads: ::overloaded with two candidates is an “overload resolution ambiguity” error until an expected type, such as val f: (Int) -> Int = ::overloaded, picks one.

SAM conversions and fun interface

PriceCheck stands in for any Java library’s functional interface:

/** A Java functional interface, as found in any Java library. */
@FunctionalInterface
public interface PriceCheck {
boolean accepts(long pricePaise);
}
import com.shelfwise.part05.javarules.PriceCheck
import java.util.concurrent.Callable
import java.util.concurrent.Executors
// A Kotlin interface needs 'fun' to accept a lambda. That marks it as a single abstract method type.
fun interface Discount {
fun discounted(pricePaise: Long): Long
}
interface PlainDiscount {
fun discounted(pricePaise: Long): Long
}
fun countAccepted(prices: List<Long>, check: PriceCheck): Int = prices.count { check.accepts(it) }
fun main() {
// SAM conversion for a Java interface: a lambda where Java expects PriceCheck.
println(countAccepted(listOf(500, 20_000, 90_000)) { it in 1_000..50_000 }) // -> 1
// fun interface: a lambda converts to it too.
val festive: Discount = Discount { it * 90 / 100 }
val none: Discount = { it } // the explicit Discount { } is optional when the type is known
println(festive.discounted(10_000) + none.discounted(1)) // -> 9001
// A plain Kotlin interface doesn't take a lambda; you need an object expression.
// val plain: PlainDiscount = { it } // error: initializer type mismatch: expected 'PlainDiscount', actual '() -> ??? (Unknown lambda return type)'.
val plain = object : PlainDiscount {
override fun discounted(pricePaise: Long): Long = pricePaise
}
println(plain.discounted(42)) // -> 42
// JDK overloads: Kotlin and Java pick different ones for the same-looking lambda.
val pool = Executors.newSingleThreadExecutor()
println(pool.submit { "reindexed" }.get()) // -> null
println(pool.submit(Callable { "reindexed" }).get()) // -> reindexed
pool.shutdown()
}

A lambda converts to a Java interface with a single abstract method, exactly as javac would convert it. That covers Runnable, Callable, Comparator and every functional interface in your Java libraries. A Kotlin interface converts only when it is declared fun interface. The plain one-method PlainDiscount needs an object expression, even though Java would happily accept a lambda for its equivalent.

The reason is that Kotlin has a better default for Kotlin code: a function type. Declare a fun interface when the type deserves a name in your API or needs other (default) members. When you forget the fun, the error (“initializer type mismatch… Unknown lambda return type”) talks about type inference, but the cause is the missing modifier.

The modifier matters only to Kotlin callers: javac converts a Java lambda to any interface with one abstract method, Kotlin-declared or not. A named interface is still kinder to Java implementers than a function type, which they see as Function1<Line, Long> with a boxed result, or, for (String) -> Unit, a lambda that must end with return Unit.INSTANCE. The hands-on shows both.

The last lines of main are a trap with no compile error; the Gotchas explain it.

inline, noinline and crossinline

// Fragment of inlining/Inlining.kt
// Not inline: the lambda becomes a Function1 object, and the Long travels boxed through invoke(Object).
fun applyBoxed(pricePaise: Long, rule: (Long) -> Long): Long = rule(pricePaise)
// inline: the body and the lambda are copied into the caller. No object, no boxing.
inline fun applyInline(pricePaise: Long, rule: (Long) -> Long): Long = rule(pricePaise)
fun callBoxed(): Long = applyBoxed(16_500) { it - 600 }
fun callInline(): Long = applyInline(16_500) { it - 600 }

Same source, different bytecode:

// javap -c -p com.shelfwise.part05.inlining.InliningKt (simplified)
private static final long callBoxed$lambda$0(long); // the lambda body
public static final long callBoxed();
0: ldc2_w 16500l
3: invokedynamic invoke:()Lkotlin/jvm/functions/Function1; // Function1 instance
8: invokestatic applyBoxed:(JLkotlin/jvm/functions/Function1;)J
11: lreturn
public static final long applyBoxed(long, kotlin.jvm.functions.Function1<? super java.lang.Long, java.lang.Long>);
6: aload_2
7: lload_0
8: invokestatic java/lang/Long.valueOf:(J)Ljava/lang/Long; // box the argument
11: invokeinterface kotlin/jvm/functions/Function1.invoke:(Ljava/lang/Object;)Ljava/lang/Object;
16: checkcast java/lang/Number
19: invokevirtual java/lang/Number.longValue:()J // unbox the result
22: lreturn

Long.valueOf going in and longValue() coming out is the price of (Long) -> Long being generic. For a lambda that captures nothing, LambdaMetafactory in practice hands back the same instance every time, so no new object is allocated per call, but the boxing remains (and outside Long’s small-value cache, each box is an allocation). callInline() has no invokedynamic, no Function1 and no boxing, just the subtraction:

public static final long callInline();
0: ldc2_w 16500l
...
11: lload_3
12: sipush 600
15: i2l
16: lsub
17: nop
18: lreturn

That is what inline buys: a higher-order function with the cost of hand-written code. It is why map, filter and forEach on collections create no lambda object and make no invoke call per element (map still builds its result list, of course). Inlining is a Kotlin compile-time step, so Java code calling an inline function gets an ordinary call with a lambda object. The bill comes in code size, because the body is copied into every call site. So inline small functions that take lambdas, and nothing else (the compiler warns when an inline function has no function parameters, because there is nothing to gain).

Two modifiers handle lambdas that can’t simply be pasted:

// Fragment of inlining/Inlining.kt
// The typical inline helper: behaviour around a block, with no allocation per call.
inline fun <T> audited(log: MutableList<String>, label: String, block: () -> T): T {
log += "start $label"
val result = block()
log += "end $label = $result"
return result
}
// noinline: 'describe' is stored for later, so it must stay a real object.
inline fun register(registry: MutableList<() -> String>, enabled: () -> Boolean, noinline describe: () -> String) {
if (enabled()) registry += describe
}
// Without noinline:
// inline fun registerAll(registry: MutableList<() -> String>, describe: () -> String) { registry += describe }
// error: illegal usage of inline parameter 'describe: () -> String'. Add 'noinline' modifier to the parameter declaration.
// crossinline: 'action' runs inside another object, so it must not 'return' from the caller.
inline fun deferred(crossinline action: () -> Unit): Runnable = Runnable { action() }
// Without crossinline:
// inline fun deferredAll(action: () -> Unit): Runnable = Runnable { action() }
// error: cannot inline 'action: () -> Unit' here: it might contain non-local returns. Add 'crossinline' modifier to parameter declaration 'action: () -> Unit'.
  • noinline marks a parameter that is used as a value, stored or passed to a non-inline function. It stays an object; the others are still inlined.
  • crossinline marks a parameter that is inlined, but into another execution context, here the body of a Runnable that runs later. A non-local return from there would be meaningless, so crossinline forbids it at the call site, and the compiler requires the modifier rather than silently allowing a broken return.

audited is the pattern you’ll write most: before/after behaviour around a block, generic in its result, with no object per call. The standard library’s measureTime, synchronized and use (Kotlin’s try-with-resources) are built the same way.

Where return goes

// forEach is inline, so this 'return' leaves firstPremium, not just the lambda.
fun firstPremium(prices: List<Long>): Long? {
prices.forEach { if (it >= 50_000) return it }
return null
}
// An anonymous function: 'return' leaves the anonymous function itself, like a Java lambda.
fun countAffordable(prices: List<Long>, budgetPaise: Long): Int {
var count = 0
prices.forEach(fun(price) {
if (price > budgetPaise) return
count++
})
return count
}
// Not inline: the lambda may be stored and called later, so a bare 'return' has no caller to leave.
fun eachLater(prices: List<Long>, action: (Long) -> Unit) {
for (price in prices) action(price)
}
fun printNonZero(prices: List<Long>) {
eachLater(prices) { if (it == 0L) return@eachLater else println("price $it") }
// eachLater(prices) { if (it == 0L) return } // error: 'return' is prohibited here.
// Sequence operations are not inline either, because they run later:
// prices.asSequence().map { if (it == 0L) return else it }.toList() // error: 'return' is prohibited here.
}
// Non-local break and continue (Stable since 2.2.0) work inside inline lambdas too.
fun firstStockedShelf(shelves: List<List<Int>>): Int {
var found = -1
for ((index, shelf) in shelves.withIndex()) {
shelf.forEach { units ->
if (units < 0) continue // a faulty sensor: skip the rest of this shelf
if (units > 0) {
found = index
break // stop scanning shelves altogether
}
}
}
return found
}
fun main() {
println(firstPremium(listOf(16_500, 72_000, 99_000))) // -> 72000
println(countAffordable(listOf(16_500, 72_000, 9_900), budgetPaise = 20_000)) // -> 2
printNonZero(listOf(0, 16_500))
// -> price 16500
println(firstStockedShelf(listOf(listOf(0, 0), listOf(-1, 5), listOf(0, 3)))) // -> 2
}
  • firstPremium relies on a non-local return: return it leaves firstPremium with the value.
  • countAffordable uses an anonymous function, fun(price) { … }. Its return leaves only itself. Kotlin keeps this syntax for exactly that reason: an anonymous function behaves like a Java lambda.
  • printNonZero passes a lambda to a non-inline function. A bare return is a compile error there, and return@eachLater is the only option. The same applies to Sequence operations, which Java developers porting Stream code meet first. Every lambda gets an implicit label named after the function it is passed to; write myLabel@{ … } to name it yourself.
  • firstStockedShelf uses continue and break inside an inline lambda to control the enclosing for loop. It was Experimental in 2.1 (-Xnon-local-break-continue) and has been Stable since 2.2, and it is the only way to stop a forEach early other than return. In practice a plain for loop usually reads better.

tailrec

class Category(val name: String, val parent: Category? = null)
// The recursive call is the last thing the function does, so the compiler turns it into a loop.
tailrec fun root(category: Category): Category {
val parent = category.parent ?: return category
return root(parent)
}
// An accumulator moves the work before the call, which is what makes it a tail call.
tailrec fun path(category: Category?, acc: String = ""): String =
if (category == null) acc
else path(category.parent, if (acc.isEmpty()) category.name else "${category.name} > $acc")
// Not a tail call: the '1 +' runs after the recursive call returns. The compiler warns and keeps the recursion.
// tailrec fun depth(category: Category): Int = if (category.parent == null) 0 else 1 + depth(category.parent)
// warning: a function is marked as tail-recursive but no tail calls are found.
fun main() {
val dal = Category("Dal", Category("Staples", Category("Grocery")))
println(root(dal).name) // -> Grocery
println(path(dal)) // -> Grocery > Staples > Dal
// 100,000 levels deep: plain recursion would need 100,000 stack frames.
var deep = Category("level-0")
for (i in 1..100_000) deep = Category("level-$i", deep)
println(root(deep).name) // -> level-0
}

tailrec asks the compiler to turn self-recursion in tail position into a loop. Here is root in bytecode:

public static final Category root(Category);
6: aload_0
7: invokevirtual Category.getParent:()LCategory;
10: dup
11: ifnonnull 17
14: pop
15: aload_0
16: areturn
17: astore_1
18: aload_1
19: astore_0 // category = parent
20: goto 6 // and go round again: no recursive call

The JVM has no tail-call elimination, so Java code walking a 100,000-level chain recursively will typically throw StackOverflowError with default stack sizes. Kotlin’s tailrec rewrite is a compile-time transformation, not a JVM feature. It applies only when the recursive call is the very last operation; 1 + depth(parent) isn’t, because the addition happens afterwards. The modifier is a request with a check: if no call qualifies, you get the warning shown in the comment and ordinary recursion.

Lambdas with receivers: a preview

class LabelBuilder {
private val lines = mutableListOf<String>()
fun line(text: String) {
lines += text
}
fun build(): String = lines.joinToString(" | ")
}
// LabelBuilder.() -> Unit: a lambda with a receiver. Inside it, 'this' is the builder.
fun label(content: LabelBuilder.() -> Unit): String {
val builder = LabelBuilder()
builder.content() // call the lambda as if it were a member of 'builder'
return builder.build()
}
fun main() {
println(label {
line("Toor Dal 1kg")
line("₹159.00")
}) // -> Toor Dal 1kg | ₹159.00
// The standard library uses the same trick: buildString's lambda has a StringBuilder receiver.
println(buildString {
append("SHW-")
append(1001)
}) // -> SHW-1001
}

LabelBuilder.() -> Unit is a function type whose lambda runs with a LabelBuilder as this, so line(…) inside the braces calls the builder’s method with no qualifier. Java approximates it with a Consumer<Builder> and a b -> prefix on every call. This one feature is behind buildString, apply, Gradle’s Kotlin DSL and Spring’s router and bean DSLs. Part 6 builds a type-safe DSL with it.


Step-by-Step Hands-On: A Pricing-Rule Engine

Code: kotlin-for-java-survivors/language/part05-functions-are-values (file rules/PricingRules.kt).

Kabir rebuilds the pricing rules from Thursday as values: small rules from factories, composed into one, applied with an audit trail. The Java pricing team will write rules too, so the new PriceRule, which replaces Thursday’s class, is a named fun interface rather than a bare function type.

Step 1 — The rule type. A rule maps a basket line to a new unit price:

// Fragment of rules/PricingRules.kt
data class Line(val sku: String, val category: String, val pricePaise: Long, val quantity: Int)
// Step 1: a rule is a fun interface, so a lambda, a function reference or an object can be one.
fun interface PriceRule {
fun price(line: Line): Long // the new unit price
}

Step 2 — Rule factories. Each returns a lambda converted to PriceRule, which captures the factory’s arguments. buyMorePayLess is built from percentOff by passing a predicate as a trailing lambda:

// Fragment of rules/PricingRules.kt
// Step 2: rule factories. Each returns a lambda that captures its configuration.
fun percentOff(percent: Int, appliesTo: (Line) -> Boolean = { true }): PriceRule =
PriceRule { line -> if (appliesTo(line)) line.pricePaise * (100 - percent) / 100 else line.pricePaise }
fun buyMorePayLess(minQuantity: Int, percent: Int): PriceRule =
percentOff(percent) { it.quantity >= minQuantity }

Step 3 — An ordinary function as a rule. roundToRupee knows nothing about PriceRule; a function reference adapts it in Step 6:

// Fragment of rules/PricingRules.kt
// Step 3: an ordinary function that will become a rule through a reference.
fun roundToRupee(line: Line): Long = line.pricePaise / 100 * 100

Step 4 — Composition. chain takes rules and returns a rule. Each rule sees the line with the price so far, thanks to copy() from Part 4:

// Fragment of rules/PricingRules.kt
// Step 4: composition. A higher-order function that takes rules and returns a rule.
fun chain(rules: List<PriceRule>): PriceRule = PriceRule { line ->
var price = line.pricePaise
for (rule in rules) price = rule.price(line.copy(pricePaise = price))
price
}

Step 5 — An inline audit helper. auditing reports each line’s amount to a callback. Because it is inline, the pricing block never becomes an object. The empty-line check sits in the loop, not in the block, so skipping is visible where it happens:

// Fragment of rules/PricingRules.kt
// Step 5: an inline helper that reports each amount to an audit callback; the block never becomes an object.
inline fun auditing(audit: (String) -> Unit, sku: String, block: () -> Long): Long {
val amount = block()
audit("$sku -> $amount")
return amount
}
fun priceBasket(lines: List<Line>, rule: PriceRule, audit: (String) -> Unit): Long {
var total = 0L
for (line in lines) {
if (line.quantity == 0) continue // nothing to price, nothing to audit
total += auditing(audit, line.sku) { rule.price(line) * line.quantity }
}
return total
}

Step 6 — Wire it up. Lambdas, a factory, PriceRule(::roundToRupee) (a SAM constructor applied to a function reference) and log::add as the audit callback. add returns Boolean, and a callable reference may drop a result where Unit is expected:

// Fragment of rules/PricingRules.kt
fun main() {
val festiveGrocery = chain(
listOf(
percentOff(10) { it.category == "staples" },
buyMorePayLess(minQuantity = 3, percent = 5),
PriceRule(::roundToRupee),
),
)
val basket = listOf(
Line("SHW-1001", "staples", 16_550, 3),
Line("SHW-1002", "snacks", 3_050, 1),
Line("SHW-1003", "staples", 9_990, 0),
)
val log = mutableListOf<String>()
println(priceBasket(basket, festiveGrocery, log::add)) // -> 45300
log.forEach(::println)
// -> SHW-1001 -> 42300
// -> SHW-1002 -> 3000
}

And the Java team’s side: a plain Java lambda implements PriceRule (getPricePaise() is the data class’s generated getter), and the (String) -> Unit callback needs return Unit.INSTANCE:

// The Java pricing team's side: Java lambdas against the Kotlin API.
public final class LegacyRules {
// A Kotlin fun interface: an ordinary functional interface to javac.
public static final PriceRule STAFF_PRICE = line -> line.getPricePaise() * 80 / 100;
public static void main(String[] args) {
List<Line> lines = List.of(new Line("SHW-1001", "staples", 16_550, 1));
// A Kotlin (String) -> Unit is a Function1<String, Unit>: the lambda must return Unit.INSTANCE.
long total = PricingRulesKt.priceBasket(lines, STAFF_PRICE, message -> {
System.out.println("audit " + message);
return Unit.INSTANCE;
});
System.out.println(total);
// -> audit SHW-1001 -> 13240
// -> 13240
}
}

The arithmetic for SHW-1001: ₹165.50 less 10% is ₹148.95, less another 5% for three or more is ₹141.50, rounded down to ₹141.00, times three is ₹423.00. The zero-quantity line never reaches the log.


Tips, Tricks & Gotchas

Gotcha — executor.submit { value } returns null. In the SAM example, pool.submit { "reindexed" }.get() prints null. ExecutorService.submit is overloaded for Runnable and Callable<T>. Java picks Callable because the lambda returns a value. Kotlin picks submit(Runnable), where a lambda’s result may be discarded, and only warns that the expression is unused. Any JDK API overloaded on Runnable and Callable behaves the same. Write Callable { … } or give the type argument, submit<String> { … }.

Tip — a fun interface is Kotlin’s non-boxing function type. fun interface Discount { fun discounted(pricePaise: Long): Long } compiles to long discounted(long), so a hot path that takes a Discount never boxes, unlike (Long) -> Long. That is the second reason PriceRule in the hands-on is a fun interface.

Gotcha — a mutable capture is not thread-safe. Java’s effectively-final rule also protected you from data races: a lambda could never write to a local. In Kotlin, var count = 0; repeat(4) { executor.submit { count++ } } compiles. Each count++ is a read and a write on a shared Ref$IntRef field that is neither synchronised nor volatile, so updates get lost and other threads may not see them. Use an AtomicInteger, or collect results from the tasks instead of writing to a captured variable.

Gotcha — a public inline function can’t touch private state. Its body is copied into callers, possibly in other modules, where private members don’t exist. inline fun withVat(…) reading a private val fails with “public-API inline function cannot access non-public-API property”. Make the member @PublishedApi internal (its accessor is public in bytecode, but Kotlin code in other modules still can’t call it), or don’t inline.

Gotcha — changing an inline function needs a recompile of its callers. A Java developer patches a library and swaps the jar. With Kotlin, the old body of every inline function is already copied into the callers’ bytecode, and it stays there until they recompile. Treat public inline functions in shared libraries as part of the binary API, like Java static final constants.


Key Takeaways

ConceptRemember
Function types(A, B) -> R is a real type; on the JVM it is FunctionN, and non-inline calls box primitives
LambdasLast expression is the value; trailing-lambda syntax; invokedynamic since 2.0
Inline functionsBody and lambda are copied into the caller: no object, no boxing, bigger bytecode
returnBare return leaves the enclosing function (inline lambdas only); return@label leaves the lambda; an anonymous function’s return leaves itself
break/continue in lambdasAllowed in inline lambdas since 2.2, targeting the enclosing loop
ClosuresCapture variables, not values; stored lambdas share them through Ref boxes, without synchronisation
References::fn, ::Class, obj::fn, Type::prop; overloads need an expected type
SAM conversionAutomatic for Java interfaces; Kotlin interfaces need fun interface
noinline / crossinlineStore the lambda / call it from another context
tailrecCompile-time loop rewrite; only for calls in tail position
ReceiversT.() -> R makes this the receiver inside the lambda, the basis of DSLs

Story Closing

The pricing engine went to review on Monday. The Java pricing team implemented two of their legacy rules as Java lambdas against PriceRule and noticed only one thing: the return Unit.INSTANCE.

The next review comment came from Kabir himself. Every rule now needed the store’s region, for regional tax, and a logger for the audit trail. He had added region and log parameters to percentOff, then to chain, then to priceBasket, then to the controller that called it, and was halfway through the sixth layer when he stopped and called Lena over.

“Java would have me put them in a ThreadLocal,” he said, “or inject them into everything.”

“Kotlin has better answers,” Lena said, “and they all work at compile time.”

In Part 6, Kabir meets extension functions, scope functions, context parameters and delegation, the tools that let Kotlin code carry context without threading it through every signature.


This is Part 5 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”