You have `@JvmInline value class Meters(val v: Double) : Comparable<Meters>`. Walk through which of these box: `meters.compareTo(other)`, `listOf(a, b).sorted()`, and passing `meters` to `fun <T> id(x: T): T`. How can boxing reintroduce the allocation cost the value class was meant to avoid?
answer
- Direct concrete-type call → static *-impl, unboxed
- listOf(...) boxes every element
- sorted()/Comparable dispatch boxes via the interface
- Generic <T> boxes in, unboxes out
- Bulk path: keep DoubleArray, wrap at the boundary
basics
~20 sCalling its own methods directly stays cheap. But the moment values flow through generic code or an interface (like sorting a list), Kotlin wraps them into objects, so you pay allocations exactly where you hoped to save them.
solid answer
~40 sA direct, statically-typed call like `meters.compareTo(other)` on the concrete `Meters` type is dispatched to the generated static `compareTo-impl` and stays unboxed. But `listOf(a, b)` already boxes each element (generic `List<Meters>`), and `.sorted()` calls `Comparable.compareTo` through the boxed objects via the interface — fully boxed. Passing `meters` to `fun <T> id(x: T): T` boxes on the way in (the parameter `T` erases to `Object`) and the call site unboxes the result. The general principle: value classes are a zero-cost abstraction only while the static type is the concrete value class and non-null. Generic APIs, interface dispatch, and collections all require an `Object`, so each crossing allocates a wrapper — reintroducing precisely the per-element allocation the class aimed to eliminate, sometimes in tight loops.
code
kotlin · 13 lines@JvmInline
value class Meters(val v: Double) : Comparable<Meters> {
override fun compareTo(other: Meters): Int = v.compareTo(other.v)
}
val a = Meters(1.0); val b = Meters(2.0)
a.compareTo(b) // unboxed: static compareTo-impl on doubles
val xs = listOf(a, b) // BOXED: each element boxed in List<Meters>
val sorted = xs.sorted() // BOXED: compareTo via Comparable on boxed objs
fun <T> id(x: T): T = x
val m = id(a) // BOXED in, unboxed out — one allocgo deeper
Recognizes that lists of value classes 'cost something' but can't trace each case.
Correctly identifies that listOf and generics box while a direct method call doesn't.
Traces all three cases precisely (static impl vs interface dispatch vs generic erasure) and explains the allocation regression.
Designs around it — primitive-backed bulk storage, boundary wrapping — and reasons about GC/throughput impact at scale.
## Setup ```kotlin @JvmInline value class Meters(val v: Double) : Comparable<Meters> { override fun compareTo(other: Meters): Int = v.compareTo(other.v) } ``` The compiler generates static helpers: `compareTo-impl(double, Meters)`, plus `box-impl`/`unbox-impl`. ## Case 1: `meters.compareTo(other)` — unboxed The receiver's static type is exactly `Meters`. The call is rewritten to the static `compareTo-impl`, operating on the underlying `double`. **No boxing.** This is the zero-cost path. ## Case 2: `listOf(a, b).sorted()` — fully boxed - `listOf(a, b)` returns `List<Meters>`; the generic element slot erases to `Object`, so `a` and `b` are **boxed** going in. - `.sorted()` relies on the `Comparable` interface. Interface dispatch needs an object reference, so it calls `compareTo` on the **boxed** wrappers. Every comparison touches boxed objects. - Net: two boxes for the list plus interface-level dispatch — none of the inlining benefit survives. ## Case 3: generic function `fun <T> id(x: T): T` ```kotlin fun <T> id(x: T): T = x val m = id(Meters(3.0)) // box on the way IN, unbox on the way OUT ``` - `T` erases to `Object`, so the argument is **boxed** to pass it. - The return is **unboxed** back to `Meters` at the call site. - One allocation per call. ## Why this defeats the purpose The whole reason to reach for a value class is to get a distinct, type-safe wrapper with **no allocation**. But: - Storing many values in a `List<Meters>` boxes **every element**. - Sorting/searching via `Comparable`/`Comparator` boxes during dispatch. - Passing through any generic utility (`map`, `filter`, `maxOf`, `reduce`) boxes. In a tight loop this can produce one allocation per iteration — the exact GC pressure you intended to avoid, now hidden behind innocent-looking generic calls. ## Mitigations - Keep hot paths on the concrete type; avoid wrapping individual values in generic collections. - For bulk numeric data, store the **underlying** primitives (`DoubleArray`) and wrap only at the boundary. - Be aware that even `Comparable` self-implementation triggers boxing when reached through the interface. ## Rule of thumb Unboxed only when: static type is exactly the value class, non-null, not crossing a generic/interface/`Any` boundary. Otherwise assume a box.
- Does implementing Comparable<Meters> force boxing for direct calls?No. A direct call on the concrete Meters type uses the static impl, unboxed. Only dispatch THROUGH the Comparable interface boxes.
- How would you sort a million Meters without per-element boxing?Store them as a DoubleArray, sort the primitives directly, and wrap into Meters only when handing values out at the boundary.
- Does `maxOf(a, b)` box?Yes if it goes through the generic/Comparable machinery; the values cross an Object boundary and box.
A value class is a sticker on raw goods. On the factory floor (concrete type) goods move bare. But the loading dock (generics/collections/interfaces) only accepts crates, so every item gets crated — allocations return.
saying these in an interview costs you the question
- Asserting collections of value classes are allocation-free
- Thinking implementing Comparable keeps interface dispatch unboxed
- Believing generic functions never box value-class arguments
- Ignoring boxing cost in hot loops
- Confusing value semantics with guaranteed zero allocation everywhere