What are the practical hazards of non-local returns in scope functions like `let`/`run`, and how do they interact with refactoring a non-inline HOF?
answer
- let/run/also/apply/with are all inline => bare return exits function
- Readability trap: looks like a local block but isn't
- Removing inline breaks callers' non-local returns
- inline-ness is part of a public HOF's contract
- use is inline: non-local return still triggers finally/close
basics
~20 sBecause let/run/forEach are inline, a return inside them quietly exits the whole function — easy to misread. And if you later change a custom inline helper to non-inline, every non-local return callers wrote suddenly stops compiling.
solid answer
~50 sScope functions `let`, `run`, `also`, `apply`, `with` are all `inline`, so a bare `return` inside them is non-local and exits the enclosing function. This is a readability hazard: `value?.let { return it }` exits the function, which surprises readers who expect the lambda to be a local block. The pitfall compounds with refactoring: if you author a custom `inline` HOF and callers rely on non-local returns, changing it to non-inline (or marking a parameter `noinline`/`crossinline`) is a **breaking change** — those call sites no longer compile. Other subtleties: non-local returns skip code placed *after* the inline call but inside the enclosing function only if the return fires; and combining them with `try/finally` or resource cleanup needs care because the return unwinds through the inlined body. Prefer labelled returns when you mean 'leave the block', and reserve bare returns for genuine early function exit.
code
kotlin · 8 linesfun load(id: String?): Config {
id?.let { return cache[it] ?: Config.DEFAULT } // exits load early
return Config.DEFAULT
}
// Block-local alternative if you do NOT want to exit load:
fun describe(id: String?): String =
id?.let { if (it.isBlank()) return@let "blank"; it.uppercase() } ?: "none"go deeper
May not realize scope functions are inline or that return in let exits the function.
Knows scope functions are inline and recommends return@let for block-local exits.
Articulates the refactor-fragility and try/finally/use interactions accurately.
Treats inline-ness as an API contract and sets team conventions/lint rules around non-local returns in scope functions.
## Scope functions are inline `let`, `run`, `with`, `apply`, `also` are declared `inline`. Therefore a bare `return` inside their lambdas is **non-local** and exits the **enclosing function**: ```kotlin fun greet(name: String?): String { name?.let { return "Hello, $it" } // returns from greet return "Hello, stranger" } ``` This is powerful but easy to misread — many developers expect `let { ... }` to behave like an isolated block. The cure for 'I only want to leave the block' is a labelled return: `return@let value`. ## Hazard 1: readability / surprise ```kotlin val result = compute() result.also { return it } // exits the whole function, not just `also` log("never reached") ``` Readers skimming `also { ... }` may not notice the function exits. Convention: avoid bare `return` inside scope functions unless an early function exit is the clear intent. ## Hazard 2: refactor fragility Non-local return is a **compile-time capability granted by `inline`**. If you have: ```kotlin inline fun withRetry(block: () -> Unit) { repeat(3) { block() } } ``` and callers wrote `withRetry { if (done) return }`, then later you: - drop `inline`, or - add `noinline`/`crossinline` to `block`, every such call site **breaks compilation**. So the inline-ness of your public HOF is part of its **contract**. Document it; treat changing it like an API break (semantic-versioning-relevant for libraries). ## Hazard 3: control-flow and cleanup Because the inlined body is spliced in, a non-local return unwinds through it. With `try/finally` inside the inline function, the `finally` still runs (the return unwinds normally), but reasoning about *which* `finally` blocks run requires seeing the inlined structure. Resource helpers like `use` (which is inline) rely on this: a non-local return from inside `file.use { ... return ... }` still closes the resource via `finally`. ## Hazard 4: nested inline calls Nested `forEach`/`let` can make it ambiguous which lambda a return targets; a bare `return` always targets the enclosing *function*, never an intermediate lambda — use explicit labels to leave only an inner lambda. ## Guidance - Use **`return@let` / `return@run`** for block-local exits. - Use bare `return` only for deliberate early function exit, and keep it visually obvious. - Treat the `inline` modifier of any HOF you publish as part of its API contract. - Remember `use`, `synchronized`, `repeat` are inline too and thus honor non-local returns.
- Why does `file.use { return x }` still close the file?`use` is inline and wraps the lambda in try/finally; the non-local return unwinds through the finally block, which closes the resource before control leaves the function.
- Is changing a public inline HOF to non-inline a breaking change?Potentially yes: callers relying on non-local returns will fail to compile, so the inline modifier is part of the function's source/binary contract.
saying these in an interview costs you the question
- Thinking `return` in `let` exits only the lambda by default
- Not realizing scope functions are inline
- Ignoring that dropping inline breaks caller non-local returns
- Assuming resources leak when returning non-locally out of `use`
- Overusing bare returns in scope functions, hurting readability