JavaRush /Курси /Kotlin SELF /Множинна реалізація інтерфейсів

Множинна реалізація інтерфейсів

Kotlin SELF
Рівень 37 , Лекція 1
Відкрита

1. Вступ

Коли ви лише починаєте писати код, здається, що один клас — це «одна роль». Але реальність швидко доводить протилежне. Один і той самий обʼєкт може одночасно бути «тим, що вміє друкувати в консоль», «тим, що вміє серіалізуватися в CSV», «тим, що можна логувати», «тим, що можна валідувати». І якщо ви спробуєте скласти все в один інтерфейс EverythingEverywhereAllAtOnce, то отримаєте не архітектуру, а шведський стіл методів.

Ключова ідея: інтерфейси зручно тримати вузькими й тематичними. Тоді клас може реалізувати одразу кілька невеликих контрактів. Kotlin це дозволяє: class X : A, B, C. Проблема зʼявляється не через множинність як таку, а через те, що контракти інколи перетинаються за іменами та сигнатурами. І тут компілятор стає вашим суворим редактором: «У цій сцені два актори одночасно промовили репліку render() — кого з них чуємо?»

Базове правило Kotlin: неоднозначність заборонена

Це правило корисно вивчити буквально як дорожній знак.

Якщо клас успадковує кілька реалізацій одного й того самого члена (функції або властивості) від своїх безпосередніх супертипів, Kotlin зобовʼязує вас написати override і самостійно визначити підсумкову поведінку.

Це стосується і випадку «дві реалізації за замовчуванням», і випадку «в одному інтерфейсі — реалізація за замовчуванням, а в іншому — лише оголошення (абстрактне)». У документації Kotlin це формулюють так: за множинного успадкування реалізацій потрібно явно усунути неоднозначність, а для вибору конкретної реалізації використовується кваліфікований super<...>.

Важливе психологічне налаштування: компілятор не вередує. Він захищає вас (і вашого майбутнього вас) від магії «нехай вибереться якось само». У мовах, де такі речі «розвʼязуються автоматично», потім зʼявляються легендарні баги, які живуть рівно до першого релізу.

2. Конфлікт методів за замовчуванням і super<...>

Конфлікт двох методів за замовчуванням

Уявімо два інтерфейси: обидва дають реалізацію за замовчуванням одного й того самого методу ping():

interface A {
    fun ping(): String = "A"
}

interface B {
    fun ping(): String = "B"
}

Тепер ми хочемо зробити клас, який реалізує обидва:

class C : A, B // <- тут почнуться питання

З погляду Kotlin це ситуація «а яку ping() ти успадковуєш?». Якщо компілятор мовчки вибере одну, ви потім довго пояснюватимете собі, чому «інколи друкує A». Тому Kotlin вимагатиме:

  1. явно написати override fun ping(),
  2. усередині (за потреби) вибрати, яку реалізацію викликати.

І тут зʼявляється наш сьогоднішній герой: super<InterfaceName>.method().

super<...>: як сказати «візьми реалізацію саме звідси»

super без уточнення працює, коли успадкування однозначне (наприклад, звичайний клас успадковується від одного базового класу — і тоді super лише один). Але коли в нас є кілька джерел одного методу, Kotlin вимагає кваліфікації в кутових дужках: super<A>.ping().

Приклад: «склеїмо» поведінку двох інтерфейсів:

interface A {
    fun ping(): String = "A"
}

interface B {
    fun ping(): String = "B"
}

class C : A, B {
    override fun ping(): String {
        return super<A>.ping() + super<B>.ping()
    }
}

fun main() {
    val c = C()
    println(c.ping()) // AB
}

З погляду читання коду це зручно: ви відкрили метод — і одразу бачите «підсумкова поведінка — це комбінація A і B». Жодного шаманства.

3. Інші види конфліктів

Реалізація за замовчуванням + абстрактний метод: «реалізація ніби є, але override все одно потрібен»

Це найчастіша «пастка новачка».

Уявімо, що один інтерфейс дає реалізацію за замовчуванням, а другий лише вимагає метод (без тіла):

interface WithDefault {
    fun value(): Int = 10
}

interface MustImplement {
    fun value(): Int
}

На перший погляд: «Та ж є готова реалізація — чого компілятору ще треба?». Але Kotlin дивиться інакше: обидва інтерфейси оголосили один і той самий член, отже в класі має бути однозначно зафіксовано, що саме відбувається.

Ви пишете:

class D : WithDefault, MustImplement {
    override fun value(): Int = super<WithDefault>.value()
}

fun main() {
    val d = D()
    println(d.value()) // 10
}

Чому так краще? Тому що MustImplement — це контракт «хтось має надати сенс value()». Якщо ви «мовчки» отримаєте реалізацію з WithDefault, людина, яка читає код, може не помітити, що клас задовольняє MustImplement не своєю логікою, а просто «підхопив» чужу. Kotlin змушує зробити це явним: або ви пишете власну реалізацію, або кажете «так, я беру варіант за замовчуванням із WithDefault».

Конфлікт між класом та інтерфейсом

«Ромб» буває і так: є базовий клас (із реалізацією) і є інтерфейс (теж із реалізацією того самого методу). Kotlin розглядає це як дві реалізації з двох джерел — отже override обовʼязковий.

Мініприклад:

open class BaseLogger {
    open fun tag(): String = "BASE"
}

interface UiTaggable {
    fun tag(): String = "UI"
}

class ScreenLogger : BaseLogger(), UiTaggable {
    override fun tag(): String {
        return super<BaseLogger>.tag() + "-" + super<UiTaggable>.tag()
    }
}

fun main() {
    val logger = ScreenLogger()
    println(logger.tag()) // BASE-UI
}

Корисна звичка: коли ви бачите class X : SomeClass(), A, B, тримайте в голові, що конфлікти можливі не лише між A і B, а й між SomeClass та A/B.

Конфлікти властивостей: та сама логіка, тільки у вигляді val

Зазвичай конфлікти обговорюють на функціях, але властивості поводяться так само, тому що властивість по суті — це get() (а інколи й set()).

Уявімо:

interface LeftLabel {
    val label: String get() = "LEFT"
}

interface RightLabel {
    val label: String get() = "RIGHT"
}

class BothLabels : LeftLabel, RightLabel {
    override val label: String
        get() = super<LeftLabel>.label + "+" + super<RightLabel>.label
}

fun main() {
    val x = BothLabels()
    println(x.label) // LEFT+RIGHT
}

super<LeftLabel>.label — це по суті виклик геттера з конкретного інтерфейсу. І знову: конфлікт розвʼязується через override та явний вибір джерела.

4. Практичний приклад: форматування витрат різними стилями

«По-людськи» і CSV: два контракти, один конфлікт

Привʼяжемо ідею до практичного консольного застосунку (умовно — трекера витрат). До цього моменту курсу в нас уже є моделі даних (наприклад, data class Expense), і ми періодично виводимо їх у консоль. З часом майже неминуче зʼявляється бажання виводити одну й ту саму сутність у різних форматах: «по-людськи» і «в CSV».

Зробімо два інтерфейси-контракти: один про «людський» текст, другий — про CSV. І спеціально влаштуємо конфлікт: обидва дадуть метод за замовчуванням formatTitle(...), але за різними правилами.

data class Expense(
    val title: String,
    val amount: Int
)

interface HumanFormat {
    fun formatTitle(title: String): String =
        title.trim().replaceFirstChar { it.uppercase() }
}

interface CsvFormat {
    fun formatTitle(title: String): String =
        "\"" + title.trim().replace("\"", "\"\"") + "\""
}

Тепер хочемо клас, який «уміє і так, і так» (наприклад, згодом ми вибиратимемо формат залежно від режиму команди):

class ExpenseTitleFormatter : HumanFormat, CsvFormat {
    override fun formatTitle(title: String): String {
        // Вибираємо правило "для людей"
        return super<HumanFormat>.formatTitle(title)
    }
}

fun main() {
    val f = ExpenseTitleFormatter()
    println(f.formatTitle("  coffee  ")) // Coffee
}

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

Якщо вам потрібне, навпаки, правило CSV — змінюєте один рядок на super<CsvFormat>.formatTitle(title).

Комбінування поведінок: коли super-виклики стають новим правилом

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

interface HumanFormat {
    fun formatTitle(title: String): String =
        title.trim().replaceFirstChar { it.uppercase() }
}

interface CsvFormat {
    fun formatTitle(title: String): String =
        "\"" + title.trim().replace("\"", "\"\"") + "\""
}

class MixedTitleFormatter : HumanFormat, CsvFormat {
    override fun formatTitle(title: String): String {
        val human = super<HumanFormat>.formatTitle(title)
        val csv = super<CsvFormat>.formatTitle(title)
        return "$human (csv=$csv)"
    }
}

fun main() {
    val f = MixedTitleFormatter()
    println(f.formatTitle("  big \"sale\"  ")) // Big "sale" (csv="big ""sale""")
}

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

5. Шпаргалка: коли потрібен override і що всередині писати

Коли ви вперше стикаєтеся з конфліктами, мозок хоче простої шпаргалки. Зробімо невелику таблицю. Вона не замінить розуміння, але допоможе не панікувати.

Ситуація Що відбувається Що робити
Два інтерфейси дають реалізацію за замовчуванням одного методу Дві реалізації → неоднозначність override + вибрати/склеїти через super<A>/super<B>
Один інтерфейс дає реалізацію за замовчуванням, інший оголошує абстрактно Kotlin вимагає явності override + або своя логіка, або super<WithDefault>
Базовий клас та інтерфейс обидва реалізують метод Дві реалізації з різних супертипів override + (за потреби) super<Base> і/або super<Interface>
Конфлікт у властивості (val) із геттером По суті конфлікт геттерів override val + вибрати/склеїти через super<...>.prop

6. Як читати такі override у чужому коді й не боятися

Коли ви побачите в проєкті щось на кшталт:

override fun foo() {
    super<A>.foo()
    super<B>.foo()
}

не сприймайте це як «складність заради складності». Це майже завжди один із трьох сенсів.

Перший сенс — «обидві поведінки важливі, виконуємо обидві». Таке часто трапляється в логуванні, сповіщеннях, збиранні метрик, ланцюжках обробників. Другий сенс — «ми вибираємо одну реалізацію, але хочемо зафіксувати вибір явно». Тоді всередині буде один super<...>. Третій сенс — «ми не довіряємо реалізаціям за замовчуванням і повністю пишемо свою» — і тоді super<...> може взагалі не бути.

Корисна звичка: якщо ви бачите конфлікт інтерфейсів, поставте собі запитання «яке бізнес-правило тут ухвалено?». Відповідь шукайте в тексті override — його зазвичай і пишуть саме заради цього.

7. Типові помилки за множинної реалізації інтерфейсів

Помилка № 1: очікувати, що Kotlin «візьме перший інтерфейс у списку».
Деякі студенти за звичкою думають, що class X : A, B означає «якщо конфлікт — переможе A, бо він раніше». Kotlin так не робить. Він спеціально змушує вас ухвалити рішення через override, інакше код поводитиметься надто магічно, а налагодження перетвориться на археологію.

Помилка № 2: писати super.ping() у разі конфлікту й дивуватися, що це не працює.
Коли реалізацій кілька, super без уточнення не дає однозначної відповіді, і компілятор не дозволить вам «вгадати». У таких випадках потрібно саме super<A>.ping() або super<B>.ping(). Цей синтаксис — не прикраса, а спосіб явно вказати джерело реалізації.

Помилка № 3: «склеїти» дві реалізації без сенсу й отримати дивний результат.
Технічно можна зробити super<A>.foo(); super<B>.foo() майже завжди, але логічно це інколи абсурдно: наприклад, дві реалізації можуть означати різні формати виводу або різні політики. Якщо ви обʼєднуєте поведінки, нехай це буде усвідомлене правило, яке читається з коду (наприклад, через проміжні val і зрозумілий рядок результату).

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

Помилка № 5: створювати надто широкі інтерфейси, які постійно конфліктують за іменами.
Якщо ви регулярно ловите конфлікти на методах на кшталт print(), render(), format(), це може бути сигналом, що інтерфейси надто загальні й перетинаються за змістом. Іноді достатньо перейменувати метод на щось конкретніше (renderCsvLine(), renderHumanLine()), щоб і код читався краще, і конфліктів стало менше.

Помилка № 6: намагатися сховати конфлікт через «універсальну милицю» замість розвʼязання.
Іноді хочеться «аби компілювалося» — і тоді пишеться override, який повертає щось випадкове, ігноруючи обидві реалізації за замовчуванням. У підсумку контракт формально виконано, але поведінка стає неочікуваною. Якщо інтерфейси конфліктують, це майже завжди означає, що вам потрібно сформулювати правило: «ми вибираємо A», «ми вибираємо B» або «ми комбінуємо ось так-то».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ