Skip to content
academia.sh

Lesson 16 / 23

Extension Functions

Writes an extension function, an extension property, and an extension on a nullable receiver, and measures that an extension cannot shadow a member and is chosen by the static type.

Published Updated

In this lesson

  • Writes an extension function and an extension property for an existing type
  • Writes an extension on a nullable receiver and calls it with a null value
  • Measures that the extension is never chosen while a member that fits the call exists
  • Separates the extension, which is chosen by the static type, from the member, which is chosen by the object
Contents

In the previous lesson a class handed its behavior to another object with by, and we saw that delegation does not forward equals. In this lesson the behavior is added to the class from the outside: you can give a new function to a type whose source you cannot touch. The question is whether this addition can take the place of a member of the type, and in particular whether it can change a member such as equals.

An extension function is a function written in front of the name of an existing type and called on an instance of that type: fun String.wrapped(...). The definition of the type is not touched; the extension only makes the call syntax look like the type’s own. Inside the body, this points to the object the function was called on, that is, the receiver.

This this should not be confused with the this and it distinction we saw in the Scope Functions lesson: there the question was which object is visible, while here this is only the receiver of the extension and the measurement is separate from that question. The measure of this lesson is the same as everywhere in this course: what makes these two values equal?

The Measurement Setup

Criterion stays in the first block, in the same form, in every lesson. Each observation carries three values: first, twin (a value built separately with the same recipe), and other (a value that differs in only one respect). The rule sees the result, not the reason.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

TN30. All the extensions were written in Main.kt, next to main; an extension defined in a separate file and imported was not measured in this lesson.

Extension Function and Property

The block below writes three extensions. wrapped adds a mark to both sides of a string. lastWord is an extension property: its value is not stored, it is computed with get() on every read. The third one has String? as its receiver; it can be called on a null value too, and inside the body this is a nullable type.

// Main.kt — extension function, extension property, and nullable receiver
fun String.wrapped(mark: String): String = mark + this + mark

val String.lastWord: String
    get() = this.substringAfterLast(' ')

fun String?.orPlaceholder(): String = this ?: "none"

fun main() {
    println("ada".wrapped("*"))
    println("one two three".lastWord)

    val present: String? = "here"
    val missing: String? = null
    println(present.orPlaceholder())
    println(missing.orPlaceholder())
}
*ada*
three
here
none

wrapped and lastWord were written on a String as if they were members of the type. orPlaceholder did not blow up on the null missing: an ordinary member call would not work on null, but because an extension takes the receiver like a parameter, null is a valid receiver too. For this reason an extension with a String? receiver can handle null without a safe call (?.).

An extension property cannot hold a field: it cannot be given a stored value. We checked this first: the fragment below did not compile in Kotlin 2.4.21, which is why it stays without output.

val String.cache: Int = 0

TN31. The reason for the rejection, that an extension property does not carry a field, is taken from the definition of the language and not from the error text of the compiler; we do not write the error text in the lesson.

An Extension Cannot Shadow a Member

What happens if you write an extension with the same name as a member of a type, but with a narrower parameter type? The block below adds an equals(other: Plain) extension to the Plain class, next to the equals(other: Any?) member that comes from Any. The extension compares the ids of two Plain values. We call it in three ways: a direct call, ==, and a call through a variable of type Any.

// Main.kt — an equals extension does not shadow the member
class Plain(val id: Int)

fun Plain.equals(other: Plain): Boolean = this.id == other.id

fun main() {
    val a = Plain(1)
    val b = Plain(1)
    val asAny: Any = b

    println(a.equals(b))
    println(a == b)
    println(a.equals(asAny))
    Criterion("equals-extension", Plain(1), Plain(1), Plain(2)).report()
}
false
false
false
equals-extension: same-object=false, twin=false, other=false -> identity

All three calls gave the same result: the comparison came out false even though the ids are equal. The extension was written, but no call went to it: the member takes Any?, so a Plain argument fits it too, and when a member fits the call the extension is not considered, even though its parameter is more specific. The extension has a different signature; it does not shadow the member and only stands as an overload. a == b always resolves to the equals(Any?) member and never calls the extension. This is why the criterion is identity: the two Plain values carry the same id, but the equality rule does not know it.

TN32. We do not write the claim that the compiler gives a warning when an extension collides with a member; the compiler warning is not checked in this lesson. Even if an extension is written with a parameter type different from the member, the member is chosen when the argument fits it; the extension gets its turn only when the member cannot be applied. This case is measured in the next section with Box.scale.

Same Name, Different Parameter

In the previous section we looked at a call whose argument fits both the member and the extension, and saw that the member wins. What if the argument does not fit the member? The member takes an Int, the extension takes a Double, and the type of the argument decides which one the call goes to. The block below measures this on the Box class. It also adds an extension marked infix and a toString call on a nullable receiver.

// Main.kt — extension with a different parameter is chosen, infix extension, toString on a null receiver
class Box(val size: Int) {
    fun scale(factor: Int): Int = size * factor
}

fun Box.scale(factor: Double): Double = size * factor

infix fun Box.bigger(other: Box): Boolean = size > other.size

fun main() {
    val box = Box(3)
    println(box.scale(2))
    println(box.scale(2.5))
    println(box bigger Box(2))

    val nothing: Any? = null
    println(nothing.toString())
    println(nothing?.toString())
    Criterion("box", Box(1), Box(1), Box(2)).report()
}
6
7.5
true
null
null
box: same-object=false, twin=false, other=false -> identity

box.scale(2) went to the member and gave 6; box.scale(2.5) did not fit the Int parameter of the member, so it went to the extension and gave 7.5. The extension lost while the member fitted the call and worked when it covered an argument type the member did not. This draws the limit of “an extension cannot shadow a member”: if the member applies, the extension is not considered.

The expression box bigger Box(2) gave true. The infix mark lets you write an extension like a binary operator, without the dot and the parentheses. This is only a convenience of writing.

The lines on the nullable receiver are interesting. The variable nothing has the type Any? and the value null; nothing.toString() printed null without blowing up. The reason is that the standard library has a toString extension with an Any? receiver; it is called as if it were a member, but it handles null. nothing?.toString() also printed null, but this time because of the safe call: the call was never made and the value of the expression became null. The two lines gave the same output by different routes; in one the function ran, in the other it did not.

The Criterion line is again identity: Box is an ordinary class, and the extensions did not touch equality.

TN33. The Box.scale member takes an Int and the extension takes a Double; their signatures differ. It was measured that the call box.scale(2) goes to the member; since the extension expects a Double, it is not a candidate for an Int argument in the first place. The Int argument fits the member, so the member was chosen.

Generic Receiver and Extension Inside a Class

An extension shows two more odd behaviors. The receiver type can be generic: an extension written on List<T> works with every element type. And an extension can be declared inside a class; then it can be called only from inside that class, and it can reach the members of the class too. The block below measures the two together.

// Main.kt — extension with a generic receiver and an extension declared inside a class
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null

class Formatter(private val mark: String) {
    fun String.marked(): String = mark + this + mark

    fun run(text: String): String = text.marked()
}

fun main() {
    println(listOf(1, 2, 3).secondOrNull())
    println(listOf("a").secondOrNull())
    println(emptyList<Int>().secondOrNull())
    println(Formatter("|").run("ada"))
    Criterion("formatter", Formatter("|"), Formatter("|"), Formatter("#")).report()
}
2
null
null
|ada|
formatter: same-object=false, twin=false, other=false -> identity

secondOrNull worked on three lists: 2 on the list of three Int values, null on the one-element String list, and null on the empty list. Although a single extension was written for different element types, every call inferred its own type; because the return type is T?, null was returned safely in the empty case.

The marked extension inside Formatter read the mark property of the class: Formatter("|").run("ada") gave |ada|. In the extensions above, the receiver was a single this; here there are two receivers, the Formatter instance and the String receiver. marked is visible only inside the body of Formatter; outside the class, "ada".marked() cannot be written.

The criterion is again identity: even though two Formatter values carry the same mark, they are not equal. That extensions do not touch the equality of the type, whether they are written outside or inside a class, was repeated in the third measurement of this lesson.

TN34. That a call to marked from outside the class does not compile was not measured separately; the scope information is taken from the definition of the language.

The Selection Rule from the Measurements

An extension function is added to a type from the outside, but the way the call is written looks like a member call. This similarity makes the question of which one is chosen necessary. The measurements of this lesson gave three rules. The first: if the argument fits the member, the member wins; the equals member takes Any?, so the Plain argument fitted it, and the Plain.equals extension was chosen in none of the three calls, even though its parameter is more specific. The second: if the member cannot be applied, the extension gets its turn; the Box.scale member expects an Int, so scale(2.5) did not fit it and went to the extension. The third: the choice is made by the static type of the variable; the extension does not look at the class at run time.

A common mistake is to take an extension as a way of changing a member. A person who writes an extension with the same name to “fix” a member of a type sees that the code keeps working and nothing changes, because the extension is never called as long as the member fits the call. If the behavior of the member has to change, the type itself changes or a new name is chosen. The same result was seen for equals in this measurement, and the criterion stayed identity.

The nullable receiver detail shows a second trap. nothing.toString() and nothing?.toString() printed the same output, but in the first the function ran and in the second it did not. You cannot tell from the output which one was used; the difference shows when the function has a side effect. A person who writes an extension with a nullable receiver must know that this function will also run on a null receiver, and must write its body accordingly.

The generic-receiver extension and the extension inside a class bring up the question of scope. secondOrNull was written once and worked on three different lists; marked was visible only inside the body of Formatter. The first is a function outside the type that can be called from anywhere; the second is a function that reaches the members of the class but can be called only from inside that class. Which one you choose depends on whether the function needs the state of the class: if it does, it is written inside the class, and if it does not, outside.

Predicting Before Measuring

Every measurement in this lesson fits the same pattern: write a call, say which one will run, then look at the output. If you can say the member for box.scale(2) and the extension for box.scale(2.5), you have understood the selection rule: the member if the argument fits the parameter of the member, the extension if it does not. The same logic holds for bigger; since Box has no member named bigger, the extension runs, and the infix mark only changes the way it is written.

On the null receiver, the prediction has two steps. A person who says “it blows up” for nothing.toString() has assumed that toString is a member and cannot be called on null. The measurement printed null, because the call went not to a member but to an extension that handles a null receiver. This means that the way an expression is written (nothing.toString()) does not tell which function it calls; you have to look at the type of the receiver and at the visible extensions.

With the generic-receiver extension, the prediction is about the return value. On a list of three elements 2 is expected, and on a one-element or empty list null; the T? return type covers these two cases with the same value. On the identity lines, Box and Formatter are ordinary classes, so how many extensions are written does not affect the result.

Equal output and equal route are two separate facts: the two ways of writing nothing gave the same output but did not take the same route, and a person who looks only at the output does not see the second. It is a good habit to write next to each line the route by which that output was reached.

The Bounding Measurement

If an extension cannot shadow a member, by which rule is an extension chosen? Members are chosen by the run-time class of the object. Extensions are chosen by the declared type of the variable, that is, by the static type. Static dispatch means that the function to be called is chosen at compile time by looking at the declared type of the variable; dynamic dispatch means that it is chosen by looking at the run-time class of the object. Extensions use the first.

The block below holds the same object with two different static types. One label extension is written for each of Speaker and Loud, and speak is a member of the class, which Loud overrides. In M08/K02 we looked at which type makes the decision; this measurement does not return to that question, and the lines below only put these two selection rules side by side.

// Main.kt — extension is chosen by the static type, member by the object
open class Speaker {
    open fun speak(): String = "base"
}

class Loud : Speaker() {
    override fun speak(): String = "loud"
}

fun Speaker.label(): String = "label-base"

fun Loud.label(): String = "label-loud"

fun main() {
    val exact = Loud()
    val asSpeaker: Speaker = exact

    println(exact.label())
    println(asSpeaker.label())
    println(exact.speak())
    println(asSpeaker.speak())
    Criterion("two-static-types", asSpeaker, exact, Speaker()).report()
}
label-loud
label-base
loud
loud
two-static-types: same-object=true, twin=true, other=false -> singleton

asSpeaker and exact hold the same object: the last line printed singleton. Even so, label gave two different results, because an extension looks at the type of the variable: the Loud extension was chosen for exact and the Speaker extension for asSpeaker. speak gave the same result on both variables, because a member looks at the real class of the object. Equality is a member too, and for this reason an extension cannot change it: == goes to the equals of the run-time object, and an extension cannot be chosen.

Summary

  • An extension function adds a new function to a type without touching its source; in the body, this is the receiver.
  • An extension property cannot hold a field and is computed on every read; an extension property with a stored value does not compile.
  • An extension whose receiver is a nullable type can be called on null and handles null itself.
  • If a member fits the call, the member wins, even when the extension’s parameter is more specific; since == always resolves to the equals(Any?) member, an extension cannot change equals.
  • An extension is chosen by the static type of the variable, a member by the run-time class of the object.
  • The criterion of this lesson: identity. Even if an extension writes equals, the member wins, so equality falls to identity unless the class redefines its own equals.

Next Step

In this lesson we added a function to the outside of a type and measured that what is added is chosen not by the type but by the type of the variable. The next lesson makes the type itself a parameter: generics let you write a class or a function so that it works with more than one type, and variance tells whether these types can be assigned to each other.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close