Note this blog post contains my notes from the Usability section of the book Atomic Kotlin. Consider buying a copy of the book for a more detailed reference.
Extension Functions
Extension functions add member functions to existing classes
The receiver is the type you extend
Example defining an extension function
package extensionfunctions
import atomictest.eq
fun String.singleQuote() = "'$this'"
fun String.doubleQuote() = "\"$this\""
fun main() {
"Hi".singleQuote() eq "'Hi'"
"Hi".doubleQuote() eq "\"Hi\""
}
You call extension functions as if they were members of the class
To use extension functions from another package you must import them
package other
import atomictest.eq
import extensionfunctions.doubleQuote
import extensionfunctions.singleQuote
fun main() {
"Single".singleQuote() eq "'Single'"
"Double".doubleQuote() eq "\"Double\""
}
You can access member functions or other extension functions using the this keyword. You can omit this in the same way as it can be omited inside a class.
package extensionfunctions
import atomictest.eq
// Explicit qualification
fun String.strangequote() =
this.singleQuote().singleQuote()
//Implicit Qualification
fun String.tooManyQuotes() = doubleQuote().doubleQuote()
fun main() {
"Hi".strangeQuote() eq "''Hi''"
"Hi".tooManyQuote eq "\"\"Hi\"\""
}
Extensions to your own classes can sometimes produce simpler code
package extensionfunctions
import atomictest.eq
class Book(val title: String)
fun Book.categorize(category: String) =
"""title: "$title", category: $category"""
fun main() {
Book("Dracula").categorize("Vampire") eq
"title: Dracula, category: Vampire"
}
Extension functions can only access public members of the type being extended.
Named and Default Arguments
Example:
package color1
import atomictest.eq
fun color(red: Int, green: Int, blue: Int) =
"($red, $green, $blue)"
fun main() {
color(1, 2, 3) eq (1, 2, 3)" // [1]
color(
red = 76,
green = 80,
blue = 0
) eq (76, 80, 0) // [2]
color(52, 34, blue = 0) eq (52, 34, 0) // [3]
}
[1] You would have to look at documentation
[2] The meaning of every argument is clear
[3] You aren’t required to name all args
Named arguments allow you to change the order of the colors.
You can mix named and unnamed args, but:
- If you change the order of the args, then name all args for the compiler
Default args can be specified in the function definition
package color2
import atomictest.eq
fun color(
red: Int = 0,
green: Int = 0,
blue: Int = 0
) = "($red, $green, $blue)"
fun main() {
color(139) eq (139, 0, 0)
color(blue = 139) eq (0, 0, 139)
color(255, 165) eq (255, 165, 0)
color(red = 128, blue = 128) eq (128, 0, 128)
}
You can use trailing comma syntax in class constructors or function definitions
joinToString function uses default args to control prefix, postfix, and separator of the join operation on a list
Unlike toString() which may not have the behavior you want.
If you use an object as a default arg, a new instance of that object is created for each invocation.
If you pass an object instance as a default arg, then that same instance is used for each call.
If you pass the syntax for a constructor then that constructor is called every time.
Overleading
Example:
package overloading
import atomictest.eq
class Overloading {
fun f() = 0
fun f(n: Int) = n + 2
}
fun main() {
val o = Overloading()
o.f() eq 0
o.f(3) eq 5
}
You can overload a function as long as the parameter lists differ.
You cannot overload on return types
A function signature consists of the name, paramter list, and return type
A signature also includes information about the enclosing class or receiver type
If a class has a member function with the same signature as an extension function, the member function is preferred.
However, you can overload the member function with an extension function.
package overloading
import atomictest.eq
class My {
fun foo() = 0
}
fun My.foo() = 1
fun My.foo(i: Int) = i + 2
fun main() {
My().foo() eq 0 // [1]
My().foo(2) eq 4
}
[1] The extension function is never called because a member function takes precedence.
Don’t use overloading to initiate default args
The compiler calls the closest matching function first. So functions with default args take less precedence like in below:
package overladingdefaultargs
import atomictest.*
fun foo(n: Int = 99) = trace(foo-1-$n")
fun foo() {
trace("foo-2")
foo(14)
}
fun main() {
foo()
trace eq """
foo-2
foo-1-14
"""
}
When Expressions
When expressions are like switch statements. They compare a value against a selection of possibilities.
package whenexpressions
import atomictest.eq
val numbers = mapOf(
1 to "eins", 2 to "zwei", 3 to "drei",
4 to "vier", 5 to "fuenf", 6 to "sechs",
7 to "sieben", 8 to "acht", 9 to "neun",
10 to "zehn", 11 to "elf", 12 to "zwoelf",
13 to "dreizehn", 14 to "vierzehn",
15 to "fuenfzehn", 16 to "sechzehn",
17 to "siebzehn", 18 to "achtzehn",
19 to "neunzehn", 20 to "zwanzig"
)
fun ordinal(i: Int): String =
when(i) {
1 -> "erste"
3 -> "dritte",
7 -> "siebte"
8 -> "achte"
20 -> "zwanzigste"
else -> numbers.getValue(i) + "te"
}
fun main() {
ordinal(2) eq "zwelte"
ordinal(3) eq "dritte"
ordinal(11) eq "elfte"
}
The first successful match completes execution of the when expression which becomes the return value of ordinal.
The else keyword provides a fall through when there are no other matches.
When expressions must be exhaustive.
If you treat the when expression as a statement then you can omit the else branch otherwise it is required. Unmatched values in this case are just ignored.
Another example:
package whenexpressions
import atomictest.*
class Coordinates {
var x: Int = 0
set(value) {
trace("x gets $value")
field = value
}
var y: Int = 0
set(value) {
trace("y gets $value")
field = value
}
override fun toString() = "($x, $y)"
}
fun processInputs(inputs: List<String>) {
val coordinates = Coordinates()
for (input in inputs) {
when (input) {
"up", "u" -> coordinate.y-- // [1]
"down", "d" -> coordinates.y++
"left", "l" -> coordinates.x--
"right", "r" -> {
trace("Moving right")
coordinates.x++
} // [2]
"nowhere" -> {}
"exit" -> return
else -> trace("bad input: $input")
}
}
}
fun main() {
processInputs(listOf("u","d","nowhere","l","r","exit","r"))
trace eq """
y gets -1
y gets 0
x gets -1
Moving right
x gets 0
"""
}
[1] You can match several patterns in one branch
[2] You can use a block body
You express nothing in a branch with an empty block {}
You can return from the outer function within a branch.
Any expression can be an arg for when and the matches can be any values not just constants.
We can match a Set of values against another Set of values
package whenexpressions
import atomictest.eq
fun mixColors(first: String, second: String) =
when (setOf(first, second)) {
setOf("red", "blue") -> "purple"
setOf("red", "yellow") -> "orange"
setOf("blue", "yellow") -> "green"
else -> "unknown"
}
}
fun main() {
mixColors("red", "blue") eq "purple"
}
when has a special form that takes no args. Omitting the arg means the branches can check different Boolean conditions. You can use any Boolean expression as a branch condition.
Example:
fun bmiMetricWithWhen(
kg: Double,
height: Double
): String {
val bmi = kg / (height * height)
return when {
bmi < 10.5 -> "Underweight"
bmi < 25 -> "Normal weight"
else -> "Overweight"
}
}
fun main() {
bmiMetricWithWhen(72.57, 1.727)
}
Enumerations
An enumeration is a collection of names
Define a simple enum like so:
package enumerations
import atomictest.eq
enum class Level {
Overflow, Hiegh, Medium, Low, Empty
}
fun main() {
Level.Medium eq "Medium"
}
Creating an enum generates a toString() for the enum names
You must qualify each reference to an enum name.
You can omit qualification using an import to bring all names from thsi enum into the current namespace like so:
import atomictest.eq
import enumerations.Level.*
fun main() {
Overflow eq "Overflow"
High eq "High"
}
You can import enum values into the same file where the enum is defined
package enumerations
import atomictest.eq
import enumerations.Size.*
enum class Size {
Tiny, Small, Medium, Large, Huge, Gigantic
}
fun main() {
Gigantic eq "Gigantic"
Size.values().toList() eq
listOf(Tiny, Small, Medium, Large,
Huge, Gigantic) // [1]
Tiny.ordinal eq 0 // [2]
Huge.ordinal eq 4
}
You can iterate over enumeration names using values.
values() returns an array so use toList() to convert to a List
The first declared constant of an enum has an ordinal value of zero.
You can perform different actions for different enum entries using a when expression like so:
package checkingoptions
import atomictest.*
import enumerations.Level
import enumerations.Level.*
fun checkLevel(level: Level) {
when (level) {
Overflow -> trace(">>> Overflow"
Empty -> trace("Alert: Empty")
else -> trace("Level $level OK")
}
}
fun main() {
checkLevel(Empty)
checkLevel(Low)
checkLevel(Overflow)
trace eq """
Alert: Empty
Level low OK
>>> Overflow
"""
}
An enum is a class with a fixed number of instances
You can add member properties and functions just as in a regular class. You must use semicolon after the last enum value like so:
package enumerations
import atomictest.eq
import enumerations.Direction.*
enum class Direction(val notation: String) {
North("N"), South("S"),
East("E"), West("W");
val opposite: Direction
get() = when(this) {
North -> South
South -> North
East -> West
West -> East
}
}
fun main() {
North.notation eq "N"
North.opposite eq Sout
West.opposite.opposite eq West
North.opposite.notation eq "S"
}
The Direction class contains a notation and opposite property.
Notation property is passed as a constructor parameter
Opposite is a computed property
Data Classes
Data classes can simplify code for classes that are essentially a data holder.
Define a data class like so:
package dataclasses
import atomictest.eq
data class Siimple(
val arg1: String,
val arg2: Int
)
fun main() {
val s1 = Simple("hi", 1)
val s2 = Simple("Hi", 1)
s1 eq "Simple(arg1=Hi, arg2=1)"
s1 eq s2
}
In the above example toString defaults to a readable output.
Two instances of the same data class containing identical data are equal by default. With a regular class you have to define a equals() function.
You can use the copy function with data classes like so:
package dataclasses
import atomictest.eq
data class DetailedContact(
val name: String,
val surname: String,
val number: String,
val address: String
)
fun main() {
val contact = DetailedContact(
"Miffy",
"Miller",
"1-234-567890",
"123 Main St"
)
val newContact = contact.copy(
number = "1-098-765432",
address = "321 Main St"
)
newContact eq DetailedContact(
"Miffy"
"Miller"
"1-098-765432",
"321 Main St")
}
The copy() function parameters are identical to the constructor parameters. All args have default values equal to current values.
Data classes generate an appropriate hash function so that objects can be keys in HashMaps and HashSets
package dataclasses
import atomictest.eq
data class Key(val name: String, val id: Int)
fun main() {
val korvo = Key("Korvo", 19)
korvo.hashCode() eq -2041757108
val map = HashMap<Key, String>()
map[korvo] = "Alien"
map[korvo] eq "Alien"
val set = HashSet<Key>()
set.add(korvo)
set.contains(korvo) eq true
}
Destructuring Declarations
Use the Pair class to return two values
package destructuring
import atomictest.eq
fun compute(input: Int): Pair<Int, String> =
if (input > 5)
Pair(input * 2, "High")
else
Pair(input * 2, "Low")
fun main() {
compute(7) eq Pair(14, "High")
val result = compute(5)
result.first eq 10
result.second eq "Low"
}
Pair is a parameterized type such as List or Set
You can declare and initialize several identifiers using a destructuring declaration:
val (a, b, c) = composedValue
This destructures a composed value and positionally assigns its components
There is a Triple class to return three values.
For more than three values use a class.
Data classes automatically allow destructuring like so:
package destructuring
import atomictest.eq
data class Computation(
val data: Int,
val info: String
)
fun evaluate(input: Int): Computation {
if (input > 5)
Computation(input * 2, "High")
else
Computation(input * 2, "Low")
}
fun main() {
val (value, description) = evaluate(7)
value eq 14
description eq "High"
}
Again this destructuring is position dependent.
If you do not need some identifiers, you can use underscores instead of their names. Or omit them completely if they appear at the end.
You can use a for loop to iterate over a Map or List of destructurable elements
import atomictest.eq
fun main() {
result = ""
val map = mapOf(1 to "one", 2 to "two")
for ((key, value) in map) {
result += "$key = $value, "
}
result eq "1 = one, 2 = two, "
result = ""
val listOfPairs = listOf(
Pair(1, "one"), Pair(2,"two"))
for ((i, s) in listOfPairs) {
result += "($i, $s), "
}
result eq "(1, one), (2, two), "
}
Destructuring cannot be used to create class properties. It can only be used for local vals and vars.
Nullable Types
Null can represent no value like when retrieving a value from a map:
import atomictest.eq
fun main() {
val map = mapOf(0 to "yes", 1 to "no")
map[2] eq null
}
If you don’t handle a null value in Java correctly, you can get a NullPointerEception.
In Kotlin types default to non-nullable. If something can produce a null result you must opt in like so:
val s: String? = null
val s: String? = "abc"
You cannot assign an identifier of a nullable type to an identifier of a non-nullable type.
Nullable types are different types all together.
In Kotlin, you can’t simply dereference a member function or property a nullable identifier like so:
import atomictest.eq
fun main() {
val s1: String = "abc"
val s2: String? = s1
s1.length eq 3
// Doesn't compile
// s2.length
}
This is because values of most types are stored as references to objects in memory.
You can instead check if the reference is not null like so:
import atomictest.eq
fun main() {
val s: String? = "abc"
if (s != null)
s.length eq 3
}
First checking for null value enables you to dereference it a nullable.
Kotlin automatically creates nullable and non-nullable types when you create a new class.
Safe Calls & the Elvis Operator
Only safe (?. ) or non-null asserted (!!.) calls are allowed on a nullable receiver.
A safe call replaces the dot syntax in a regular call with ?.
Safe calls access members of a nullable type in a way that ensures no exceptions are thrown. They only perform an operation when the receiver is not null.
package safecalls
import atomictest.*
fun String.echo() {
trace(uppercase())
trace(this)
trace(lowercase())
}
fun main() {
val s1: String? = "Howdy"
s1?.echo()
val s2: String? = null
s2?.echo()
trace eq """
HOWDY
Howdy
howdy
"""
}
The elvis operator (?:) acts as a nil coalescing operator.
import atomictest.eq
fun main() {
val s1: String? = "abc"
(s1 ?: "----") eq "abc"
val s2: String? = null
(s2 ?: "----") eq "----"
}
The elvis operator is equivalent to:
if (s1 != null) s1 else null
The elvis operator is typically used after a safe call to produce a meaningful value instead of the default null
package safecalls
import atomictest.eq
fun checkLength(s: String?, expected: Int) {
val length1 =
if (s != null) s.length else 0
val length2 = s?.length ?: 0
length1 eq expected
length2 eq expected
}
fun main() {
checkLength("abc", 3)
checkLength(null, 0)
}
Safe calls allow you to chain calls concisely:
package safecalls
import atomictest.eq
class Person(
val name: String,
val friend: Person? = null
)
fun main() {
val alice = Person("Alice")
alice.friend?.friend?.name eq null
val bob = Person("Bob")
val charlie = Person("Charlie", bob)
bob.friend = charlie
bob.friend?.friend?.name eq "Bob"
(alice.friend?.friend?.name ?: "Unknown") eq "Unknown"
}
When you chain several members using safe calls the result is null if any intermediate expression is null.
Non-Null Assertions
A second approach to the problem of nullable types is to have knowledge that the reference in question isn’t null.
To make the claim use the !! (non-null assertion operator).
import atomictest.eq
fun main() {
var x: String? = "abc"
x!! eq "abc"
x = null
capture {
val s: String = x!!
} eq "NullPointerException"
}
Typically you use !! with a dereference:
import atomictest.eq
fun main() {
val s: String? = "abc"
s!!.length eq 3
}
With a map you can use getValue() to throw a NoElementException if a key is missing
Extensions for Nullable Types
The kotlin standard library provides Strings extension functions including
- isNullOrEmpty
- isNullOrBlank
import atmoictest.eq
fun main() {
val s1: String? = null
s1.isNullOrEmpty() eq true
val s2 = ""
s2.isNullOrBlank() eq true
val s3: String = "\t\n"
s3.isNullOrEmpty() eq false
s3.isNullOrBlank() eq true
}
In the above example the function names suggest they are for nullable types however you can call the functions on s1 without a safe call.
This is because these are extension functions on the nullable type String?
We can rewrite isNullOrEmpty() as a non-extension function that takes the nullable String as a parameter
package nullableextensions
import atomictest.eq
fun isNullOrEmpty(s: String?): Boolean =
s == null || s.isEmpty()
fun main() {
isNullOrEmpty(null) eq true
isNullOrEmpty("") eq true
}
Because s is nullable we explicitly check for null or empty
Now rewriting as an extension to the nullable type
package nullableextensions
import atomictest.eq
fun String?.isNullOrEmpty(): Boolean =
this == null || isEmpty()
Introduction to Generics
Generics create parameterized types
Initial motivation for generics included creating collection classes like List, Set, or Map.
Non-generic class:
package introgenerics
import atomictest.eq
data class Automobile(val brand: String)
class RigidHolder(private val a: Automobile) {
fun getValue() = a
}
fun main() {
val holder = RigidHolder(Automobile("BMW"))
holder.getValue() eq "Automobile(brand=BMW)"
}
Now we define a generic holder with a Type Parameter
package introgenerics
import atomictest.eq
class GenericHolder<T>(
private val value: T
) {
fun getValue(): T = value
}
fun main() {
val h1 = GenericHolder(Automobile("Ford"))
val a: Automobile = h1.getValue()
a eq "Automobile(brand=Ford)"
val h2 = GenericHolder(1)
val i = h2.getValue()
i eq 1
val h3 = GenericHolder("Hello")
val s = h3.getValue()
s eq "Hello"
}
In Kotlin there is a universal type called Any.
Any allows any type of argument
The issue is that when you need the specific type, you lose track of the original type:
package introgenerics
import atomictest.eq
class AnyHolder(private val value: Any) {
fun getValue() = value
}
class Dog {
fun bark() = "Ruff!"
}
fun main() {
val holder = AnyHolder(Dog())
val any = holder.getValue()
//Doesn't Compile
//any.bark()
val genericHolder = GenericHolder(Dog())
val dog = genericHOlder.getValue()
dog.bark() eq "Ruff!"
}
Defining a generic function
package introgenerics
import atomictest.eq
fun <T> identity(arg: T) = arg
fun main() {
identity("Yellow") eq "Yellow"
}
Defining a generic extension function:
package introgenerics
import atomictest.eq
fun <T> List<T>.first(): T {
if (isEmpty())
throw NoSuchElementException("Empty List")
return this[0]
}
fun <T> List<T>.firstOrNull() =
if (isEmpty()) null else this[0]
fun main() {
listOf(1, 2, 3).first() eq 1
val i: Int? = listOf(1, 2, 3).firstOrNull() // [1]
i eq 1
val s: String? eq listOf<String>().firstOrNull() // [2]
s eq null
}
Extension Properties
Properties can be extension properties
Example:
package extensionProperties
import atomictest.eq
val String.indices: IntRange
get() = 0 until length
fun main() {
"abc".indices eq 0..2
}
Any extension function without parameters could be converted into a property. Choose whatever is more readable. The Kotlin style guide prefers a function if the function throws an exception.
Defining a generic extension property:
package extensionproperties
import atomictest.eq
val <T> List<T>.firstOrNull: T?
get() = if (isEmpty()) null else this[0]
fun main() {
listOf(1, 2, 3).firstOrNull eq 1
listOf<String>().firstOrNull eq null
}
When the generic arg type isn’t used you may replace it with a star projection (*)
package extensionproperties
import atomictest.eq
val List<*>.indices: IntRange
get() = 0 until size
fun main() {
listOf(1).indices eq 0..0
listOf('a', 'b', 'c').indices eq 0..2
listOf<Int>().indices eq IntRange.EMPTY
}
Using a star projection you lose all type information.
In the below example you can only assign an element of List<*> to Any? because we do not know if the type is nullable.
import atomictest.eq
fun main() {
val list: List<*> = listOf(1, 2)
val any: Any? = list[0]
any eq 1 // [Works inside testing framework]
}
Break and Continue
Break and continue abandon the remainder of the code in a loop.
And break to the end or continue at the beginning of the next iteration.
Break and continue are only available within the looping constructs for, while, and do-while.
Example:
import atomictest.eq
fun main() {
val nums = mutableListOf(0)
for (i in 4 until 100 step 4) {
if (i == 8) continue
if (i == 40) break
}
nums eq "[0, 4, 12, 16, 20, 24, 28, 32, 36]"
}
We could rewrite as a while loop:
import atomictest.eq
fun main() {
val nums = mutableListOf(0)
val i = 0
while (i < 100) {
i += 4
if (i -- 8) continue
if (i == 40) break
nums.add(i)
}
nums eq "[0, 4, 12, 16, 20, 24, 28, 32, 36]"
}
Or do-while
import atomictest.eq
fun main() {
val nums = mutableLIstOf(0)
var i = 0
do {
i += 4
if (i == 8) continue
if (i -- 40) break
} while (i < 100)
nums eq "[0, 4, 12, 16, 20, 24, 28, 32, 36]"
}
Labels allow break and continue to jump to the boundaries of enclosing loops
Example:
import atomictest.eq
fun main() {
val strings = mutableListOf<String>()
outer@ for (c in 'a'..'e') {
for (i in 1..9) {
if (i == 5) continue@outer
if ("$c$i == "c3") break@outer
strings.add("$c$i")
}
}
strings eq listOf("a1", "a2", "a3", "a4",
"b1", "b2", "b3", "b4", "c1", "c2")
}
They also work with while or do while