CS 346 (W23)
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Idiomatic Kotlin

Urs Peters: Idiomatic Kotlin

This section summarizes a talk by Urs Peters, presented on Kotlin Dev Day 2022. No claim of authorship is intended – it’s a very interesting talk and worth watching in it’s entirety! https://www.youtube.com/watch?v=zYH6zTtl-nc

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. Favor 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.
Criteria Immutable Mutable
Reasoning Simple: one state only Hard: many possible states
Safety Safer: state remains the same and valid Unsafe: accidental errors due to state changes
Testability No side effects which makes tests deterministic Side effects: can lead to unexpected failures
Thread-safety Inherently thread-safe Manual 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.