Функции и лямбды
В Kotlin функция — это значение первого класса, и почти всё в теме выводится из того, во что это значение компилируется. Функциональный тип (Int) -> String — не абстракция: на JVM это интерфейс Function1<Integer, String>, поэтому каждая не встроенная лямбда — это объект, аллоцируемый в куче, а её примитивные аргументы при этом упаковываются в обёртки (Integer вместо int). Это и есть та цена, которую убирает inline (см. Inline-функции). Лямбда, которая захватывает переменную, компилируется в класс, хранящий это захваченное состояние; в отличие от Java, где захваченная переменная обязана быть effectively final, лямбда Kotlin умеет захватить и var — компилятор упаковывает её в Ref-объект. Передать в функцию высшего порядка можно не только лямбду, но и ссылку на функцию (::isEven, String::length), а Java-интерфейс с одним методом принимает лямбду через SAM-конверсию (для интерфейсов на самом Kotlin эту роль играет fun interface).
Главный водораздел темы — чем на самом деле является каждая конструкция под капотом. Функция-расширение и лямбда с ресивером — это один и тот же приём: ресивер передаётся скрытым первым параметром. Отсюда важнейший факт всей темы: расширения диспетчеризуются статически — они компилируются в статический метод и разрешаются по объявленному типу выражения, а не по типу объекта в рантайме, поэтому член класса всегда побеждает расширение, а расширение ничего не переопределяет. Операторы разрешаются не по символу, а по условному имени (plus, get, invoke, contains), и функция должна быть помечена operator. А tailrec переписывает хвостовой самовызов в цикл — но если условие хвостовой позиции не выполнено, компилятор выдаёт лишь предупреждение, и вы молча остаётесь с обычной рекурсией. Разбор по слоям — ниже.
Карта темы
- Функции высшего порядка — что значит «функция принимает или возвращает функцию», как устроен функциональный тип на JVM и почему каждая лямбда — это объект.
- Синтаксис trailing lambda — почему лямбда-последний-параметр выносится за скобки и как это делает вызовы
apply {}и DSL похожими на языковые блоки. - Функции-расширения — расширение как статический метод с ресивером первым параметром: почему оно не член, не видит
privateи не переопределяется. - Свойства-расширения — почему у них нет backing field, зачем обязателен собственный
get()и почему инициализатор — ошибка компиляции. - Лямбда с ресивером — тип
T.() -> Unit,thisвместоit, движок builder-DSL и@DslMarkerпротив утечки внешнего ресивера. - Локальные функции — именованная функция внутри другой, которая замыкает параметры и локальные переменные объемлющей функции, и чем она отличается от лямбды-значения.
- Перегрузка операторов — операторы как условные имена, а не символы,
operatorкак обязательное слово и оператор-расширение на чужом классе. - tailrec — переписывание хвостового самовызова в цикл, условия хвостовой позиции и молчаливое предупреждение вместо ошибки.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Путать функцию верхнего уровня с функцией высшего порядка | Верхний уровень — про место объявления; высший порядок — про функцию как аргумент или результат |
Считать, что параметр функционального типа обязан быть интерфейсом вроде Runnable | Функциональный тип (Int) -> Boolean самодостаточен; лямбда, ссылка ::isEven — уже значения этого типа |
| Забывать про цену лямбды | Не встроенная лямбда — это объект в куче, а её примитивные аргументы боксируются; лечит только inline |
| Выносить лямбду за скобки, когда она не последний параметр | Синтаксис trailing lambda работает лишь для последнего параметра функционального типа |
Считать, что trailing lambda требует inline | Синтаксис не зависит от inline — это чистое правило разбора вызова |
| Считать, что расширение добавляет член в класс | Расширение — статический метод с ресивером первым параметром; в класс ничего не добавляется |
| Ждать от расширения виртуальности и переопределения версии базового класса | Расширение выбирается по объявленному типу выражения; член класса всегда побеждает расширение |
Помечать класс open и ждать, что это изменит разрешение расширения | На разрешение расширения open не влияет никак — оно статическое по определению |
Писать val String.x = 1 и ждать, что значение сохранится в строке | У свойства-расширения нет backing field; инициализатор — ошибка компиляции, нужен get() |
Путать T.() -> Unit с (T) -> Unit | В первом ресивер — это this без квалификатора; во втором объект приходит параметром it |
| Считать, что во вложенном блоке DSL всегда побеждает самый внутренний ресивер | Внешний ресивер тоже в области видимости; неявный вызов может уйти к нему — на это и ставят @DslMarker |
| Думать, что локальная функция — это просто лямбда с именем | Локальная функция замыкает локальные переменные напрямую и не требует объекта-функции на вызов |
Забыть слово operator и ждать, что + скомпилируется | Без operator форма оператора не собирается; имя тоже фиксировано соглашением, а не символом |
| Считать, что оператор обязан быть членом класса | Оператор может быть расширением — так + или [] добавляют к чужому типу, не трогая библиотеку |
| Называть хвостовым вызов, результат которого ещё умножается или складывается | n * fact(n - 1) — не хвостовая позиция; работа с результатом остаётся, кадр не отбросить |
Ждать ошибки компиляции, когда tailrec неприменим | Компилятор выдаёт лишь предупреждение — вы молча остаётесь с обычной рекурсией и StackOverflowError |
Значение для собеседований
Эта тема проверяет не словарь, а модель исполнения: интервьюер хочет услышать, во что именно компилируется конструкция. «Чем расширение отличается от метода?» — правильный ответ не «расширение снаружи класса», а «расширение — это статический метод с ресивером первым параметром, разрешаемый по объявленному типу, поэтому член его всегда перебивает и переопределить он ничего не может». Тот же приём применяют ко всей теме: что физически происходит с лямбдой, когда она захватывает переменную; почему + не компилируется без operator; в какой ровно момент tailrec превращается в цикл, а когда — молча нет.
Что обычно проверяют:
- Что такое функция высшего порядка, как устроен функциональный тип на JVM и почему лямбда — это объект.
- Когда лямбда выносится за скобки и почему синтаксис trailing lambda не требует
inline. - Что расширение диспетчеризуется статически: член побеждает, наследник берёт базовую версию,
privateнедоступен. - Почему у свойства-расширения нет backing field и почему инициализатор запрещён.
- Как лямбда с ресивером
T.() -> Unitделает возможным builder-DSL и что чинит@DslMarker. - Чем локальная функция отличается от лямбды-значения и что она замыкает.
- Что операторы разрешаются по имени, а не по символу, и что оператор может быть расширением.
- Как
tailrecубирает расход стека и почему при нарушении условия это предупреждение, а не ошибка.
Типичный неверный ответ: «функция-расширение — это способ добавить метод в класс, и она работает как виртуальный, поэтому у наследника вызовется его версия». На деле в класс не добавляется ничего: расширение компилируется в статический метод, принимающий ресивер первым параметром, и выбирается по объявленному типу выражения. Список List<Notification> — это Notification для каждого элемента, поэтому даже на Urgent вызовется версия для Notification, а не «более конкретная». Расширение не участвует в виртуальной диспетчеризации и не переопределяет ничего; если рядом есть член с той же сигнатурой, побеждает член. Хотите полиморфизма — делайте поведение open-членом и override, а расширение оставьте для чужих классов, которые нельзя менять.