Notes from Effective Kotlin: Best Practice
The general rule is that one should not create unnecessary ways to mutate a state. Every way to mutate a state is a cost. Every mutation point needs to be understood and maintained. We prefer to limit mutability. General rules:
- Prefer val over var.
- Prefer an immutable property over a mutable one
- Prefer objects and classes that are immutable over mutable ones
- If you need immutable objects to change, consider making them data classes and using copy.
- When you hold a state, prefer read-only over mutable collections. e.g
var list: listOf
overval mutableList: mutableListOf()
. - Design your mutation points wisely and do not produce unnecessary ones.
- Do not expose mutable objects.
- For many reasons, we should prefer to define variables for the closest possible scope. Also, we should prefer val over var for local variables, and we should always be aware of the fact that variables are captured in lambdas.
- Platform type - a type that comes from another language and has unknown nullability. e.g java String is always nullable unless annotated with
@NotNull
- The general rule is that if we are not sure about the type, we should specify it because this is important information that should not be hidden. Additionally, for the sake of safety, 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 a project evolves.
- require block - a universal way to specify expectations for arguments.
- check block - a universal way to specify expectations for states.
- assert block - a universal way to check if something is true. Such checks in the JVM are evaluated only in the testing mode.
- The Elvis operator with return or throw.
- prefer the standrad exceptions if they exiest instead of creating custom ones. Developers are aware of them.
- We should prefer to return null or Result.failure when an error is expected, and we should throw an exception when an error is not expected.
3 ways to handle nulls
- Handle nullability safely using a safe call ?., smart casting, the Elvis operator, etc.
- Throw an error
- Refactor this function or property so that it won’t be nullable
Operate on objects that implement Closeable (e.g InputStream) or AutoCloseable using use
. This is a safe and easy option. When you need to operate on a file, consider useLines, which produces a sequence to iterate over the next lines.
As this support is often needed for files and it is common to read files line by line, there is also a useLines
function in the Kotlin Standard Library that gives us a sequence of lines (String) and closes the underlying reader once the processing is complete.
Use operator overloading conscientiously. A function’s name should always be coherent with its behavior. Avoid cases where operator meaning is unclear. Clarify it by using a regular function with a descriptive name instead. If you wish to have a more operator-like syntax, then use the infix modifier or a top-level function.
Example
operator fun Int.times(operation: () -> Unit) {
repeat(this) { operation() }
}
infix fun Int.timesRepeated(operation: () -> Unit) {
repeat(this) { operation() }
}
- The default variance behavior of a type parameter is invariance. If, in
Cup<T>
, type parameterT
is invariant and A is a subtype of B, then there is no relation betweenCup<A>
andCup<B>
. - The
out
modifier makes a type parameter covariant. If, inCup<T>
, type parameterT
is covariant and A is a subtype of B, thenCup<A>
is a subtype ofCup<B>
. Covariant types can be used at out-positions.
class Cup<out T>
open class Dog
class Puppy: Dog()
fun main(args: Array<String>) {
val b: Cup<Dog> = Cup<Puppy>() // OK
val a: Cup<Puppy> = Cup<Dog>() // Error
val anys: Cup<Any> = Cup<Int>() // OK
val nothings: Cup<Nothing> = Cup<Int>() // Error
}
in
makes a type parameter contravariant. If, inCup<T>
, type parameterT
is contravariant and A is a subtype of B, thenCup<B>
is a subtype ofCup<A>
. Contravariant types can be used at in-positions.
class Cup<in T>
open class Dog
class Puppy: Dog()
fun main(args: Array<String>) {
val b: Cup<Dog> = Cup<Puppy>() // Error
val a: Cup<Puppy> = Cup<Dog>() // Ok
val anys: Cup<Any> = Cup<Int>() // Error
val nothings: Cup<Nothing> = Cup<Int>() // Ok
}
-
Use Semantic Versioning (SemVer): in this system, we compose the version number from 3 parts: MAJOR.MINOR.PATCH. Each of those parts is a positive integer starting from 0, and we increment each of them when changes in the public API have concrete importance. So we increment:
- MAJOR version when you make incompatible API changes.
- MINOR version when you add functionality in a backward-compatible manner.
- PATCH version when you make backward-compatible bug fixes.
-
When we increment MAJOR, we set MINOR and PATCH to 0. When we increment MINOR we set PATCH to 0. Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. Major version zero (0.y.z) is for initial development; with this version.