642 lines
14 KiB
Markdown
642 lines
14 KiB
Markdown
# Immutability Patterns
|
|
|
|
@Immutable annotation, data classes, and immutable collections for Compose performance.
|
|
|
|
## Table of Contents
|
|
- [Why Immutability Matters](#why-immutability-matters)
|
|
- [@Immutable Annotation](#immutable-annotation)
|
|
- [Data Classes](#data-classes)
|
|
- [Immutable Collections](#immutable-collections)
|
|
- [Common Patterns](#common-patterns)
|
|
- [Performance Impact](#performance-impact)
|
|
|
|
---
|
|
|
|
## Why Immutability Matters
|
|
|
|
### Compose Recomposition
|
|
|
|
**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition.
|
|
|
|
```kotlin
|
|
// Without @Immutable - Recomposes on every parent recomposition
|
|
data class User(val name: String, val age: Int)
|
|
|
|
@Composable
|
|
fun UserCard(user: User) { // Recomposes unnecessarily
|
|
Text(user.name)
|
|
}
|
|
|
|
// With @Immutable - Only recomposes when user reference changes
|
|
@Immutable
|
|
data class User(val name: String, val age: Int)
|
|
|
|
@Composable
|
|
fun UserCard(user: User) { // Smart recomposition
|
|
Text(user.name)
|
|
}
|
|
```
|
|
|
|
**Performance difference:**
|
|
- Without `@Immutable`: 1000 `UserCard` recompositions per screen update
|
|
- With `@Immutable`: 10 `UserCard` recompositions (only changed users)
|
|
|
|
### Thread Safety
|
|
|
|
Immutable objects are inherently thread-safe:
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class Event(
|
|
val id: String,
|
|
val content: String,
|
|
val createdAt: Long
|
|
)
|
|
|
|
// Safe to share across coroutines without synchronization
|
|
val sharedEvent: Event = fetchEvent()
|
|
launch { processEvent(sharedEvent) } // Safe
|
|
launch { saveEvent(sharedEvent) } // Safe
|
|
```
|
|
|
|
---
|
|
|
|
## @Immutable Annotation
|
|
|
|
### Basic Usage
|
|
|
|
**Pattern from Amethyst:**
|
|
|
|
```kotlin
|
|
// TextNoteEvent.kt:51-63
|
|
@Immutable
|
|
class TextNoteEvent(
|
|
id: HexKey,
|
|
pubKey: HexKey,
|
|
createdAt: Long,
|
|
tags: Array<Array<String>>,
|
|
content: String,
|
|
sig: HexKey
|
|
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
|
// All properties are val (immutable)
|
|
// No var properties
|
|
// No mutable collections
|
|
}
|
|
```
|
|
|
|
**Requirements for @Immutable:**
|
|
1. All properties must be `val` (no `var`)
|
|
2. All property types must be immutable or primitives
|
|
3. No mutable collections (`MutableList`, `MutableMap`)
|
|
4. Arrays are allowed (treated as immutable by contract)
|
|
5. No public mutable state
|
|
|
|
### @Immutable vs @Stable
|
|
|
|
**@Immutable:** Value never changes after construction
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class User(val name: String, val age: Int)
|
|
// Once created, user.name and user.age never change
|
|
```
|
|
|
|
**@Stable:** Value can change, but changes are tracked
|
|
|
|
```kotlin
|
|
@Stable
|
|
class MutableCounter {
|
|
var count by mutableStateOf(0) // Changes tracked by Compose
|
|
}
|
|
```
|
|
|
|
**Amethyst uses @Immutable extensively:**
|
|
- 173+ event classes annotated with `@Immutable`
|
|
- All Nostr events immutable by design
|
|
- Critical for feed performance (thousands of events)
|
|
|
|
---
|
|
|
|
## Data Classes
|
|
|
|
### Immutable Data Classes
|
|
|
|
**Pattern:**
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class RelayStatus(
|
|
val url: NormalizedRelayUrl,
|
|
val connected: Boolean,
|
|
val error: String? = null,
|
|
val messageCount: Int = 0
|
|
) {
|
|
// Immutable properties only (val)
|
|
// Default values allowed
|
|
}
|
|
```
|
|
|
|
**Benefits:**
|
|
1. **Structural equality:** `equals()` compares values, not references
|
|
2. **copy():** Create modified copies without mutation
|
|
3. **toString():** Debugging-friendly output
|
|
4. **hashCode():** Consistent hashing for collections
|
|
5. **componentN():** Destructuring support
|
|
|
|
### copy() for Updates
|
|
|
|
**Mental model:** Instead of mutating, create modified copies.
|
|
|
|
```kotlin
|
|
val status = RelayStatus(
|
|
url = "wss://relay.damus.io",
|
|
connected = false,
|
|
error = null
|
|
)
|
|
|
|
// Immutable update
|
|
val updatedStatus = status.copy(connected = true)
|
|
|
|
// Original unchanged
|
|
assert(status.connected == false)
|
|
assert(updatedStatus.connected == true)
|
|
```
|
|
|
|
**StateFlow pattern:**
|
|
|
|
```kotlin
|
|
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
|
|
|
|
fun updateRelay(url: String, connected: Boolean) {
|
|
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
|
|
if (key == url) {
|
|
status.copy(connected = connected) // Immutable update
|
|
} else {
|
|
status
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### All Properties in Constructor
|
|
|
|
**Why important for data classes:**
|
|
|
|
```kotlin
|
|
// BAD: Properties outside constructor not included in equals/hashCode
|
|
data class User(val name: String) {
|
|
var age: Int = 0 // NOT in equals/hashCode/copy!
|
|
}
|
|
|
|
val user1 = User("Alice")
|
|
val user2 = User("Alice")
|
|
user1.age = 25
|
|
user2.age = 30
|
|
|
|
assert(user1 == user2) // TRUE! age not compared
|
|
assert(user1.copy() == user1) // TRUE! age not copied
|
|
|
|
// GOOD: All properties in constructor
|
|
@Immutable
|
|
data class User(
|
|
val name: String,
|
|
val age: Int // Included in equals/hashCode/copy
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Immutable Collections
|
|
|
|
### kotlinx.collections.immutable
|
|
|
|
**Installation:**
|
|
|
|
```kotlin
|
|
// build.gradle.kts
|
|
dependencies {
|
|
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
|
|
}
|
|
```
|
|
|
|
**Why use:**
|
|
- Structural sharing (efficient copies)
|
|
- Explicit immutability (compiler enforced)
|
|
- Safe for Compose state
|
|
|
|
### ImmutableList
|
|
|
|
```kotlin
|
|
import kotlinx.collections.immutable.ImmutableList
|
|
import kotlinx.collections.immutable.persistentListOf
|
|
import kotlinx.collections.immutable.toImmutableList
|
|
|
|
// Create immutable list
|
|
val relays: ImmutableList<String> = persistentListOf(
|
|
"wss://relay1.com",
|
|
"wss://relay2.com"
|
|
)
|
|
|
|
// Add returns NEW list
|
|
val updated = relays.add("wss://relay3.com")
|
|
assert(relays.size == 2) // Original unchanged
|
|
assert(updated.size == 3) // New list has 3 items
|
|
|
|
// Convert from regular list
|
|
val mutableList = mutableListOf("a", "b", "c")
|
|
val immutable = mutableList.toImmutableList()
|
|
```
|
|
|
|
### ImmutableMap
|
|
|
|
```kotlin
|
|
import kotlinx.collections.immutable.ImmutableMap
|
|
import kotlinx.collections.immutable.persistentMapOf
|
|
import kotlinx.collections.immutable.toImmutableMap
|
|
|
|
// Create immutable map
|
|
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
|
|
"wss://relay1.com" to RelayStatus(...),
|
|
"wss://relay2.com" to RelayStatus(...)
|
|
)
|
|
|
|
// Put returns NEW map
|
|
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
|
|
|
|
// Remove returns NEW map
|
|
val removed = relayStatuses.remove("wss://relay1.com")
|
|
```
|
|
|
|
### ImmutableSet
|
|
|
|
```kotlin
|
|
import kotlinx.collections.immutable.ImmutableSet
|
|
import kotlinx.collections.immutable.persistentSetOf
|
|
|
|
val connectedRelays: ImmutableSet<String> = persistentSetOf(
|
|
"wss://relay1.com",
|
|
"wss://relay2.com"
|
|
)
|
|
|
|
val updated = connectedRelays.add("wss://relay3.com")
|
|
```
|
|
|
|
### Structural Sharing
|
|
|
|
**Mental model:** Immutable collections reuse internal structure for efficiency.
|
|
|
|
```kotlin
|
|
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
|
|
val list2 = list1.add(6) // Shares structure with list1
|
|
|
|
// Internally:
|
|
// list1 and list2 share nodes for items 1-5
|
|
// list2 has one additional node for item 6
|
|
// O(1) time, O(1) space for add operation
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern: Immutable State Updates
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class FeedState(
|
|
val events: ImmutableList<Event>,
|
|
val loading: Boolean,
|
|
val error: String?
|
|
)
|
|
|
|
class FeedViewModel {
|
|
private val _state = MutableStateFlow(
|
|
FeedState(
|
|
events = persistentListOf(),
|
|
loading = false,
|
|
error = null
|
|
)
|
|
)
|
|
val state: StateFlow<FeedState> = _state.asStateFlow()
|
|
|
|
fun loadEvents() {
|
|
_state.value = _state.value.copy(loading = true, error = null)
|
|
|
|
viewModelScope.launch {
|
|
try {
|
|
val events = repository.getEvents()
|
|
_state.value = _state.value.copy(
|
|
events = events.toImmutableList(),
|
|
loading = false
|
|
)
|
|
} catch (e: Exception) {
|
|
_state.value = _state.value.copy(
|
|
loading = false,
|
|
error = e.message
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun addEvent(event: Event) {
|
|
_state.value = _state.value.copy(
|
|
events = _state.value.events.add(event) // Immutable add
|
|
)
|
|
}
|
|
|
|
fun removeEvent(eventId: String) {
|
|
_state.value = _state.value.copy(
|
|
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern: Deep Immutability
|
|
|
|
```kotlin
|
|
// Nested immutable structures
|
|
@Immutable
|
|
data class User(
|
|
val name: String,
|
|
val profile: Profile // Also immutable
|
|
)
|
|
|
|
@Immutable
|
|
data class Profile(
|
|
val bio: String,
|
|
val avatar: String,
|
|
val relays: ImmutableList<String> // Immutable collection
|
|
)
|
|
|
|
// Safe deep copy
|
|
val user = User(
|
|
name = "Alice",
|
|
profile = Profile(
|
|
bio = "Nostr enthusiast",
|
|
avatar = "https://...",
|
|
relays = persistentListOf("wss://relay1.com")
|
|
)
|
|
)
|
|
|
|
val updatedUser = user.copy(
|
|
profile = user.profile.copy(
|
|
bio = "Bitcoin & Nostr enthusiast" // Deep update
|
|
)
|
|
)
|
|
```
|
|
|
|
### Pattern: Collection Builder to Immutable
|
|
|
|
```kotlin
|
|
// Build mutable, convert to immutable
|
|
fun processEvents(input: List<Event>): ImmutableList<Event> {
|
|
val processed = mutableListOf<Event>()
|
|
|
|
for (event in input) {
|
|
if (event.isValid()) {
|
|
processed.add(event.normalize())
|
|
}
|
|
}
|
|
|
|
return processed.toImmutableList() // Convert once at end
|
|
}
|
|
```
|
|
|
|
### Pattern: Immutable Map Updates
|
|
|
|
```kotlin
|
|
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
|
|
persistentMapOf()
|
|
)
|
|
|
|
fun updateRelay(url: String, connected: Boolean) {
|
|
val currentStatuses = _relayStatuses.value
|
|
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
|
|
|
|
_relayStatuses.value = currentStatuses.put(
|
|
url,
|
|
currentStatus.copy(connected = connected)
|
|
)
|
|
}
|
|
|
|
fun removeRelay(url: String) {
|
|
_relayStatuses.value = _relayStatuses.value.remove(url)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Impact
|
|
|
|
### Benchmarks (Approximate)
|
|
|
|
**Recomposition cost:**
|
|
|
|
```kotlin
|
|
// 1000 items in LazyColumn
|
|
// Without @Immutable: ~100ms per frame (skipped frames)
|
|
// With @Immutable: ~16ms per frame (smooth 60fps)
|
|
|
|
@Immutable
|
|
data class Item(val id: String, val name: String)
|
|
|
|
@Composable
|
|
fun ItemList(items: ImmutableList<Item>) {
|
|
LazyColumn {
|
|
items(items, key = { it.id }) { item ->
|
|
ItemRow(item) // Only recomposes when item changes
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Structural sharing efficiency:**
|
|
|
|
```kotlin
|
|
val list1 = persistentListOf(1..10000)
|
|
val list2 = list1.add(10001) // O(log n) time, shares structure
|
|
|
|
// Regular list (copy on modification):
|
|
val mutableList = (1..10000).toMutableList()
|
|
val copy = mutableList.toList() + 10001 // O(n) time, full copy
|
|
```
|
|
|
|
### When to Use Immutable Collections
|
|
|
|
**Use ImmutableList/Map/Set when:**
|
|
- Storing in Compose state (@Immutable class)
|
|
- Sharing across coroutines
|
|
- Frequent modifications (structural sharing efficient)
|
|
- Need compile-time immutability guarantee
|
|
|
|
**Use Array when:**
|
|
- Fixed size, no modifications
|
|
- Nostr protocol (tags are `Array<Array<String>>`)
|
|
- Performance-critical (array access is fastest)
|
|
|
|
**Use regular List/Map/Set when:**
|
|
- Local scope only
|
|
- Build once, read many times
|
|
- Converting to immutable at boundary
|
|
|
|
---
|
|
|
|
## Anti-Patterns
|
|
|
|
### ❌ Mutable Properties in @Immutable Class
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class BadEvent(
|
|
val id: String,
|
|
var content: String // BAD: var breaks immutability
|
|
)
|
|
```
|
|
|
|
### ✅ All val Properties
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class GoodEvent(
|
|
val id: String,
|
|
val content: String
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### ❌ Mutable Collections in @Immutable Class
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class BadState(
|
|
val items: MutableList<Item> // BAD: Can mutate items
|
|
)
|
|
|
|
// Caller can mutate:
|
|
val state = BadState(mutableListOf())
|
|
state.items.add(newItem) // Breaks immutability!
|
|
```
|
|
|
|
### ✅ Immutable Collections
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class GoodState(
|
|
val items: ImmutableList<Item>
|
|
)
|
|
|
|
// Caller must create new state:
|
|
val updated = state.copy(items = state.items.add(newItem))
|
|
```
|
|
|
|
---
|
|
|
|
### ❌ Direct Mutation
|
|
|
|
```kotlin
|
|
val status = RelayStatus(url, connected = false)
|
|
status.connected = true // Compile error (val)
|
|
|
|
// But could happen with mutable nested objects:
|
|
@Immutable
|
|
data class Config(
|
|
val settings: Settings // If Settings is mutable...
|
|
)
|
|
|
|
class Settings {
|
|
var theme: String = "dark" // BAD
|
|
}
|
|
|
|
val config = Config(Settings())
|
|
config.settings.theme = "light" // Mutates "immutable" config!
|
|
```
|
|
|
|
### ✅ Deep Immutability
|
|
|
|
```kotlin
|
|
@Immutable
|
|
data class Config(
|
|
val settings: Settings
|
|
)
|
|
|
|
@Immutable
|
|
data class Settings(
|
|
val theme: String // val only
|
|
)
|
|
|
|
val config = Config(Settings("dark"))
|
|
val updated = config.copy(
|
|
settings = config.settings.copy(theme = "light")
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### ❌ Exposing Mutable Internal State
|
|
|
|
```kotlin
|
|
@Immutable
|
|
class BadViewModel {
|
|
private val _items = mutableListOf<Item>()
|
|
val items: List<Item> = _items // BAD: Exposes mutable list
|
|
|
|
fun addItem(item: Item) {
|
|
_items.add(item)
|
|
}
|
|
}
|
|
|
|
// Caller can cast and mutate:
|
|
val vm = BadViewModel()
|
|
(vm.items as MutableList).clear() // Breaks encapsulation!
|
|
```
|
|
|
|
### ✅ Convert to Immutable at Boundary
|
|
|
|
```kotlin
|
|
@Immutable
|
|
class GoodViewModel {
|
|
private val _items = mutableListOf<Item>()
|
|
val items: ImmutableList<Item>
|
|
get() = _items.toImmutableList() // GOOD: Copy to immutable
|
|
|
|
fun addItem(item: Item) {
|
|
_items.add(item)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist for Immutability
|
|
|
|
**For @Immutable classes:**
|
|
- [ ] All properties are `val`, never `var`
|
|
- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`)
|
|
- [ ] Nested objects are also `@Immutable` or primitives
|
|
- [ ] No public mutable state
|
|
- [ ] Use `copy()` for updates, never mutation
|
|
- [ ] Arrays used only when truly immutable by contract
|
|
|
|
**For StateFlow state:**
|
|
- [ ] State class is `@Immutable`
|
|
- [ ] Use immutable collections (ImmutableList, ImmutableMap)
|
|
- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`)
|
|
- [ ] Never mutate state in-place
|
|
|
|
**For Compose performance:**
|
|
- [ ] All `@Composable` parameters are `@Immutable` or `@Stable`
|
|
- [ ] Lists use `ImmutableList` and `key` parameter in `items()`
|
|
- [ ] Heavy objects (events, profiles) cached and reused
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- TextNoteEvent.kt:51-63 - @Immutable event example
|
|
- RelayConnectionManager.kt - Immutable map updates
|
|
- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability)
|
|
- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable)
|
|
- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix)
|