1. Введение
Когда вы только начинаете писать код, кажется, что один класс — это «одна роль». Но реальность быстро доказывает обратное. Один и тот же объект может одновременно быть «тем, что умеет печататься в консоль», «тем, что умеет сериализоваться в CSV», «тем, что можно логировать», «тем, что можно валидировать». И если вы попытаетесь свалить всё в один интерфейс EverythingEverywhereAllAtOnce, то получите не архитектуру, а шведский стол из методов.
Ключевая идея: интерфейсы удобно держать узкими и тематическими. Тогда класс может реализовать сразу несколько маленьких контрактов. Kotlin это разрешает: class X : A, B, C. Проблема появляется не из‑за множественности как таковой, а из‑за того, что контракты иногда пересекаются по именам и сигнатурам. И вот тут компилятор становится вашим строгим редактором: «В этой сцене два актёра одновременно произнесли реплику render() — кого из них слышим?»
Базовое правило Kotlin: неоднозначность запрещена
Правило полезно выучить буквально как дорожный знак.
Если класс наследует несколько реализаций одного и того же члена (функции или свойства) от своих непосредственных супертипов, Kotlin обязывает вас сделать override и самому определить итоговое поведение.
Это относится и к случаю «две default‑реализации», и к случаю «в одном интерфейсе default, а в другом просто объявление (абстрактно)». В документации Kotlin это формулируется так: при множественном наследовании реализаций нужно явно устранить неоднозначность, а для выбора конкретной реализации используется квалифицированный super<...>.
Важная психологическая настройка: компилятор не вредничает. Он защищает вас (и будущего вас) от магии «пусть выберется как‑нибудь само». В языках, где такие вещи «разруливаются автоматически», потом появляются легендарные баги, которые живут ровно до первого релиза.
2. Конфликт default‑методов и super<...>
Конфликт двух default‑методов
Представим два интерфейса, оба дают default‑реализацию одного и того же метода 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. Другие виды конфликтов
Default + абстрактный метод: «реализация вроде есть, но override всё равно нужен»
Это самая частая «ловушка новичка».
Представим, что один интерфейс даёт default‑реализацию, а второй только требует метод (без тела):
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. И специально устроим конфликт: оба дадут default‑метод 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 и что внутри писать
Когда вы в первый раз сталкиваетесь с конфликтами, мозг хочет простую шпаргалку. Сделаем небольшую таблицу. Она не заменит понимание, но поможет не паниковать.
| Ситуация | Что происходит | Что делать |
|---|---|---|
| Два интерфейса дают default одного метода | Две реализации → неоднозначность | override + выбрать/склеить через super<A>/super<B> |
| Один интерфейс default, другой объявляет абстрактно | 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: не замечать конфликт, потому что «реализация вроде одна».
Случай «default + абстрактный» часто воспринимают как «ну реализация же есть». Но Kotlin требует override, потому что пересекаются контракты. Если вы не понимаете, почему компилятор заставляет переопределять метод, проверьте: возможно, второй интерфейс объявляет тот же метод без тела. Это не баг, это защита читабельности.
Ошибка №5: создавать слишком широкие интерфейсы, которые постоянно конфликтуют по именам.
Если вы регулярно ловите конфликты на методах вроде print(), render(), format(), это может быть сигналом, что интерфейсы слишком общие и пересекаются смыслом. Иногда достаточно переименовать метод во что-то более конкретное (renderCsvLine(), renderHumanLine()), чтобы и код читался лучше, и конфликтов стало меньше.
Ошибка №6: пытаться спрятать конфликт через «универсальный костыль» вместо решения.
Иногда хочется «лишь бы компилировалось» и пишется override, который возвращает что-то случайное, игнорируя обе default‑реализации. В итоге контракт формально выполнен, но поведение становится неожиданным. Если интерфейсы конфликтуют, это почти всегда означает, что вам нужно сформулировать правило: «мы выбираем A», «мы выбираем B» или «мы комбинируем вот так-то».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ