How would you expose a collection across a Java/Kotlin module boundary so that NO caller — Kotlin or Java — can mutate it?
answer
- Read-only List = intent, not enforcement
- Collections.unmodifiableList throws for all callers
- Copy before wrapping (don't back it with live list)
- kotlinx ImmutableList/PersistentList = strongest guarantee
- Tradeoff: guarantee strength vs allocation cost
basics
~10 sA plain Kotlin read-only List isn't enough because Java can still change it. Use a defensive copy wrapped so mutation throws, or a truly immutable collection type whose add/remove always fail for everyone.
solid answer
~40 sKotlin's read-only List gives no runtime protection: at the JVM it's java.util.List, so Java callers see add/remove and can mutate, and even Kotlin can mutate via an aliased MutableList. To enforce immutability for all callers you need a runtime guarantee, not a compile-time view. Options: (1) return a defensive copy wrapped in Collections.unmodifiableList(...) — its mutators throw UnsupportedOperationException for Kotlin and Java alike; (2) use kotlinx.collections.immutable PersistentList/ImmutableList, whose mutating methods are absent/throwing and which give cheap structurally-shared copies; (3) copy on every read with toList() if callers shouldn't share storage. The principal-level tradeoff is enforcement strength vs allocation cost vs API ergonomics, plus documenting ownership so callers know not to retain references.
code
kotlin · 8 linesprivate val internal = mutableListOf<String>()
// Strongest + idiomatic: type names the contract, no copies on derivation
fun snapshot(): ImmutableList<String> = internal.toImmutableList()
// JDK-only alternative: copy then wrap so mutators throw for Java and Kotlin
fun snapshotJdk(): List<String> =
java.util.Collections.unmodifiableList(internal.toList())go deeper
Might suggest returning List, not realizing Java can still mutate it.
Knows to defensively copy and can name Collections.unmodifiableList as a runtime guard.
Compares unmodifiable wrappers, defensive copies, and kotlinx immutable collections with their tradeoffs.
Selects an enforcement strategy by guarantee strength, allocation cost, API clarity, and ownership documentation across module boundaries.
## Why read-only List is not enough A Kotlin `List<T>` return type is, on the JVM, a `java.util.List`. The read-only/mutable split is a **compile-time fiction**. Therefore: - A **Java** caller sees `add`/`remove`/`set` and can mutate the object (if its backing is mutable). - A **Kotlin** caller can mutate it through an aliased `MutableList` reference if one exists, or by smart-casting/unsafe-casting back to `MutableList` (since at runtime it *is* one). So `List` communicates *intent*, not an enforced contract. ## Options to enforce true immutability ### 1. Wrap in an unmodifiable view (defensive copy first) ```kotlin fun snapshot(): List<String> = java.util.Collections.unmodifiableList(internal.toList()) ``` `Collections.unmodifiableList` returns a wrapper whose mutating methods throw `UnsupportedOperationException` — for **both** Java and Kotlin callers. Copy first (`toList()`) so the wrapper isn't backed by your live mutable list (otherwise *you* can still mutate it and callers see it change). ### 2. Persistent/immutable collections Use **`kotlinx.collections.immutable`**: ```kotlin val snapshot: ImmutableList<String> = internal.toImmutableList() ``` `ImmutableList`/`PersistentList` have **no** working mutators (operations return a new collection via structural sharing). This is the strongest, most idiomatic guarantee and avoids O(n) copies on each derivation. The type itself documents immutability. ### 3. Copy on every read Return `internal.toList()` each time. Callers get an independent snapshot; even if they mutate it (e.g. via Java), your internal state is untouched. Simple, but allocates per call and doesn't stop the *returned* snapshot from being mutated by its holder. ## Choosing (the principal lens) - **Strength of guarantee:** persistent collections > unmodifiable-wrapper-over-copy > plain copy > read-only view. - **Cost:** persistent collections amortize via sharing; copies are O(n) per call; unmodifiable wrapper is O(1) over an existing copy. - **Ergonomics/clarity:** an `ImmutableList` return type *names* the contract; a `List` return type does not. - **Ownership docs:** whatever you choose, document that callers must not assume they own/may mutate shared state. ## Keywords/APIs `Collections.unmodifiableList`, `UnsupportedOperationException`, `kotlinx.collections.immutable` (`ImmutableList`, `PersistentList`, `toImmutableList`, `toPersistentList`), defensive copy, structural sharing, JVM erasure of read-only/mutable.
- Why copy with toList() before Collections.unmodifiableList()?unmodifiableList is a live view over its backing list; if you back it with your mutable list, you can still mutate it and callers will see changes. Copying first severs that link.
- What advantage do persistent collections have over copy-then-wrap?Structural sharing makes derived collections cheap (no full O(n) copy), and the ImmutableList type documents the contract while guaranteeing it at runtime.
saying these in an interview costs you the question
- Returning a Kotlin List and calling it immutable/safe from Java
- Backing an unmodifiable wrapper with the live mutable list
- Ignoring allocation cost of per-call copies at scale
- Assuming the read-only return type enforces anything at runtime