Перейти к основному содержимому
Мобилка08ГЛАВА 08ООП на Kotlin: классы, наследование, интерфейсыsealed?

ООП на Kotlin: классы, наследование, интерфейсы

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

В фундаменте была глава про ООП вообще — что такое класс, объект, инкапсуляция, наследование, полиморфизм. Здесь мы не повторяем теорию, а смотрим, как всё это выглядит именно в Kotlin и чем отличается от Java, откуда ООП в Android и пришёл.

Разница огромная и заметна сразу. Класс, на который в Java уходит тридцать строк с конструктором, геттерами, equals, hashCode и toString, в Kotlin занимает одну строку. Не потому, что Kotlin что-то прячет, — он просто дописывает очевидное сам, а программисту оставляет то, что нужно решить головой.

Кроме короткой записи в Kotlin есть три вещи, которых в Java долго не было и которые меняют стиль кода полностью: классы закрыты по умолчанию, data class для данных и sealed class для закрытых наборов вариантов. Последний работает в паре с when из главы про условия и даёт то, чего в Java нет до сих пор: компилятор сам находит место, которое сломается после добавления нового варианта.

Всё, что напечатано ниже как вывод программы или как сообщение компилятора, снято с настоящего запуска Kotlin 2.4.10.

Класс в одну строку​

Начнём с самого маленького класса, который вообще можно написать:

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.

ЗАГОЛОВОК КЛАССА — ЭТО СРАЗУ И КОНСТРУКТОР, И СВОЙСТВАclass Wallet(val owner: String, var balance: Int = 0) {classключевое словоWalletимя с большой буквыval owner: Stringсвойство и параметр сразуvar balance: Int = 0значение по умолчаниютело класса:init { ... }fun deposit(amount: Int)init выполняется при создании объектаметод — обычная функция, живущая внутри классав Java на то же самое ушло бы 30 строк
Заголовок класса в Kotlin делает сразу две работы: объявляет конструктор и объявляет свойства. Слова val и var перед параметром — это и есть вся разница между «просто параметром» и «полем объекта».
Нажми на часть выражения

Та же самая пара строк на 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: тяжёлое вычисление откладывается до первого обращения, а результат сохраняется.

Отвечено вопросов: 0 из 5
Что делает слово val в заголовке класса: class Wallet(val owner: String)?
Почему в Kotlin объект создают как Wallet(), а не new Wallet()?
Что такое вычисляемое свойство с get()?
Что означает private set у свойства?
lateinit var owner: String прочитали до присваивания. Что будет?
Отвечено верно: 0 из 5

Наследование: почему всё закрыто по умолчанию​

Наследование — способ сказать «этот класс такой же, как тот, плюс кое-что своё». В 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.

В KOTLIN КЛАСС ЗАКРЫТ, ПОКА НЕ НАПИСАЛИ OPENclass Animalзамок закрытнаследоваться нельзя: This type is finalopen class Animalзамок открытнаследники разрешены, метод — тоже через openто же и с методом: без open его нельзя переопределить, а без override — нельзя спрятать
Kotlin поменял умолчание местами по сравнению с Java: закрыто, пока не открыли. Наследование — это договор, и его теперь заключают осознанно.

Рабочая версия:

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.

Три вещи стоит запомнить:

  1. abstract fun area(): Double — объявление без тела. Наследник обязан его реализовать.
  2. report() — обычный метод с телом, доступный всем наследникам. В этом отличие абстрактного класса от интерфейса: он умеет хранить и общий код, и состояние.
  3. Слово 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
Отвечено вопросов: 0 из 5
Почему class Cat : Animal() не компилируется, если Animal объявлен как обычный class?
Что сделает компилятор, если у наследника забыть слово override?
Чем абстрактный класс отличается от интерфейса?
Два интерфейса дают свою реализацию showOff(). Что произойдёт?
Что напечатает цикл по List<Animal> из Cat и Dog, вызывающий общий describe()?
Отвечено верно: 0 из 5

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
ОДНО СЛОВО DATA — ПЯТЬ ГОТОВЫХ ВОЗМОЖНОСТЕЙclass Coin(val t: String)println(c1) → Coin@5ca881b5c1 == c2 → falsecopy() нет вообщеdata class Coin(val t: String)println(c1) → Coin(t=TON)c1 == c2 → truecopy(t = "BTC") работаетчто компилятор дописывает сам:toString()печать по полямequals()сравнение по содержимомуhashCode()работа в Set и Mapcopy()клон с заменой поляcomponent1()разбор на переменныеданные без поведения — data class; поведение и состояние — обычный класс
Одно слово data — и компилятор дописывает toString, equals, hashCode, copy и функции для разбора объекта на переменные. Всё то, что в Java генерировали кнопкой в редакторе.

Разберём вывод построчно:

  1. toString() — печатает имя класса и все поля основного конструктора. В логах это разница между «понятно, что не так» и «непонятно ничего».
  2. equals() — c1 == c2 теперь true: сравнение по содержимому.
  3. === по-прежнему false — это разные объекты в памяти, и data этого не меняет.
  4. copy() — создаёт новый объект, меняя перечисленные поля. Незаменимая вещь для неизменяемых данных: не «поправить объект», а «сделать новый, почти такой же».
  5. Деструктуризация — 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, если перечислены все варианты. Компилятор видит список целиком.

Отвечено вопросов: 0 из 4
Что произойдёт при попытке создать объект абстрактного класса?
Что вернёт свойство ordinal у второго варианта enum?
Чем entries лучше старого values()?
Почему when по enum не требует ветки else?
Отвечено верно: 0 из 4

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: КОМПИЛЯТОР ЗНАЕТ ВСЕХ НАСЛЕДНИКОВ ПОИМЁННОsealed class ResultOk(value)Fail(message)Loading?снаружи файла новых не добавитьwhen (r) { is Ok -> ... is Fail -> ... }ветка else не нужна: все случаи уже перечисленыдобавили нового наследника —компилятор сам покажет этот when
Наследники sealed-класса объявлены рядом с ним, и снаружи новых не добавить. Именно поэтому when по такому типу обходится без else — а при появлении нового варианта компилятор сам приведёт к этому месту.

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 из прошлых глав — все они дописаны именно так.

Отвечено вопросов: 0 из 5
Что добавляет слово data перед class?
Зачем нужен copy() у data class?
Почему в when по sealed-классу не пишут else?
Что означает object Config { ... }?
Где в Kotlin живёт то, что в Java было static?
Отвечено верно: 0 из 5

Ошибки этой главы и что они значат​

Все сообщения сняты с настоящего запуска компилятора 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)
клавиша ``клавиша 11клавиша 22клавиша 33клавиша 44клавиша 55клавиша 66клавиша 77клавиша 88клавиша 99клавиша 00клавиша --клавиша ==клавиша ⌫⌫клавиша TabTabклавиша QQклавиша WWклавиша EEклавиша RRклавиша TTклавиша YYклавиша UUклавиша IIклавиша OOклавиша PPклавиша [[клавиша ]]клавиша \\клавиша Caps LockCaps Lockклавиша AAклавиша SSклавиша DDклавиша FFклавиша GGклавиша HHклавиша JJклавиша KKклавиша LLклавиша ;;клавиша ''клавиша EnterEnterклавиша ShiftShiftклавиша ZZклавиша XXклавиша CCклавиша VVклавиша BBклавиша NNклавиша MMклавиша ,,клавиша ..клавиша //клавиша ShiftShiftклавиша CtrlCtrlклавиша WinWinклавиша AltAltклавиша ПробелПробелклавиша AltAltклавиша WinWinклавиша MenuMenuклавиша CtrlCtrl
следующая: C — средний левой руки

Символы и слова этой главы​

Слово или знакЧто означает
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​

ЧтоKotlinTypeScript
Создание объектаWallet()new Wallet()
Свойства в конструктореclass W(val owner: String)constructor(public owner: string)
Наследование по умолчаниюзакрыто, нужен openоткрыто всегда
overrideобязателеннеобязателен
Класс для данныхdata classобычный класс или interface
Сравнение объектов по содержимому== у data classвручную или через библиотеку
Закрытый набор вариантовsealed classразмеченное объединение типов
Статические членыcompanion objectstatic
Одиночка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. Это язык поддерживает шаблон «композиция вместо наследования» синтаксисом, а не советом.

Дальше по теме:

Сводная проверка по всей главе: 15 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоУчим Kotlin с нуля | №11 Классы и объекты.SyntaxSugarDaddy
ВидеоУрок 14: ООП. Наследование. open class. Переопределение (override) // Котлин курс с нуляKotlin & Android с нуля – Иван Ветров
ВидеоУрок 15: Интерфейсы, абстрактные классы. Имплементация. // Котлин курс с нуляKotlin & Android с нуля – Иван Ветров
ВидеоKotlin enum. Sealed class. Sealed interfaceFunDroid
ВидеоKotlin: Урок 17. Sealed КлассыDevcolibri

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.