Kotlin - Idioms

Overview

Kotlin idioms are conventional ways of writing code that leverage Kotlin's unique features for maximum expressiveness and conciseness. This tutorial covers essential Kotlin idioms, patterns, and best practices that make your code more readable and maintainable.

๐ŸŽฏ Learning Objectives:
  • Understand fundamental Kotlin idioms and conventions
  • Learn to write concise and expressive code
  • Master functional programming patterns in Kotlin
  • Apply idiomatic approaches to common programming tasks
  • Recognize and avoid non-idiomatic code patterns

Data Classes and Destructuring

Data Classes for Simple Data Holders

// โœ… Idiomatic: Use data classes for simple data holders
data class Person(val name: String, val age: Int, val email: String)

// โœ… Destructuring declarations
val person = Person("Alice", 30, "[email protected]")
val (name, age, email) = person
println("$name is $age years old")

// โœ… Copy with modifications
val updatedPerson = person.copy(age = 31)

// โŒ Non-idiomatic: Regular class for simple data
class PersonOld(val name: String, val age: Int, val email: String) {
    // Manual equals, hashCode, toString implementation...
}

Destructuring in Loops and Functions

// โœ… Destructuring in loops
val people = listOf(
    "Alice" to 30,
    "Bob" to 25,
    "Charlie" to 35
)

for ((name, age) in people) {
    println("$name is $age years old")
}

// โœ… Destructuring with maps
val scores = mapOf("Alice" to 95, "Bob" to 87, "Charlie" to 92)
for ((name, score) in scores) {
    println("$name scored $score")
}

// โœ… Multiple return values using Pair/Triple
fun parseNameAndAge(input: String): Pair? {
    val parts = input.split(",")
    return if (parts.size == 2) {
        parts[0].trim() to parts[1].trim().toIntOrNull() ?: 0
    } else null
}

val input = "Alice, 30"
val (name, age) = parseNameAndAge(input) ?: return

String Templates and Operations

String Templates

// โœ… Idiomatic: String templates
val name = "Alice"
val age = 30
val message = "Hello, $name! You are $age years old."

// โœ… Complex expressions in templates
val person = Person("Bob", 25, "[email protected]")
val greeting = "Welcome ${person.name.uppercase()}!"

// โœ… Multi-line strings with trimMargin
val sql = """
    |SELECT name, age, email 
    |FROM users 
    |WHERE age > $age 
    |ORDER BY name
""".trimMargin()

// โŒ Non-idiomatic: String concatenation
val messageOld = "Hello, " + name + "! You are " + age + " years old."

String Processing

// โœ… Idiomatic string operations
val text = "  Hello, World!  "

// Clean and process strings
val cleaned = text.trim().lowercase().replace(",", "")

// Check string content
val isEmpty = text.isNullOrBlank()
val hasContent = text.isNotBlank()

// Safe string operations
val length = text?.length ?: 0
val firstChar = text.firstOrNull() ?: ' '

// Pattern matching
val email = "[email protected]"
val isValidEmail = email.contains("@") && email.contains(".")

// โœ… Use when for multiple conditions
fun validateInput(input: String): String = when {
    input.isBlank() -> "Input cannot be empty"
    input.length < 3 -> "Input too short"
    input.length > 50 -> "Input too long"
    else -> "Valid"
}

Collections and Functional Operations

Collection Creation

// โœ… Idiomatic collection creation
val numbers = listOf(1, 2, 3, 4, 5)
val mutableNumbers = mutableListOf(1, 2, 3)
val uniqueNumbers = setOf(1, 2, 3, 2, 1) // [1, 2, 3]
val nameToAge = mapOf("Alice" to 30, "Bob" to 25)

// โœ… Empty collections
val emptyList = emptyList()
val emptySet = emptySet()
val emptyMap = emptyMap()

// โœ… Building collections
val evenNumbers = (1..10).filter { it % 2 == 0 }
val squares = numbers.map { it * it }
val names = people.map { it.name }

// โŒ Non-idiomatic: Verbose creation
val numbersOld = ArrayList()
numbersOld.add(1)
numbersOld.add(2)
numbersOld.add(3)

Functional Collection Operations

val people = listOf(
    Person("Alice", 30, "[email protected]"),
    Person("Bob", 25, "[email protected]"),
    Person("Charlie", 35, "[email protected]")
)

// โœ… Idiomatic functional operations
val adults = people.filter { it.age >= 18 }
val names = people.map { it.name }
val totalAge = people.sumOf { it.age }
val averageAge = people.map { it.age }.average()

// โœ… Find operations
val alice = people.find { it.name == "Alice" }
val firstAdult = people.firstOrNull { it.age >= 18 }
val hasMinors = people.any { it.age < 18 }
val allAdults = people.all { it.age >= 18 }

// โœ… Grouping and partitioning
val byAgeGroup = people.groupBy { 
    when {
        it.age < 30 -> "Young"
        it.age < 50 -> "Middle"
        else -> "Senior"
    }
}

val (minors, adults) = people.partition { it.age < 18 }

// โœ… Chaining operations
val result = people
    .filter { it.age >= 25 }
    .map { it.name.uppercase() }
    .sorted()
    .joinToString(", ")

Null Safety Idioms

Safe Calls and Elvis Operator

// โœ… Safe calls
val person: Person? = findPerson("Alice")
val name = person?.name
val nameLength = person?.name?.length

// โœ… Elvis operator for defaults
val displayName = person?.name ?: "Unknown"
val age = person?.age ?: 0

// โœ… Safe calls with let
person?.let { p ->
    println("Found person: ${p.name}, age ${p.age}")
}

// โœ… Multiple safe calls
val email = person?.email?.lowercase()?.trim()

// โœ… Safe casting
val stringValue: Any = "Hello"
val length = (stringValue as? String)?.length ?: 0

// โŒ Non-idiomatic: Explicit null checks
if (person != null) {
    if (person.name != null) {
        println(person.name.length)
    }
}

Handling Collections with Nulls

// โœ… Filter out nulls
val names: List = listOf("Alice", null, "Bob", null, "Charlie")
val validNames = names.filterNotNull()

// โœ… Map with null handling
val lengths = names.mapNotNull { it?.length }

// โœ… Safe operations on nullable collections
val people: List? = getPeople()
val count = people?.size ?: 0
val firstPerson = people?.firstOrNull()

// โœ… Use scope functions with nullable objects
people?.takeIf { it.isNotEmpty() }?.let { peopleList ->
    println("Found ${peopleList.size} people")
    peopleList.forEach { println(it.name) }
}

Control Flow Idioms

When Expression

// โœ… When as expression
fun getGrade(score: Int): String = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    score >= 60 -> "D"
    else -> "F"
}

// โœ… When with sealed classes
sealed class Result
data class Success(val data: T) : Result()
data class Error(val message: String) : Result()

fun handleResult(result: Result) = when (result) {
    is Success -> println("Success: ${result.data}")
    is Error -> println("Error: ${result.message}")
}

// โœ… When with ranges and types
fun describe(obj: Any) = when (obj) {
    1 -> "one"
    in 2..10 -> "small number"
    is String -> "string of length ${obj.length}"
    is List<*> -> "list with ${obj.size} items"
    else -> "unknown"
}

Loops and Iterations

// โœ… Idiomatic loops
val items = listOf("apple", "banana", "cherry")

// For each with index
items.forEachIndexed { index, item ->
    println("$index: $item")
}

// Ranges
for (i in 1..5) println(i)
for (i in 1 until 5) println(i) // 1 to 4
for (i in 5 downTo 1) println(i)
for (i in 1..10 step 2) println(i)

// โœ… Use functional approaches when appropriate
val doubled = numbers.map { it * 2 }
val sum = numbers.fold(0) { acc, n -> acc + n }

// โŒ Non-idiomatic: Traditional for loops
for (i in 0 until items.size) {
    println("${i}: ${items[i]}")
}

Scope Functions

let, run, with, apply, also

// โœ… let - for nullable objects and transformations
val person: Person? = findPerson("Alice")
person?.let { p ->
    println("Processing ${p.name}")
    sendEmail(p.email)
}

// โœ… run - for object configuration and result computation
val result = person?.run {
    println("Name: $name")
    println("Age: $age")
    age * 2 // return value
}

// โœ… with - for operating on non-null objects
val greeting = with(person) {
    "Hello, $name! You are $age years old."
}

// โœ… apply - for object configuration
val newPerson = Person("", 0, "").apply {
    // Note: This example shows apply, but data classes are usually immutable
    println("Configuring person...")
}

// โœ… also - for side effects
val processedPerson = person?.also { p ->
    println("Processing person: ${p.name}")
    logPersonAccess(p)
}

// โœ… Real-world example: File operations
File("config.properties").takeIf { it.exists() }?.let { file ->
    Properties().apply {
        load(file.inputStream())
    }
}

Function and Lambda Idioms

Function Declarations

// โœ… Single-expression functions
fun double(x: Int) = x * 2
fun isEven(x: Int) = x % 2 == 0
fun max(a: Int, b: Int) = if (a > b) a else b

// โœ… Extension functions
fun String.removeWhitespace() = this.replace("\\s".toRegex(), "")
fun List.average() = this.sum().toDouble() / size

// โœ… Infix functions for DSL-like syntax
infix fun String.shouldContain(substring: String) = this.contains(substring)
val result = "Hello World" shouldContain "World"

// โœ… Higher-order functions
fun  List.takeUntil(predicate: (T) -> Boolean): List {
    val result = mutableListOf()
    for (item in this) {
        if (predicate(item)) break
        result.add(item)
    }
    return result
}

val numbers = listOf(1, 2, 3, 4, 5, 6)
val upToFour = numbers.takeUntil { it > 4 } // [1, 2, 3, 4]

Lambda Conventions

// โœ… Trailing lambda syntax
val filtered = numbers.filter { it > 3 }
val mapped = numbers.map { it * 2 }

// โœ… Use 'it' for single parameter lambdas
val doubled = numbers.map { it * 2 }

// โœ… Destructuring in lambdas
val nameToAge = mapOf("Alice" to 30, "Bob" to 25)
nameToAge.forEach { (name, age) -> 
    println("$name is $age years old")
}

// โœ… Multiple parameters with meaningful names
val people = listOf(Person("Alice", 30, "[email protected]"))
people.filter { person -> person.age >= 18 }

// โœ… Member references
val names = people.map(Person::name)
val emails = people.map { it.email }
println(names.joinToString())

// โŒ Non-idiomatic: Verbose lambda syntax
numbers.filter({ n -> n > 3 })
numbers.map({ number -> number * 2 })

Class and Object Patterns

Class Design

// โœ… Primary constructor with validation
class User(val name: String, val email: String) {
    init {
        require(name.isNotBlank()) { "Name cannot be blank" }
        require(email.contains("@")) { "Invalid email format" }
    }
}

// โœ… Properties with custom getters
class Circle(val radius: Double) {
    val area: Double
        get() = Math.PI * radius * radius
    
    val diameter: Double
        get() = radius * 2
}

// โœ… Sealed classes for type-safe hierarchies
sealed class NetworkResult
data class Success(val data: T) : NetworkResult()
data class Error(val exception: Throwable) : NetworkResult()
object Loading : NetworkResult()

// โœ… Object declarations for singletons
object DatabaseConfig {
    const val URL = "jdbc:postgresql://localhost:5432/mydb"
    const val DRIVER = "org.postgresql.Driver"
}

// โœ… Companion objects for factory methods
class Person(val name: String, val age: Int) {
    companion object {
        fun child(name: String) = Person(name, 0)
        fun adult(name: String, age: Int = 18) = Person(name, age)
    }
}

val child = Person.child("Alice")
val adult = Person.adult("Bob", 25)

Error Handling Idioms

Exception Handling

// โœ… Use runCatching for safe operations
val result = runCatching {
    val number = "123".toInt()
    number * 2
}.getOrElse { 0 }

// โœ… Specific exception types
fun parseNumber(input: String): Int {
    return input.toIntOrNull() 
        ?: throw IllegalArgumentException("Invalid number: $input")
}

// โœ… Result classes for functional error handling
fun divide(a: Int, b: Int): Result = runCatching {
    if (b == 0) throw ArithmeticException("Division by zero")
    a.toDouble() / b
}

val divisionResult = divide(10, 2)
    .onSuccess { println("Result: $it") }
    .onFailure { println("Error: ${it.message}") }

// โœ… Use nullable returns for expected failures
fun findUser(id: String): User? {
    return if (id.isBlank()) null else User(id, "[email protected]")
}

// โŒ Non-idiomatic: Catching generic exceptions
try {
    someOperation()
} catch (e: Exception) {
    // Too broad
}

Performance and Optimization Idioms

Lazy Initialization

// โœ… Lazy properties
class ExpensiveResource {
    val heavyComputation: String by lazy {
        println("Performing heavy computation...")
        "Result of heavy computation"
    }
    
    // โœ… Thread-safe lazy with custom lock
    val threadSafeResource: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        "Thread-safe resource"
    }
}

// โœ… Sequences for large data processing
fun processLargeDataset(data: List): List {
    return data.asSequence()
        .filter { it.isNotBlank() }
        .map { it.trim().uppercase() }
        .filter { it.length > 3 }
        .take(100)
        .toList()
}

// โœ… Use inline functions for performance-critical lambdas
inline fun  measureTime(operation: () -> T): Pair {
    val start = System.currentTimeMillis()
    val result = operation()
    val time = System.currentTimeMillis() - start
    return result to time
}

val (result, time) = measureTime {
    (1..1000000).sum()
}
println("Operation took ${time}ms")

DSL and Builder Patterns

Building DSLs

// โœ… HTML DSL example
class HTML {
    private val content = StringBuilder()
    
    fun head(init: HEAD.() -> Unit) {
        val head = HEAD()
        head.init()
        content.append("${head.content}")
    }
    
    fun body(init: BODY.() -> Unit) {
        val body = BODY()
        body.init()
        content.append("${body.content}")
    }
    
    override fun toString() = "$content"
}

class HEAD {
    var content = ""
    fun title(text: String) {
        content += "$text"
    }
}

class BODY {
    var content = ""
    fun h1(text: String) {
        content += "

$text

" } fun p(text: String) { content += "

$text

" } } fun html(init: HTML.() -> Unit): HTML { val html = HTML() html.init() return html } // Usage val webpage = html { head { title("My Page") } body { h1("Welcome") p("This is a paragraph") } } // โœ… Configuration DSL class DatabaseConfig { var host = "localhost" var port = 5432 var database = "" var username = "" var password = "" fun connection(init: DatabaseConfig.() -> Unit) { this.init() } } fun database(init: DatabaseConfig.() -> Unit): DatabaseConfig { val config = DatabaseConfig() config.init() return config } val dbConfig = database { host = "production.db.com" port = 5432 database = "myapp" username = "app_user" password = "secret" }

Testing Idioms

Test Structure

import kotlin.test.*

class PersonTest {
    // โœ… Use descriptive test names
    @Test
    fun `should create person with valid name and age`() {
        val person = Person("Alice", 30, "[email protected]")
        
        assertEquals("Alice", person.name)
        assertEquals(30, person.age)
    }
    
    // โœ… Test edge cases
    @Test
    fun `should throw exception when name is blank`() {
        assertFailsWith {
            Person("", 30, "[email protected]")
        }
    }
    
    // โœ… Use data-driven tests
    @Test
    fun `should validate email formats`() {
        val validEmails = listOf(
            "[email protected]",
            "[email protected]",
            "[email protected]"
        )
        
        validEmails.forEach { email ->
            assertTrue(email.isValidEmail(), "Expected $email to be valid")
        }
    }
    
    // โœ… Setup and teardown
    private lateinit var testDatabase: TestDatabase
    
    @BeforeTest
    fun setup() {
        testDatabase = TestDatabase()
        testDatabase.initialize()
    }
    
    @AfterTest
    fun teardown() {
        testDatabase.cleanup()
    }
}

// โœ… Extension functions for testing
fun String.isValidEmail(): Boolean = contains("@") && contains(".")

Common Anti-Patterns to Avoid

What Not to Do

// โŒ Don't use !! unless absolutely necessary
val name = person!!.name // Risky!

// โœ… Use safe calls instead
val name = person?.name

// โŒ Don't use mutable collections when immutable ones suffice
fun getItems(): MutableList = mutableListOf("a", "b", "c")

// โœ… Return immutable collections
fun getItems(): List = listOf("a", "b", "c")

// โŒ Don't ignore function return values
list.filter { it > 5 } // Creates new list but doesn't use it

// โœ… Assign or chain operations
val filtered = list.filter { it > 5 }

// โŒ Don't use Java-style iterations
for (i in 0 until list.size) {
    println(list[i])
}

// โœ… Use Kotlin idioms
list.forEachIndexed { index, item ->
    println("$index: $item")
}

// โŒ Don't use explicit type declarations when not needed
val name: String = "Alice"
val numbers: List = listOf(1, 2, 3)

// โœ… Let type inference work
val name = "Alice"
val numbers = listOf(1, 2, 3)

Key Takeaways

  • Use data classes for simple data containers
  • Leverage string templates instead of concatenation
  • Prefer functional collection operations over imperative loops
  • Use safe calls and Elvis operator for null safety
  • Apply scope functions appropriately for different contexts
  • Write single-expression functions when possible
  • Use when expressions instead of multiple if-else chains
  • Avoid non-null assertions (!!) unless absolutely necessary

Practice Exercises

  1. Refactor a Java class to use Kotlin idioms and data classes
  2. Create a DSL for building SQL queries using Kotlin idioms
  3. Implement a functional approach to data processing using collection operations
  4. Build a configuration system using extension functions and scope functions

Quiz

  1. When should you use the Elvis operator vs let function?
  2. What's the difference between apply and also scope functions?
  3. When is it appropriate to use single-expression functions?
Show Answers
  1. Use Elvis operator for simple default values (person?.name ?: "Unknown"), use let for conditional execution on nullable objects (person?.let { ... }).
  2. apply returns the object being configured, also returns the same object but is used for side effects. Use apply for configuration, also for additional operations.
  3. Use single-expression functions when the function body contains only one expression, making the code more concise and readable.