1. Интерфейс как контракт
Когда вы впервые узнаёте про наследование, появляется приятное чувство: «О, я могу сделать базовый класс и от него наследовать всё подряд!». Это чувство обычно длится ровно до первого проекта, где выясняется, что «похожесть» бывает очень разной. Книга и пользователь не похожи друг на друга (данные разные), но иногда оба должны «уметь иметь id». Платёж и расход не родственники, но оба должны «уметь показываться строкой в отчёте».
Вот тут интерфейсы и начинают сиять: интерфейс описывает не «что это такое», а «что это умеет».
Если упрощать до человеческой аналогии: наследование — это «быть разновидностью» (кот — разновидность животного), а интерфейс — это «уметь делать» (кот умеет meow(), робот тоже может «уметь мяукать» — если вы так решили… и никто не вправе вас остановить).
Интерфейс в Kotlin — это контракт. Контракт означает: «Если ты говоришь, что ты X, тогда ты обязан предоставить вот такие функции и/или свойства». При этом интерфейс почти ничего не говорит о том, как именно вы это сделаете.
2. Объявление интерфейса и реализация в классе
Объявляем контракт и «подписываемся» на него
Сейчас будет момент, где Kotlin выглядит дружелюбно: интерфейсы пишутся проще, чем кажется. Вы объявляете interface, а внутри перечисляете, что должна уметь реализация: функции и свойства. Дальше любой класс может «подписаться под контрактом», добавив : InterfaceName после имени класса.
Начнём с минимального примера — контракт «у объекта есть идентификатор».
interface Identifiable {
val id: String
}
class User(override val id: String, val name: String) : Identifiable
Здесь важно заметить несколько вещей.
Во‑первых, val id: String внутри интерфейса — это не «поле», не «ячейка памяти», не «коробочка под строку». Это именно требование: «у тебя должен быть доступ к id».
Во‑вторых, класс User выполняет требование через override val id: String прямо в primary constructor. Это очень удобный и читаемый стиль: вы сразу видите, какие поля являются частью контракта.
В‑третьих, никаких скобок () у интерфейса нет: интерфейс нельзя создать как объект напрямую. Он — только описание правил игры.
Абстрактные методы: «если реализовал — обязан написать»
Интерфейс часто начинается с мысли «у типа есть такие-то обязанности». Самый прямой способ выразить обязанность — объявить функцию без тела. Тогда она становится абстрактной: реализация обязана написать код.
Например, сделаем контракт «умеет приветствовать».
interface Greeter {
fun greet(name: String): String
}
class FriendlyGreeter : Greeter {
override fun greet(name: String): String = "Привет, $name!"
}
Если вы забудете override, компилятор не промолчит. И это одна из лучших черт Kotlin: он не даёт «случайно почти реализовать интерфейс». Тут либо контракт выполнен, либо нет.
Чтобы быстро почувствовать, как это используется, давайте сделаем мини‑проверку в main:
fun main() {
val g: Greeter = FriendlyGreeter()
println(g.greet("Котлин")) // Привет, Котлин!
}
Обратите внимание: переменная g имеет тип Greeter, а не FriendlyGreeter. Это не занудство — это и есть смысл контрактов. Мы работаем через обещание «умеет greet», а не через конкретный класс.
3. Свойства в интерфейсе и способы реализации
Свойства в интерфейсе — один из мест, где новички чаще всего спотыкаются, потому что интуитивно кажется: «Ну раз val id — значит где-то хранится строка». А вот и нет.
Интерфейс может требовать наличие свойства, но не может заставить вас хранить его конкретным образом. Реализация может хранить значение в поле, а может вычислять его на лету.
Давайте посмотрим на три варианта реализации одного и того же требования.
Хранение в поле (самый частый вариант)
interface Identifiable {
val id: String
}
class Expense(override val id: String, val title: String) : Identifiable
Expense реально хранит id как свойство (у него будет backing field).
Вычисляемое свойство (без хранения)
interface Identifiable {
val id: String
}
class Session(private val token: String) : Identifiable {
override val id: String
get() = "session:$token"
}
Здесь id каждый раз вычисляется через getter. Никакого отдельного поля для id может и не быть.
var в интерфейсе: контракт на чтение и запись
Интерфейс может потребовать var, то есть и чтение, и запись. Но это стоит делать аккуратно: вы заставляете реализации быть изменяемыми (мутабельными), а это не всегда желательно.
interface Renameable {
var title: String
}
class Note(override var title: String) : Renameable
Здесь класс обязан предоставить и getter, и setter. И, да, снова: интерфейс не хранит — хранит реализация.
Чтобы зафиксировать разницу наглядно, вот маленькая табличка:
| Что написано в интерфейсе | Что это означает | Где живёт значение |
|---|---|---|
|
«можно прочитать x» | в реализации (или вычисляется) |
|
«можно прочитать и изменить x» | в реализации |
|
«функция обязательна» | код в реализации |
|
«есть поведение по умолчанию» | код в интерфейсе |
4. Default‑реализации: базовое поведение прямо в контракте
Вот теперь начинается магия, которую Kotlin делает довольно цивилизованной: в интерфейсе можно писать функции с телом. Такие функции становятся default‑реализациями: класс может их не переопределять и просто унаследовать поведение.
В документации Kotlin это выглядит ровно так: интерфейс может содержать реализацию метода (пример с interface Polygon { fun draw() { ... } }).
Представим, что в нашем учебном консольном приложении (трекере расходов) мы хотим печатать сущности «красиво», и нам нужен общий контракт «может показать себя строкой». Создадим интерфейс:
interface RenderableLine {
fun renderLine(): String
}
Но пока это просто абстрактная обязанность. Давайте сделаем интереснее: добавим свойства в контракт, а renderLine() дадим по умолчанию.
interface TitledAmount {
val title: String
val amount: Double
fun renderLine(): String = "$title: $amount"
}
Здесь renderLine() использует только то, что гарантирует контракт: title и amount. Это ключевое правило хорошей default‑реализации: она не должна требовать «скрытых» вещей, о которых интерфейс молчит.
Теперь класс может «просто подписаться»:
class Expense(
override val title: String,
override val amount: Double,
) : TitledAmount
И всё — Expense уже умеет renderLine().
Проверим:
fun main() {
val e = Expense(title = "Кофе", amount = 3.5)
println(e.renderLine()) // Кофе: 3.5
}
Важно понимать нюанс: default‑метод — это не «штука для ленивых». Это способ вынести общее поведение ближе к контракту, чтобы все реализации вели себя одинаково «по умолчанию», а отличия были явными.
5. Интерфейс как тип: универсальные функции
Интерфейс становится по‑настоящему полезным, когда вы начинаете принимать его как параметр функции и возвращать как результат. Тогда код перестаёт быть привязанным к конкретным классам.
Например, напишем функцию, которая печатает список любых объектов, умеющих рендериться строкой.
fun printLines(items: List<RenderableLine>) {
for (item in items) {
println(item.renderLine())
}
}
А теперь дадим нашей модели расходов соответствовать этому контракту. Можно сделать так (с явной реализацией метода):
data class Expense(
val id: String,
val title: String,
val amount: Double,
) : RenderableLine {
override fun renderLine(): String = "[$id] $title — $amount"
}
Проверим, что всё работает:
fun main() {
val items = listOf(
Expense("e1", "Кофе", 3.5),
Expense("e2", "Проезд", 2.75),
)
printLines(items)
// [e1] Кофе — 3.5
// [e2] Проезд — 2.75
}
Обратите внимание на «приятную наглость» Kotlin: List<Expense> можно передать туда, где ждут List<RenderableLine>, потому что Expense : RenderableLine, а List в Kotlin ковариантен (это вы уже трогали на теме про variance). Но сегодня нам важно не слово «ковариантность», а эффект: функция стала универсальной, а код — менее липким.
Мини‑шаг в приложении: общий контракт для вывода в консоль
Сейчас у нас есть консольное приложение, где мы добавляем и печатаем расходы. Обычно новичковая версия list‑команды выглядит примерно так: внутри команды вручную форматируем строку, потом ещё раз форматируем строку в отчёте, потом ещё раз в экспорте… и вы внезапно обнаруживаете, что у вас три разных формата «одного и того же расхода». Программа превращается в сериал «Форматирование: месть строки».
Чтобы этого избежать, начнём с маленького, честного контракта: «всё, что можно показать пользователю, умеет представить себя строкой».
Сделаем интерфейс:
interface CliPrintable {
fun toCliLine(): String
}
Теперь Expense реализует его:
data class Expense(
val id: String,
val title: String,
val amount: Double,
) : CliPrintable {
override fun toCliLine(): String = "[$id] $title: $amount"
}
И в main (или в функции команды list) мы печатаем не «как попало», а одинаково:
fun printAll(items: List<CliPrintable>) {
for (item in items) {
println(item.toCliLine())
}
}
Пример использования:
fun main() {
val expenses: List<CliPrintable> = listOf(
Expense("e1", "Кофе", 3.5),
Expense("e2", "Проезд", 2.75),
)
printAll(expenses)
// [e1] Кофе: 3.5
// [e2] Проезд: 2.75
}
Здесь мы сделали важную вещь: отделили «что печатать» от «как печатать». Да, слово «архитектура» звучит громко, но по факту это просто дисциплина: один формат живёт в одном месте, и все пользуются им.
Ещё один аккуратный шаг — добавить default‑реализацию там, где у нас уже есть свойства, из которых строка строится. Например, для объектов «с заголовком»:
interface HasTitle {
val title: String
fun titleLine(): String = "• $title"
}
Класс может просто реализовать val title, а titleLine() получить бесплатно. Именно так и должны ощущаться default‑методы: как «разумное стандартное поведение», а не как «костыль».
6. Типичные ошибки при работе с интерфейсами
Ошибка №1: думать, что свойства интерфейса — это поля хранения.
Частая путаница: студент видит val id: String в интерфейсе и ожидает, что интерфейс где-то «держит строку». В Kotlin интерфейс задаёт только доступ (getter/setter), а хранение или вычисление — задача реализации. Если помнить «интерфейс = требования», а «класс = конкретика», голова начинает болеть заметно меньше.
Ошибка №2: забывать override и пытаться «реализовать по наитию».
Иногда пишут в классе val id: String, но не помечают override, а потом удивляются ошибкам компиляции или тому, что реализовалось «не то». В Kotlin override — это не формальность, а маркер намерения: «я выполняю контракт». Лучше привыкнуть ставить его сразу и воспринимать как ремень безопасности.
Ошибка №3: делать интерфейс слишком широким («комбайн на 18 функций»).
Очень соблазнительно создать интерфейс EverythingManager, где и печать, и валидация, и сохранение, и «ещё пусть кофе варит». Потом оказывается, что половина реализаций вынуждена писать пустые методы или выбрасывать исключения. Хороший интерфейс обычно узкий и тематический: один контракт — одна ответственность.
Ошибка №4: писать default‑методы, которые зависят от того, чего нет в контракте.
Default‑реализация должна опираться только на то, что интерфейс гарантирует: его свойства и методы. Если default‑метод внутри «ожидает», что реализация хранит какое-то скрытое поле или делает побочный эффект, вы получаете хрупкое поведение и неожиданности. Лучший тест: «смогу ли я реализовать этот интерфейс в новом классе, не читая чужую душу?»
Ошибка №5: путать «интерфейс как контракт» и «наследование как иерархия».
Интерфейс не обязан отражать семейное древо объектов. Он отражает способности. В этом его сила: один и тот же класс может быть «расходом», но одновременно «тем, что умеет печататься» и «тем, что имеет id». Это нормально и даже желательно, потому что код становится более модульным.
7. Контрольные вопросы
- Почему интерфейс называют контрактом, и что именно «обещает» класс, когда пишет : SomeInterface?
- Чем отличается метод интерфейса без тела от метода интерфейса с телом, и зачем вообще нужна default‑реализация?
- Почему val в интерфейсе — это не «поле», и какие два основных способа реализовать такое свойство в классе?
- В чём практический смысл писать функции, которые принимают интерфейс (CliPrintable), а не конкретный класс (Expense)?
- Какой признак говорит, что ваш интерфейс стал слишком «толстым», и что обычно происходит с реализациями такого интерфейса?
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ