Введение: «добавить метод чужому классу»

«Можно ли добавить метод классу, который мы не можем менять?» — на собеседованиях я слышу этот вопрос в разных формулировках, и он всегда ведёт к extension-функциям. Половина кандидатов отвечает «да, конечно», а на уточняющем вопросе «а что там с диспетчеризацией?» замолкает.

Extension-функции — это механизм Kotlin, который позволяет вызывать новую функцию на объекте класса так, будто она его член, — без наследования и без изменения исходного класса. При этом сам класс не модифицируется: никаких новых членов в нём не появляется, вы просто делаете функцию «вызываемой через точку». Это главный пункт, с которого начинается понимание темы.

В Android-проектах extensions повсюду: утилиты для String, Context, View, RecyclerView и десятки функций из стандартной библиотеки вроде map, filter, joinToString. Разберём, как это устроено и что реально спрашивают.

Синтаксис: receiver и примеры

Синтаксис простой: перед именем функции указывается тип receiver — класс, который вы расширяете. Внутри функции через this доступен объект, на котором её вызвали:

StringExtensions.kt
fun String.truncate(maxLength: Int): String =
    if (length <= maxLength) this else take(maxLength - 3) + "..."

fun main() {
    println("KotlinFan42".truncate(8))       // Kotl...
    println("Hi".truncate(8))                // Hi — короче лимита
}

Здесь String — receiver type, "KotlinFan42" — receiver (объект), а length и take() внутри функции — это this.length и this.take().

Extensions работают и с интерфейсами, и с generic-типами:

MapExtensions.kt
fun Map<String, Int>.mostVoted(): String? =
    maxByOrNull { (_, votes) -> votes }?.key

fun main() {
    val poll = mapOf("Kotlin" to 37, "Java" to 22)
    println(poll.mostVoted()) // Kotlin
}

Можно расширять и nullable-типы: тогда внутри функции this может быть null, и вы обязаны это обработать, например проверкой this == null.

В Android-проектах extension-функции — рабочий инструмент номер один для утилит: Context-хелперы, хелперы для View и RecyclerView, форматирование дат и строк. И почти всё, что вы каждый день вызываете на коллекциях, — тоже extensions из стандартной библиотеки: map, filter, joinToString — всё это extension-функции, объявленные в kotlin.collections. Так что вы пользуетесь ими постоянно, просто не замечаете.

Как запомнить

Extension-функция — это обычная функция, у которой просто есть receiver. Внутри — как метод класса: доступ к this, к публичным членам receiver. Но на самом деле это не метод: компилятор подставляет вызов статической функции с receiver в качестве первого аргумента.

Статическая диспетчеризация и тенирование

Самый важный вопрос собеседования. Extension-функции диспетчеризуются статически: какая функция будет вызвана, решается на этапе компиляции по объявленному типу, а не по реальному типу объекта в рантайме. Это принципиально отличается от виртуальных методов.

Shape.kt
open class Shape
class Rectangle : Shape()

fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"

fun main() {
    val shape: Shape = Rectangle()
    println(shape.getName()) // Shape — по объявленному типу, а не по Rectangle
}

Переменная shape объявлена как Shape, поэтому вызывается Shape.getName(), хотя в рантайме там Rectangle. Виртуальный метод в такой ситуации вызвал бы версию Rectangle — а extension нет. Этот пример стоит повторить вслух перед собеседованием.

Второй блок вопросов — тенирование. Если у класса есть член-метод с тем же именем и совместимыми параметрами, он всегда выигрывает у extension-функции:

Example.kt
class Example {
    fun printMessage() = println("member")
}

fun Example.printMessage() = println("extension")

fun main() {
    Example().printMessage() // member — член класса побеждает
}

Типичная ошибка: кандидат говорит «extension можно переопределить в подклассе». Нельзя: extension не участвует в виртуальной диспетчеризации, её нельзя переопределить, только объявить ещё одну для конкретного типа. Но extension может перегрузить член класса, если сигнатура отличается — например, printMessage(index: Int) рядом с printMessage().

Extension-свойства и companion object

Extensions бывают не только функциями, но и свойствами. Главное ограничение: у extension-свойства нет backing field, поэтому нельзя задать начальное значение — только getter и setter:

User.kt
data class User(val firstName: String, val lastName: String)

val User.displayName: String
    get() = "$firstName $lastName"

fun main() {
    println(User("Rustem", "Bikbulatov").displayName)
}

Расширять можно и companion object класса:

Logger.kt
class Logger {
    companion object
}

fun Logger.Companion.logStartup() {
    println("Application started")
}

fun main() {
    Logger.logStartup() // вызов через имя класса
}

Отдельная тема — member extensions: extension, объявленный внутри другого класса. Тогда у него два receiver: dispatch receiver (класс, где объявлен) и extension receiver (расширяемый тип). На собеседовании это скорее уровень senior — но упомянуть, что такие extensions бывают и что при конфликте имён выигрывает extension receiver, — плюс к ответу.

Область видимости и импорт

Extensions подчиняются тем же правилам видимости, что и обычные функции: public, internal, private работают как обычно. А вот область действия специфична:

  • объявленные top-level в файле — доступны в этом файле без импорта;
  • чтобы использовать extension в другом пакете, его нужно импортировать явно;
  • объявленные как члены класса — доступны только внутри этого класса (и его наследникам).
ImportExample.kt
package app.utils

fun String.isEmailLike(): Boolean = contains("@")
UseImport.kt
package app.ui

import app.utils.isEmailLike

fun main() {
    println("user@mail.ru".isEmailLike()) // true — без импорта не скомпилируется
}

На практике это значит: extension-функции проекта лучше складывать в понятные файлы (StringExtensions.kt, ViewExtensions.kt) и не разбрасывать по коду — иначе импорты превращаются в хаос. Это и вопрос кода, и вопрос культуры.

  • Extension не модифицирует класс — это функция с receiver, вызываемая через точку.
  • Диспетчеризация статическая: выбор по объявленному типу, а не по рантайму.
  • Член класса тенирует extension с тем же именем; переопределить extension нельзя.
  • У extension-свойства нет backing field — только getter/setter.
  • Вне пакета объявления extension нужно импортировать явно.

Правила

Два главных правила, которые проверяют на собеседованиях: статическая диспетчеризация (выбор по объявленному типу) и приоритет члена класса над extension. Оба — в этом разборе, с примерами.

Готовитесь к собеседованию на Android-разработчика?

Темы вроде extensions кажутся простыми, пока интервьюер не начинает копать. На менторстве мы тренируем такие ответы и закрываем пробелы в языке и архитектуре перед выходом на middle.

Итоги

Extension-функции — базовый инструмент Kotlin, но именно на «базовых» темах собеседования отделяют тех, кто знает, от тех, кто догадывается. Три тезиса напоследок:

  1. Extension — это функция с receiver, а не метод: класс не модифицируется, вызов решается статически по объявленному типу.
  2. Член класса всегда тенирует extension с тем же именем и сигнатурой, а переопределить extension нельзя.
  3. Extension-свойства живут без backing field, а вне своего пакета extension требует явного импорта.

Вся теория с примерами — в официальной документации Extensions на kotlinlang.org.

Главное, что нужно запомнить

Extension-функция — это статически диспетчеризуемая функция с receiver. Член класса побеждает extension, переопределение невозможно, тип решает компилятор. Эти три факта закрывают 90% вопросов по теме.

Если на пробном ответе вы поняли, что язык знаете «в целом, но не глубоко» — это не повод откладывать собеседование, а повод готовиться системно. Напишите в Telegram, разберём вашу ситуацию и составим план.