JavaRush /Курси /Kotlin SELF /Інтерфейс як контракт — методи, властивості та реалізації...

Інтерфейс як контракт — методи, властивості та реалізації за замовчуванням

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. Реалізації за замовчуванням: базова поведінка прямо в контракті

Ось тепер починається магія, яку Kotlin робить доволі цивілізовано: в інтерфейсі можна писати функції з тілом. Такі функції стають реалізаціями за замовчуванням: клас може їх не перевизначати й просто успадкувати поведінку.

У документації 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. Це ключове правило хорошої реалізації за замовчуванням: вона не має вимагати «прихованих» речей, про які інтерфейс мовчить.

Тепер клас може «просто підписатися»:

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
}

Важливо розуміти нюанс: метод за замовчуванням — це не «штука для лінивих». Це спосіб винести спільну поведінку ближче до контракту, щоб усі реалізації поводилися однаково «за замовчуванням», а відмінності були явними.

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
}

Тут ми зробили важливу річ: відокремили «що друкувати» від «як друкувати». Так, слово «архітектура» звучить голосно, але на практиці це просто дисципліна: один формат живе в одному місці, і всі ним користуються.

Ще один акуратний крок — додати реалізацію за замовчуванням там, де в нас уже є властивості, з яких рядок будується. Наприклад, для обʼєктів «із заголовком»:

interface HasTitle {
    val title: String
    fun titleLine(): String = "• $title"
}

Клас може просто реалізувати val title, а titleLine() отримати безплатно. Саме так і мають відчуватися методи за замовчуванням: як «розумна стандартна поведінка», а не як «милиця».

6. Типові помилки під час роботи з інтерфейсами

Помилка № 1: думати, що властивості інтерфейсу — це поля зберігання.
Часта плутанина: студент бачить val id: String в інтерфейсі й очікує, що інтерфейс десь «тримає рядок». У Kotlin інтерфейс задає лише доступ (getter/setter), а зберігання або обчислення — задача реалізації. Якщо памʼятати «інтерфейс = вимоги», а «клас = конкретика», голова починає боліти помітно менше.

Помилка № 2: забувати override і намагатися «реалізувати за натхненням».
Інколи пишуть у класі val id: String, але не позначають override. А потім дивуються помилкам компіляції або тому, що реалізувалося «не те». У Kotlin override — це не формальність, а маркер наміру: «я виконую контракт». Краще звикнути ставити його одразу й сприймати як пасок безпеки.

Помилка № 3: робити інтерфейс надто широким («комбайн на 18 функцій»).
Дуже спокусливо створити інтерфейс EverythingManager, де і друк, і валідація, і збереження, і «ще нехай каву варить». Потім виявляється, що половина реалізацій змушена писати порожні методи або викидати винятки. Хороший інтерфейс зазвичай вузький і тематичний: один контракт — одна відповідальність.

Помилка № 4: писати методи за замовчуванням, які залежать від того, чого немає в контракті.
Реалізація за замовчуванням має спиратися лише на те, що інтерфейс гарантує: його властивості й методи. Якщо метод за замовчуванням усередині «очікує», що реалізація зберігає якесь приховане поле або робить побічний ефект, ви отримуєте крихку поведінку й несподіванки. Найкращий тест: «чи зможу я реалізувати цей інтерфейс у новому класі, не читаючи чужу душу?»

Помилка № 5: плутати «інтерфейс як контракт» і «наслідування як ієрархію».
Інтерфейс не зобовʼязаний відображати родовід обʼєктів. Він відображає здібності. У цьому його сила: один і той самий клас може бути «витратою», але водночас — «тим, що вміє друкуватися» і «тим, що має id». Це нормально й навіть бажано, бо код стає більш модульним.

7. Контрольні запитання

  1. Чому інтерфейс називають контрактом, і що саме «обіцяє» клас, коли пише : SomeInterface?
  2. Чим відрізняється метод інтерфейсу без тіла від методу інтерфейсу з тілом, і навіщо взагалі потрібна реалізація за замовчуванням?
  3. Чому val в інтерфейсі — це не «поле», і які два основні способи реалізувати таку властивість у класі?
  4. У чому практичний сенс писати функції, які приймають інтерфейс (CliPrintable), а не конкретний клас (Expense)?
  5. Яка ознака говорить, що ваш інтерфейс став занадто «товстим», і що зазвичай відбувається з реалізаціями такого інтерфейсу?
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ