Files
obsidian/Kotlin/Функциональный Kotlin/Инфиксные и операторные функции.md
T

21 KiB
Raw Blame History

title, description, tags
title description tags
Инфиксные и операторные функции infix-нотация, перегрузка операторов, invoke, деструктуризация и делегирование свойств
kotlin
functional

Kotlin позволяет задать для своих типов тот же синтаксис, что у встроенных: a + b, a[i], a(), a in b. Делается это обычными функциями с модификатором operator или infix.

infix

infix fun Int.упаковатьВ(unit: String): String = "$this $unit"

data class Money(val amount: Long, val currency: String)

infix fun Long.of(currency: String) = Money(this, currency)

fun main() {
    println(5 упаковатьВ "кг")        // 5 кг
    println(100L of "RUB")             // Money(amount=100, currency=RUB)
    println(100L.of("RUB"))            // Money(amount=100, currency=RUB) — обычный вызов тоже работает
}

Требования к infix: ровно один параметр, не vararg, без значения по умолчанию, и функция должна быть методом или расширением.

В stdlib infix используется для операций, читающихся как фраза:

fun main() {
    println(setOf(1, 2) union setOf(2, 3))        // [1, 2, 3]
    println(setOf(1, 2) intersect setOf(2, 3))     // [2]
    println("a" to 1)                               // (a, 1)
    println(5 shl 2)                                 // 20
    println(5 and 3)                                 // 1
    println(1 until 5)                               // 1..4
    println(5 downTo 1)                              // 5 downTo 1 step 1
    println(1..10 step 2)                            // 1..9 step 2
}

Приоритет инфиксных функций ниже арифметики, но выше сравнений - это источник ошибок:

infix fun Int.plusTen(x: Int) = this + x + 10

fun main() {
    println(1 plusTen 2 * 3)        // 17 — это 1 plusTen (2*3)
    println((1 plusTen 2) * 3)       // 39
}

При сомнении ставьте скобки.

Перегрузка операторов

Каждый оператор отображается в функцию с фиксированным именем:

Оператор Функция
a + b a.plus(b)
a - b a.minus(b)
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
+a / -a a.unaryPlus() / a.unaryMinus()
!a a.not()
a++ / a-- a.inc() / a.dec()
a += b a.plusAssign(b) или a = a.plus(b)
a == b a?.equals(b) ?: (b === null)
a > b a.compareTo(b) > 0
a[i] a.get(i)
a[i] = v a.set(i, v)
a() a.invoke()
a in b b.contains(a)
a..b a.rangeTo(b)
a..<b a.rangeUntil(b)
for (x in a) a.iterator()
val (x, y) = a a.component1(), a.component2()

Полный пример - вектор:

data class Vec(val x: Double, val y: Double) {
    operator fun plus(other: Vec) = Vec(x + other.x, y + other.y)
    operator fun minus(other: Vec) = Vec(x - other.x, y - other.y)
    operator fun times(scalar: Double) = Vec(x * scalar, y * scalar)
    operator fun unaryMinus() = Vec(-x, -y)
    operator fun get(index: Int): Double = when (index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBoundsException("индекс $index")
    }

    val length: Double get() = kotlin.math.sqrt(x * x + y * y)
}

fun main() {
    val a = Vec(1.0, 2.0)
    val b = Vec(3.0, 4.0)

    println(a + b)            // Vec(x=4.0, y=6.0)
    println(b - a)            // Vec(x=2.0, y=2.0)
    println(a * 2.0)          // Vec(x=2.0, y=4.0)
    println(-a)               // Vec(x=-1.0, y=-2.0)
    println(b[0])             // 3.0
    println(b.length)         // 5.0
}

Умножение на скаляр справа - метод класса; чтобы работало и слева, нужно расширение:

data class Vec2(val x: Double, val y: Double) {
    operator fun times(scalar: Double) = Vec2(x * scalar, y * scalar)
}

operator fun Double.times(v: Vec2) = v * this

fun main() {
    val v = Vec2(1.0, 2.0)
    println(v * 3.0)        // Vec2(x=3.0, y=6.0)
    println(3.0 * v)        // Vec2(x=3.0, y=6.0)
}

Операторы как расширения

Оператор можно добавить к чужому типу:

operator fun String.times(n: Int) = repeat(n)

operator fun <T> List<T>.get(range: IntRange): List<T> = subList(range.first, range.last + 1)

fun main() {
    println("ab" * 3)                         // ababab
    println(listOf(1, 2, 3, 4, 5)[1..3])      // [2, 3, 4]
}

Так в stdlib добавлены Map.get через [], List.plus и операции над множествами.

invoke

operator fun invoke позволяет вызывать объект как функцию:

class Multiplier(private val factor: Int) {
    operator fun invoke(x: Int) = x * factor
}

fun main() {
    val triple = Multiplier(3)
    println(triple(5))          // 15
    println(triple.invoke(5))   // 15 — то же самое
}

Практическое применение - объект с состоянием, который ведёт себя как функция:

class RateLimiter(private val maxCalls: Int) {
    private var calls = 0
    operator fun invoke(): Boolean {
        calls++
        return calls <= maxCalls
    }
}

fun main() {
    val limiter = RateLimiter(2)
    println("${limiter()} ${limiter()} ${limiter()}")      // true true false
}

invoke на companion object даёт фабрику с синтаксисом конструктора:

class Email private constructor(val value: String) {
    companion object {
        operator fun invoke(raw: String): Email? =
            if (raw.contains("@")) Email(raw.trim().lowercase()) else null
    }
    override fun toString() = value
}

fun main() {
    println(Email("  Ivan@Example.COM "))      // ivan@example.com
    println(Email("не почта"))                  // null
}

Это способ сделать «конструктор, возвращающий null» - настоящий конструктор так не может.

Сравнение: compareTo

class Version(val major: Int, val minor: Int) : Comparable<Version> {
    override fun compareTo(other: Version): Int =
        compareValuesBy(this, other, { it.major }, { it.minor })

    override fun toString() = "$major.$minor"
}

fun main() {
    val a = Version(1, 5)
    val b = Version(2, 0)

    println(a < b)                               // true
    println(a >= Version(1, 5))                   // true
    println(listOf(b, a, Version(1, 9)).sorted()) // [1.5, 1.9, 2.0]
    println(maxOf(a, b))                           // 2.0
    println(a in Version(1, 0)..Version(1, 9))     // true
}

Одна функция compareTo даёт <, >, <=, >=, сортировку, maxOf/minOf и диапазоны. compareValuesBy сравнивает по нескольким ключам по порядку.

in: contains

data class DateRange(val from: Int, val to: Int) {
    operator fun contains(day: Int) = day in from..to
}

fun main() {
    val week = DateRange(1, 7)
    println(3 in week)          // true
    println(10 in week)         // false
    println(10 !in week)        // true — !in получается автоматически
}

Итератор

class Countdown(private val from: Int) {
    operator fun iterator(): Iterator<Int> = object : Iterator<Int> {
        private var current = from
        override fun hasNext() = current > 0
        override fun next() = current--
    }
}

fun main() {
    for (i in Countdown(3)) print("$i ")
    println()        // 3 2 1
}

iterator можно добавить и расширением - тогда for заработает на чужом типе:

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

fun main() {
    val from = java.time.LocalDate.of(2026, 1, 1)
    val to = java.time.LocalDate.of(2026, 1, 3)
    for (d in from..to) print("${d.dayOfMonth} ")
    println()        // 1 2 3
}

get и set

class Matrix(private val rows: Int, private val cols: Int) {
    private val data = DoubleArray(rows * cols)

    operator fun get(i: Int, j: Int): Double = data[i * cols + j]
    operator fun set(i: Int, j: Int, value: Double) { data[i * cols + j] = value }

    override fun toString() = (0 until rows).joinToString("\n") { i ->
        (0 until cols).joinToString(" ") { j -> this[i, j].toString() }
    }
}

fun main() {
    val m = Matrix(2, 2)
    m[0, 0] = 1.0
    m[1, 1] = 2.0
    println(m[0, 0])        // 1.0
    println(m)
    // 1.0 0.0
    // 0.0 2.0
}

get и set принимают любое число аргументов - это даёт многомерную индексацию, которой нет в Java.

plusAssign против plus

class Basket {
    private val items = mutableListOf<String>()
    operator fun plusAssign(item: String) { items.add(item) }
    override fun toString() = items.toString()
}

fun main() {
    val basket = Basket()
    basket += "хлеб"
    basket += "молоко"
    println(basket)        // [хлеб, молоко]
}

Если объявлены оба (plus и plusAssign) и переменная var - компилятор сообщит об неоднозначности. Правило: plusAssign для изменяемых коллекций-подобных типов, plus для значений.

Деструктуризация

class Pair3(val first: Int, val second: String) {
    operator fun component1() = first
    operator fun component2() = second
}

fun main() {
    val (num, text) = Pair3(1, "один")
    println("$num = $text")        // 1 = один
}

data class объявляет componentN автоматически. Подробнее: Деструктуризация

Делегирование свойств

Операторы getValue/setValue позволяют подменить реализацию свойства:

import kotlin.reflect.KProperty

class Trimmed {
    private var value: String = ""
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value
    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        value = newValue.trim()
    }
}

class Form {
    var login: String by Trimmed()
}

fun main() {
    val f = Form()
    f.login = "   ivan   "
    println("[${f.login}]")        // [ivan]
}

Подробнее о делегатах и готовых реализациях (lazy, observable, map): Делегирование

Когда перегружать оператор

Ситуация Стоит ли
математический тип (вектор, матрица, деньги, дата) да
коллекция-подобный тип ([], in, for) да
сравнение по естественному порядку да, через Comparable
«красивее выглядит» нет
смысл оператора неочевиден (user + role) нет
операция может бросить исключение неожиданно нет

Критерий простой: оператор должен означать то же, что для встроенных типов. + обязан быть коммутативным по смыслу и не иметь побочных эффектов; == - соответствовать hashCode.

[!info] Отличия от Java

Java Kotlin
перегрузки операторов нет operator fun
a.add(b) a + b
list.get(i) list[i]
map.put(k, v) map[k] = v
a.compareTo(b) < 0 a < b
a.equals(b) a == b
BigDecimal.add().multiply() a + b * c
инфиксной нотации нет infix fun

Java не поддерживает перегрузку операторов принципиально - кроме + для строк, который встроен в компилятор. Это сознательное решение дизайнеров Java: операторы с произвольным смыслом усложняют чтение. Kotlin выбрал компромисс: перегрузка есть, но набор операторов фиксирован и имена функций заданы жёстко, так что plus всегда означает + и нельзя придумать свой оператор.

Разница заметнее всего на числовых типах. BigDecimal в Java:

BigDecimal result = a.multiply(b).add(c).divide(d, RoundingMode.HALF_UP);

В Kotlin те же операторы уже объявлены в stdlib как расширения:

import java.math.BigDecimal

fun main() {
    val a = BigDecimal("10.5")
    val b = BigDecimal("2")
    println(a + b)          // 12.5
    println(a * b)          // 21.0
    println(a > b)          // true
}

Для денежных расчётов это главный практический выигрыш: формула в коде выглядит как формула.

== в Kotlin - это equals, а не сравнение ссылок. Самое важное отличие на практике: в Java a == b для объектов сравнивает ссылки, и забытый .equals() - классическая ошибка со строками. В Kotlin == вызывает equals (с корректной обработкой null), а для ссылок есть отдельный ===:

fun main() {
    val a = StringBuilder("x").toString()
    val b = StringBuilder("x").toString()
    println(a == b)        // true — по значению
    println(a === b)       // false — разные объекты
}

infix даёт читаемость без DSL-библиотек. В Java тестовые фреймворки имитируют инфиксную запись цепочкой методов: assertThat(x).isEqualTo(y). В Kotlin это записывается как x shouldBe y - подробнее: Kotest

invoke аналога не имеет. В Java объект нельзя вызвать как функцию; ближайшее - интерфейс с методом apply, вызываемый явно.

Сравнить: Операторы и выражения в Java, Класс Object

[!warning] Подводные камни Приоритет infix ниже арифметики:

infix fun Int.append(x: Int) = "$this$x"

fun main() {
    println(1 append 2 + 3)        // 15 — это 1 append (2+3)
    println((1 append 2) + 3)       // 123
}

Выражение выглядит как последовательность слева направо, но арифметика вычисляется первой.

infix нельзя использовать без получателя и с this неявно:

class Calc {
    infix fun add(x: Int) = x + 1
    fun demo(): Int {
        // return add 5           // ошибка
        return this add 5          // так можно
    }
}

fun main() { println(Calc().demo()) }      // 6

plus не меняет объект, plusAssign меняет - и перепутать легко:

fun main() {
    var list1 = listOf(1, 2)
    list1 += 3                      // новый список: plus + присваивание
    println(list1)                   // [1, 2, 3]

    val list2 = mutableListOf(1, 2)
    list2 += 3                       // изменение на месте: plusAssign
    println(list2)                    // [1, 2, 3]
}

Результат одинаковый, стоимость разная: первый вариант копирует весь список.

Объявление и plus, и plusAssign даёт ошибку компиляции при var:

class Both(val v: Int) {
    operator fun plus(x: Int) = Both(v + x)
    operator fun plusAssign(x: Int) { }
}

fun main() {
    val b = Both(1)
    b += 1              // выбран plusAssign — для val это единственный вариант
    println(b.v)        // 1
}

Для var-переменной компилятор не сможет выбрать между b = b + 1 и b.plusAssign(1).

inc/dec должны возвращать новый объект:

class Counter4(val value: Int) {
    operator fun inc() = Counter4(value + 1)
}

fun main() {
    var c = Counter4(0)
    c++
    println(c.value)        // 1
}

Если inc изменит объект и вернёт this, постфиксный c++ сломается: он должен вернуть старое значение, а вернёт изменённое.

equals нельзя объявить расширением:

class NoEquals(val v: Int)

// operator fun NoEquals.equals(other: Any?) = ...     // не заработает

fun main() {
    println(NoEquals(1) == NoEquals(1))      // false — сравнение ссылок из Any
}

equals должен быть override в классе: он объявлен в Any, а расширение не переопределяет член. Подробнее: equals, hashCode и toString

compareTo обязан быть согласован с equals. Если a.compareTo(b) == 0, но a != b, то TreeSet и sortedSetOf начнут терять элементы - они используют сравнение, а не равенство.

get без set создаёт асимметричный API:

class ReadOnlyBox(private val items: List<Int>) {
    operator fun get(i: Int) = items[i]
}

fun main() {
    val b = ReadOnlyBox(listOf(1, 2))
    println(b[0])           // 1
    // b[0] = 5              // ошибка: нет set
}

Это нормально (так работает List), но пользователь увидит [] и может ожидать присваивания.

Оператор на расширении не виден, если есть член класса - действует то же правило затенения, что у обычных расширений.

Операторы с неочевидным смыслом ухудшают код:

data class User13(val name: String, val roles: List<String>)

// operator fun User13.plus(role: String) = copy(roles = roles + role)
fun User13.withRole(role: String) = copy(roles = roles + role)

fun main() {
    println(User13("Иван", emptyList()).withRole("админ"))
    // User13(name=Иван, roles=[админ])
}

user + "админ" короче, но непонятно: что значит «сложить пользователя со строкой»? Именованный метод честнее.

invoke на обычном классе запутывает: service() не говорит, что делает объект. Оправдано для функциональных обёрток (UseCase, Validator), где у объекта ровно одна операция.

Нельзя придумать новый оператор. Набор фиксирован: +, -, *, /, %, [], (), in, .., сравнения, ++/--, унарные. Произвольные символы вроде <*> или |> недоступны - только infix-функция со словесным именем.

Далее: Receivers и context parameters