#Idiomatic Kotlin

This section summarizes a talk by Urs Peters, presented on Kotlin Dev Day 2022. it's a very interesting talk and worth watching in its entirety!

#Why Idiomatic Kotlin?

It's possible to use Kotlin as a "better Java", but you would be missing out on some of the features that make Kotlin unique and interesting.

#Principles

#1. Favour immutability over mutability.

Kotlin favors immutability by providing various immutable constucts and defaults.

data class Programmer(val name: String, val languages: List<String>) fun known(language:String) = languages.contains(language) val urs = Programmer("Urs", listOf("Kotlin", "Scale", "Java")) val joe = urs.copy(name = "Joe")

What is so good about immutability?

  • Immutability: exactly one state that will never change.
  • Mutable: an infinite amount of potential states.
CriteriaImmutableMutable
ReasoningSimple: one state onlyHard: many possible states
SafetySafer: state remains the same and validUnsafe: accidental errors due to state changes
TestabilityNo side effects which makes tests deterministicSide effects: can lead to unexpected failures
Thread-safetyInherently thread-safeManual synchronization required

How to leverage it?

  • prefer vals over vars
  • prefer read-only collections (listOf instead of mutableListOf)
  • use immutable value objects instead of mutable ones (e.g. data classes over classes)

Local mutability that does not leak outside is ok (e.g. a var within a function is ok if nothing external to the function relies on it).

#2. Use Nullability

Think twice before using !!

val uri = URI("...") val res = loadResource(uri) val lines = res!!read() // bad! val lines = res?.read() ?: throw IAE("$uri invalid") // more reasonable

Stick to nullable types only

public Optional<Goody> findGoodyForAmount(amount:Double) val goody = findGoodyForAmount(100) if(goody.isPresent()) goody.get() ... else ... // bad val goody = findGoodyForAmount(100).orElse(null) if(goody != null) goody ... else ... // good uses null consistently

Use nullability where applicable but don't overuse it.

data class Order( val id: Int? = null, val items: List<LineItem>? = null, val state: OrderState? = null, val goody: Goody? = null ) // too much! data class Order( val id: Int? = null, val items: List<LineItem> = emptyList()), val state: OrderState = UNPAID, val goody: Goody? = null ) // some types made more sense as not-null values

Avoid using nullable types in Collections

val items: List<LineItem?> = emptyList() val items: List<LineItem>? = null, val items: Lilst<LineItem?>? = null // all terribad val items: List<LineItem> = emptyList() // that's what this is for

Use lateinit var for late initialization rather than nullability

// bad class CatalogService(): ResourceAware { var catalog: Catalog? = null override fun onCreate(resource: Bundle) { this.catalog = Catalog(resource) } fun message(key: Int, lang: String) = catalog?.productDescription(key, lang) ?: throw IllegalStateException("Impossible") } // good class CatalogService(): ResourceAware { lateinit var catalog: Catalog override fun onCreate(resource: Bundle) { this.catalog = Catalog(resource) } fun message(key: Int, lang: String) = catalog.productDescription(key, lang)

#3. Get The Most Out Of Classes and Objects

Use immutable data classes for value classes, config classes etc.

class Person(val name: String, val age: Int) val p1 = Person("Joe", 42) val p2 = Person("Joe", 42) p1 == p2 // false data class Person(val name: String, val age: Int) val p1 = Person("Joe", 42) val p2 = Person("Joe", 42) p1 == p2 // true

Use normal classes instead of data classes for services etc.

class PersonService(val dao: PersonDao) { fun create(p: Person) { if (op.age >= MAX_AGE) LOG.warn("$p ${bornInYear(p.age)} too old") dao.save(p) } companion object { val LOG = LogFactory.getLogger() val MAX_AGE = 120 fun bornInYear(age: Int) = ... } }

Use value classes for domain specific types instead of common types.

value class Email(val value: String) value class Password(val value: String) fun login(email: Email, pwd: Password) // no performance impact! type erased in bytecode

Seal classes for exhaustive branch checks

// problematic data class Square(val length: Double) data class Circle(val radius: Double) when (shape) { is Circle -> "..." is Rectangle -> "..." else -> throw IAE("unknown shape $shape") // annoying } // fixed sealed interface Shape // prevents additions data class Square(val length: Double) data class Circle(val radius: Double) when (shape) { is Circle -> "..." is Rectangle -> "..." }

#4. Use Available Extensions

// bad val fis = FileInputStream("path") val text = try { val sb = StringBuilder() var line: String? while(fis.readLine().apply {line = this} != null) { sb.append(line).append(System.lineSeparator()) } sb.toString() } finally { try { fis.close() } catch (ex:Throwable) { } } // good, via extension functions val text = FileInputStream("path").use { it.reader().readText() }

Extend third party classes

// bad fun toDateString(dr: LocalDateTime) = dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) // good, works with code completion! fun LocalDateTime.toDateString() = this.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))

#5. Use Control-Flow Appropriately

Use if/else for single branch conditions rather than when

// too verbose val reduction = when { customer.isVip() -> 0.05 else -> 0.0 } // better val reduction = if (customer.isVip()) 0.05 else 0.0

Use when with multi-branch conditions.

fun reduction(customerType: CustomerTypeEnum) = when (customerType) { REGULAR -> 0 GOLD -> 0.1 PLATINUM -> 0.3 }

#6. Expression Oriented Programming

Imperative Programming

Imperative programming relies on declaring variables that are mutated along the way.

var kotlinDevs = mutableListOf<Person>() for (person in persons) { if (person.langs.contains("Kotlin")) kotlinDevs.add(person) } kotlinDevs.sort()

Think of

  • var, lops, mutable collections
  • Mutating data, side effects

Expression Oriented Programming (on the way to Functional Programming!)

Expression oriented programming relies on thinking in functions where every input results in an output.

persons.filter { it.langs.contains("Kotlin") }.sorted()

Think of:

  • Val, (higher-order) functions, functional + read-only collections
  • Input/Output, transforming data

This is better because it results in more concise, deterministic, more easily testable and clearly scoped code that is easy to reason about compared to the imperative style.

if/else is an expression returning a result.

// imperative style var result: String if(number % 2 == 0) result = "EVEN" else result = "ODD" // expression style, better val result = if(number % 2 == 0) "EVEN" else "ODD"

when is an expression too, returning a result.

// imperative style var hi: String when(lang) { "NL" -> hi = "Goede dag" "FR" -> hi = "Bonjour" else -> hi = "Good day" } // expression style, better val hi = when(lang) { "NL" -> "Goede dag" "FR" -> "Bonjour" else -> "Good day" }

try/catch also.

// imperative style var text: String try { text = File("path").readText() } catch (ex: Exception) { text = "" } // expression style, better val text = try { File("path").readText() } catch (ex: IOException) { "" }

Most functional collections return a result, so the return keyword is rarely needed!

fun firstAdult(ps: List<Person>, age: Int) = ps.firstOrNull{ it.age >= 18 }

#7. Favor Functional Collections Over For-Loops

Program on a higher abstraction level with (chained) higher-order functions from the collection.

// bad! val kids = mutableSetOf<Person>() for(person in persons) { if(person.age < 18) kids.add(person) } names.sorted() // better! val kids: mutableSetOf<Person> = persons.filter{ it.age < 18}

You are actually programming at a higher-level of abstraction, since you're manipulating the collection directly instead of considering each of its elements. e.g. it's obvious in the second example that we're filtering, instead of needing to read the implementation to figure it out.

For readability, write multiple chained functions from top-down instead of left-right.

// bad! val names = mutableSetOf<String>() for(person in persons) { if(person.age < 18) names.add(person.name) } names.sorted() // better! val names = persons.filter{ it.age < 18} .map{ it.name } .sorted()

Use intermediate variables when chaining more than ~3-5 operators.

// bad! val sortedAgeGroupNames = persons .filter{ it.age >= 18 } .groupBy{ it.age / 10 * 10 } .mapValues{ it.value.map{ it.name }} .toList() .sortedBy{ it.first } // better, more readable val ageGroups = persons.filter{ it.age >= 18 } .groupBy{ it.age / 10 * 10 } val sortedNamesByAgeGroup = ageGroups .mapValues{ (_, group) -> group.map(Person::name) } .toList() .sortedBy{ (ageGroup, _) -> ageGroup }

#8. Scope Your Code

Use apply/with to configure a mutable object.

// old way fun client(): RestClient { val client = RestClient() client.username = "xyz" client.secret = "secret" client.url = "https://..../employees" return client } // better way fun client() = RestClient().apply { username = "xyz" secret = "secret" url = "https://..../employee" }

Use let/run to manipulate the context object and return a different type.

// old way val file = File("/path") file.setReadOnly(true) val created = file.createNewFile() // new way val created = File("/path").run { setReadOnly(true) createNewFile() // last expression so result from this function is returned }

Use also to execute a side-effect.

// old way if(amount <= 0) { val msg = "Payment amount is < 0" LOGGER.warn(msg) throw IAE(msg) } else ... // new way require(amount > 0) { "Payment amount is < 0".also(LOGGER::warn) }

#9. Embrace Coroutines

// No coroutines // Using Mono from Sprint React and many combinators (flatMap) // Standard constructs like if/else cannot be used // Business intent cannot be derived from this code @PutMapping("/users") @ResponseBody fun upsertUser(@RequestBody user: User): Mono<User> = userByEmail(user.email) .switchIfEmpty{ verifyEmail(user.email).flatMap{ valid -> if(valid) Mono.just(user) else Mono.error(ResponseStatusException(BAD_REQUEST, "Bad Email")) } }.flatMap{ toUpsert -> save(toUpsert) } // Coroutines clean this up // Common language constructs can be used // Reads like synchronous code @PutMapping("/users") @ResponseBody fun upsertUser(@RequestBody user: User): User = userByEmail(user.email).awaitSingle() ?: if (verifyEmail(user.email)) user else throw ResponseStatusException(BAD_REQUEST, "Bad Email")).let{ toUpsert -> save(toUpsert)}

Project Loom will (eventually) result in support for coroutines running on the JVM. This will greatly simplify running coroutines.