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 вимагатиме:
- явно написати override fun ping(),
- усередині (за потреби) вибрати, яку реалізацію викликати.
І тут зʼявляється наш сьогоднішній герой: 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» або «ми комбінуємо ось так-то».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ