ООП на Kotlin: классы, наследование, интерфейсы
Зачем это нужно
В фундаменте была глава про ООП вообще — что такое класс, объект, инкапсуляция, наследование, полиморфизм. Здесь мы не повторяем теорию, а смотрим, как всё это выглядит именно в Kotlin и чем отличается от Java, откуда ООП в Android и пришёл.
Разница огромная и заметна сразу. Класс, на который в Java уходит тридцать строк с конструктором, геттерами, equals, hashCode и toString, в Kotlin занимает одну строку. Не потому, что Kotlin что-то прячет, — он просто дописывает очевидное сам, а программисту оставляет то, что нужно решить головой.
Кроме короткой записи в Kotlin есть три вещи, которых в Java долго не было и которые меняют стиль кода полностью: классы закрыты по умолчанию, data class для данных и sealed class для закрытых наборов вариантов. Последний работает в паре с when из главы про условия и даёт то, чего в Java нет до сих пор: компилятор сам находит место, которое сломается после добавления нового варианта.
Всё, что напечатано ниже как вывод программы или как сообщение компилятора, снято с настоящего запуска Kotlin 2.4.10.
- Зачем это нужно
- Класс в одну строку
- Свойства:
val,varи вычисляемое значение - Модификаторы доступа
init, второй конструктор иlateinit- Наследование: почему всё закрыто по умолчанию
- Абстрактные классы: чертёж без реализации
- Интерфейсы: договор о поведении
data class: класс для данныхenum: фиксированный список вариантовsealed class: закрытая иерархияobjectиcompanion object- Функции-расширения: чужой класс с новым методом
- Ошибки этой главы и что они значат
- Тренажёр: объявления этой главы
- Символы и слова этой главы
- Если рядом учат TypeScript
- Что должен уметь
- Куда дальше: проверенные ресурсы
Класс в одну строку
Начнём с самого маленького класса, который вообще можно написать:
class Empty
fun main() {
val e = Empty()
println(e)
}
Что выведет: что-то вроде Empty@2c7b84de. Это стандартное представление объекта: имя класса, собака и адрес в памяти в шестнадцатеричном виде. Число у тебя будет другим — оно зависит от запуска.
Обрати внимание на две вещи. Во-первых, у класса нет фигурных скобок: если тело пустое, скобки не пишут. Во-вторых, объект создаётся как Empty() — без слова new. В Java было бы new Empty(), в Kotlin слово new из языка убрали: круглые скобки после имени класса и так означают создание.
Теперь настоящий класс:
class Wallet(val owner: String, var balance: Int = 0) {
fun deposit(amount: Int) {
balance += amount
}
}
fun main() {
val w = Wallet("Соня")
w.deposit(1500)
println(w.owner)
println(w.balance)
}
Что выведет: Соня и 1500.
Та же самая пара строк на Java выглядела бы так — это не преувеличение, а буквальный перевод:
public class Wallet {
private final String owner;
private int balance;
public Wallet(String owner, int balance) {
this.owner = owner;
this.balance = balance;
}
public Wallet(String owner) {
this(owner, 0);
}
public String getOwner() { return owner; }
public int getBalance() { return balance; }
public void setBalance(int balance) { this.balance = balance; }
public void deposit(int amount) { this.balance += amount; }
}
Двадцать строк против четырёх. И в этих двадцати нет ни одного решения — только механическая работа, которую редактор генерирует по кнопке. Kotlin убрал сам жанр такого кода.
Геттеры и сеттеры в Kotlin никуда не делись — их пишет компилятор. w.balance = 100 в готовом байт-коде превращается в вызов setBalance(100), а w.balance — в getBalance(). Именно поэтому Kotlin-класс без всяких переделок виден из Java-кода как обычный Java-класс с геттерами: на этом и держится совместимость двух языков внутри одного проекта.
Свойства: val, var и вычисляемое значение
Свойство — это то, что объект знает о себе. Метод — то, что он умеет.
Свойства объявляют либо в заголовке класса, либо в теле:
class Wallet(val owner: String) {
var balance: Int = 0
val bank: String = "ТОН-банк"
val isRich: Boolean
get() = balance > 1000
}
fun main() {
val w = Wallet("Соня")
println(w.isRich)
w.balance = 1500
println(w.isRich)
}
Что выведет: false, потом true.
isRich — вычисляемое свойство: у него нет собственного места в памяти, оно каждый раз считается заново из balance. Пишется через get(), читается снаружи как обычное поле: w.isRich, без скобок.
Правило выбора простое: если значение можно вывести из других свойств — делай его вычисляемым. Тогда его невозможно рассинхронизировать с реальностью. Хранить отдельно balance и отдельно isRich, а потом не забывать обновлять второе при изменении первого — это заготовка для ошибки.
Порядок объявления свойств имеет значение. Свойство нельзя использовать раньше, чем оно объявлено:
class Wallet(val owner: String) {
val greeting = "привет, $owner, баланс $balance"
val balance = 0
}
Variable 'balance' must be initialized.
Свойства инициализируются сверху вниз, и на момент вычисления greeting поля balance ещё не существует. Достаточно поменять две строки местами.
Есть и сеттер — например, чтобы читать свойство можно было всем, а менять только изнутри класса:
class Counter {
var count = 0
private set
fun tick() {
count++
}
}
fun main() {
val c = Counter()
c.tick()
c.tick()
println(c.count)
}
Что выведет: 2.
Попытка написать c.count = 10 снаружи класса не скомпилируется:
Cannot access 'count': it is private in 'Counter'.
Это и есть инкапсуляция — не пустой термин из учебника, а конкретный компилятор, который не пускает чужой код менять внутреннее состояние мимо метода tick().
Модификаторы доступа
Четыре слова управляют тем, кто видит член класса:
| Модификатор | Кто видит | Когда нужен |
|---|---|---|
public | все | по умолчанию, писать не надо |
private | только сам класс (или сам файл) | всё внутреннее устройство |
protected | класс и его наследники | редко, в основе иерархии |
internal | весь модуль сборки | API библиотеки внутри проекта |
Главное отличие от Java: по умолчанию в Kotlin public, а в Java — «видно всему пакету». Второе отличие: internal в Java аналога не имеет вовсе.
class Wallet {
private val secret = "ключ"
}
println(Wallet().secret)
Cannot access 'val secret: String': it is private in 'Wallet'.
Практическое правило: закрывай всё, что не является частью договорённости с внешним кодом. Публичным делают только то, чем реально пользуются снаружи. Чем меньше публичная поверхность, тем спокойнее менять внутренности потом.
init, второй конструктор и lateinit
Если при создании объекта надо что-то сделать — проверить аргументы, посчитать значение, записать в лог, — для этого есть блок init:
class Wallet(val owner: String) {
val createdAt: String
init {
println("init: создаём кошелёк для ${owner}")
createdAt = "2026-09-04"
}
constructor(owner: String, bonus: Int) : this(owner) {
println("второй конструктор, бонус ${bonus}")
}
}
fun main() {
val w = Wallet("Соня", 100)
println(w.createdAt)
}
Что выведет:
init: создаём кошелёк для Соня
второй конструктор, бонус 100
2026-09-04
Порядок вывода показывает главное: init выполняется раньше второго конструктора. Второй конструктор обязан через : this(owner) позвать основной, основной запускает init, и только потом выполняется тело второго. Это не мелочь: если что-то в объекте выглядит неинициализированным, порядок надо знать наизусть.
Вторые конструкторы в Kotlin нужны редко — чаще хватает значений по умолчанию у параметров. Wallet(owner: String, balance: Int = 0) покрывает оба случая создания без единой лишней строки.
lateinit — обещание «значение появится позже, но точно до первого чтения». В Android это нужно постоянно: часть объектов система создаёт не в конструкторе, а на этапе onCreate.
class Wallet {
lateinit var owner: String
}
val w = Wallet()
println(w.owner)
kotlin.UninitializedPropertyAccessException: lateinit property owner has not been initialized
Обрати внимание: это исключение во время работы, а не ошибка компиляции. lateinit — та же расписка, что и !! из предыдущей главы: программист говорит «я отвечаю», а язык верит на слово. Есть ::owner.isInitialized, чтобы проверить; но лучше обходиться без lateinit вовсе, когда значение можно передать в конструктор.
lateinit работает только с var и только с не-nullable типом; для Int, Double и других примитивов он запрещён. Если очень хочется отложить инициализацию числа — нужен либо Int? с обычной проверкой, либо by lazy, который считает значение при первом чтении и запоминает результат.
class Heavy {
val value: String by lazy {
println("считаю один раз")
"готово"
}
}
fun main() {
val h = Heavy()
println(h.value)
println(h.value)
}
Что выведет:
считаю один раз
готово
готово
Строка «считаю один раз» напечаталась ровно однажды, хотя h.value читали дважды. В этом и смысл by lazy: тяжёлое вычисление откладывается до первого обращения, а результат сохраняется.
Наследование: почему всё закрыто по умолчанию
Наследование — способ сказать «этот класс такой же, как тот, плюс кое-что своё». В Kotlin оно устроено строже, чем в Java.
Попробуем унаследоваться от обычного класса:
class Animal(val name: String)
class Cat(name: String) : Animal(name)
This type is final, so it cannot be extended.
Любой класс в Kotlin закрыт для наследования, пока не написали open. В Java наоборот: наследоваться можно от всего, кроме помеченного словом final.
Рабочая версия:
open class Animal(val name: String) {
open fun voice() = "..."
fun describe() = "${name} говорит: ${voice()}"
}
class Cat(name: String) : Animal(name) {
override fun voice() = "мяу"
}
class Dog(name: String) : Animal(name) {
override fun voice() = "гав"
fun fetch() = "${name} принёс палку"
}
fun main() {
val animals: List<Animal> = listOf(Cat("Барсик"), Dog("Рекс"))
for (a in animals) println(a.describe())
}
Что выведет:
Барсик говорит: мяу
Рекс говорит: гав
Вот он, полиморфизм — в четыре строки. Список объявлен как List<Animal>, метод describe() написан один раз в родителе, и он ничего не знает про котов и собак. Но внутри него вызывается voice(), и каждый объект отвечает по-своему. Выбор нужной реализации происходит во время работы программы, по фактическому типу объекта.
Двоеточие после имени класса читается «это разновидность»: class Cat(name: String) : Animal(name) — «кот является животным». Круглые скобки после Animal — вызов конструктора родителя; параметр name без val здесь не свойство кота, а транзитный аргумент: свойство уже есть у родителя, дублировать его не надо.
Два слова управляют переопределением, и оба обязательны:
openу родителя — «этот метод разрешено переопределять»;overrideу наследника — «я знаю, что переопределяю».
Забыл open у родителя:
'voice' in 'Animal' is final and cannot be overridden.
Забыл override у наследника:
'voice' hides member of supertype 'Animal' and needs an 'override' modifier.
Второе сообщение — самое ценное во всей главе. В Java такой код скомпилировался бы молча, а метод бы просто перекрыл родительский, и разбираться, почему приложение ведёт себя странно, пришлось бы вручную. В Java для этой ситуации даже придумали необязательную аннотацию @Override. Kotlin сделал её обязательным ключевым словом.
Из родительского метода можно вызвать его прежнюю версию через super:
class Cat(name: String) : Animal(name) {
override fun voice() = super.voice() + " мяу"
}
Абстрактные классы: чертёж без реализации
Иногда родитель нужен только как договорённость: у любой фигуры есть площадь, но у «фигуры вообще» её посчитать невозможно.
abstract class Shape {
abstract fun area(): Double
fun report() = "площадь = ${area()}"
}
class Square(val side: Double) : Shape() {
override fun area() = side * side
}
fun main() {
println(Square(3.0).report())
}
Что выведет: площадь = 9.0.
Три вещи стоит запомнить:
abstract fun area(): Double— объявление без тела. Наследник обязан его реализовать.report()— обычный метод с телом, доступный всем наследникам. В этом отличие абстрактного класса от интерфейса: он умеет хранить и общий код, и состояние.- Слово
openне нужно: абстрактный класс открыт для наследования по определению, а абстрактный метод — для переопределения.
Создать объект абстрактного класса нельзя:
println(Shape())
Cannot create an instance of an abstract class.
Это тоже разумно: у такого объекта не было бы area().
Интерфейсы: договор о поведении
Интерфейс — список того, что класс умеет, без единого слова о том, что он знает. Наследоваться в Kotlin можно только от одного класса, а интерфейсов реализовать сколько угодно.
interface Clickable {
fun click()
fun showOff() = "я кликабельный"
}
interface Focusable {
fun showOff() = "я фокусируемый"
}
class Button : Clickable, Focusable {
override fun click() = println("клик")
override fun showOff() = super<Clickable>.showOff() + " и " + super<Focusable>.showOff()
}
fun main() {
val b = Button()
b.click()
println(b.showOff())
}
Что выведет:
клик
я кликабельный и я фокусируемый
Разберём по пунктам.
Метод без тела (fun click()) — обязательство. Класс, который его не реализовал, не соберётся:
Class 'Button' is not abstract and does not implement abstract member:
fun click(): Unit
Метод с телом (fun showOff() = "...") — реализация по умолчанию. Наследник может её взять как есть, а может переопределить.
Конфликт двух умолчаний. Оба интерфейса дают свой showOff(). Компилятор не выбирает за программиста — он требует решить конфликт руками, и синтаксис super<Clickable>.showOff() позволяет обратиться к конкретному из них. В Java это называлось «ромбовидной проблемой» и десятилетиями было причиной отсутствия множественного наследования вообще.
Интерфейс не хранит состояние. Свойство в интерфейсе объявить можно, но у него не будет собственного места в памяти — наследник обязан его предоставить:
interface Named {
val name: String // требование
fun greet() = "привет, $name" // реализация поверх требования
}
Правило выбора, которое работает почти всегда:
| Нужно | Что брать |
|---|---|
| Общий код и общее состояние для родственных классов | abstract class |
| Только договор «умеет вот это» | interface |
| Класс должен уметь несколько разных вещей | несколько interface |
data class: класс для данных
Огромная доля классов в приложении не имеет никакого поведения — это просто наборы полей: ответ сервера, строка списка, точка на карте. Для них есть отдельное слово.
Сначала посмотрим, как ведёт себя обычный класс:
class Coin(val ticker: String, val price: Int)
fun main() {
val c1 = Coin("TON", 5)
val c2 = Coin("TON", 5)
println(c1)
println(c1 == c2)
}
Что выведет: Coin@5ca881b5 и false. Печать бесполезна, а два одинаковых по содержимому объекта считаются разными — потому что стандартный equals сравнивает адреса.
Теперь добавим одно слово:
data class Coin(val ticker: String, val price: Int)
fun main() {
val c1 = Coin("TON", 5)
val c2 = Coin("TON", 5)
println(c1)
println(c1 == c2)
println(c1 === c2)
println(c1.copy(price = 7))
val (t, p) = c1
println("${t} / ${p}")
}
Что выведет:
Coin(ticker=TON, price=5)
true
false
Coin(ticker=TON, price=7)
TON / 5
Разберём вывод построчно:
toString()— печатает имя класса и все поля основного конструктора. В логах это разница между «понятно, что не так» и «непонятно ничего».equals()—c1 == c2теперьtrue: сравнение по содержимому.===по-прежнемуfalse— это разные объекты в памяти, иdataэтого не меняет.copy()— создаёт новый объект, меняя перечисленные поля. Незаменимая вещь для неизменяемых данных: не «поправить объект», а «сделать новый, почти такой же».- Деструктуризация —
val (t, p) = c1разбирает объект на переменные. Ровно тот же механизм, что вfor ((i, coin) in coins.withIndex())из главы про циклы.
Заодно генерируется hashCode(), согласованный с equals(). Без него объект нельзя нормально положить в Set или использовать ключом в Map: коллекции ищут сначала по хэшу и только потом сравнивают.
Ограничение у data class ровно одно, и о нём напоминает компилятор:
data class Empty()
Data class must have at least one primary constructor parameter.
Класс данных без данных смысла не имеет.
В data class поля почти всегда объявляют как val, а не var. Изменяемый объект данных ломает hashCode: положили объект в Set, поменяли поле — хэш стал другим, и коллекция больше не находит собственный элемент. С val и copy() такой проблемы нет вовсе, а код становится предсказуемее. То же правило действует в Java-мире с приходом record, который решает ровно ту же задачу и появился в 2021 году.
enum: фиксированный список вариантов
enum — набор именованных значений, известный целиком заранее. В Kotlin у каждого варианта могут быть свои данные и общие методы:
enum class Level(val minScore: Int) {
JUNIOR(0), MIDDLE(50), SENIOR(100);
fun label() = "${name} от ${minScore}"
}
fun main() {
for (l in Level.entries) println(l.label())
println(Level.valueOf("MIDDLE").minScore)
println(Level.SENIOR.ordinal)
}
Что выведет:
JUNIOR от 0
MIDDLE от 50
SENIOR от 100
50
2
Что тут стоит заметить:
- Точка с запятой после списка вариантов — единственное место в Kotlin, где она обязательна. Она отделяет варианты от остального тела
enum. name— готовое свойство: имя варианта строкой, писать его самому не надо.ordinal— порядковый номер, считая с нуля.entries— список всех вариантов. Это современная замена старомуvalues(), который создавал новый массив при каждом вызове.valueOf("MIDDLE")— разбор из строки; если такого варианта нет, будет исключение.
И главное: when по enum не требует ветки else, если перечислены все варианты. Компилятор видит список целиком.
sealed class: закрытая иерархия
enum хорош, пока варианты — просто имена. Но чаще у вариантов разные данные: у успеха есть значение, у ошибки — текст, у загрузки нет ничего. Для этого есть sealed.
sealed class Result {
data class Ok(val value: Int) : Result()
data class Fail(val message: String) : Result()
object Loading : Result()
}
fun describe(r: Result): String = when (r) {
is Result.Ok -> "успех: ${r.value}"
is Result.Fail -> "ошибка: ${r.message}"
Result.Loading -> "грузим"
}
fun main() {
println(describe(Result.Ok(42)))
println(describe(Result.Fail("нет сети")))
println(describe(Result.Loading))
}
Что выведет:
успех: 42
ошибка: нет сети
грузим
sealed буквально значит «запечатанный»: наследоваться от такого класса можно только внутри того же модуля, а обычно — прямо внутри самого класса, как в примере. Список наследников закрыт, и компилятор знает его целиком.
Отсюда — главная выгода. Убираем одну ветку:
fun describe(r: Result): String = when (r) {
is Result.Ok -> "ок"
}
'when' expression must be exhaustive. Add the 'is Fail' branch or an 'else' branch.
Компилятор назвал недостающую ветку по имени. Это и есть тот сценарий, ради которого sealed используют в каждом Android-приложении: добавили в Result новый вариант — и компилятор сам провёл по всем when, которые надо дописать. Ничего не забудется, потому что забыть физически не дают.
Ветка else в when по sealed-классу убивает всю пользу. С ней новый вариант молча попадёт в else, и компилятор промолчит. Поэтому в when по запечатанному типу else не пишут вообще — даже если ветка «на всякий случай» кажется безопасной. Здесь «на всякий случай» и есть источник будущего бага.
Обрати внимание на третий вариант: object Loading : Result(). Слово object вместо class означает «этот вариант существует ровно в одном экземпляре» — у загрузки нет данных, поэтому и создавать копии незачем. И в when он сравнивается без is: Result.Loading ->, потому что объект один и сравнить можно прямо с ним.
object и companion object
Слово object в Kotlin делает три разные вещи, и их стоит различать.
Первое: одиночка. Объявление object Config создаёт класс и его единственный экземпляр одновременно:
object Config {
val apiUrl = "https://ton.org"
var retries = 3
}
fun main() {
println(Config.apiUrl)
Config.retries = 5
println(Config.retries)
}
Что выведет: https://ton.org и 5.
Создать второй Config невозможно, и это то, что в других языках называют шаблоном «одиночка» (singleton) и пишут вручную десятком строк с приватным конструктором.
Второе: статические члены класса. В Kotlin нет слова static. То, что в Java было статическим, живёт в companion object — «объекте-спутнике» класса:
class User private constructor(val nick: String) {
companion object {
const val MAX_LEN = 12
fun of(nick: String): User? =
if (nick.length <= MAX_LEN) User(nick) else null
}
}
fun main() {
println(User.of("soня")?.nick)
println(User.of("оченьдлинныйникнейм")?.nick)
println(User.MAX_LEN)
}
Что выведет: soня, null, 12.
Здесь показан классический приём: конструктор закрыт словом private, а создание идёт через функцию of(), которая может отказать и вернуть null. Снаружи это выглядит как User.of(...) — точно так же, как статический метод в Java.
const val — константа времени компиляции: её значение подставляется в код прямо при сборке. Работает только для примитивов и строк и только внутри companion object или на верхнем уровне файла.
Третье: безымянный объект на месте. Когда нужен один-единственный объект, реализующий интерфейс, класс для него не заводят:
interface Listener {
fun onEvent(name: String)
}
fun main() {
val anon = object : Listener {
override fun onEvent(name: String) = println("поймал ${name}")
}
anon.onEvent("click")
}
Что выведет: поймал click.
Это прямой аналог анонимного класса из Java, и в Android так до сих пор задают обработчики событий там, где не подходит лямбда.
Функции-расширения: чужой класс с новым методом
Возможность, которой в Java нет вовсе. Можно дописать метод классу, исходники которого недоступны, — например, стандартному String:
fun String.shorten(max: Int): String =
if (length <= max) this else take(max) + "…"
fun main() {
println("оченьдлинныйникнейм".shorten(8))
println("TON".shorten(8))
}
Что выведет: оченьдли… и TON.
String. перед именем функции означает «эта функция вызывается на строке». Внутри доступны this и — как видно из примера — все свойства получателя напрямую: length без всякого this..
Класс String при этом не меняется: компилятор превращает вызов в обычную статическую функцию с получателем в первом параметре. Отсюда два следствия:
- расширение не видит приватных членов класса — оно снаружи;
- расширение не переопределяет существующий метод: если у класса уже есть метод с таким именем, выиграет всегда он.
Половина стандартной библиотеки Kotlin — это расширения к типам из Java. isNullOrBlank(), filter, sumOf из прошлых глав — все они дописаны именно так.
Ошибки этой главы и что они значат
Все сообщения сняты с настоящего запуска компилятора Kotlin 2.4.10.
| Сообщение | Что случилось | Как чинить |
|---|---|---|
This type is final, so it cannot be extended. | наследуемся от закрытого класса | дописать open родителю |
'voice' in 'Animal' is final and cannot be overridden. | у метода родителя нет open | дописать open методу |
'voice' hides member of supertype 'Animal' and needs an 'override' modifier. | забыт override | дописать override |
Cannot create an instance of an abstract class. | создаём объект абстрактного класса | создавать наследника |
Class 'Button' is not abstract and does not implement abstract member: fun click(): Unit | не реализован метод интерфейса | реализовать или сделать класс абстрактным |
'when' expression must be exhaustive. Add the 'is Fail' branch or an 'else' branch. | в when по sealed не хватает ветки | дописать названную ветку |
Data class must have at least one primary constructor parameter. | data class без полей | добавить поля или убрать data |
Cannot access 'count': it is private in 'Counter'. | пишем в свойство с private set | менять через метод класса |
Cannot access 'val secret: String': it is private in 'Wallet'. | читаем приватное свойство снаружи | открыть доступ через метод или свойство |
Variable 'balance' must be initialized. | свойство используется раньше объявления | переставить строки |
'val' cannot be reassigned. | меняем val-поле объекта | объявить как var, если менять надо |
UninitializedPropertyAccessException: lateinit property owner has not been initialized | читаем lateinit до присваивания | присвоить до чтения или отказаться от lateinit |
Тренажёр: объявления этой главы
Цель: скорость ≥ 105 зн/мин, точность ≥ 90%
class Wallet(val owner: String, var balance: Int = 0)
Символы и слова этой главы
| Слово или знак | Что означает |
|---|---|
class | объявление класса |
: после имени класса | «является разновидностью»: наследование или реализация интерфейса |
open | разрешает наследование класса или переопределение метода |
override | обязательное объявление «я переопределяю метод родителя» |
abstract | объявление без тела; наследник обязан реализовать |
interface | договор о поведении, без состояния |
super | обращение к реализации родителя |
super<Имя> | выбор конкретного родителя при конфликте умолчаний |
data | генерирует toString, equals, hashCode, copy, componentN |
sealed | закрытая иерархия: список наследников известен компилятору |
enum class | фиксированный список именованных вариантов |
object | одиночка, безымянный объект или вариант sealed-класса |
companion object | замена static из Java |
const val | константа, подставляемая при компиляции |
init | блок, выполняемый при создании объекта |
constructor | дополнительный конструктор |
lateinit | обещание присвоить значение до первого чтения |
by lazy | вычислить при первом обращении и запомнить |
private / protected / internal | ограничение видимости |
private set | читать можно всем, менять — только изнутри класса |
fun Тип.имя() | функция-расширение: новый метод у чужого класса |
val (a, b) = obj | деструктуризация объекта на переменные |
Если рядом учат TypeScript
| Что | Kotlin | TypeScript |
|---|---|---|
| Создание объекта | Wallet() | new Wallet() |
| Свойства в конструкторе | class W(val owner: String) | constructor(public owner: string) |
| Наследование по умолчанию | закрыто, нужен open | открыто всегда |
override | обязателен | необязателен |
| Класс для данных | data class | обычный класс или interface |
| Сравнение объектов по содержимому | == у data class | вручную или через библиотеку |
| Закрытый набор вариантов | sealed class | размеченное объединение типов |
| Статические члены | companion object | static |
| Одиночка | object Config | экспортированная константа |
| Расширение чужого типа | функция-расширение | объявление слияния или обёртка |
| Множественная реализация | несколько interface | несколько interface |
Ключевое различие: в Kotlin компилятор проверяет договор — open, override, исчерпывающий when. В TypeScript проверок такого рода почти нет, там дисциплину поддерживает разработчик и линтер.
Что должен уметь
- Объявлять класс со свойствами прямо в заголовке и объяснять роль
val/varтам. - Создавать объект без слова
new. - Писать вычисляемое свойство через
get()и объяснять, зачем оно лучше хранимого. - Закрывать изменение свойства через
private set. - Понимать порядок инициализации: свойства сверху вниз,
init, потом второй конструктор. - Отличать
lateinitотby lazyи знать, какое исключение даёт первый. - Объяснять, почему в Kotlin класс закрыт для наследования по умолчанию.
- Пользоваться
openиoverrideи знать оба сообщения компилятора при их отсутствии. - Показывать полиморфизм на списке общего типа.
- Отличать абстрактный класс от интерфейса и выбирать между ними.
- Реализовывать несколько интерфейсов и разрешать конфликт умолчаний через
super<Имя>. - Объявлять
data classи перечислять все пять сгенерированных возможностей. - Пользоваться
copy()и деструктуризацией. - Объяснять, почему поля
data classделаютval. - Писать
enum classс данными у вариантов и знатьname,ordinal,entries. - Объявлять
sealed classс наследниками разной формы. - Объяснять, почему
elseвwhenпоsealedвредит. - Различать три роли слова
object. - Заменять
staticнаcompanion objectи закрывать конструктор ради фабричной функции. - Писать функцию-расширение и объяснять, почему она не видит приватных членов.
- Узнавать в лицо двенадцать сообщений компилятора из таблицы ошибок.
Углубиться
Почему закрыто по умолчанию. Это прямое следствие книги Джошуа Блоха «Effective Java», пункт «Design and document for inheritance or else prohibit it». Класс, от которого наследуются, обязан описывать, какие методы вызывают какие, — иначе наследник ломается при любом изменении родителя. Блох советовал по умолчанию писать final; авторы Kotlin просто сделали этот совет умолчанием языка. Обратная сторона известна: библиотеки тестирования вроде Mockito не могут подменить закрытый класс, из-за чего в Android-проектах подключают плагин all-open для Spring и предпочитают интерфейсы.
Во что превращается data class. Компилятор дописывает пять методов ровно по полям основного конструктора. Поле, объявленное в теле класса, в equals, hashCode и toString не попадает — это регулярный источник удивления. Проверить легко: два объекта, различающиеся только полем из тела, окажутся равными.
data class и record. Java получила похожую конструкцию — record — только в версии 16, в 2021 году, через пять лет после выхода Kotlin 1.0. Идея та же: неизменяемый носитель данных с автоматическими equals, hashCode и toString. Разница в том, что record жёстче: поля всегда неизменяемы, наследование запрещено полностью, а аналога copy() нет.
sealed class и sealed interface. С Kotlin 1.5 запечатанным может быть и интерфейс, и это часто удобнее: у sealed interface нет конструктора, а наследник может реализовать сразу несколько таких интерфейсов. В новом коде состояния экрана чаще описывают именно через sealed interface. Java тоже получила sealed — в версии 17, в 2021-м.
Что такое Any. У всех классов Kotlin общий предок — Any. Именно оттуда приходят equals(), hashCode() и toString(), которые переопределяет data class. Отличие от Java-предка Object в том, что Any не имеет методов wait(), notify() и getClass() — авторы намеренно не пустили в язык устаревший механизм синхронизации.
Делегирование через by. Кроме by lazy слово by умеет делегировать целый интерфейс другому объекту: class Button(l: Clickable) : Clickable by l — все методы Clickable автоматически переадресуются в l. Это язык поддерживает шаблон «композиция вместо наследования» синтаксисом, а не советом.
Дальше по теме:
- Kotlin docs: Classes — классы, конструкторы,
init - Kotlin docs: Inheritance —
open,override,super - Kotlin docs: Data classes — что именно генерируется
- Kotlin docs: Sealed classes and interfaces — закрытые иерархии
- Kotlin docs: Delegation — слово
byцеликом
Сводная проверка по всей главе: 15 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
-
Kotlin docs: Classes — конструкторы,
init, свойства. -
Kotlin docs: Inheritance —
open,override, вызовsuper. -
Kotlin docs: Properties — геттеры, сеттеры,
lateinit. -
Kotlin docs: Interfaces — реализации по умолчанию и разрешение конфликтов.
-
Kotlin docs: Data classes — что именно генерируется и по каким полям.
-
Kotlin docs: Sealed classes and interfaces — закрытые иерархии.
-
Kotlin docs: Object declarations — все три роли слова
object. -
Kotlin docs: Extensions — функции и свойства-расширения.
-
Kotlin docs: Visibility modifiers —
private,protected,internal. -
Kotlin Koans: Classes — задачи по теме прямо в браузере.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.