Skip to content
academia.sh

Lesson 15 / 23

Interfaces and Delegation

Measures the interface, the default implementation, and delegation with `by` using the criterion that makes two values equal, and shows that delegation forwards the members of the interface but does not forward `equals`.

Published Updated

In this lesson

  • Writes a property and a function with a default implementation in an interface
  • Shows how the same default function of two interfaces is combined in a single class
  • Delegates the implementation of an interface to another object with `by`
  • Measures that delegation does not forward equality and what happens when it is combined with a data class
Contents

In the previous lesson we looked at whether an object is built as a single one or anew every time. In this lesson we take the behavior of the object out of the class: an interface writes a contract, and a class either implements that contract itself or delegates it to another object. The question comes down to delegation: when an object leaves its behavior to someone else, what makes two delegating objects equal?

An interface defines the members a type must conform to. In Kotlin an interface can carry not only signatures but also functions with bodies and read-only properties with bodies; these are called default implementations. When a class implements an interface, it has to write the members without bodies, and it overrides the ones with bodies if it wants to.

This lesson recalls the measurement axis once more. The measure from the Java curriculum that asks which type makes the decision (M08/K02) is not here: we look not at the type but at the reason two values count as equal. Being the same object is singleton, equality falling to identity is identity, the contents being equal is content, and only some of them being equal is partial content.

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 }}")
    }
}

The model of the lesson itself stays in a second helper file. The Printer interface carries a read-only property and a default function; two classes implement it in different ways.

// Printers.kt — a property and a default implementation in an interface
interface Printer {
    val prefix: String
    val width: Int
        get() = prefix.length

    fun render(text: String): String = prefix + text
}

class PlainPrinter : Printer {
    override val prefix: String = "> "
}

class LoudPrinter : Printer {
    override val prefix: String = "# "
    override fun render(text: String): String = prefix + text.uppercase()
}

TN26. Printer has only one property without a body and one read-only property with a body; a property stored in the interface (one that carries a field) cannot be written, and this was not measured here. The blocks below compile the two Printer implementations and the wrappers written with by together with this helper.

An Interface Member Can Carry a Body

The block below uses the two classes side by side. PlainPrinter wrote only the property without a body; render and width took the bodies in the interface. LoudPrinter, on the other hand, redefined render. The last line measures two PlainPrinter objects.

// Main.kt — interface default and override
fun main() {
    val plain = PlainPrinter()
    val loud = LoudPrinter()
    println(plain.render("ada"))
    println(loud.render("ada"))
    println(plain.width)
    val printers: List<Printer> = listOf(plain, loud)
    println(printers.map { it.render("x") })
    Criterion("two-defaults", PlainPrinter(), PlainPrinter(), loud).report()
}
> ada
# ADA
2
[> x, # X]
two-defaults: same-object=false, twin=false, other=false -> identity

plain used the render body of the interface and loud used its own body; from the printers list both were called under the same Printer name, and each gave its own result. width was defined only in the interface and gave the length of prefix. In the last line, two separate PlainPrinter objects came out identity: because the class does not redefine equals, equality fell to identity, and the two instances were not equal even though they use the same interface body. A default implementation shares behavior; it does not share equality.

Conflicting Defaults

If a class implements two interfaces and both carry a function with a body and the same signature, the compiler cannot know which one to choose. The class has to redefine the function itself, and inside the body each one can be reached separately with super<Interface>.

// Main.kt — the same default of two interfaces combined in a single class
interface Reader {
    fun describe(): String = "reader"
}

interface Writer {
    fun describe(): String = "writer"
}

class Both : Reader, Writer {
    override fun describe(): String = super<Reader>.describe() + "+" + super<Writer>.describe()
}

fun main() {
    println(Both().describe())
    println(Both().let { it as Reader }.describe())
}
reader+writer
reader+writer

Both combined the two sources. The second line matters: when the Both object was seen as a Reader, the same combined body ran, because the choice was made by the function the object itself redefined, not by the type of the variable.

TN27. In this block we did not separately verify that a class that does not resolve the conflict fails to compile; only the resolved form was measured.

Delegation with by

Delegation is leaving the implementation of an interface to another object. In Kotlin this is written in the form : Printer by inner: for every member of the interface, the compiler generates a function that forwards the call to inner. The object that forwards is called the delegating object, and the object the call goes to is called the delegate.

// Main.kt — delegation with by forwards the members of the interface
class Labeled(private val inner: Printer) : Printer by inner

class Quiet(private val inner: Printer) : Printer by inner {
    override fun render(text: String): String = inner.render(text).lowercase()
}

fun main() {
    val loud = LoudPrinter()
    val labeled = Labeled(loud)
    println(labeled.render("ada"))
    println(labeled.prefix)
    println(labeled.width)
    println(Quiet(loud).render("ADA"))
    val checks = listOf(
        Criterion("same-delegate", Labeled(loud), Labeled(loud), Labeled(PlainPrinter())),
        Criterion("overridden", Quiet(loud), Quiet(loud), Quiet(PlainPrinter())),
    )
    for (check in checks) check.report()
    summarize(checks)
}
# ADA
#
2
# ada
same-delegate: same-object=false, twin=false, other=false -> identity
overridden: same-object=false, twin=false, other=false -> identity
singleton: 0
identity: 2
content: 0
partial-content: 0

Labeled wrote no members; render, prefix, and width still worked: all three went to loud. That the render result is in capital letters shows that the call went not to the default body of Printer but to the body redefined by LoudPrinter. Quiet, on the other hand, redefined one member itself and delegated the rest.

Two Labeled objects wrapping the same delegate and two Quiet objects wrapping the same delegate both came out identity. Even though the two delegating objects are bound to the same loud, they were not counted as equal.

TN28. The expression after by does not have to be a constructor parameter; here we measured only the form that shows the parameter. Whether delegation also forwards a member that is not in the interface was not measured.

The Functional Interface, the Object Expression, and the Delegate Expression

An interface is not implemented only by classes. An interface with a single abstract function can be built with a lambda if it is marked with fun interface. An interface can also be implemented by a nameless object expression. Third, the expression after by does not have to be a constructor parameter; it can be any expression. We measure these three in a single block.

Shout is built with two lambdas: the bodies of the two are the same. anon implements Printer with an object expression. The Cached class builds its delegate with the makePrinter() call, and the made counter counts how many times this call ran.

// Main.kt — fun interface, object expression, and the delegate expression evaluated once
fun interface Shout {
    fun apply(text: String): String
}

var made = 0

fun makePrinter(): Printer {
    made += 1
    return PlainPrinter()
}

class Cached : Printer by makePrinter()

fun main() {
    val first = Shout { it.uppercase() }
    val second = Shout { it.uppercase() }
    println(first.apply("ada"))
    val anon = object : Printer {
        override val prefix: String = "~ "
    }
    println(anon.render("x"))
    val one = Cached()
    println(one.render("a"))
    println(one.render("b"))
    println(made)
    Cached()
    println(made)
    val checks = listOf(
        Criterion("functional-interface", first, second, Shout { it }),
        Criterion("object-expression", anon, anon, PlainPrinter()),
    )
    for (check in checks) check.report()
}
ADA
~ x
> a
> b
1
2
functional-interface: same-object=false, twin=false, other=false -> identity
object-expression: same-object=true, twin=true, other=false -> singleton

The first result of the block is that two lambdas with the same body are not counted as equal: the functional-interface line came out identity. The bodies being the same as text did not produce equality; each lambda expression built its own object, and the functional interface does not redefine equals. Implementing the same interface with a class and implementing it with a lambda do not part ways as far as equality goes.

The second result is for the object expression. The object-expression line came out singleton, because anon is a single variable and the same value was given to the measure twice. The distinction we saw in the previous lesson holds here too: an expression builds a new object every time it is evaluated, but an object built once and put into a variable stays single. The ~ prefix in the output of anon.render("x") showed that the default render body in the interface is used in the object expression too.

The third result is for the by expression. made printed 1 after the first Cached() call and 2 after the second. The delegate expression is evaluated once while each Cached instance is being built: two instances carry two separate PlainPrinter objects. The two render calls of the same instance did not rebuild the delegate; made increased only in the constructor. This says that delegation is not a method call but a field stored in the constructor: the delegate is built once per instance and then forwarded to.

TN29. Whether a lambda capturing or not capturing an outer variable changes equality was not measured; neither lambda captured anything.

The Equality Rule from the Measurements

Every line we measured in this lesson asked where a behavior comes from and then asked where equality would come from. An interface shares behavior but carries no state and writes no equals; by forwards behavior to a delegate but does not forward equals; fun interface turns a lambda into an interface value but does not count two identical lambdas as equal. The common result of the three setups is that equality is determined not by the sharing of behavior but by the class’s own equals definition.

A common mistake is to read delegation like inheritance. In inheritance, a subclass inherits the equals of the superclass; a class that delegates with by forwards the members of the interface, but equals is not forwarded, because it is not a member defined in the interface. This is why two Labeled or two Quiet instances wrapping the same delegate, did not come out equal, even though their behaviors are exactly the same. If equality is needed, the class writes it itself or is declared as a data class; delegation does not do this job.

The delegate expression being evaluated once per instance gives a second practical conclusion. The made counter printed 2 for two instances; that is, the delegate is a field and stays the same for the lifetime of the instance. If state is kept inside a delegate, for example a counter or a cache, that state belongs to the delegating instance and is not shared between two instances.

The same lesson holds for interface values built with a lambda: even if two bodies carry the same text, they are two separate values, and the result comes out identity. Expecting equality from behavior turned out wrong in every setup we measured in this lesson. Thinking of behavior and equality separately keeps you from running into an unexpected result in a collection or in a == comparison.

Predicting Before Measuring

Because the criterion of this lesson is identity, the prediction is easy, but saying why each line gave identity is more instructive. In an interface implemented by a class, equality depends on the equals of the class; if the class did not write it, the identity comparison of Object applies. by adds nothing to this table, because the forwarded members are the ones defined in the interface. In a class declared as a data class, equals is generated by the compiler and looks at the content; this lesson showed it as a comparison.

There is a prediction trap in the functional interface line. The two lambdas carry the same text and are turned into the same interface; it seems natural to think “they do the same job, so they are equal”. The measurement gave identity. In the object expression line, on the other hand, the same variable was given twice and the result was singleton; a new object is built while the expression is evaluated, but an object put into a variable stays single from then on. The difference between these two lines is the difference between “two expressions” and “one expression, two reads”.

In the delegate expression, what is to be predicted is a number. For a program that builds two Cached objects, the value of made will be 2, because the delegate is evaluated once in the constructor. A person who expects this number to be 1 or as many as the number of calls has misunderstood whether delegation is a field or an expression recalculated on every call.

The Bounding Measurement

Why are two delegating objects not equal? Because by forwards only the members of the interface; equals, hashCode, and toString are not members of the interface but members of Any. We verified this first: the fragment below did not compile on Kotlin 2.4.21, which is why it stays without output. It is also forbidden for an interface to give a default implementation for the equals of Any.

interface Same {
    override fun equals(other: Any?): Boolean = true
}

Since delegation does not forward equals, the only way to change equality is for the class itself to redefine it. A data class does this automatically. The block below measures an ordinary class and a data class wrapping the same delegate side by side; the third line compares the delegating object with its delegate.

// Main.kt — delegation does not forward equals, a data class generates its own
class Wrapped(val inner: Printer) : Printer by inner
data class Tagged(val inner: Printer) : Printer by inner

fun main() {
    val loud = LoudPrinter()
    val checks = listOf(
        Criterion("class-delegation", Wrapped(loud), Wrapped(loud), Wrapped(PlainPrinter())),
        Criterion("data-class-delegation", Tagged(loud), Tagged(loud), Tagged(PlainPrinter())),
        Criterion("delegator-and-delegate", Wrapped(loud), loud, PlainPrinter()),
    )
    for (check in checks) check.report()
    summarize(checks)
}
class-delegation: same-object=false, twin=false, other=false -> identity
data-class-delegation: same-object=false, twin=true, other=false -> content
delegator-and-delegate: same-object=false, twin=false, other=false -> identity
singleton: 0
identity: 2
content: 1
partial-content: 0

The criterion came out identity in the ordinary class and content in the data class. Because the data class generates its own equals and compares the inner in the primary constructor, two instances wrapping the same delegate became equal; the one wrapping a different delegate did not. The third line shows the limit of delegation openly: Wrapped(loud) and loud are not equal to each other. The delegating object behaves like its delegate but is not its delegate.

Summary

  • An interface writes the members a type must conform to; it can carry functions with bodies and read-only properties with bodies.
  • If two interfaces bring the same default function, the class redefines it and reaches the two separately with super<Interface>.
  • by forwards the members of the interface to the delegate; the bodies the delegate redefined itself are preserved.
  • Delegation does not forward equals, and an interface cannot give a default implementation for equals; two ordinary objects wrapping the same delegate are not equal, while two data classes are equal.
  • This lesson’s criterion: identity. Even when behavior is shared, equality falls to identity unless the class redefines its equals.

Next Step

Delegation moved behavior to another object inside the class. The next lesson adds behavior from outside the class: extension functions let you write a new function for a type whose source you do not touch. We will measure whether an extension can take the place of a member of the type itself, especially over equals.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close