1. Операторный синтаксис
Когда вы впервые видите, что в Kotlin можно написать a + b для своих классов, возникает ощущение, что язык разрешил вам “переписать математику”. На самом деле всё куда приземлённее (и это хорошо): оператор — это просто короткая запись вызова функции с фиксированленным именем. Компилятор делает перевод, а дальше всё работает как обычные методы/функции.
Самое важное соглашение дня: выражение a + b компилятор переводит в вызов a.plus(b). То есть оператор — это синтаксический сахар, а не отдельная “операторная вселенная”.
Сведём в небольшую табличку то, что нам сегодня нужно (не пытайтесь выучить наизусть — важнее понять принцип):
| Что пишем | Во что переводится компилятором |
|---|---|
|
|
|
|
|
|
|
|
И отсюда следует практический вывод: чтобы ваш класс “понимал” оператор +, вы пишете обычную функцию plus, но помечаете её модификатором operator, чтобы компилятор разрешил операторный синтаксис.
2. plus и +=: дизайн без сюрпризов
С plus есть одна тонкая, но очень жизненная проблема: символ + выглядит безобидно, поэтому читатель кода ожидает, что операция будет быстрой, предсказуемой и без побочных эффектов. Если внутри plus вы будете печатать в консоль, менять глобальное состояние или удалять файлы (ну мало ли), то виноват окажется не читатель — виноват будете вы. + должен вести себя “как сложение”, иначе код превращается в загадку.
Давайте продолжим развивать наше учебное консольное приложение (условно назовём его ExpenseTracker), где мы храним расходы и строим отчёты. Самая частая боль в таких программах — деньги. Хранить “деньги” в Double — это как хранить пельмени в кармане: формально возможно, но почему-то всё потом липнет и округляется не туда. Поэтому заведём тип Money, который хранит сумму в центах.
Мини-модель Money и оператор +
@JvmInline
value class Money(val cents: Long)
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
val a = Money(150)
val b = Money(200)
println((a + b).cents) // 350
}
Здесь важны две идеи. Первая: Money — это “значение”, и + создаёт новое значение, а не меняет старое. Вторая: + для денег читается естественно, без расшифровки. Если человеку нужно открывать реализацию, чтобы понять смысл +, значит оператор выбран неудачно.
Есть распространённая ловушка: “а давайте plus будет добавлять к текущему объекту”. Это ломает ожидания. В большинстве языков и библиотек a + b воспринимается как создание результата, а не как изменение a. Мутацию лучше делать явно (метод add, append, increase) или через plusAssign (+=), где мутация хотя бы ожидается по форме записи.
Если вы всё-таки делаете мутацию через plus, ваш код начнёт вести себя как кот: иногда ласковый, иногда кусается, и непонятно почему.
Как работает += и чем тут помогает plusAssign
Оператор += выглядит как “тоже сложение”, но у него другой смысл: это расширенное присваивание. В Kotlin компилятор старается вызвать plusAssign, а если не может — превращает в “a = a + b”. Это не наше предположение, это правило переводов, заложенное в соглашениях операторов.
Чтобы не запутаться, полезно держать в голове такую мини-блок-схему:
flowchart TD
A["Код: a += b"] --> B{"Есть a.plusAssign(b)?"}
B -- "Да" --> C["Вызываем a.plusAssign(b) (должен вернуть Unit)"]
B -- "Нет" --> D["Пробуем a = a + b (то есть a = a.plus(b))"]
Это важно не только “для теории”, а потому что поведение += может зависеть от того, val у вас или var, и является ли объект мутабельным.
Допустим, у нас нет plusAssign, но есть plus. Тогда += может стать просто удобной записью “переприсваивания”:
@JvmInline
value class Money(val cents: Long)
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
var total = Money(0)
total += Money(99)
println(total.cents) // 99
}
Почему это работает? Потому что total — var, значит ему можно присвоить новое значение, и total += Money(99) превращается в total = total + Money(99).
Если plusAssign нет, а переприсвоить нельзя — компилятор не может “додумать” за вас, что вы имели в виду:
@JvmInline
value class Money(val cents: Long)
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
val total = Money(0)
// total += Money(99) // ошибка компиляции: val нельзя переприсваивать
}
Тут начинается то самое “весёлое место”, где новичок говорит: “подождите, но у меня val list = mutableListOf(...), и list += x работает!”. Да, работает — потому что у MutableCollection есть операции записи, и для мутабельных коллекций += может означать изменение содержимого, а не переприсваивание переменной. Стандартная библиотека отдельно описывает, что plusAssign (+=) существует для коллекций и для мутабельных коллекций добавляет элементы “на месте”.
Ключевая мысль: val запрещает менять ссылку (переменную), но не запрещает менять содержимое мутабельного объекта. Поэтому:
fun main() {
val items = mutableListOf("coffee")
items += "pizza"
println(items) // [coffee, pizza]
}
Это не нарушение законов физики, это просто разные уровни мутабельности: переменная неизменна, объект изменяем.
Свой plusAssign: когда это уместно
Когда вы объявляете operator fun plusAssign(...), вы как бы обещаете читателю: “да, здесь будет изменение текущего объекта”. Это полезно для классов‑накопителей, счётчиков, буферов, отчётов — везде, где “накапливать” естественно.
Компилятор предъявляет к plusAssign несколько строгих ожиданий. Одно из них — возвращаемый тип должен быть Unit, иначе будет ошибка. И это тоже логично: a += b — это присваивание, а не выражение, от него не ждут результата.
Представим, что в ExpenseTracker у нас есть “бюджет на категорию” — объект, который живёт долго и меняется со временем.
@JvmInline
value class Money(val cents: Long)
class Budget(var limit: Money) {
operator fun plusAssign(delta: Money) {
limit = limit + delta
}
}
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
val b = Budget(Money(10_00))
b += Money(2_50)
println(b.limit.cents) // 1250
}
Здесь мутация ожидаема: бюджет меняется. Мы сделали plusAssign “скучным”: он просто обновил поле. Никаких принтов, никаких сюрпризов.
В Kotlin есть правило про неоднозначность: если у вас одновременно есть plusAssign и plus, а левую часть можно присваивать, компилятор может ругаться на неоднозначность в некоторых случаях. На практике это означает: не пытайтесь сделать два разных смысла для + и += в одном типе, если это может запутать и компилятор, и людей.
Хорошее общее правило: + — “получить новый результат”, += — “изменить текущий накопитель”. Если это правило не выполняется, стоит задуматься, не лучше ли обычный метод.
3. Сравнения через compareTo
Сравнения — одна из самых важных причин перегружать операторы в бизнес‑коде. Когда вы пишете if (total > limit), код читается как человеческий язык. И Kotlin это поддерживает: операторы <, >, <=, >= переводятся в вызовы compareTo и сравнение с нулём.
Но тут легко сделать ошибку, которая будет проявляться редко и максимально неприятно: реализовать compareTo через вычитание.
Правильный compareTo для Money
@JvmInline
value class Money(val cents: Long) : Comparable<Money> {
override operator fun compareTo(other: Money): Int =
this.cents.compareTo(other.cents)
}
fun main() {
println(Money(10) < Money(20)) // true
println(Money(10) > Money(20)) // false
}
Мы делегировали сравнение в Long.compareTo, и это безопасно. Смысл compareTo простой: положительное — “больше”, отрицательное — “меньше”, ноль — “равно”. Kotlin именно так и интерпретирует результат, когда переписывает a < b в a.compareTo(b) < 0.
Почему нельзя “return (this.cents - other.cents).toInt()”
Потому что вычитание может переполниться. На маленьких числах всё будет прекрасно, а на больших вы получите неправильный знак. Ошибка будет редкой, и именно поэтому её любят баг‑репорты из продакшена: они звучат как “у вас иногда бюджет отрицательный, но только по вторникам”.
Безопасный путь — .compareTo(...).
Сравнение по нескольким полям и здравый смысл
В ExpenseTracker у нас могут быть расходы с датой, категорией и суммой. Представим простую модель расхода (мы не лезем в даты глубоко, берём строку для примера, потому что тема лекции — операторы):
data class Expense(
val title: String,
val category: String,
val amount: Money
)
Если мы захотим “естественный порядок” расходов по сумме, можно сделать отдельный тип‑обёртку или реализовать Comparable у другой сущности. Но главное — сравнение должно быть очевидным. Если вы сделаете так, что расходы сравниваются “по длине названия, потом по сумме, потом по категории”, пользователь кода будет страдать.
В Kotlin стандартная библиотека отдельно объясняет, что естественный порядок задаётся Comparable и compareTo — то есть это контракт, который потом используется сортировками. Поэтому “естественный порядок” должен быть действительно естественным.
4. Операторы в прикладной логике
Сейчас мы добавим операторы в кусочки логики, которые реально встречаются в консольном приложении учёта расходов: накопление суммы, сравнение с лимитом и печать предупреждения. Важно, что операторы здесь не ради красоты, а ради ясного текста: total + amount и total > limit читаются лучше, чем total.add(amount) или total.isGreaterThan(limit) (хотя и такие варианты иногда нужны).
Подсчёт общей суммы расходов
@JvmInline
value class Money(val cents: Long) : Comparable<Money> {
override operator fun compareTo(other: Money): Int =
cents.compareTo(other.cents)
}
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
val expenses = listOf(Money(199), Money(350), Money(451))
var total = Money(0)
for (x in expenses) total += x
println(total.cents) // 1000
}
Обратите внимание: мы использовали total += x, но у Money нет plusAssign. И это нормально: здесь += работает как “переприсваивание через +”, потому что total — var.
Проверка лимита и предупреждение
@JvmInline
value class Money(val cents: Long) : Comparable<Money> {
override operator fun compareTo(other: Money): Int =
cents.compareTo(other.cents)
}
operator fun Money.plus(other: Money): Money =
Money(this.cents + other.cents)
fun main() {
val limit = Money(500)
val spent = Money(650)
if (spent > limit) {
println("Осторожно: вы превысили лимит!") // Осторожно: вы превысили лимит!
}
}
Оператор > здесь превращается в spent.compareTo(limit) > 0, но читать код можно без знания этой детали — и это главный плюс операторов.
5. Правила читабельности операторов
Операторы — это очень мощный инструмент, и именно поэтому их легко испортить. В хорошем коде оператор — это короткая, честная запись очевидного действия. В плохом — это ребус, который требует комментариев “на всякий случай”. Комментарии, конечно, полезны, но лучше, когда они не нужны для расшифровки +.
Хорошая проверка на адекватность такая: представьте, что ваш код читает человек, который знает Kotlin, но не знает ваш проект. Если он видит a + b, он ожидает “сложили”. Если видит a += b, ожидает “добавили к накопителю / изменили состояние”. Если видит a < b, ожидает “сравнили по естественному порядку”. Если ваш код делает что-то другое — лучше выбрать именованную функцию.
Ещё одно правило из практики: оператор должен быть предсказуемо быстрым. Не обязательно O(1), но точно не “сходить в сеть и скачать курс валют” (да, я видел и такое — и да, это было больно).
И наконец, самый важный “анти‑героизм”: не используйте оператор там, где смысл не общеизвестен. Money + Money — окей. User + User — уже сомнительно. Logger + "hello" — подозрительно. Когда вы сомневаетесь, именованный метод почти всегда выигрывает в поддерживаемости.
6. Типичные ошибки при работе с operator, plus, compareTo, plusAssign
Ошибка №1: делать plus с побочными эффектами (печать, изменение чужих коллекций, логирование “на всякий случай”).
Символ + выглядит как чистая операция получения результата, и читатель кода ожидает, что выражение можно безопасно перемещать, переупорядочивать, выносить в переменную. Если plus внезапно печатает в консоль или меняет состояние, вы получите код, который “иногда шумит” и “иногда ломает логику” просто из-за рефакторинга.
Ошибка №2: реализовать compareTo через вычитание.
На маленьких числах “this.x - other.x” почти всегда работает, поэтому ошибка долго не проявляется. Потом приходит переполнение, знак становится неправильным, и сортировка начинает вести себя как будто у элементов перемешались места в очереди. Безопасный вариант — вызывать .compareTo(...) у сравниваемого поля.
Ошибка №3: путать смыслы + и += и пытаться сделать их “совсем разными операциями”.
+ обычно означает создание нового значения, а += — изменение накопителя или переприсваивание переменной через +. Если a + b делает одно, а a += b делает другое (не связанное по смыслу), код становится непредсказуемым. Особенно неприятно, если у вас есть и plus, и plusAssign, и компилятор начинает ругаться на неоднозначность — это хороший сигнал остановиться и упростить дизайн.
Ошибка №4: писать plusAssign с возвращаемым значением (не Unit).
Иногда хочется “вернуть новый объект” и продолжить цепочку. Но контракт += в Kotlin ожидает, что plusAssign возвращает Unit, иначе это ошибка компиляции. Если вам нужно “вернуть результат”, значит вам нужен plus или обычная функция с говорящим именем.
Ошибка №5: перегружать операторы ради “красоты” и экономии букв.
Операторы экономят символы, но могут тратить часы чтения и поддержки. Если после появления оператора команда начинает задавать вопросы “а что делает + у этого типа?”, значит оператор выбрали неправильно. В таком случае обычный метод (например, addExpense, increaseLimit, mergeWith) часто делает код длиннее на 5–10 символов, но дешевле на 5–10 нервных клеток.
Ошибка №6: забывать, что += для разных типов может означать разное (переприсваивание или мутацию объекта).
Для иммутабельных значений += часто превращается в a = a + b, и это требует var. Для мутабельных коллекций += может менять содержимое даже у val. Если это не держать в голове, можно случайно сделать код, который выглядит одинаково, но ведёт себя по-разному — и потом долго объяснять себе же, почему так.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ