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.
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
overvars
- prefer read-only collections (
listOf
instead ofmutableListOf
) - 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)}