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 «нудним»: він просто оновив поле. Жодних println і жодних сюрпризів.

У 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. Якщо цього не тримати в голові, легко випадково отримати код, який виглядає однаково, але поводиться по-різному — і потім довго пояснювати самому собі, чому так сталося.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ