JavaRush /Курсы /Kotlin SELF /Интерфейс как контракт — методы, свойства и default‑реали...

Интерфейс как контракт — методы, свойства и default‑реализации

Kotlin SELF
37 уровень , 0 лекция
Открыта

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. И, да, снова: интерфейс не хранит — хранит реализация.

Чтобы зафиксировать разницу наглядно, вот маленькая табличка:

Что написано в интерфейсе Что это означает Где живёт значение
val x: String
«можно прочитать x» в реализации (или вычисляется)
var x: String
«можно прочитать и изменить x» в реализации
fun f(): Int
«функция обязательна» код в реализации
fun f(): Int = ...
«есть поведение по умолчанию» код в интерфейсе

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. Контрольные вопросы

  1. Почему интерфейс называют контрактом, и что именно «обещает» класс, когда пишет : SomeInterface?
  2. Чем отличается метод интерфейса без тела от метода интерфейса с телом, и зачем вообще нужна default‑реализация?
  3. Почему val в интерфейсе — это не «поле», и какие два основных способа реализовать такое свойство в классе?
  4. В чём практический смысл писать функции, которые принимают интерфейс (CliPrintable), а не конкретный класс (Expense)?
  5. Какой признак говорит, что ваш интерфейс стал слишком «толстым», и что обычно происходит с реализациями такого интерфейса?
1
Задача
Kotlin SELF, 37 уровень, 0 лекция
Недоступна
Паспорт пользователя
Паспорт пользователя
1
Задача
Kotlin SELF, 37 уровень, 0 лекция
Недоступна
Дружелюбный бот
Дружелюбный бот
1
Задача
Kotlin SELF, 37 уровень, 0 лекция
Недоступна
Ценники витрины
Ценники витрины
1
Задача
Kotlin SELF, 37 уровень, 0 лекция
Недоступна
Переименование заметки
Переименование заметки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ