Avatar ·

Геттеры и сеттеры в Kotlin: как объявлять и использовать свойства с кастомными аксессорами?

Всем привет! Я недавно начал изучать Kotlin после Java и столкнулся с непониманием работы геттеров и сеттеров. В Java я привык явно объявлять поля с модификаторами доступа, а затем писать для них методы getField() и setField(). В Kotlin же, как я понял, всё иначе — есть свойства (properties), которые автоматически генерируют геттеры и сеттеры.

// Пример из моей попытки
class Person {
    var name: String = "Unknown"
    var age: Int = 0
    
    // Я пытаюсь сделать кастомный сеттер для age
    set(value) {
        if (value >= 0) {
            // Как тут обратиться к полю? field? Или this.age?
            this.age = value // Вызывает рекурсию!
        } else {
            println("Возраст не может быть отрицательным")
        }
    }
}

Когда я запускаю этот код, получаю StackOverflowError из-за бесконечной рекурсии. Я понимаю, что делаю что-то не так, но не могу разобраться:

  • Как правильно объявлять кастомные геттеры и сеттеры в Kotlin?
  • Что такое field и зачем он нужен?
  • Можно ли сделать свойство только для чтения (как final в Java)?
  • Как изменить видимость геттера и сеттера по отдельности?

Буду благодарен за подробное объяснение с примерами! В документации написано сложно для новичка.

<<>>

Полное руководство по геттерам и сеттерам (аксессорам) в Kotlin

Вы наткнулись на классическую ошибку новичков в Kotlin. Давайте разберём всё по порядку.

1. Почему возникает рекурсия?

В вашем коде вы используете this.age = value внутри сеттера. Но this.age = value — это вызов того же самого сеттера, а не присвоение значения полю. Получается бесконечный цикл: сеттер вызывает сам себя. Чтобы обратиться к реальному полю (backing field), нужно использовать ключевое слово field.

2. Правильное объявление свойств

В Kotlin свойства объявляются с помощью ключевых слов var (изменяемое) и val (только для чтения). Для каждого свойства автоматически создаются:

  • Геттер — метод get(), который возвращает значение.
  • Сеттер — метод set(), который присваивает значение (только для var).

Пример корректного кода:

class Person {
    var name: String = "Unknown"  // стандартный геттер и сеттер
    var age: Int = 0
        set(value) {
            if (value >= 0) {
                field = value  // Обращаемся к backing field
            } else {
                println("Возраст не может быть отрицательным")
            }
        }
    val isAdult: Boolean  // свойство только для чтения
        get() = age >= 18  // кастомный геттер (без backing field)
}

3. Что такое field (backing field)?

field — это идентификатор, который ссылается на реальное поле, хранящее значение свойства. Он доступен только внутри геттера и сеттера. Если вы не используете field ни в геттере, ни в сеттере, Kotlin не создаёт backing field (экономия памяти).

Пример без backing field:

class Rectangle {
    var width: Int = 0
    var height: Int = 0
    val area: Int
        get() = width * height  // вычисляется каждый раз, нет поля
}

4. Изменение видимости геттера и сеттера

Вы можете сделать геттер публичным, а сеттер — приватным. Это часто используется для инкапсуляции:

class BankAccount {
    var balance: Double = 0.0
        private set  // сеттер теперь приватный, доступен только внутри класса
    
    fun deposit(amount: Double) {
        if (amount > 0) {
            balance += amount  // внутри класса сеттер работает
        }
    }
}

5. Полный пример с пояснениями

class User {
    var email: String = ""
        // Кастомный геттер: всегда возвращает email в нижнем регистре
        get() = field.lowercase()
        // Кастомный сеттер: проверяет, содержит ли email '@'
        set(value) {
            if (value.contains('@')) {
                field = value
            } else {
                println("Некорректный email")
            }
        }
    
    val id: String = "user_${System.currentTimeMillis()}"  // инициализируется один раз
    // Геттер для id стандартный, сеттера нет (val)
}

6. Сравнение с Java

В Java вы бы написали:

public class User {
    private String email;
    
    public String getEmail() { return email; }
    public void setEmail(String email) { 
        if (email.contains("@")) {
            this.email = email;
        }
    }
}

В Kotlin это сокращается до:

class User {
    var email: String = ""
        set(value) {
            if (value.contains('@')) field = value
        }
}

7. Важные правила

  • Используйте field внутри сеттера/геттера для доступа к полю.
  • Не используйте this.property внутри сеттера — это вызовет рекурсию.
  • Для val можно объявить только геттер (и он может быть кастомным).
  • Если геттер просто возвращает field, его можно не писать — Kotlin сделает это автоматически.
  • Backing field создаётся только если геттер или сеттер используют field.

Теперь вы знаете всё необходимое для работы со свойствами в Kotlin. Практикуйтесь — и ошибка с рекурсией больше не появится!

<<>>

Альтернативный подход: делегированные свойства и ранняя инициализация

В дополнение к основному ответу, хочу показать ещё один способ управления геттерами и сеттерами — через делегированные свойства. Это мощный механизм, который позволяет переиспользовать логику аксессоров.

1. Использование Delegates.observable()

Если вам нужно просто отслеживать изменения свойства (например, для логирования или валидации после присвоения), используйте стандартный делегат:

import kotlin.properties.Delegates

class Person {
    var name: String by Delegates.observable("Unknown") { 
        prop, old, new -> 
            println("Имя изменено с '$old' на '$new'")
    }
    
    var age: Int by Delegates.vetoable(0) { 
        prop, old, new -> 
            new >= 0  // если false — значение не присвоится
    }
}

2. Ленивая инициализация

Для свойств, которые должны вычисляться только при первом обращении, используйте lazy:

class Database {
    val connection: Connection by lazy {
        println("Создаём подключение...")
        DriverManager.getConnection("jdbc:...")
    }
    // connection создаётся только при первом вызове
}

3. Кастомный делегат

Вы можете создать свой делегат для повторного использования логики:

class ValidatedEmail {
    private var email: String = ""
    
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return email
    }
    
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        if (value.contains('@')) {
            email = value
        } else {
            throw IllegalArgumentException("Неверный email")
        }
    }
}

class User {
    var email: String by ValidatedEmail()
}

Делегированные свойства особенно полезны, когда одна и та же логика (валидация, ленивая загрузка, уведомления) применяется к нескольким свойствам в разных классах. Они делают код чище и уменьшают дублирование.