Введение: «добавить метод чужому классу»
«Можно ли добавить метод классу, который мы не можем менять?» — на собеседованиях я слышу этот вопрос в разных формулировках, и он всегда ведёт к extension-функциям. Половина кандидатов отвечает «да, конечно», а на уточняющем вопросе «а что там с диспетчеризацией?» замолкает.
Extension-функции — это механизм Kotlin, который позволяет вызывать новую функцию на объекте класса так, будто она его член, — без наследования и без изменения исходного класса. При этом сам класс не модифицируется: никаких новых членов в нём не появляется, вы просто делаете функцию «вызываемой через точку». Это главный пункт, с которого начинается понимание темы.
В Android-проектах extensions повсюду: утилиты для String, Context, View, RecyclerView и десятки функций из стандартной библиотеки вроде map, filter, joinToString. Разберём, как это устроено и что реально спрашивают.
Синтаксис: receiver и примеры
Синтаксис простой: перед именем функции указывается тип receiver — класс, который вы расширяете. Внутри функции через this доступен объект, на котором её вызвали:
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-типами:
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-функции диспетчеризуются статически: какая функция будет вызвана, решается на этапе компиляции по объявленному типу, а не по реальному типу объекта в рантайме. Это принципиально отличается от виртуальных методов.
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-функции:
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:
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 класса:
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 в другом пакете, его нужно импортировать явно;
- объявленные как члены класса — доступны только внутри этого класса (и его наследникам).
package app.utils
fun String.isEmailLike(): Boolean = contains("@")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, но именно на «базовых» темах собеседования отделяют тех, кто знает, от тех, кто догадывается. Три тезиса напоследок:
- Extension — это функция с receiver, а не метод: класс не модифицируется, вызов решается статически по объявленному типу.
- Член класса всегда тенирует extension с тем же именем и сигнатурой, а переопределить extension нельзя.
- Extension-свойства живут без backing field, а вне своего пакета extension требует явного импорта.
Вся теория с примерами — в официальной документации Extensions на kotlinlang.org.
Главное, что нужно запомнить
Extension-функция — это статически диспетчеризуемая функция с receiver. Член класса побеждает extension, переопределение невозможно, тип решает компилятор. Эти три факта закрывают 90% вопросов по теме.
Если на пробном ответе вы поняли, что язык знаете «в целом, но не глубоко» — это не повод откладывать собеседование, а повод готовиться системно. Напишите в Telegram, разберём вашу ситуацию и составим план.