Lecture notes — Operator Overloading and Other Conventions
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
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.
What we will cover
- Arithmetic —
+,-, unary operators, and the+=subtlety. - Comparison —
==with a free null check, andcompareTofor all four ordering operators. - Collection conventions — indexing,
in,.., andfor-in. - Destructuring — the
componentNfunctions 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) // aaaUnary operators

+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 avar - to
plusAssign— modify the object in place, returningUnit, which works on aval
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 listLearning 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:
plusreturns a new object;plusAssignmutates
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:
equalsis markedoverride, notoperator, because it is defined inAnyand 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:
==callsequalswith a free null check; all four ordering operators callcompareTo
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]) // 20Neither 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) // trueiterator
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.
for (c in "abc")worked becauseCharSequencehas aniteratorextensionc in 'a'..'z'worked because..built a range andincalled itscontains
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/setfor indexing,containsforin,rangeTofor..,iteratorforfor-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.
Destructuring is positional, not by name.
val (name, age) = personbinds 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:
componentNfunctions, 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

getValue and setValue on it.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:
byon a property,getValue/setValueon 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 meansplus, whateverplusmeans for that type.==callsequalsand 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.
inwascontainsanditerator— two functions, one keyword.towas an infix extension, and the destructuring that unpacked itsPairwascomponent1andcomponent2.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 callingsetValue.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.