Введение: почему классы — половина собеседования по Kotlin

«Расскажите про классы в Kotlin» — универсальный вопрос, который раскрывает кандидата быстрее любого другого: конструкторы, свойства, наследование, data class, sealed class — и всё это укладывается в один ответ.

Классы — это то, как вы моделируете предметную область: ViewModel — класс, Repository — класс, экранные состояния — data class или sealed class. На собеседовании тему проверяют с двух сторон: синтаксис (что и как объявляется) и дизайн (почему именно такая конструкция). Разберём оба уровня. Все факты — из официальной документации Kotlin.

Конструкторы и init-блоки

Класс объявляется ключевым словом class. Primary constructor (основной конструктор) пишется прямо в заголовке класса. Если параметр помечен val или var, он автоматически становится свойством класса:

UserProfile.kt
class UserProfile(
    val name: String,
    var age: Int,
) {
    init {
        require(age >= 0) { "age must be non-negative" }
    }
}

fun main() {
    val profile = UserProfile("Анна", 29)
    println(profile.name) // Анна
}

Моменты, которые нужно знать:

  1. Экземпляр создаётся без new — просто UserProfile("Анна", 29), как вызов функции.
  2. init-блоки выполняются при создании экземпляра в порядке объявления — вместе с инициализаторами свойств. Это стандартное место для валидации через require.
  3. Secondary constructor (дополнительный конструктор) объявляется внутри тела класса и обязан делегировать основному через this — вся логика primary выполнится первой:
Person.kt
class Person(val name: String, var age: Int) {
    constructor(name: String, ageText: String) : this(name, ageText.toIntOrNull() ?: 0) {
        println("Создан $name с возрастом $age")
    }
}
  1. Конструктор без конструктора. Если класс не объявляет конструкторов, у него есть неявный public-конструктор без параметров. А на JVM, если все параметры primary имеют значения по умолчанию, компилятор дополнительно генерирует конструктор без параметров — это важно для библиотек вроде Jackson, которые создают объекты через пустой конструктор.

Что спросят

Primary constructor — в заголовке класса, параметры с val/var становятся свойствами. Secondary — в теле, делегирует primary через this. init-блоки выполняются в порядке объявления.

Свойства и видимость

Свойства — это поля с логикой доступа. В Kotlin у свойства есть автоматический геттер (и сеттер для var), который можно переопределить:

Counter.kt
class Counter {
    private var count = 0

    val current: Int
        get() = count

    fun increment() {
        count++
    }
}

fun main() {
    val counter = Counter()
    counter.increment()
    println(counter.current) // 1
}

Модификаторы видимости — четыре штуки: private, protected, internal и public. По умолчанию — public. По документации:

  • private — виден только внутри класса (для членов) или внутри файла (для top-level объявлений).
  • protected — как private, плюс виден в подклассах.
  • internal — виден в пределах модуля. Это аналог «public, но только для моего модуля»: идеально для библиотек и многомодульных проектов.
  • public — виден везде.

Ошибка-классика: кандидат говорит, что protected в Kotlin — «как в Java». Не совсем: в Kotlin protected не виден из того же пакета — только в классе и его подклассах. А internal в Java вообще нет.

Наследование и абстракция

Ключевой факт: в Kotlin классы закрыты для наследования по умолчанию. Чтобы класс можно было наследовать, его нужно пометить open. Для переопределения методов — override:

Animals.kt
open class Animal(val name: String) {
    open fun speak(): String = "..."

    fun eat() = println("$name ест")
}

class Dog(name: String) : Animal(name) {
    override fun speak(): String = "Woof!"
}

fun main() {
    val dog = Dog("Барсик")
    println(dog.speak()) // Woof!
    dog.eat()            // Барсик ест
}

Если метод не помечен open, переопределить его нельзя; если переопределение не помечено override — ошибка компиляции. Это жёстче, чем в Java, и это осознанный дизайн: наследование — дорогая операция, её нужно включать явно.

Абстрактные классы — наоборот, открыты по умолчанию: abstract-члены не имеют реализации, их обязаны реализовать подклассы, а сам абстрактный класс инстанцировать нельзя:

Shapes.kt
abstract class Shape {
    abstract fun area(): Double
}

class Circle(val radius: Double) : Shape() {
    override fun area(): Double = Math.PI * radius * radius
}

fun main() {
    val circle = Circle(2.0)
    println(circle.area()) // 12.566370614359172
}

По документации о наследовании, все классы в Kotlin неявно наследуются от Any — аналога java.lang.Object, у которого есть equals(), hashCode() и toString().

Сравнение с Java, которое любят на собеседованиях: в Java классы открыты по умолчанию (effective final — ручная дисциплина), в Kotlin закрыты и открываются явным open. В Kotlin переопределить можно только open-члены и только с override — компилятор не даст «случайного» полиморфизма.

Data class и sealed class

Data class — класс для хранения данных: компилятор автоматически генерирует equals(), hashCode(), toString(), copy() и componentN() из свойств primary-конструктора. По документации, требования: минимум один параметр в primary, все параметры — val или var, и класс не может быть abstract, open, sealed или inner.

User.kt
data class User(val name: String, val age: Int)

fun main() {
    val user = User("Анна", 29)
    val older = user.copy(age = 30) // копия с одним изменённым полем

    println(user)          // User(name=Анна, age=29)
    println(user == older) // false

    val (name, age) = older // деструктуризация через componentN()
    println("$name, $age")  // Анна, 30
}

copy() делает поверхностную копию: ссылки на вложенные объекты не дублируются. А свойства, объявленные в теле класса (не в primary), в equals/toString/copy не участвуют.

Sealed class — ограниченная иерархия: все прямые подклассы известны компилятору, поэтому when по sealed-классу не требует else. Классический сценарий — состояния экрана:

UiState.kt
sealed class UiState {
    data object Loading : UiState()
    data class Success(val items: List<String>) : UiState()
    data class Error(val message: String) : UiState()
}

fun render(state: UiState) {
    when (state) {
        is UiState.Loading -> println("Загрузка...")
        is UiState.Success -> println("Элементов: ${state.items.size}")
        is UiState.Error -> println("Ошибка: ${state.message}")
    }
}

В примере Loading объявлен как data object — синглтон без данных; Success и Error — data class, потому что несут данные. Эта связка «sealed + data + object» — стандарт в реальных проектах, и интервьюеры это очень любят.

Где кандидаты ошибаются

Соберу типичные промахи, которые я вижу на собеседованиях.

  • Классы и члены по умолчанию final: наследование и переопределение — только через open + override.
  • Primary-конструктор в заголовке, val/var создают свойства; параметры без модификатора — просто параметры.
  • Secondary-конструктор обязан делегировать primary через this.
  • Видимость: public по умолчанию, protected — класс и подклассы (без «того же пакета»), internal — модуль.
  • Data class: только свойства primary участвуют в equals/hashCode/toString/copy; copy() — поверхностная копия.
  • Sealed-класс: подклассы известны компилятору, when без else.

Ошибка-классика №1: кандидат пишет class Dog : Animal() без open у Animal и удивляется ошибке компиляции. Ошибка-классика №2: data class, у которого «странно работает equals» — потому что изменяемое поле объявлено в теле класса и не участвует в сравнении. Обе ловятся за минуту, если правила держать в голове.

Когда я провожу собеседования, тема классов идёт сразу после базового Kotlin. По ответу видно, как человек мыслит: называет только синтаксис или объясняет, зачем нужен каждый механизм — sealed вместо enum для состояний, data для моделей, internal для границ модуля. В менторстве я вижу ту же картину: разработчики с опытом 1–2 года обычно знают синтаксис, но плавают в дизайне — а именно на дизайне и проверяют middle-уровень.

Плаваете в дизайне, хотя синтаксис знаете?

Бесплатный разбор: пришлите код или резюме — посмотрю и скажу, что мешает перейти на следующий грейд.

Итоги

Классы в Kotlin — это не «ещё один способ написать класс», а другой дизайн объектной модели.

  • Конструкторы: primary в заголовке со свойствами, secondary с делегацией, init-блоки в порядке объявления.
  • Наследование: закрыто по умолчанию, open + override явно; абстрактные классы открыты.
  • Специальные классы: data для моделей данных, sealed для ограниченных иерархий, internal для границ модуля.

Главное

В Kotlin классы закрыты по умолчанию, а специальные формы — data class и sealed class — закрывают типовые задачи: модели данных и ограниченные состояния. Уверенно отвечаете про конструкторы, open/override и data class — тема закрыта.

Если чувствуете, что знания Kotlin «на уровне синтаксиса», а вопросы про дизайн выбивают из колеи, — не нужно готовиться в одиночку. Напишите в Telegram, разберём вашу ситуацию и составим план, что закрыть в первую очередь.