Inline, Value, and Data Classes: Representation and Semantics

Kotlin offers several class abstractions that go beyond traditional object-oriented classes. Understanding these can help you write more efficient, readable, and maintainable code. We’ll explore their internals, JVM representations, and when to use each. By the end, you’ll be equipped to make informed decisions in your projects.

Class Categories in Kotlin

In Kotlin, classes aren’t one-size-fits-all. We have regular classes, data classes, inline classes (renamed to value classes in Kotlin 1.5+), and even sealed or enum classes. But today, we’re zooming in on regular, data, and value classes because they represent different trade-offs in terms of semantics, performance, and representation on the JVM.

A regular class is your standard OOP class. It can have properties, methods, inheritance, and so on. It’s great for modeling behavior-heavy objects.

A data class is a specialized class for holding data. Kotlin auto-generates useful methods like equals(), hashCode(), toString(), and copy(). This makes them perfect for DTOs (Data Transfer Objects) or immutable data holders.

An inline class (or value class) is a lightweight wrapper around a single value, typically a primitive. It’s designed to add type safety without runtime overhead. When compiled to JVM bytecode, it often “inlines” the wrapped value, avoiding object creation.

Why categorize them? Because each affects how your code performs, how objects are represented in memory, and how semantics like equality work. For mid-level devs, think of it like choosing tools: a hammer (regular class) for general tasks, a screwdriver (data class) for precise data handling, and a laser cutter (value class) for efficiency.

Let’s look at a simple example to contrast them:

Kotlin

// Regular Class
class Person(val name: String, val age: Int) {
    fun greet() = "Hello, I'm $name"
}

// Data Class
data class DataPerson(val name: String, val age: Int)

// Value Class (requires @JvmInline in Kotlin 1.5+)
@JvmInline
value class Age(val value: Int)

// Usage
fun main() {
    val regular = Person("Alice", 30)
    val data = DataPerson("Bob", 25)
    val age = Age(40)

    println(regular)  // Outputs: Person@1b6d3586 (hash code)
    println(data)     // Outputs: DataPerson(name=Bob, age=25)
    println(age)      // Outputs: 40 (inlined value)
}

In this snippet, the regular class prints a default toString() with a memory address, the data class gives a nice representation, and the value class behaves like its underlying primitive. This sets the stage for deeper dives.

Data Classes: Generated Methods and Costs

Data classes are Kotlin’s gift to developers tired of boilerplate. When you declare a class with the data modifier, Kotlin generates several methods based on the primary constructor parameters.

Generated methods include:

  • equals(): Structural equality based on properties.
  • hashCode(): Consistent with equals() for use in collections.
  • toString(): Human-readable string like “ClassName(prop1=value1, prop2=value2)”.
  • copy(): Creates a shallow copy with optional property overrides.
  • componentN(): Destructuring support, e.g., val (name, age) = person.

These are generated only for properties in the primary constructor. Secondary constructors or body properties are ignored for these methods.

Example with full usage:

Kotlin

data class User(val id: Int, val name: String, val email: String? = null) {
    // This property is not used in generated methods
    val isActive: Boolean = true
}

fun main() {
    val user1 = User(1, "Alice")
    val user2 = User(1, "Alice", "alice@example.com")
    val user3 = user1.copy(email = "alice@example.com")

    println(user1)  // User(id=1, name=Alice, email=null)
    println(user1 == user2)  // false (different email)
    println(user2 == user3)  // true (structural equality ignores isActive)

    val (id, name, email) = user3  // Destructuring
    println("$id $name $email")  // 1 Alice alice@example.com

    val users = hashSetOf(user1, user2)
    println(users.size)  // 2, thanks to hashCode()
}

Costs? Data classes aren’t free. The generated code adds bytecode overhead. For small classes, it’s negligible, but in performance-critical paths (e.g., large lists in Android), the extra methods can increase APK size or slow down reflection-heavy code.

Also, data classes must have at least one primary constructor parameter. They can’t be abstract, open, sealed, or inner (pre-Kotlin 1.1 restrictions lifted somewhat). Inheritance is allowed but tricky—subclasses must override generated methods if needed.

For mid-level devs: Use data classes for immutable data models, like API responses. Avoid them for entities with complex behavior; stick to regular classes there.

Inline (Value) Classes and JVM Representation

Inline classes were introduced in Kotlin 1.3 as an experimental feature and stabilized as “value classes” in 1.5 with the @JvmInline annotation. They wrap a single value (usually a primitive) to provide type safety without allocating extra objects.

On the JVM, a value class is represented as its underlying type wherever possible. This means no object overhead—no headers, no GC pressure. It’s like a type alias with methods.

Key rules:

  • Must have exactly one property in the primary constructor.
  • That property must be val (immutable).
  • No other init blocks or backing fields allowed.
  • Can have methods and extensions.

JVM representation: In bytecode, calls to the value class are replaced with operations on the wrapped type. For example, a value class UUID(val value: String) becomes just a String at runtime.

Full example:

Kotlin

@JvmInline
value class Email(val address: String) {
    init {
        require(address.contains("@")) { "Invalid email" }
    }

    fun domain(): String = address.substringAfter("@")
}

@JvmInline
value class UserId(val id: Long)

fun processUser(id: UserId, email: Email) {
    println("User ${id.id} has domain ${email.domain()}")
}

fun main() {
    val email = Email("user@example.com")
    val id = UserId(123L)

    processUser(id, email)  // At JVM: just Long and String

    // No boxing: email is a String in bytecode
    val asString: String = email.address  // Direct access
}

In decompiled Java, processUser takes long and String, not objects. This optimizes performance in hot loops.

For mid-level devs: Use value classes for domain-specific primitives, like Money(val amount: BigDecimal) to avoid mixing units.

Boxing, Unboxing, and ABI Concerns

Boxing/unboxing is a JVM concept where primitives (int) are wrapped in objects (Integer) for generics or nullability. Regular classes always box; data classes do too since they’re objects.

Value classes shine here: They avoid boxing by inlining. But if you use them in generics (e.g., List<Email>), the JVM might box them to Object. Kotlin mitigates this with mangling—renaming methods to preserve types.

ABI (Application Binary Interface) concerns arise in libraries. Changing a value class’s underlying type breaks binary compatibility. For example, switching from Int to Long requires recompilation of dependents.

Example of boxing:

Kotlin

@JvmInline
value class Counter(val value: Int)

fun main() {
    val counter = Counter(42)
    val list: List<Any> = listOf(counter)  // Boxes to Object

    // Unboxing
    val unboxed: Int = counter.value

    // In generics without boxing (Kotlin optimizes where possible)
    val counters: List<Counter> = listOf(Counter(1), Counter(2))
    // Internally, might be List<Int> with mangled names
}

ABI tip: For public APIs, document if a class is value-based, as consumers might rely on its representation.

For mid-level devs: Profile your code with tools like Android Profiler to see boxing impacts. Value classes reduce allocations in performance-sensitive areas like game loops or data processing.

Equality and Identity Semantics

Semantics refer to how objects compare: structural equality (== checks values) vs referential equality (=== checks identity).

Regular classes: Default equals() is referential (like ===). You must override for structural.

Data classes: Auto-structural equals() based on properties.

Value classes: equals() delegates to the underlying type’s equality. So, two Email(“a@b.com”) are equal if strings match.

Identity: Value classes have no identity since they’re inlined; === might not make sense and could fall back to underlying.

Example:

Kotlin

data class DataPoint(val x: Int, val y: Int)

@JvmInline
value class ValuePoint(val coords: Pair<Int, Int>)

class RegularPoint(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean =
        other is RegularPoint && x == other.x && y == other.y
    override fun hashCode(): Int = 31 * x + y
}

fun main() {
    val dp1 = DataPoint(1, 2)
    val dp2 = DataPoint(1, 2)
    println(dp1 == dp2)  // true (structural)
    println(dp1 === dp2) // false (different objects)

    val vp1 = ValuePoint(1 to 2)
    val vp2 = ValuePoint(1 to 2)
    println(vp1 == vp2)  // true (underlying Pair equals)

    val rp1 = RegularPoint(1, 2)
    val rp2 = RegularPoint(1, 2)
    println(rp1 == rp2)  // true (overridden)
    println(rp1 === rp2) // false
}

For mid-level devs: Always use == for value comparisons. Override equals() in regular classes for consistency.

Limitations and Edge Cases

Data classes: Can’t have open/abstract; generated methods ignore body properties. Edge case: If properties are mutable, equals() can change over time—bad for hash maps.

Value classes: Limited to one property; no inheritance (except interfaces); no recursion (e.g., value class wrapping itself). Edge case: Nullable value classes box to nullable underlying (e.g., Int?).

Common edge: Mixing with Java—value classes appear as objects in Java, losing inlining.

Example of limitation:

Kotlin

@JvmInline
value class Invalid(val a: Int, val b: Int)  // Error: only one property allowed

data class MutableData(var name: String)  // Mutable: equals() can change
fun main() {
    val md = MutableData("Alice")
    val set = hashSetOf(md)
    md.name = "Bob"
    println(set.contains(md))  // false? Hash changed!
}

Avoid mutability in data classes. For value classes, use for simple wrappers only.

Public API Stability Considerations

When exposing classes in libraries:

  • Data classes: Stable, but adding properties breaks equals()/binary compat.
  • Value classes: Unstable for public APIs because underlying representation can change (e.g., from inline to object in future Kotlin versions). Use @JvmInline sparingly in APIs; prefer interfaces.

Kotlin’s KAPT/KSP help with compat, but test across versions.

Example: If you change a value class to regular, callers break if they accessed .value.

Tip: Use semantic versioning; document “experimental” for value classes in APIs.

Choosing the Right Class Abstraction

Choose based on needs:

  • Behavior-heavy? Regular class.
  • Data holder with auto-methods? Data class.
  • Type-safe primitive wrapper? Value class.

For performance: Profile! Value classes for micro-optimizations.

In Android: Data classes for Parcelables; value for IDs.

Final example combining all:

Kotlin

data class Employee(val id: EmployeeId, val name: String)

@JvmInline
value class EmployeeId(val value: Long)

class Department(val employees: List<Employee>) {
    fun findById(id: EmployeeId): Employee? = employees.find { it.id == id }
}

fun main() {
    val emp = Employee(EmployeeId(1L), "Alice")
    val dept = Department(listOf(emp))
    println(dept.findById(EmployeeId(1L)))  // Works efficiently
}

Leave a Reply

Your email address will not be published. Required fields are marked *