Lecture notes — Operator Overloading and Other Conventions

Published

2026-08-21 00:00

Keywords

ver. 1.0.0

← Operator Overloading and Other Conventions

Where we are

Two things were left unexplained in earlier units, and they turn out to be the same thing.

Unit 2 used in to test membership and to drive a for loop, and noted that the double duty was not a coincidence. Unit 3 showed that 1 to "one" is an ordinary infix extension function rather than map syntax.

This unit gives the general principle. Kotlin has conventions: certain language constructs are defined as calls to functions with specific names. Define such a function on your type, and the syntax works for it.

What you will be able to do

  1. explain-the-convention-principle — Explain what a Kotlin convention is and how it differs from Java’s approach.
  2. overload-arithmetic-operators — Overload binary and unary arithmetic operators with the operator keyword.
  3. overload-compound-assignment — Choose correctly between plus and plusAssign for compound assignment.
  4. overload-comparison-operators — Implement equals and compareTo to get ==, !=, and the ordering operators.
  5. implement-collection-conventions — Implement get, set, contains, rangeTo and iterator for your own types.
  6. use-destructuring-declarations — Use destructuring declarations and explain the componentN convention behind them.
  7. use-delegated-properties — Use a delegated property to hand accessor logic to another object.
  8. implement-a-property-delegate — Implement your own delegate and state the translation rule the compiler applies.
  9. use-map-backed-and-framework-delegates — Store property values in a map, and recognise the pattern in frameworks.

What we will cover

  • Arithmetic+, -, unary operators, and the += subtlety.
  • Comparison== with a free null check, and compareTo for all four ordering operators.
  • Collection conventions — indexing, in, .., and for-in.
  • Destructuring — the componentN functions behind it.
  • Delegated properties — the largest idea in the unit.

Why conventions rather than interfaces

Java ties functionality to interfaces: to be iterable, you implement Iterable. Kotlin ties it to function names.

That sounds like a stylistic difference and is not. Because a function can be an extension, you can give a type you do not own the ability to be indexed, iterated, compared or ranged — which Java cannot do at all, since you cannot make somebody else’s class implement your interface.

Kotlin also sits deliberately between Java and Scala here.

Java allows operator overloading on built-in types only. Scala allows arbitrary new operators, which produces libraries whose syntax no newcomer can read.

Kotlin fixes the set of operators and lets you decide what each means for your type. So + always means whatever plus means — never something invented.

Learning outcomes

  • explain-the-convention-principle: Explain what a Kotlin convention is and how it differs from Java’s approach.

Arithmetic operators

a + b compiles to a.plus(b).
data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

val p1 = Point(10, 20)
val p2 = Point(30, 40)
println(p1 + p2)      // Point(x=40, y=60)

The operator keyword is required, and deliberately so. It prevents a function that happens to be named plus from accidentally gaining operator syntax, and it tells a reader that operator use is intended.

The full set: plus, minus, times, div, rem.

Three extensions of the basic idea

As an extension function, so types you do not own gain operators:

operator fun Point.plus(other: Point) = Point(x + other.x, y + other.y)

Mixed operand types, by overloading:

operator fun Point.times(scale: Double): Point =
    Point((x * scale).toInt(), (y * scale).toInt())

println(p * 1.5)

Note the asymmetry: p * 1.5 works, 1.5 * p does not, unless you also define Double.times(Point).

An unconstrained result type — the book multiplies a Char by an Int to get a String:

operator fun Char.times(count: Int): String = toString().repeat(count)
println('a' * 3)      // aaa

Unary operators

Unary +a compiles to a.unaryPlus().
operator fun Point.unaryMinus() = Point(-x, -y)

The set is unaryPlus, unaryMinus, not, inc and dec — the last two giving you ++ and --.

Bit operations are the exception to the operator story. Kotlin has no bitwise operator symbols, using infix functions instead: shl, shr, ushr, and, or, xor, inv.

0x0F and 0xF0 is arguably more readable than 0x0F & 0xF0, and it avoids the precedence surprises that & and | cause in C-family languages.

Compound assignment: the one to be careful about

+= may compile to either plus with reassignment, or plusAssign with mutation.

a += b can compile two ways:

  • to plus — compute a new object and reassign the variable, which requires a var
  • to plusAssign — modify the object in place, returning Unit, which works on a val
ImportantDefine one or the other, never both

With both defined, += is genuinely ambiguous and the compiler reports an error rather than picking.

The standard library’s convention is worth internalising as a design lesson:

  • read-only collections define plus, returning a new collection
  • mutable ones define plusAssign, modifying in place

So which operator is available tells you which kind of collection you have:

val list = arrayListOf(1, 2)
list += 3                       // plusAssign — mutates the list

val newList = list + listOf(4, 5)   // plus — a new list

Learning outcomes

  • overload-arithmetic-operators: Overload binary and unary arithmetic operators with the operator keyword.
  • overload-compound-assignment: Choose correctly between plus and plusAssign for compound assignment.

Concepts

  • kotlin-conventions: syntax wired to specifically named functions
  • operator-keyword: required, so operator syntax is never gained by accident
  • arithmetic-operator-overloading: plus, times, the unary set, and extensions on types you do not own
  • compound-assignment-operators: plus returns a new object; plusAssign mutates

Comparison operators

Equality

In Kotlin == calls equals, so it compares contents. Reference comparison is ===. That is the opposite of Java’s convention, and the better way round — content comparison is what you want almost always, so it should have the shorter syntax.

But the more valuable difference is this:

println(null == null)       // true
println(a == b)             // works when either is null

== performs a null check first. In Java, a.equals(b) throws when a is null, which is why defensive Java code puts the constant first — "expected".equals(actual) — or reaches for Objects.equals.

Kotlin’s == translates to a?.equals(b) ?: (b === null), so the check is free and automatic.

Two notes on implementing it:

  • equals is marked override, not operator, because it is defined in Any and already marked there
  • unlike other conventions it cannot be an extension, because the inherited member would always win — which is the extension dispatch rule from unit 3 showing its consequences

Ordering

p1 < p2 compiles to p1.compareTo(p2) < 0.

All four operators — <, >, <=, >= — call compareTo. Implement Comparable once and you get all of them, plus the standard library’s sorting, max and min:

class Person(val firstName: String, val lastName: String) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return compareValuesBy(this, other,
                               Person::lastName, Person::firstName)
    }
}

compareValuesBy takes the two objects and a list of selectors, comparing by the first, then the second where the first ties, and so on. That replaces the nested if/else chain a hand-written compareTo usually becomes.

The book adds a performance note worth keeping: the field-by-field version is faster than the callback version. Write the readable form first, and optimise only if a profiler says to.

Learning outcomes

  • overload-comparison-operators: Implement equals and compareTo to get ==, !=, and the ordering operators.

Concepts

  • equality-and-comparison-conventions: == calls equals with a free null check; all four ordering operators call compareTo

Collection conventions

Four more conventions, and together they explain unit 2.

get and set

a[b] compiles to a.get(b).

a[b] = c compiles to a.set(b, c).
operator fun Point.get(index: Int): Int {
    return when(index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
    }
}

println(p[1])     // 20

Neither is restricted to integer indices — a map’s get takes a key, and yours can take whatever suits. Multiple parameters are allowed too, so a two-dimensional structure can be indexed as matrix[row, col].

in

x in c compiles to c.contains(x).
data class Rectangle(val upperLeft: Point, val lowerRight: Point)

operator fun Rectangle.contains(p: Point): Boolean {
    return p.x in upperLeft.x until lowerRight.x &&
           p.y in upperLeft.y until lowerRight.y
}

println(Point(20, 30) in rect)

That reads exactly as you would say it aloud. Note the until for a half-open interval — the same distinction from unit 2, now in a type of your own.

rangeTo

.. calls rangeTo. Any Comparable type gets one from the standard library — so having implemented compareTo in the previous section, you can already build ranges of your own type:

val now = LocalDate.now()
val vacation = now..now.plusDays(10)
println(now.plusWeeks(1) in vacation)     // true

iterator

for-in calls iterator, and here the payoff is largest, because it is usually an extension:

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
    object : Iterator<LocalDate> {
        var current = start
        override fun hasNext() = current <= endInclusive
        override fun next() = current.apply {
            current = plusDays(1)
        }
    }

for (dayOff in newYear..daysOff) { println(dayOff) }

LocalDate is a JDK class. That extension makes it iterable in a for loop without touching it.

ImportantNow unit 2 makes sense
  • for (c in "abc") worked because CharSequence has an iterator extension
  • c in 'a'..'z' worked because .. built a range and in called its contains

The same keyword did two jobs because there are two functions, and the syntax dispatches to whichever fits the position.

And the reason this matters more than it looks: these can all be extensions. You can make a class from a Java library indexable, iterable or range-able without modifying it. Java ties this to implementing interfaces, which you cannot do to somebody else’s class.

Learning outcomes

  • implement-collection-conventions: Implement get, set, contains, rangeTo and iterator for your own types.

Concepts

  • collection-access-conventions: get/set for indexing, contains for in, rangeTo for .., iterator for for-in

Destructuring declarations

val (a, b) = obj compiles to obj.component1() and obj.component2().

Destructuring has appeared twice already — iterating a map in unit 2, and unpacking a Pair in unit 3. Here is what makes it work:

val (a, b) = point
// compiles to
val a = point.component1()
val b = point.component2()

Data classes generate them for the properties declared in the primary constructor. That is why a data class can be unpacked immediately, and it is one more item on the list of things data generates, alongside equals, hashCode, toString and copy from unit 4.

Defining them by hand takes one line each:

class Point(val x: Int, val y: Int) {
    operator fun component1() = x
    operator fun component2() = y
}

Returning two values

data class NameComponents(val name: String, val extension: String)

fun splitFilename(fullName: String): NameComponents {
    val result = fullName.split('.', limit = 2)
    return NameComponents(result[0], result[1])
}

val (name, ext) = splitFilename("example.kt")

A real improvement on an out-parameter or an array, and it is the pattern behind the file-parsing examples from earlier units.

In loops

Any destructurable type can be unpacked in a for loop header:

for ((key, value) in map) {
    println("$key -> $value")
}

That is exactly what map iteration was doing all along, since Map.Entry has component1 and component2 extensions.

NoteThe limitation to know

Destructuring is positional, not by name.

val (name, age) = person

binds the first property to name regardless of what it is called. Reorder the primary constructor of a data class, and every destructuring of it silently changes meaning — the code still compiles, and the values are now wrong.

That is a genuine hazard, and the reason to keep destructuring to small types where the order is obvious.

Learning outcomes

  • use-destructuring-declarations: Use destructuring declarations and explain the componentN convention behind them.

Concepts

  • destructuring-declarations: componentN functions, generated for data classes and definable by hand

Delegated properties

The largest idea in the unit, and the one with the widest reach in real code.

The idea. A property does not have to store its value in a field. With by, its accessors delegate to another object — and the property syntax stays completely ordinary at the call site.

class Foo {
    var p: Type by Delegate()
}

by lazy — the case that sells it

A property whose value is expensive and might not be needed. By hand:

class Person(val name: String) {
    private var _emails: List<Email>? = null
    val emails: List<Email>
        get() {
            if (_emails == null) {
                _emails = loadEmails(this)
            }
            return _emails!!
        }
}

A nullable backing field, a null check, a !!, and no thread safety. With a delegate:

class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}

The lambda runs on first access, the result is cached, and it is thread-safe by default.

Delegates.observable

Notifying listeners when a property changes means writing a custom setter for every property, each doing the same thing:

class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {
    private val observer = { prop: KProperty<*>, oldValue: Int, newValue: Int ->
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }

    var age: Int by Delegates.observable(age, observer)
    var salary: Int by Delegates.observable(salary, observer)
}

The notification logic exists in exactly one place, and adding a third observable property is one line.

Implementing your own

A delegate is a class with getValue and setValue, both marked operator:

class ObservableProperty(
        var propValue: Int, val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue

    operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

Each receives the owning object and a KProperty describing the property. That second parameter is what lets a delegate behave differently per property — by using the property’s name as a key, which the map-backed case relies on entirely.

The translation rule

A delegated property is backed by a hidden delegate object; accesses call getValue and setValue on it.
ImportantThis is what makes unfamiliar delegates readable

A delegated property compiles to:

  • a hidden field holding the delegate object
  • a getter returning delegate.getValue(this, property)
  • a setter calling delegate.setValue(this, property, value)

That is the whole mechanism. Any delegate you meet, however clever it looks, is doing only this.

Knowing the rule converts a framework that seems magical into one you can read.

Storing property values in a map

Because the delegate receives the property name, a Map can be used directly as a delegate:

class Person {
    private val _attributes = hashMapOf<String, String>()

    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    val name: String by _attributes      // the map is the delegate
}

p.name reads _attributes["name"]. This suits objects whose attributes are not known at compile time — a fixed set of properties alongside arbitrary additional ones — and the fixed and dynamic attributes are then accessed identically.

In frameworks

The same mechanism is how an ORM maps a property to a database column:

object Users : IdTable() {
    val name = varchar("name", length = 50).index()
    val age = integer("age")
}

class User(id: EntityID) : Entity(id) {
    var name: String by Users.name       // reads and writes the database
    var age: Int by Users.age
}

The class reads like a plain object. The delegate is doing the database work.

Recognising this pattern is what lets you read such a framework’s code rather than treating it as magic — and the last unit of this module builds on the same idea.

Learning outcomes

  • use-delegated-properties: Use a delegated property to hand accessor logic to another object.
  • implement-a-property-delegate: Implement your own delegate and state the translation rule the compiler applies.
  • use-map-backed-and-framework-delegates: Store property values in a map, and recognise the pattern in frameworks.

Concepts

  • delegated-properties: by on a property, getValue/setValue on the delegate, and one simple translation rule

What conventions now give you

You can give your own types arithmetic, comparison, indexing, iteration, destructuring and delegated properties — and you can give those things to types you do not own.

Learning outcomes

  • explain-the-convention-principle: Explain what a Kotlin convention is and how it differs from Java’s approach.
  • overload-arithmetic-operators: Overload binary and unary arithmetic operators with the operator keyword.
  • overload-compound-assignment: Choose correctly between plus and plusAssign for compound assignment.
  • overload-comparison-operators: Implement equals and compareTo to get ==, !=, and the ordering operators.
  • implement-collection-conventions: Implement get, set, contains, rangeTo and iterator for your own types.
  • use-destructuring-declarations: Use destructuring declarations and explain the componentN convention behind them.
  • use-delegated-properties: Use a delegated property to hand accessor logic to another object.
  • implement-a-property-delegate: Implement your own delegate and state the translation rule the compiler applies.
  • use-map-backed-and-framework-delegates: Store property values in a map, and recognise the pattern in frameworks.

Conclusion

  • Kotlin ties syntax to function names, not to interfaces.

    Which means a convention can be an extension — so a class from a Java library can be made indexable, iterable, comparable or range-able without touching it. Java cannot do this at all.

  • The operator set is fixed; only the meanings are yours.

    Deliberately between Java, which allows nothing, and Scala, which allows arbitrary new symbols. + always means plus, whatever plus means for that type.

  • == calls equals and checks null first.

    Content comparison gets the short syntax, and the null-safety comes free. Java’s a.equals(b) throwing on a null receiver is a bug class this simply removes.

  • Two loose ends from earlier units are now closed.

    in was contains and iterator — two functions, one keyword. to was an infix extension, and the destructuring that unpacked its Pair was component1 and component2.

  • Destructuring is positional, and that is a hazard.

    Reordering a data class’s primary constructor silently changes every destructuring of it. Keep it to small types where the order is self-evident.

  • Delegated properties have one translation rule and unlimited reach.

    A hidden field, a getter calling getValue, a setter calling setValue. lazy, observables, map-backed attributes and ORM column mapping are all that one rule, and knowing it converts magic into code.

Where next

The next unit, Higher-Order Functions: Lambdas as Parameters and Return Values, returns to the functional thread.

Unit 5 taught you to use lambdas. This one is about writing functions that take and return them — function types, the inline keyword that removes their runtime cost, and the control-flow rules that follow from inlining.