"Functions Are Values" — Lambdas, References and Inline
A return inside forEach silently skips half of a pricing audit. We look at what a Kotlin lambda really is: function types instead of functional interfaces, closures over mutable variables, references, fun interfaces, inline functions and where each kind of return goes, plus tailrec and a first look at lambdas with receivers.
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.ktclass 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
| Java | Kotlin | Note |
|---|---|---|
Function<Long, Long>, LongUnaryOperator, BiFunction, Supplier… | (Long) -> Long, (A, B) -> R, () -> T | One 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 final | Lambdas capture and mutate vars | Shared through a Ref box when the lambda is stored |
Foo::bar, Foo::new, obj::bar | ::bar, ::Foo, obj::bar, Foo::prop | Top-level functions and properties too |
@FunctionalInterface | fun interface | Java interfaces convert automatically; Kotlin ones need fun |
| (JIT inlining, maybe) | inline fun | The compiler copies the body and the lambda into the caller |
| Recursion, then a manual rewrite to a loop | tailrec fun | The compiler does the rewrite |
Consumer<Builder> | Builder.() -> Unit | Lambda 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
inlinefunction, 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… | return | break / continue of an outer loop | Captured vars | Runtime cost |
|---|---|---|---|---|
An inline function (forEach) | Leaves the enclosing function | Allowed (Stable since 2.2) | Plain locals | No lambda object, no boxing |
An ordinary function (Sequence.map, your own) | Prohibited; only return@label | Prohibited | Moved into a heap Ref box | One 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 timeThe 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. */@FunctionalInterfacepublic interface PriceCheck { boolean accepts(long pricePaise);}import com.shelfwise.part05.javarules.PriceCheckimport java.util.concurrent.Callableimport 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: lreturnLong.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: lreturnThat 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'.noinlinemarks 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.crossinlinemarks a parameter that is inlined, but into another execution context, here the body of aRunnablethat runs later. A non-localreturnfrom there would be meaningless, socrossinlineforbids it at the call site, and the compiler requires the modifier rather than silently allowing a brokenreturn.
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}firstPremiumrelies on a non-local return:return itleavesfirstPremiumwith the value.countAffordableuses an anonymous function,fun(price) { … }. Itsreturnleaves only itself. Kotlin keeps this syntax for exactly that reason: an anonymous function behaves like a Java lambda.printNonZeropasses a lambda to a non-inline function. A barereturnis a compile error there, andreturn@eachLateris the only option. The same applies toSequenceoperations, which Java developers porting Stream code meet first. Every lambda gets an implicit label named after the function it is passed to; writemyLabel@{ … }to name it yourself.firstStockedShelfusescontinueandbreakinside an inline lambda to control the enclosingforloop. 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 aforEachearly other thanreturn. In practice a plainforloop 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 callThe 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.ktdata 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 * 100Step 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.ktfun 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 }returnsnull. In the SAM example,pool.submit { "reindexed" }.get()printsnull.ExecutorService.submitis overloaded forRunnableandCallable<T>. Java picksCallablebecause the lambda returns a value. Kotlin pickssubmit(Runnable), where a lambda’s result may be discarded, and only warns that the expression is unused. Any JDK API overloaded onRunnableandCallablebehaves the same. WriteCallable { … }or give the type argument,submit<String> { … }.
Tip — a
fun interfaceis Kotlin’s non-boxing function type.fun interface Discount { fun discounted(pricePaise: Long): Long }compiles tolong discounted(long), so a hot path that takes aDiscountnever boxes, unlike(Long) -> Long. That is the second reasonPriceRulein the hands-on is afun 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. Eachcount++is a read and a write on a sharedRef$IntReffield that is neither synchronised norvolatile, so updates get lost and other threads may not see them. Use anAtomicInteger, 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
privatemembers don’t exist.inline fun withVat(…)reading aprivate valfails 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
inlinefunction 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 Javastatic finalconstants.
Key Takeaways
| Concept | Remember |
|---|---|
| Function types | (A, B) -> R is a real type; on the JVM it is FunctionN, and non-inline calls box primitives |
| Lambdas | Last expression is the value; trailing-lambda syntax; invokedynamic since 2.0 |
| Inline functions | Body and lambda are copied into the caller: no object, no boxing, bigger bytecode |
return | Bare return leaves the enclosing function (inline lambdas only); return@label leaves the lambda; an anonymous function’s return leaves itself |
break/continue in lambdas | Allowed in inline lambdas since 2.2, targeting the enclosing loop |
| Closures | Capture 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 conversion | Automatic for Java interfaces; Kotlin interfaces need fun interface |
noinline / crossinline | Store the lambda / call it from another context |
tailrec | Compile-time loop rewrite; only for calls in tail position |
| Receivers | T.() -> 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.”