JavaRush /Курсы /Kotlin SELF /operator в Kotlin: plus, compareTo, plusAssign

operator в Kotlin: plus, compareTo, plusAssign

Kotlin SELF
38 уровень , 2 лекция
Открыта

1. Операторный синтаксис

Когда вы впервые видите, что в Kotlin можно написать a + b для своих классов, возникает ощущение, что язык разрешил вам “переписать математику”. На самом деле всё куда приземлённее (и это хорошо): оператор — это просто короткая запись вызова функции с фиксированленным именем. Компилятор делает перевод, а дальше всё работает как обычные методы/функции.

Самое важное соглашение дня: выражение a + b компилятор переводит в вызов a.plus(b). То есть оператор — это синтаксический сахар, а не отдельная “операторная вселенная”.

Сведём в небольшую табличку то, что нам сегодня нужно (не пытайтесь выучить наизусть — важнее понять принцип):

Что пишем Во что переводится компилятором
a + b
a.plus(b)
a += b
либо a.plusAssign(b), либо a = a + b (в зависимости от наличия plusAssign и возможности присваивания)
a < b
a.compareTo(b) < 0
a > b
a.compareTo(b) > 0

И отсюда следует практический вывод: чтобы ваш класс “понимал” оператор +, вы пишете обычную функцию 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
}

Почему это работает? Потому что totalvar, значит ему можно присвоить новое значение, и 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. И это нормально: здесь += работает как “переприсваивание через +”, потому что totalvar.

Проверка лимита и предупреждение

@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. Если это не держать в голове, можно случайно сделать код, который выглядит одинаково, но ведёт себя по-разному — и потом долго объяснять себе же, почему так.

1
Задача
Kotlin SELF, 38 уровень, 2 лекция
Недоступна
Очки игрока
Очки игрока
1
Задача
Kotlin SELF, 38 уровень, 2 лекция
Недоступна
Сравнение сумм
Сравнение сумм
1
Задача
Kotlin SELF, 38 уровень, 2 лекция
Недоступна
Шагомер дня
Шагомер дня
1
Задача
Kotlin SELF, 38 уровень, 2 лекция
Недоступна
Температуры смены
Температуры смены
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ