Effective Kotlin

Table of Contents

link
Kotlin
start
<2022-08-17 Wed 10:09>

1. Introduction: Be pragmatic

1.1. The philosophy of Kotlin

The central point of Kotlin's philosophy is pragmatism. This means that in the end, all choices need to serve business needs, like:

  • Productivity: application production is fast.
  • Scalability: with application growth, its development does not become more expensive. It may even get cheaper.
  • Maintainability: maintenance is easy.
  • Reliability: applications behave as expected, and there are fewer errors.
  • Efficiency: the application runs fast and needs fewer resources (memory, processor, etc.).

1.2. The purpose of this book

The main goal of this book is to explain how to use different Kotlin features to achieve safe, readable, scalable, and efficient code.

This book concentrates on higher-level good practices that come from authorities, the Kotlin creators, and from my experience as a developer, consultant, and trainer for international companies worldwide.

1.3. For whom is this book written?

I will assume that even experienced developers might not know some features. This is why I explain some concepts like:

  • Property
  • Platform type
  • Named arguments
  • Property delegation
  • DSL creation
  • Inline classes and functions

1.4. Book design

1.5. Chapters organization

1.6. How should this book be read?

1.7. Labels

  • Not Kotlin-specific
  • Basics
  • Edu

1.8. Suggestions

2. Part 1: Good code

2.1. Chapter 1: Safety

2.1.1. Item 1: Limit mutability

2.1.2. Item 2: Minimize the scope of variables

2.1.3. Item 3: Eliminate platform types as soon as possible

Platform type - a type that comes from another language and has unknown nullability.

public class UserRepo {
  public User getUser() {
    //...
  }
}
val repo = UserRepo()
val user1 = repo.user        // Type of user1 is User!
val user2: User = repo.user  // Type of user2 is User
val user3: User? = repo.user // Type of user3 is User?

What're the differences between User! and User?

2.1.4. Item 4: Do not expose inferred types

If we are not sure about the type, we should specify it. In an external API, we should always specify types. We cannot let them be changed by accident. Inferred types can be too restrictive or can too easily change when our project envolves.

2.1.5. Item 5: Specify your expectations on arguments and state

  • require block: a universal way to specify expectations on arguments.
  • check block: a universal way to specify expectations on the state.
  • assert block: a universal way to check if something is true in testing mode for our own implementation.
  • Elvis operator(?:) with return or throw.

2.1.6. Item 8: Handle nulls properly

2.1.6.1. Handling nulls safely
2.1.6.2. Throw an error
2.1.6.3. Avoiding meaningless nullability
2.1.6.4. lateinit property and notNull delegate

2.2. Chatper 2: Readability

2.2.1. Item 11: Design for readability

2.2.1.1. Reducing cognitive load
2.2.1.2. Do not get extreme
2.2.1.3. Conventions

2.2.2. Item 12: Operator meaning should be consistent with its function name

2.2.3. Item 16: Properties should represent state, not behavior

Properties are essentially functions, we can make extension properties as well:

val Context.preferences: SharedPreferences
  get() = PreferencesManager
    .getDefaultSharedPreferences(this)

Properties represent accessors, not fields. The general rule is that we should use them only to represent or set state, and no other logic should be involved. If I would define a property as a function, would I prefix it with get/set? If not, it should rather not be a property.

The most typical situations when we should not use properties, and we should use functions instead:

  • Operation is computationally expensive or has computational complexity higher than O(1)
  • It involves business logic
  • It is not deterministic: Calling the member twice in succession produces different results.
  • It is a conversion, such as Int.toDouble()
  • Getters should not change property state

2.2.4. Item 17: Consider naming arguments

2.2.5. Item 18: Respect coding conventions

3. Part 2: Code design

3.1. Chapter 3: Reusability

3.1.1. Item 19: Do not repeat knowledge

The biggest enemy of changes is knowledge repetition.

Are they more likely going to change together or separately?

3.1.2. Item 20: Do not repeat common algorithms

3.1.3. Item 21: Use Property delegation to extract common property patterns

var token: String? by LoggingProperty(null)
var attempts: Int by LoggingProperty(0)

private class LoggingProperty<T>(var value: T) {
  operator fun getValue(
    thisRef: Any?,
    prop: KProperty<*>
  ): T {
    print("${prop.name} returned value $value")
    return value
  }

  operator fun setValue(
    thisRef: Any?,
    prop: KProperty<*>,
    newValue: T
  ) {
    print("${prop.name} changed from ${value} to ${newValue}")
    value = newValue
  }
}
@Suppress("UNCHECKED_CAST")
  /**
   * @param key must be exist, otherwise will throw exception
   */
  class ArgExtraDelegate<T>(val key: String) {
    operator fun getValue(thisRef: Activity, property: KProperty<*>): T {
      return thisRef.intent.extras!!.get(key) as T
    }
  }

  /**
   * @param key must be exist, otherwise will throw exception
   */
  fun <T> argExtra(key: String) = ArgExtraDelegate<T>(key)

  private const val EXTRA_IS_CREATING = "isCreating"
  private val isCreating: Boolean by argExtra(EXTRA_IS_CREATING)

3.1.4. Item 23: Avoid shadowing type parameters

3.1.5. Item 24: Consider variance for generic types

3.1.6. Item 25: Reuse between different platforms by extracting common modules

3.2. Chapter 4: Abstraction design

Car metaphor

3.2.1. Item 26: Each function should be written in terms of a single level of abstraction

3.2.2. Item 27: Use abstraction to protect code against changes

3.2.2.1. Abstractions give freedom
  • Extracting constant
  • Wrapping behavior into a function
  • Wrapping function into a class
  • Hiding a class behind an interface
  • Wrapping universal objects into specialistic
  • Using generic type parameters
  • Extracting inner classes
  • Restricting creation, for instance by forcing object creation via factory method
3.2.2.2. Problems with abstraction

3.2.3. Item 28: Specify API stability

Version names, documentation, and annotations.

3.2.4. Item 29: Consider wrapping external API

3.2.5. Item 30: Minimize elements visibility

3.2.6. Item 31: Define contract with documentation

When the behavior is not documented and the element name is not clear, developers will depend on current implementation instead of on the abstraction we intended to create.

When a contract is well specified, creators do not need to worry about how the class is used, and users do not need to worry about how something is implemented under the hood.

3.2.7. Item 32: Respect abstraction contracts

3.3. Chapter 5: Object creation

3.3.1. Item 33: Consider factory functions instead of constructors

  • Unlike constructors, functions have names.
  • Unlike constructors, functions can return an object of any subtype of their return type.
  • Unlike constructors, functions are not required to create a new object each time they're invoked.
  • Factory functions can provide objects that might not yet exist.
  • When we define a factory function outside of an object, we can control its visibility.
  • Factory functions can be inline and so their type parameters can be reified.
  • Factory functions can construct objects which might otherwise be complicated to construct.
  • A constructor needs to immediately call a constructor of a superclass or a primary constructor. When we use factory functions, we can postpone constructor usage.

Factory functions are mainly a competition to secondary constructors.

3.3.1.1. Companion object factory function
  • from: A type-conversion function that takes a single parameter and returns a corresponding instance of the same type, for example: val date: Date = Date.from(instant)
  • of: An aggregation function that takes multiple parameters and returns an instance of the same type that incorporates them, for example: val faceCards: Set<Rank> = EnumSet.of(JACK, QUEEN, KING)
  • valueOf: A more verbose alternative to from and of, for example: val prime: BigInteger = BigInteger.valueOf(Integer.MAX_VALUE)
  • instance or getInstance: Used in singletons to get the only instance.
  • createInstance or newInstance: Like getInstance, but this function guarantees that each call returns a new instance.
  • getType: Like getInstance, but used if the factory function is in a different class. Type is the type of object returned by the factory function, for example: val fs: FileStore = Files.getFileStore(path)
  • newType: Like newInstance, but used if the factory function is in a different class, for example: val br: BufferedReader = Files.newBufferedReader(path)
3.3.1.2. Extension factory functions
interface Tool {
  companion object { /*...*/ }
}

fun Tool.Companion.create( /*...*/ ): Tool { /*...*/ }

Tool.create( /*...*/ )
3.3.1.3. Top-level functions

listOf, setOf and mapOf.

3.3.1.4. Fake constructors

Constructors in Kotlin are used the same way as top-level functions.

class A
val a = A()

val reference: () -> A = ::A
3.3.1.5. Methods on a factory class
data class Student(
  val id: Int,
  val name: String,
  val surname: String
)

class StudentFactory {
  private var nextId = 0
  fun next(name: String, surname: String) = Student(nextId++, name, surname)
}

val factory = StudentFactory()
val student1 = factory.next("John", "Doe")
val student2 = factory.next("Jane", "Wojda")

Factory classes can have properties and those properties can be used to optimize object creation. When we can hold a state we can introduce different kinds of optimizations or capabilities. We can for instance use caching, or speed up object creation by duplicating previously created objects.

3.3.2. Item 34: Consider a primary constructor with named optional arguments

val dialog = context.alert(R.string.fire_missiles) {
  positiveButton(R.string.fire) { fire() }
  negativeButton { cancel() }
}

val route = router {
  "/home" directsTo ::showHome
  "/settings" directsTo ::showSettings
}

These kinds of DSL builders are generally preferred over classic builder pattern, since they give more flexibility and cleaner notation.

3.3.3. Item 35: Consider defining a DSL for complex object creation

3.4. Chapter 6: Class design

3.4.1. Item 36: Prefer composition over inheritance

3.4.2. Item 37: Use the data modifier to represent a bundle of data

3.4.3. Item 38: Use function types instead of interfaces to pass operations and actions

3.4.4. Item 39: Prefer class hierarchies to tagged classes

sealed classes

3.4.5. Item 40: Respect the contract of equals

3.4.5.1. Equality

In kotlin, there are two types of equality:

  • Structural equality: checked by the equals method or == operator (and its negated counterpart !=) which is based on the equals method. a==b translates to a.equals(b) when a is not nullable, or otherwise to a?.equals(b) ?: (b===null).
  • Referential equality: checked by the === operator (and its negated counterpart !==), returns true when both sides point to the same object.
3.4.5.2. Why do we need equals?

The default implementation of equals coming from Any checks if another object is exactly the same instance. Just like referential equality (===). It means that every object by default is unique.

3.4.5.3. When to implement equals ourselves
  • We need its logic to differ from the default one
  • We need to compare only a subset of properties
  • We do not want our object to be a data class or properties we need to compare are not in the primary constructor

3.4.6. Item 41: Respect the contract of hashCode

3.4.6.1. Hash table
3.4.6.2. Problem with mutability

A hash is calculated for an element only when this element is added.

3.4.7. Item 42: Respect the contract of compareTo

class User(val name: String, val surname: String)
val users = listOf<User>(/* ... */)
users.sortedBy { it.surname }
users.sortedWith(compareBy({ it.surname }, { it.name }))

3.4.8. Item 43: Consider extracting non-essential parts of your API into extensions

The biggest difference between members and extensions in terms of use is that extensions need to be imported separately.

Extenstions are not virtual, they cannot be redefined in derived classes.

We define extensions on types, not on classes. For instance, we can define an extension on a nullable or a concrete substitution of a generic type.

fun Iterable<Int>.sum(): Int {
    var sum = 0
    for (i in this) {
        sum += i
    }
    return sum
}

Extensions are not listed as members in the class reference. This is why they are not considered by annotation processors and why, when we process a class using annotation processing, we cannot extract elements that should be processed into extension functions.

3.4.9. Item 44: Avoid member extensions

// Bad practice, do not do this
class PhoneBookIncorrect {
  // ...

  fun String.isPhoneNumber() =
    length == 11 && all { it.isDigit() }
}

4. Part 3: Efficiency

4.1. Chapter 7: Make it cheap

4.1.1. Item 46: Use inline modifier for functions with parameters of functional types

4.1.1.1. A type argument can be reified

Function calls are replaced with its body, so type parameters uses can be replaced with type arguments, by using the reified modifier:

inline fun <reified T> printTypeName() {
  print(T::class.simpleName)
}
4.1.1.2. Functions with functional parameters are faster when they are inlined
4.1.1.3. Non-local return is allowed
4.1.1.4. Costs of inline modifier
  • Inline functions cannot be recursive.
  • Inline functions cannot be elements with more restrictive visibility.
4.1.1.5. Crossinline and noinline

4.1.2. Item 47: Consider using inline classes

4.1.2.1. Indicate unit of measure
inline class Minutes(val minutes: Int) {
  fun toMillis(): Millis = Millis(minutes * 60_000)
}

inline class Millis(val milliseconds: Int) {
  // ...
}

inline val Int.min get() = Minutes(this)
inline val Int.ms get() = Millis(this)
4.1.2.2. Protect us from type misuse
4.1.2.3. Inline classes and interfaces
4.1.2.4. Typealias

4.1.3. Item 48: Eliminate obsolete object references

4.2. Chapter 8: Efficient collection processing

4.2.1. Item 50: Limit the number of operations

class Student(val name: String?)

fun List<Student>.getNames(): List<String> = this.mapNotNull { it.name }
Instead of: Use:
.filter { it != null} .filterNotNull()
.map { it!! }  
.map { <Transformation> } .mapNotNull { <Transformation> }
.filterNotNull()  
.map { <Transformation> } .joinToString { <Transformation> }
.joinToString()  
.filter { <Predicate 1> } .filter {
.filter { <Predicate 2> } <Predicate 1> && <Predicate 2> }
.filter { it is Type } .filterIsInstance<Type>()
.map { it as Type }  
.sortedBy { <Key 2> } .sortedWith(
.sortedBy { <Key 1> } compareBy( { <Key 1> }, { <Key 2> }))
listOf(…) listOfNotNull(…)
.filterNotNull()  
.withIndex() .filterIndexed { index, elem ->
.filter { (index, elem) -> <Predicate using index> }
<Predicate using index> }  
.map { it.value } (Similarly for map, forEach, reduce and fold)

4.2.2. Item 51: Consider Arrays with primitives for performance-critical processing

Kotlin type Java type
Int int
List<Int> List<Integer>
Array<Int> Integer[]
IntArray int[]

4.2.3. Item 52: Consider using mutable collections

In local scope we generally do not need the control over how they are changed, so mutable collections should be preferred. Especially in utils, where element insertion might happen many times.