Перейти к основному содержимому

5.09. ООП в Kotlin

Разработчику Архитектору

## Объектно-ориентированное программирование в Kotlin

Объектно-ориентированное программирование (ООП) остаётся одной из ключевых парадигм разработки программного обеспечения на протяжении уже нескольких десятилетий, и Kotlin, как современный статически типизированный язык общего назначения, реализует её принципы с рядом уточнений, направленных на повышение выразительности, безопасности и удобства использования. В отличие от чисто функциональных или гибридных языков, где ООП может быть опциональным или второстепенным, в Kotlin объектная модель образует основу языковой архитектуры: даже базовые типы данных обёрнуты в объекты, а классы выступают как единственная структурная единица для инкапсуляции состояния и поведения.

Kotlin основан на той же фундаментальной системе, что используется в Java и других JVM-языках, однако вводит ряд синтаксических и семантических улучшений, устраняющих исторические недостатки, упрощающих типовой код и повышающих надёжность. Эти улучшения не нарушают совместимости с существующей Java-экосистемой, а, напротив, позволяют постепенно модернизировать legacy-код, сохраняя полную взаимодействуемость.

Классы и объекты

В Kotlin классы объявляются с помощью ключевого слова class, после которого следует идентификатор и, необязательно, список параметров первичного конструктора. Первичный конструктор — одна из наиболее заметных особенностей Kotlin: он интегрирован непосредственно в заголовок класса и позволяет объявлять свойства (val, var) и инициализировать их без необходимости писать отдельный конструкторный блок, как это делается в Java.

Пример:

class Person(val name: String, var age: Int) {
fun greet() {
println("Hello, my name is $name")
}
}

В этом объявлении val name и var age — полноценные свойства экземпляра: name неизменяемо (аналог final в Java), age — изменяемо. Значения передаются при создании объекта:

val person = Person("Alice", 30)
person.greet() // Вывод: Hello, my name is Alice

Синтаксис Person("Alice", 30) инициирует вызов первичного конструктора и создание нового экземпляра. В отличие от Java, где каждая строка вида new X() сопровождается явным указанием оператора new, Kotlin использует более лаконичную запись, при этом сохраняя строгую типизацию: компилятор проверяет соответствие типов аргументов, а также разрешает перегрузку конструкторов через вторичные конструкторы или factory-функции.

Каждый класс в Kotlin по умолчанию закрыт для наследования — это принципиальное изменение по сравнению с Java, где классы открыты по умолчанию. Такое решение мотивировано практиками проектирования: большинство классов не предназначено для расширения, и их непреднамеренное наследование может привести к нарушению инвариантов и трудноуловимым ошибкам. Чтобы разрешить наследование, класс должен быть явно объявлен как open:

open class Animal(val name: String)

class Cat(name: String) : Animal(name)

Здесь Cat наследует от Animal, передавая имя через конструктор базового класса. Обратите внимание: синтаксис : заменяет Java-овый extends, а вызов конструктора родителя включён прямо в объявление наследования. Если базовый класс содержит первичный конструктор, его аргументы должны быть переданы непосредственно в месте объявления класса-наследника — это исключает ошибки, связанные с отложенной инициализацией или пропущенными вызовами super().

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

Инкапсуляция и управление видимостью

Инкапсуляция — механизм, обеспечивающий сокрытие внутреннего состояния объекта и предоставление доступа к нему только через строго определённые методы. В Kotlin эта концепция реализована через систему модификаторов видимости и свойств с геттерами и сеттерами.

Модификаторы доступа в Kotlin:

  • public — по умолчанию, доступно из любого места;
  • private — доступно только внутри объявляющего класса или файла (для top-level элементов);
  • protected — доступно внутри объявляющего класса и его подклассов;
  • internal — доступно в пределах модуля компиляции (обычно — одного Gradle/Maven-проекта).

Пример с private:

class User(private val login: String) {
private var _passwordHash: String? = null

fun setPassword(password: String) {
_passwordHash = hash(password)
}

private fun hash(s: String): String = /* ... */
}

Здесь login неизменяем и недоступен извне, _passwordHash — внутреннее представление, изменяемое только через метод setPassword. Отсутствие публичного сеттера гарантирует, что пароль всегда проходит хеширование и не может быть установлен напрямую.

Важной особенностью Kotlin является то, что свойства (val/var) не обязаны соответствовать физическим полям. Компилятор автоматически генерирует байт-код с приватными полями и публичными методами-аксессорами (getLogin(), getPasswordHash() и т.д.), совместимыми с JavaBeans-спецификацией. При этом разработчик может переопределить поведение геттера или сеттера без изменения сигнатуры:

class Counter {
var count: Int = 0
private set // сеттер приватный, геттер — по умолчанию public

fun increment() { count++ }
}

Такой подход позволяет сохранить полную совместимость с Java-библиотеками и фреймворками, ожидая наличие геттеров и сеттеров, при этом оставаясь выразительным и безопасным на уровне исходного кода.

Полиморфизм и динамическое связывание

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

open class Shape {
open fun draw() {
println("Generic shape")
}
}

class Circle : Shape() {
override fun draw() {
println("Drawing a circle")
}
}

Вызов draw() на ссылке типа Shape, указывающей на объект Circle, приведёт к выполнению переопределённой версии — так работает динамическое связывание (late binding). Эта модель полностью совместима с JVM-механизмом виртуальных вызовов и позволяет строить гибкие иерархии.

Kotlin также поддерживает абстрактные классы и методы (abstract class, abstract fun), а также интерфейсы с реализацией по умолчанию, что расширяет возможности композиции. Например:

interface Drawable {
fun draw()
fun render() {
println("Default rendering logic")
draw()
}
}

class Rectangle : Drawable {
override fun draw() {
println("Drawing rectangle")
}
}

Метод render() может быть унаследован без изменения, а draw() — обязан быть реализован. Такой подход уменьшает дублирование и позволяет вводить новые методы в интерфейсы без разрушения совместимости.

Data-классы

Особое внимание в Kotlin уделено классам, основная цель которых — хранение данных. В таких случаях разработчик обычно ожидает наличие стандартных методов: сравнения (equals()), хеширования (hashCode()), строкового представления (toString()), копирования (copy()). В Java их приходится писать вручную (или генерировать IDE), что порождает шаблонный, многострочный код, подверженный ошибкам.

Kotlin решает эту проблему через ключевое слово data:

data class User(val id: Int, val name: String)

Для такого класса компилятор автоматически генерирует:

  • equals(other: Any?) и hashCode(), основанные на всех свойствах, объявленных в первичном конструкторе;
  • toString(), возвращающий читаемое представление вида User(id=1, name=Alice);
  • copy(...), позволяющий создать копию объекта с изменением отдельных полей, например: user.copy(name = "Bob").

Важно: генерируемые методы учитывают только свойства из первичного конструктора. Поля, объявленные внутри тела класса, в семантику equals и hashCode не входят — это осознанное ограничение, обеспечивающее предсказуемость и соответствие интуитивным ожиданиям. Data-классы также деструктурируются в выражениях вида val (id, name) = user, что упрощает работу с кортежами и возвратом нескольких значений из функций.

Data-классы не обязаны быть immutable, но практика показывает, что их чаще всего объявляют с val, что способствует написанию более надёжного, потокобезопасного кода.

Статические члены и компаньонные объекты

Kotlin не имеет понятия «статических методов» или «статических полей» в том виде, как они существуют в Java. Вместо этого вводится концепция компаньонных объектов — объектов, принадлежащих классу и инициализируемых вместе с ним:

class Database {
companion object {
const val VERSION = "2.1"
fun connect(url: String): Connection { ... }
}
}

// Использование:
val version = Database.VERSION
val conn = Database.connect("jdbc:...")

companion object — это полноценный singleton-объект, вложенный в класс. Он может реализовывать интерфейсы, наследоваться от других классов, содержать свойства и методы. При компиляции в байт-код JVM такие члены преобразуются в статические поля и методы, что обеспечивает полную совместимость с Java.

Для констант, известных на этапе компиляции, предпочтительно использовать const val внутри компаньонного объекта — это позволяет компилятору встраивать их значение напрямую в клиентский код (как static final в Java), повышая производительность.

Функции высшего порядка, лямбды и замыкания

Хотя Kotlin — не функциональный язык в строгом смысле, он предоставляет мощные средства для функционального стиля программирования, включая функции высшего порядка, лямбда-выражения и замыкания. Это не противоречит ООП — наоборот, расширяет его: функции становятся полноценными объектами первого класса, которые можно передавать, хранить и возвращать.

Лямбда-выражение — это анонимная функция, заключённая в фигурные скобки:

val sum: (Int, Int) -> Int = { a, b -> a + b }

Здесь (Int, Int) -> Int — тип функции: два целочисленных аргумента, возвращающая Int. Выражение { a, b -> a + b } создаёт экземпляр Function2<Int, Int, Int>, который совместим с Java-интерфейсом java.util.function.BiFunction.

Функция высшего порядка принимает другую функцию в качестве параметра:

fun process(n: Int, block: (Int) -> Unit) {
block(n)
}

process(5) { println(it) } // "it" — неявный параметр по умолчанию

Вызов process(5) { ... } использует синтаксический сахар — если последний аргумент является лямбдой, её можно вынести за скобки. Если лямбда — единственный аргумент, скобки вообще опускаются. Это делает код похожим на встроенные управляющие конструкции, несмотря на то, что process — обычная функция.

Kotlin поддерживает замыкания: лямбда захватывает переменные из окружающей области видимости, и они остаются доступными даже после выхода из функции, в которой были объявлены. При этом захваченные val-переменные используются по значению, а var — по ссылке (через обёртку Ref<T>), что позволяет им сохранять изменённое состояние между вызовами.

Такой подход позволяет элегантно реализовывать стратегии, обработчики событий, асинхронные цепочки и другие паттерны, где поведение параметризуется кодом, а не только данными.

Расширения

Одной из наиболее выразительных возможностей Kotlin является механизм расширений (extensions) — способ добавления новых функций и свойств к существующим классам без изменения их исходного кода и без наследования. Это позволяет обогащать сторонние или стандартные типы поведением, специфичным для конкретного контекста, сохраняя при этом чистоту архитектуры и избегая «разбухания» базовых классов.

Синтаксис расширения прост: имя получателя указывается перед именем функции как префикс:

fun String.addExclamation(): String {
return this + "!"
}

// Использование:
println("Hello".addExclamation()) // Hello!

Здесь String.addExclamation() — это расширяющая функция, доступная для любого экземпляра String. Внутри тела функции this ссылается на получатель (receiver), то есть на строку, для которой вызван метод.

Важно понимать: расширения не модифицируют исходный класс. Они компилируются в статические вспомогательные методы, принимающие получатель в качестве первого параметра. На уровне JVM код str.addExclamation() превращается в вызов ExtensionsKt.addExclamation(str). Это означает:

  • расширения не могут получить доступ к private или protected членам класса;
  • они не участвуют в полиморфизме — выбор конкретной реализации происходит статически, на этапе компиляции, а не динамически, как при переопределении методов;
  • если в классе уже существует метод с такой же сигнатурой, он имеет приоритет над расширением.

Аналогично можно объявлять расширяющие свойства:

val String.isPalindrome: Boolean
get() = this.lowercase() == this.lowercase().reversed()

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

Расширения особенно эффективны в связке с обобщёнными типами, функциями высшего порядка и специализированными DSL. Например, библиотека kotlinx.html строит HTML-разметку с помощью расширений, превращая вызовы в декларативные блоки вида div { h1 { +"Title" } }. Это не синтаксический сахар компилятора — это полноценный механизм, реализованный средствами языка.

Защита от ошибок, связанных с отсутствием значения

Одной из наиболее значимых и практически полезных особенностей Kotlin является встроенная система типов с явной поддержкой отсутствия значения. Понятие null не устранено (так как необходимо для совместимости с JVM и внешними API), но его использование жёстко регламентировано: тип, допускающий значение null, должен быть объявлен явно с помощью суффикса ?.

Пример:

var name: String = "Alice"     // не может быть null
var nullableName: String? = null // может быть null

Попытка присвоить null переменной типа String приведёт к ошибке компиляции. Аналогично, вызов метода напрямую на nullableName (например, nullableName.length) запрещён — компилятор потребует обработки возможного отсутствия значения.

Для безопасной работы с nullable-типами Kotlin предоставляет несколько механизмов:

  • Проверка через if:

    if (nullableName != null) {
    println(nullableName.length) // внутри ветки nullableName имеет тип String
    }
  • Оператор безопасного вызова ?.:

    val length = nullableName?.length  // тип Int?
  • Оператор Элвиса ?: для задания значения по умолчанию:

    val name = nullableName ?: "Anonymous"
  • Оператор утверждения !! — явное подавление проверки (используется редко, только при уверенности в ненулевом значении):

    val length = nullableName!!.length  // бросит NPE, если null

Система работает на уровне типов и анализируется статически. Это позволяет полностью исключить NullPointerException в коде, написанном на Kotlin, при условии корректного объявления типов. Исключения возможны только при взаимодействии с Java-кодом, где аннотации @Nullable и @NotNull помогают компилятору Kotlin восстановить информацию о nullability.

Такой подход кардинально повышает надёжность: ошибка проектирования, допускающая неконтролируемое распространение null-значений, выявляется на этапе компиляции, а не во время выполнения.

Корутины

Асинхронное и параллельное программирование — одна из самых сложных тем в разработке. Традиционные подходы (коллбэки, Future, ExecutorService) порождают сложный, трудночитаемый и подверженный ошибкам код. Kotlin предлагает единый, лёгкий и выразительный механизм — корутины (coroutines).

Корутина — это лёгковесная единица выполнения, управляемая пользовательским кодом, а не планировщиком ОС. В отличие от потоков, корутины не привязаны к конкретному системному потоку: они могут приостанавливаться (suspend), освобождая поток для выполнения другой работы, и возобновляться позже — возможно, уже в другом потоке. При этом синтаксис остаётся последовательным, без вложенности коллбэков.

Основные компоненты:

  • ключевое слово suspend, помечающее функцию, которая может приостанавливаться;
  • встроенные функции launch, async, runBlocking для запуска корутин;
  • диспетчеры (Dispatchers.IO, Dispatchers.Default, Dispatchers.Main) для управления контекстом выполнения.

Пример:

import kotlinx.coroutines.*

fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}

Здесь runBlocking создаёт корутинную область и блокирует текущий поток до завершения всех дочерних корутин. launch запускает новую корутину, которая выполняется параллельно. Вызов delay(1000L) — это приостановка, а не блокировка потока: в течение этой секунды поток может выполнять другие задачи.

Функция delay объявлена как suspend, что означает: её можно вызывать только из других suspend-функций или корутинных блоков. Это обеспечивает статическую проверку корректности асинхронного кода: невозможно случайно вызвать приостанавливающую операцию в синхронном контексте.

Корутины не являются частью языка в строгом смысле — они реализованы в библиотеке kotlinx.coroutines, но настолько тесно интегрированы с языком (через suspend), что воспринимаются как встроенные. Механизм поддерживает структурированную конкурентность: корутины образуют иерархию, и отмена родительской корутины автоматически отменяет всех потомков, предотвращая утечки ресурсов.

Аннотации

Kotlin полностью поддерживает аннотации — метаданные, добавляемые к объявлениям классов, функций, свойств, параметров и других элементов. Аннотации не влияют на выполнение программы напрямую, но используются компилятором, фреймворками и инструментами для генерации кода, проверок, сериализации и других задач.

Синтаксис аналогичен Java:

@Deprecated("Use newFunction() instead", ReplaceWith("newFunction()"))
fun oldFunction() { ... }

@JvmStatic
fun utility() { ... }

Некоторые аннотации специфичны для Kotlin и влияют на генерацию JVM-байт-кода:

  • @JvmStatic — размещает метод в классе как static, а не в компаньонном объекте;
  • @JvmOverloads — генерирует перегруженные Java-методы для параметров по умолчанию;
  • @JvmField — преобразует свойство в публичное поле без геттера и сеттера;
  • @JvmName — задаёт альтернативное имя для Java-вызова.

Аннотации могут иметь параметры, включая классы, массивы и лямбды. Они поддерживают удержание (Retention), цель (Target) и наследование, как и в Java. Благодаря полной совместимости, Kotlin-код может использовать аннотации из Spring, JPA, JUnit и других Java-фреймворков без ограничений.

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

Kotlin позволяет переопределять поведение арифметических, логических и других операторов для пользовательских типов — но только для заранее определённого набора. Это строго регламентированная функциональность: каждый оператор связан с конкретным именем функции (например, + — с plus, == — с equals, [i] — с get(i)), и компилятор заменяет операторный вызов на вызов соответствующего метода.

Пример:

data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
}

val p1 = Point(1, 2)
val p2 = Point(3, 4)
val p3 = p1 + p2 // эквивалентно p1.plus(p2)

Ключевое слово operator обязательно — оно сигнализирует, что метод предназначен для поддержки синтаксиса операторов. Без него запись p1 + p2 не компилируется.

Поддерживаемые операторы включают:

  • арифметические: +, -, *, /, %, ++, --;
  • операторы присваивания: +=, -= и т.д.;
  • сравнения: ==, !=, <, <=, >, >= (через equals, compareTo);
  • индексные операции: a[i], a[i] = v;
  • вызов как функции: a();
  • диапазоны: .., until.

Важно: семантика операторов должна соответствовать ожиданиям. Например, == всегда вызывает equals, и его нельзя переопределить иначе — это гарантирует, что равенство остаётся рефлексивным, симметричным и транзитивным. Перегрузка == вручную невозможна: вместо этого переопределяется equals.

Такой подход обеспечивает выразительность без потери предсказуемости: операторы остаются просто синтаксическим сахаром над именованными методами, и их поведение можно всегда проследить в исходном коде.

Взаимодействие с Java

Kotlin разрабатывался с приоритетом на полный bidirectional interoperability с Java. Это не просто возможность вызывать Java из Kotlin — это гарантия того, что:

  • любой Kotlin-класс может быть использован из Java без обёрток;
  • любой Java-класс доступен в Kotlin с улучшенным синтаксисом;
  • артефакты (JAR-файлы) Kotlin и Java могут свободно смешиваться в одном проекте.

Компилятор Kotlin генерирует стандартный JVM-байт-код, совместимый с Java 6 и выше. Все свойства преобразуются в private-поля с публичными getter/setter-методами, соответствующими соглашениям JavaBeans. Data-классы генерируют equals, hashCode, toString в том же формате, что и IDE-генераторы в Java. Компаньонные объекты становятся вложенными статическими классами с именем Companion, а их члены — статическими методами и полями.

Обратная совместимость также обеспечена: из Java можно вызывать Kotlin-код, используя сгенерированные методы напрямую:

// Java
User user = new User(1, "Alice");
String s = user.toString(); // вызывает сгенерированный toString()
User copy = user.copy(2, "Bob"); // метод copy доступен как copy(int, String)

Для улучшения взаимодействия Kotlin предоставляет специальные аннотации (@JvmName, @JvmStatic, @JvmOverloads), а также распознаёт Java-аннотации @Nullable и @NotNull (из javax.annotation, androidx.annotation, org.jetbrains.annotations), чтобы корректно обрабатывать nullability.

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


Делегирование

Kotlin реализует фундаментальный принцип объектно-ориентированного проектирования — предпочитать композицию наследованию — не только как рекомендацию, но как встроенную языковую конструкцию: ключевое слово by позволяет реализовать интерфейс через делегирование за одну строку.

Идея проста: если класс декларирует реализацию интерфейса, но не содержит собственной логики для его методов, он может передать эту ответственность другому объекту — делегату. При этом компилятор автоматически создаёт реализацию всех методов интерфейса, перенаправляя вызовы делегату.

Пример:

interface Printer {
fun print(message: String)
}

class ConsolePrinter : Printer {
override fun print(message: String) = println(message)
}

class LoggingPrinter(printer: Printer) : Printer by printer {
// Весь интерфейс Printer реализован через printer
}

Здесь LoggingPrinter реализует Printer, но не содержит ни одного метода. Компилятор генерирует:

// Эквивалент на Java:
public final class LoggingPrinter implements Printer {
private final Printer printer;

public LoggingPrinter(Printer printer) {
this.printer = printer;
}

public void print(String message) {
this.printer.print(message);
}
}

Такой подход исключает дублирование кода, упрощает сопровождение и повышает гибкость: логика может быть заменена в runtime, если делегат объявлен как var (хотя по умолчанию — val).

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

Ключевое слово by также применяется к свойствам через механизм делегированных свойств (delegated properties). Он позволяет вынести логику хранения и доступа к значению в отдельный объект-делегат, реализующий стандартный интерфейс ReadWriteProperty или ReadOnlyProperty.

Стандартная библиотека Kotlin предоставляет несколько встроенных делегатов:

  • lazy — отложенная инициализация (однократная, потокобезопасная по умолчанию):

    val config by lazy { loadConfig() }
  • Delegates.observable — наблюдение за изменениями:

    var name: String by Delegates.observable("Anonymous") { _, old, new ->
    println("Name changed from $old to $new")
    }
  • Delegates.vetoable — возможность отклонить изменение на основе условия.

Делегированные свойства особенно эффективны в связке с фреймворками: например, в Android by viewBinding() или в Ktor by inject(). При этом семантика остаётся прозрачной: x = 5 и x внутри тела функции работают так же, как с обычным свойством, несмотря на то, что доступ контролируется внешним кодом.

Делегаты не нарушают инкапсуляцию — они не получают прямого доступа к внутренним данным класса, а взаимодействуют только через контракт интерфейса.

Sealed-классы

Одной из центральных проблем при работе с иерархиями типов является необходимость обработки всех возможных подтипов. В традиционных ООП-языках instanceof-проверки или switch по типу не являются исчерпывающими: добавление нового подкласса не вызывает ошибок компиляции, что приводит к неполному покрытию логики и runtime-сбоям.

Kotlin решает эту проблему через sealed-классы (запечатанные классы) — специальный вид абстрактных классов, для которых множество прямых подклассов фиксировано на этапе компиляции и должно быть объявлено в том же файле.

Пример:

sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}

Здесь Result не может иметь подклассов вне этого файла. Это позволяет компилятору проверять полноту при сопоставлении с образцом (when):

fun handle(result: Result) = when (result) {
is Result.Success -> println("Data: ${result.data}")
is Result.Error -> println("Error: ${result.message}")
Result.Loading -> println("Loading...")
// Если добавить новый подкласс без добавления ветки — ошибка компиляции
}

Ключевые особенности:

  • sealed-классы неявно open, но могут наследоваться только локально;
  • подклассы могут быть как классами, так и объектами (singleton’ами);
  • ветви when не требуют else, если перечислены все подтипы;
  • when с sealed-типом возвращает значение, что делает его полноценным выражением.

Sealed-классы особенно ценны при моделировании состояний (например, в архитектурах MVI), сетевых ответов, парсеров и конечных автоматов — везде, где требуется гарантия исчерпывающей обработки вариантов.

Внутренние и вложенные классы

Kotlin различает два вида классов, объявленных внутри другого класса:

  • Вложенные классы (nested class) — по умолчанию. Они не имеют доступа к экземпляру внешнего класса и ведут себя как статические вложенные классы в Java.

    class Outer {
    private val x = 10
    class Nested {
    fun foo() = "Independent" // нет доступа к x
    }
    }
  • Внутренние классы (inner class) — явно помеченные ключевым словом inner. Они содержат неявную ссылку на экземпляр внешнего класса и могут обращаться к его членам.

    class Outer {
    private val x = 10
    inner class Inner {
    fun foo() = "x = $x" // доступ к x возможен
    }
    }

    val outer = Outer()
    val inner = outer.Inner() // создание требует экземпляра Outer

На уровне JVM inner class компилируется в отдельный класс с синтетическим полем this$0, ссылающимся на внешний объект — точно так же, как и в Java.

Выбор между nested и inner — архитектурное решение:

  • nested используется, когда вложенная сущность логически принадлежит пространству имён внешнего класса, но не зависит от его состояния (например, вспомогательные builder’ы, DTO, специализированные исключения);
  • inner — когда требуется доступ к закрытым или защищённым членам внешнего класса, и экземпляр внутреннего класса семантически не существует без внешнего (например, итераторы, обработчики событий, делегаты представления).

Kotlin не позволяет объявлять внутренние классы внутри интерфейсов или объектов — только внутри классов, что исключает неоднозначность.

Объектные выражения и компаньоны как реализация паттернов

Помимо companion object, Kotlin поддерживает объектные выражения — анонимные реализации классов или интерфейсов, похожие на Java-анонимные классы, но с расширенными возможностями.

Синтаксис:

val comparator = object : Comparator<String> {
override fun compare(a: String, b: String): Int {
return a.length - b.length
}
}

Объектное выражение создаёт новый анонимный класс и единственный экземпляр этого класса (singleton в локальной области). В отличие от Java, такие выражения могут:

  • наследовать от класса и реализовывать несколько интерфейсов одновременно:

    object : BaseClass(), InterfaceA, InterfaceB { ... }
  • содержать собственные свойства и методы, недоступные извне, но используемые внутри:

    val handler = object {
    private var count = 0
    fun onClick() {
    count++
    println("Clicked $count times")
    }
    }
    handler.onClick() // допустимо; handler имеет тип этого анонимного объекта

Эта возможность позволяет реализовывать паттерн одиночка (Singleton) без boilerplate-кода private constructor + getInstance(): просто объявляется object, и его экземпляр создаётся lazily и потокобезопасно при первом доступе.

Пример использования в качестве фабрики:

interface ConnectionFactory {
fun create(): Connection
}

object DatabaseConnectionFactory : ConnectionFactory {
override fun create(): Connection {
return DriverManager.getConnection(url, user, pass)
}
}

Здесь DatabaseConnectionFactory — глобально доступный, лениво инициализированный singleton, реализующий интерфейс. Никакого дополнительного кода для управления жизненным циклом не требуется.