Функции и лямбды
Kotlin как функциональный язык — функции высшего порядка, функциональные типы, лямбды с ресивером и builder-DSL на их основе, синтаксис trailing lambda, локальные функции, tailrec, перегрузка операторов, функции и свойства-расширения.
13 вопросов
JuniorТеорияОчень частоЧто такое функция-расширение в Kotlin и как выглядит её место вызова?
Что такое функция-расширение в Kotlin и как выглядит её место вызова?
Функция-расширение объявляется вне класса, а сам класс становится её ресивером: fun String.shout() = uppercase(). Внутри this — это ресивер, место вызова выглядит как вызов члена, но в класс ничего не добавляется.
Типичные ошибки
- ✗Считать, что расширение действительно добавляет член в класс
- ✗Ожидать, что расширение дотянется до
private-членов ресивера - ✗Думать, что наследник может переопределить функцию-расширение
Уточняющие вопросы
- →Что произойдёт, если у расширения та же сигнатура, что у существующей функции-члена?
- →Почему расширение можно объявить на nullable-ресивере вроде
String??
JuniorТеорияОчень частоЧто такое функция высшего порядка в Kotlin и как передать в неё лямбду?
Что такое функция высшего порядка в Kotlin и как передать в неё лямбду?
Функция высшего порядка принимает функцию параметром или возвращает функцию. Её параметр объявлен функциональным типом вроде (Int) -> Boolean, а вызывающий передаёт лямбду, ссылку на функцию ::isEven или любое значение этого типа.
Типичные ошибки
- ✗Путать функцию верхнего уровня с функцией высшего порядка
- ✗Считать, что параметр функционального типа обязан быть интерфейсом вроде
Runnable - ✗Забывать, что ссылка на функцию
::isEvenсама является значением функционального типа
Уточняющие вопросы
- →Чем ссылка на функцию вроде
String::lengthотличается от равнозначной лямбды? - →Что получает вызывающий от функции высшего порядка, которая возвращает функцию?
JuniorТеорияЧастоКакие функции стоят за операторами +, [] и () и что делает operator?
Какие функции стоят за операторами +, [] и () и что делает operator?
Kotlin сопоставляет оператору условное имя функции: + вызывает plus, a[i] вызывает get, a(x) вызывает invoke. Функция должна быть помечена operator и совпасть по сигнатуре; без этого слова форма оператора не компилируется.
Типичные ошибки
- ✗Забыть слово
operatorи ждать, что+всё равно скомпилируется - ✗Называть функцию по символу, а не по соглашению (
plus,get,invoke) - ✗Считать, что подойдёт любая сигнатура — соглашение фиксирует параметры и тип возврата
Уточняющие вопросы
- →Какая функция стоит за оператором
inи что она обязана возвращать? - →Чем
plusAssignотличается отplus, когда в коде написаноa += b?
JuniorТеорияЧастоЧто такое синтаксис trailing lambda и почему вызовы DSL читаются как блоки?
Что такое синтаксис trailing lambda и почему вызовы DSL читаются как блоки?
Если последний параметр функции имеет функциональный тип, лямбду можно писать после скобок: items.filter { it > 0 }. Когда это единственный аргумент, скобки исчезают, и вызов measure { ... } читается как языковой блок.
Типичные ошибки
- ✗Пытаться вынести лямбду за скобки, когда она не последний параметр
- ✗Считать, что синтаксис требует, чтобы функция была
inline - ✗Забывать, что при единственном аргументе-лямбде скобки исчезают совсем
Уточняющие вопросы
- →Почему авторы библиотек ставят параметр функционального типа последним в сигнатуре?
- →Как передать в функцию две лямбды, если вынести за скобки можно лишь одну?
JuniorТеорияИногдаЧто такое хвостовой вызов и почему глубокая рекурсия переполняет стек вызовов?
Что такое хвостовой вызов и почему глубокая рекурсия переполняет стек вызовов?
Вызов в хвостовой позиции, когда он — последнее, что делает функция: с его результатом больше ничего не вычисляется. Обычная рекурсия держит на стеке каждый незавершённый кадр, поэтому глубокая цепочка исчерпывает стек и бросает StackOverflowError.
Типичные ошибки
- ✗Называть хвостовым вызов, результат которого ещё умножается или складывается
- ✗Считать, что JVM сама убирает рекурсивные кадры, как только функция вернула значение
- ✗Путать глубину рекурсии с размером её аргументов
Уточняющие вопросы
- →Какое ключевое слово позволяет компилятору Kotlin переиспользовать один кадр для хвостовой рекурсии?
- →Находится ли рекурсивный вызов внутри блока
tryв хвостовой позиции и почему?
MiddleТеорияИногдаПочему у свойства-расширения не может быть backing field и как его объявить?
Почему у свойства-расширения не может быть backing field и как его объявить?
В класс ничего не добавляется, поэтому хранить поле негде: у свойства-расширения нет backing field. Определяйте его через собственный get() и при необходимости set(), вычисляя значение из ресивера; инициализатор — ошибка компиляции.
Типичные ошибки
- ✗Писать
val String.x = 1и ждать, что значение сохранится в строке - ✗Думать, что значение где-то кешируется между двумя чтениями свойства
- ✗Считать, что backing field запрещён лишь
var-свойству-расширению
Уточняющие вопросы
- →Как дать свойству-расширению
set(), если писать некуда — поля нет? - →Чего стоит
get(), который пересчитывает тяжёлое значение при каждом чтении?
MiddleДебаггингИногдаУведомление Urgent всё равно отрисовывается как обычное — найдите и исправьте ошибку
Уведомление Urgent всё равно отрисовывается как обычное — найдите и исправьте ошибку
Расширение разрешается по объявленному статическому типу, а не по типу времени выполнения: оно компилируется в статическую функцию и ничего не переопределяет, поэтому при it: Notification берутся версии для Notification. Нужны open-члены и override.
Типичные ошибки
- ✗Ждать от расширения виртуальности и переопределения версии для базового класса
- ✗Читать ресивер как тип времени выполнения значения, а не как объявленный тип
- ✗Пометить класс как
openи ждать, что это изменит разрешение расширения
Уточняющие вопросы
- →Что делать, если
Notificationпришёл из библиотеки, которую нельзя менять? - →Какая версия победит, если у функции-члена и расширения одна сигнатура?
MiddleТеорияИногдаЧто такое лямбда с ресивером и как она делает возможным builder-DSL?
Что такое лямбда с ресивером и как она делает возможным builder-DSL?
Её тип — T.() -> Unit: внутри тела ресивер доступен как this, поэтому члены ресивера видны без квалификатора. Билдер создаёт новый T и передаёт его в такую лямбду — поэтому html { body { ... } } вызывает члены, не называя объект.
Типичные ошибки
- ✗Путать
T.() -> Unitс(T) -> Unit, где объект приходит какit - ✗Считать, что ресивер работает только потому, что функция-билдер помечена
inline - ✗Терять во вложенных блоках понимание того, какому ресиверу принадлежит неквалифицированный вызов
Уточняющие вопросы
- →Какие scope-функции построены на лямбде с ресивером, а какие нет?
- →Как аннотация
@DslMarkerменяет разрешение имён во вложенных блоках билдера?
MiddleТеорияИногдаЧем локальная функция отличается от лямбды, сохранённой в переменной?
Чем локальная функция отличается от лямбды, сохранённой в переменной?
Локальная функция — именованная функция внутри другой: она напрямую читает параметры и локальные переменные объемлющей функции и не требует объекта-функции на вызов. Лямбда в переменной — значение функционального типа, объект: его можно передать и вернуть.
Типичные ошибки
- ✗Думать, что локальная функция — это просто лямбда с именем
- ✗Считать, что локальную функцию можно передать в функцию высшего порядка как значение без
:: - ✗Считать, что лямбда в переменной не может пережить создавшую её функцию
Уточняющие вопросы
- →Как передать локальную функцию туда, где ждут значение функционального типа?
- →Когда вынести приватную функцию-член лучше, чем держать локальную?
MiddleДебаггингИногдаВызовы grid[1, 0] и 3 in grid не компилируются — найдите и исправьте ошибку
Вызовы grid[1, 0] и 3 in grid не компилируются — найдите и исправьте ошибку
Форма оператора разрешается только в функцию с условным именем, помеченную operator. Переименуйте at в get, а has в contains и пометьте обе operator: расширение может нести это слово, поэтому класс библиотеки править не нужно.
Типичные ошибки
- ✗Считать, что оператор обязан быть членом класса и не может быть расширением
- ✗Оставлять произвольное имя вроде
atилиhasвместо условного - ✗Забывать, что для работы
inфункцияcontainsобязана возвращатьBoolean
Уточняющие вопросы
- →Что сделает компилятор, если у класса уже есть член
getс такой же сигнатурой? - →Какое соглашение вы добавите, чтобы заработал и
for (cell in grid)?
MiddleПроизводительностьИногдаКак tailrec убирает расход стека и когда компилятор его не применяет?
Как tailrec убирает расход стека и когда компилятор его не применяет?
tailrec заставляет компилятор переписать самовызов в хвостовой позиции в цикл: кадр переиспользуется, и глубокая рекурсия не переполняет стек. n * fact(n - 1) не в хвостовой позиции — перенесите отложенную работу в аккумулятор локального помощника.
Типичные ошибки
- ✗Помечать функцию
tailrec, когда результат рекурсии ещё умножается или складывается - ✗Ждать ошибки компиляции вместо предупреждения, когда вызов не в хвостовой позиции
- ✗Считать, что
tailrecувеличивает стек, а не убирает рекурсию
Уточняющие вопросы
- →Почему рекурсивный вызов внутри
try/catchникогда не в хвостовой позиции? - →Что хранит параметр-аккумулятор локального помощника на каждой итерации?
SeniorТеорияИногдаЧем расширения Kotlin отличаются от статических методов утилитного класса StringUtils в Java?
Чем расширения Kotlin отличаются от статических методов утилитного класса StringUtils в Java?
Компилируются они в одно и то же: статический метод, принимающий ресивер первым параметром, в файловом классе вроде StringUtilsKt. Отличается место вызова — s.shout() вместо shout(s), — плюс свойства-расширения и импорт.
Типичные ошибки
- ✗Считать, что расширение диспетчеризуется виртуально, а статический метод — нет
- ✗Ждать, что расширение увидит
private-состояние ресивера так же, как член класса - ✗Забывать, что из Java расширение вызывается как статический метод файлового класса
Уточняющие вопросы
- →Как
@JvmNameна файле меняет вызов ваших расширений из Java-кода? - →Когда настоящая функция-член — честный выбор вместо расширения?
SeniorДизайнРедкоВаша команда поставляет конфигурационный DSL на Kotlin: вложенные блоки-билдеры как лямбды с ресивером, функции-расширения на этих ресиверах и два перегруженных оператора — унарный + добавляет зависимость, а - исключает её. Новички жалуются, что места вызова нечитаемы: внутри вложенного блока голый implementation(...) может принадлежать любому из трёх объемлющих ресиверов, неквалифицированное имя может разрешиться в расширение, импортированное из постороннего файла, а переход к объявлению уводит далеко от DSL. Руководитель хочет сохранить DSL и его лаконичность. Чего стоит читателю магия неявных ресиверов и как сохранить DSL, сделав каждое место вызова объяснимым?
Ваша команда поставляет конфигурационный DSL на Kotlin: вложенные блоки-билдеры как лямбды с ресивером, функции-расширения на этих ресиверах и два перегруженных оператора — унарный + добавляет зависимость, а - исключает её. Новички жалуются, что места вызова нечитаемы: внутри вложенного блока голый implementation(...) может принадлежать любому из трёх объемлющих ресиверов, неквалифицированное имя может разрешиться в расширение, импортированное из постороннего файла, а переход к объявлению уводит далеко от DSL. Руководитель хочет сохранить DSL и его лаконичность. Чего стоит читателю магия неявных ресиверов и как сохранить DSL, сделав каждое место вызова объяснимым?
Неявные ресиверы прячут субъект каждого вызова: неквалифицированное имя может прийти от любого объемлющего ресивера и связывается по объявленному типу, поэтому читатель не разрешит его локально. DSL стоит ограничить: @DslMarker, минимум операторов.
Типичные ошибки
- ✗Считать, что всегда побеждает самый внутренний ресивер, хотя внешний в области видимости тоже
- ✗Считать читаемость бесплатной, ведь имя за вас разрешает IDE
- ✗Добавлять операторы ради операторов, так что место вызова перестаёт называть действие
Уточняющие вопросы
- →Как именно аннотация
@DslMarkerограничивает, какие ресиверы остаются неявными? - →Где вы проведёте границу между оператором и функцией с явным именем?