Как стать автором
Поиск
Написать публикацию
Обновить
242.58
Альфа-Банк
Лучший мобильный банк по версии Markswebb

Что не пишут в документации Kotlin Contracts: тёмные закоулки и пасхалки

Уровень сложностиСложный
Время на прочтение9 мин
Количество просмотров2K

Небольшой дисклеймер

Я не писал компилятор Kotlin и, как и любой живой человек, мог что‑то упустить или неверно истолковать. Моя задача — не расписать каждый винтик в механизме Contracts, а поделиться теми любопытными моментами, которые удалось накопать при чтении исходников. Если что‑то покажется неточным или спорным — пишите в комментариях, всегда рад открытому диалогу и коллективному «докапыванию до сути».

Примечание. Это вторая часть «эпопеи» про контракты в Kotlin. Рекомендую прочитать первую часть по ссылке ниже:

Не хотите поговорить о Kotlin Contracts?
Привет! Меня зовут Виталий. Я — Android‑разработчик в Альфа‑Банке. За время собеседований я заметил ...
habr.com

Очень краткий экскурс в тему Kotlin компилятора

Три «рандомных» факта на тему...

№1. Kotlin компилятор состоит из двух частей:

  • Frontend — отвечает за построение синтаксического дерева (структуры кода) и добавление семантической информации (смысл кода).

  • Backend — отвечает за генерацию кода для целевой (target) платформы: JVM, JS, Native, WASM (экспериментальный).

№2. У компилятора Kotlin есть две фронтенд‑реализации:

  • K1 (FE10-).

  • K2 (Fir‑).

№3. Упрощённо весь процесс работы фронтенда выглядит так:

  • Компилятор принимает исходный код.

  • Анализирует код лексически, синтаксически и семантически.

  • Отправляет данные на бэкенд для последующей генерации IR (Intermediate representation) и целевого кода платформы (target).

В рамках данной статьи мы будем рассматривать только Frontend-часть, потому что именно в этой части находится вся «магия» Kotlin контрактов. Также в этой части будет рассматриваться работа с версией K2, в связи с тем, что K1 теряет свою актуальность.

Кому интересно поглубже разобраться в работе Kotlin компилятора, рекомендую начать с этой статьи:

Как новый компилятор K2 ускоряет компиляцию Kotlin на 94%
Привет, меня зовут Мялкин Максим, я занимаюсь мобильной разработкой в KTS. Не за горами выпуск новой...
habr.com

Ну что ж, пристёгивайтесь, нас ждёт увлекательное путешествие в недры Kotlin контрактов!

Не только fun: где ещё компилятор ждёт контракт?

Основа Kotlin K2 компилятора — это FIR‑дерево (Frontend Intermediate Representation).

Вкратце: FIR — это AST (абстрактное синтаксическое дерево), обогащённое семантической (смысловой) информацией. Подробнее про FIR — по ссылке ниже.

Как новый компилятор K2 ускоряет компиляцию Kotlin на 94%
Привет, меня зовут Мялкин Максим, я занимаюсь мобильной разработкой в KTS. Не за горами выпуск новой...
habr.com

Оказывается, что у этой основополагающей технологии есть своя небольшая документация: fir‑basics.md. Давайте в неё заглянем и прочитаем что в ней написано про контракты. А написано там следующее (в моём вольном переводе):

Компилятор разрешает использовать контракты в свойствах, функциях и конструкторах классов

Чтооо? Для меня это было удивительно, потому что ранее я видел их использование только внутри тела функций. Давайте проверять!

В доке написано, что для свойств должно работать, но на практике получаем ошибку.

Давайте...

Заглянем в компилятор и посмотрим, с чем связана эта ошибка

В Kotlin компиляторе за проверки валидности контрактов отвечает класс FirContractChecker. У него есть 1 публичный переопределённый метод check(FirFunction), где параметр FirFunction — это нода в FIR‑дереве, которая описывает функцию.

Как мы видим, в первой строке метода check(FirFunction) отсеиваются любые «сущности», которые не реализуют интерфейс FirContractDescriptionOwner.

В интерфейсе содержится нулабельное поле contractDescription типа FirContractDescription, которое отвечает за полное описание контракта. Не у каждой функции реализован контракт, но если он реализован, то это поле будет заполнено.

Давайте посмотрим у каких «сущностей» реализован интерфейс FirContractDescriptionOwner, чтобы понять где на самом деле на уровне компилятора есть поддержка контрактов:

В очередной раз убедились, что на уровне компилятора можно использовать контракты не только на уровне функций.

Давайте разбираться дальше....

Где находится то самое ограничение на использование контрактов вне функций?

Вернёмся к методу check(FirFunction) и обратим внимание, что на второй строке мы получаем то самое описание контракта contractDescription, а если его нет (возвращается null), то выходим из функции.

Ведь зачем нам анализировать валидность контракта, если его не существует, так ведь?

Далее в функции check(FirFunction) вызывается метод `checkContractNotAllowed(FirFunction, FirContractDescription): Boolean`, с параметрами которого мы уже знакомы. Заглянем в функцию и увидим, что в блоке when первым же условием компилятор проверяет, является ли наша «сущность» свойством.

  • Если является таковым, то компилятор проверяет включён ли Feature Flag под названием AllowContractsOnPropertyAccessors.

  • Если выключен — мы получаем ту самую ошибку: «Contracts are only allowed for functions.».

Посмотрим при каких условиях включается Feature Flag AllowContractsOnPropertyAccessors.

Класс, в котором лежат Feature Flag'и
Класс, в котором лежат Feature Flag'и

В этом Feature Flag'е есть информация как о том, с какой версии ожидается фича (с версии Kotlin 2.3), так и тикет KT-27090 в рамках которой реализуется данная функциональность.

Конкретные Feature Flag'и
Конкретные Feature Flag'и

Получается, что ориентировочно с версии Kotlin 2.3, если релиз фичи не сдвинется, мы наконец‑то сможем потрогать контракты в свойствах.

В качестве бонуса мы теперь можем подсматривать будущие фичи Kotlin'а через LanguageVersionSettings, в котором лежат Feature Flag'ы для будущих фич!

Новый Contracts API

Теперь давайте взглянем на одну ещё не финализированную, но уже доступную для экспериментов, фичу в мире Kotlin Contracts — новый синтаксис контрактов (я её открыл чисто случайно). Этот подход выглядит куда лаконичнее и органичнее с точки зрения дизайна языка.

Примечание. Если интересно узнать, какие ограничения и недостатки есть у текущего Contracts API в Kotlin, рекомендую заглянуть в тикет KT-56127 — там собрана самая актуальная информация

Посмотрим, как выглядит новый API контрактов в Kotlin.

fun checkAndRun(x: Any, block: (x: String) -> Unit): Boolean contract [
    callsInPlace(block, AT_MOST_ONCE),
    returns(true) implies (x is String)
] {
    if (x is String) {
        block()
        return true
    } else {
        return false
    }
}

В новом API контракт теперь сразу прописывается в декларации функции — не нужно прятать его внутри тела. Ещё на этапе разбора PSI компилятор видит, есть ли контракт, и не тратит время на поиски внутри тела функции. Плюс стало удобно явно указывать несколько эффектов: после contract просто открываем квадратные скобки, как в аннотациях.

Как включить новый API

Если вы попробуете использовать новый синтаксис контрактов, то скорее всего получите ошибку компиляции.

Новый синтаксис контрактов V2 до включения ContractSyntaxV2
Новый синтаксис контрактов V2 до включения ContractSyntaxV2

Новый Contract API сейчас спрятан за Feature Flag'ом ContractSyntaxV2. Чтобы его включить, добавьте в опции Kotlin компилятора аргумент "-XXLanguage:+ContractSyntaxV2", а затем обязательно пересинхронизируйте Gradle.

Пример Project Gradle файла с включенной опцией ContractSyntaxV2
Пример Project Gradle файла с включенной опцией ContractSyntaxV2

После этого вы сможете попробовать новый синтаксис контрактов в своём проекте.

Новый синтаксис контрактов после включения ContractSyntaxV2
Новый синтаксис контрактов после включения ContractSyntaxV2

Как работает новый Contracts API изнутри

Теперь посмотрим, как под капотом Kotlin парсит новый синтаксис контрактов. Для начала парсер сканирует исходный код и разбивает его на лексемы (KtTokens). Например:

  • [   KtTokens.LBRACKET 

  • ]   KtTokens.RBRACKET

  • contract   KtTokens.CONTRACT_KEYWORD

Пример токенов из KtTokens
Пример токенов из KtTokens

Обратите внимание: в новом API contract — это уже зарезервированное слово, выделенное в отдельный токен. В старом API конструкция contract { ... } была обычным вызовом метода, и компилятор на этапе лексического анализа вообще не знал, что это какой‑то специальный контракт — для него это был просто идентификатор (название метода) и фигурные скобки.

После лексического анализа компилятор переходит к синтаксическому, где из списка лексем строится дерево токенов. Этот этап начинается в классе KotlinParsing.

При парсинге сущности «функция» вызывается метод parseFunction(boolean), внутри которого дважды происходит вызов parseFunctionContract() (почему так, обсудим чуть позже).

Тело функции parseFunction(boolean)
Тело функции parseFunction(boolean)

Далее parseFunctionContract() передаёт управление парсинга блока контракта, вызывая метод parseContractDescriptionBlock() у объекта KotlinExpressionParsing.

Метод parseFunctionContract из класса KotlinParsing
Метод parseFunctionContract из класса KotlinParsing

В методе parseContractDescriptionBlock() парсер сначала сдвигает курсор на следующий токен с помощью advance(), а после этого приступает к разбору списка эффектов контракта.

Метод parseContractDescriptionBlock
Метод parseContractDescriptionBlock

Кстати, заметили, что мы нигде явно не передаём информацию о текущей позиции парсера? Тем не менее, парсер всегда «в курсе», где он находится в коде. Вся магия в том, что состояние курсора хранится внутри специального свойства myBuilder — это экземпляр класса SemanticWhitespaceAwarePsiBuilder.

Теперь давайте посмотрим как в методе parseContractEffectList() парсится список эффектов.

Как парсится список эффектов

Метод parseContractEffectList()
Метод parseContractEffectList()

Функция разбирает блок эффектов контракта, который пишется в квадратных скобках на узлы PSI дерева.

№1. mark()

PsiBuilder.Marker block = mark();
  • Создаёт «маркер» начала нового узла PSI‑дерева.

  • Парсер как бы «помечает», что сейчас начнётся новая структура.

  • Всё, что будет спаршено между этим mark() и последующим done(), станет узлом PSI дерева.

№2. expect(LBRACKET, "Expecting '['")

expect(LBRACKET, "Expecting '['");
  • Проверяет, стоит ли сейчас токен [.

  • Если да — сдвигает позицию курсора на следующий токен.

  • Если нет — кидает ошибку с сообщением «Expecting '['».

№3. parseContractEffects()

  • Разбирает сами эффекты контракта внутри блока — например,
    returns() implies (value != null).

  • returns(), эффект до implies, будет помечен как обычный вызов метода: CALL_EXPRESSION

  • implies будет помечен как оператор: OPERATION_EXPRESSION, аналогично != или &&

  • (value != null) будет помечена как группа: PARENTHESIZED, с бинарной операцией: BINARY_EXPRESSION

  • Всё выражение одного эффекта будет помечено как CONTRACT_EFFECT c вложенной BINARY_EXPRESION

В итоге, выражение returns() implies (value != null) в дереве PSI будет выглядеть так:

Нода эффекта PSI дерева
Нода эффекта PSI дерева

№4. block.done(CONTRACT_EFFECT_LIST)

block.done(CONTRACT_EFFECT_LIST);
  • Закрывает маркер, созданный на первом шаге, и объявляет:
    «Всё, что мы только что разобрали между скобками — это узел типа CONTRACT_EFFECT_LIST.»

  • Этот узел появится в PSI‑дереве и будет представлять весь список эффектов контракта.

Нода списка эффектов PSI дерева
Нода списка эффектов PSI дерева

В результате этих этапов у нас получается PSI‑дерево, которое затем преобразуется в RawFIR, обогащается семантикой и превращается в ResolvedFIR.

Иконка плагина PsiViewer
Иконка плагина PsiViewer

Если хотите посмотреть, какое именно PSI‑дерево строит компилятор, попробуйте установить плагин PsiViewer для IntelliJ IDEA — после установки его иконка появится в правой панели IDE.

Просто кликните по иконке PsiViewer — и перед вами откроется сгенерированное PSI‑дерево для текущего файла.

Сгенерированное PSI дерево
Сгенерированное PSI дерево

Двойной вызов парсинга контрактов

Помните в функции parseFunction(boolean) дважды вызывался метод parseFunctionContract(): до и после вызова parseTypeConstraintsGuarded(boolean)?

Тело функции parseFunction(boolean)
Тело функции parseFunction(boolean)

Вызов parseTypeConstraintsGuarded(boolean) как раз отвечает за парсинг ограничений generic типов (where). Это значит, что контракт можно объявлять как до, так и после блока where в функции — получилось довольно гибко и удобно.

Команда Kotlin компилятора думает где правильно указать Kotlin контракты
Команда Kotlin компилятора думает где правильно указать Kotlin контракты

Можно объявить контракт до блока where

Контракт до блока where
Контракт до блока where

Можно объявить контракт после блока where

Контракт после блока where
Контракт после блока where

Но нельзя объявлять контракт одновременно и до, и после where

Контракт и до, и после блока where
Контракт и до, и после блока where

Заключение

Спасибо, что дочитали до конца! Надеюсь, после этой статьи внутренности Kotlin Contracts и компилятора в целом стали для вас чуть понятнее и ближе. Как видите, не так уж страшно заглядывать «под капот» языка — иногда там можно найти ответы на свои вопросы и даже кое‑какие сюрпризы.

Если остались вопросы или хочется что‑то обсудить — пишите в комментариях, всегда рад диалогу и новым находкам. Спасибо за внимание и удачных раскопок в мире исходников!

Дополнительные материалы

Теги:
Хабы:
+17
Комментарии2

Полезные ссылки

Как много языков может влезть в одного программиста?

Уровень сложностиПростой
Время на прочтение10 мин
Количество просмотров28K
Всего голосов 25: ↑21 и ↓4+19
Комментарии67

100 дней из жизни новичка: как устроен онбординг в мобильной разработке

Уровень сложностиПростой
Время на прочтение7 мин
Количество просмотров5.2K
Всего голосов 30: ↑29 и ↓1+30
Комментарии7

Как подружить JUnit 5 и Robolectric?

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров2.4K
Всего голосов 9: ↑9 и ↓0+9
Комментарии0

Что делать, если кандидат оказался сеньором или психом: советы на случай форс-мажоров

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров78K
Всего голосов 75: ↑58 и ↓17+59
Комментарии154

Информация

Сайт
digital.alfabank.ru
Дата регистрации
Дата основания
1990
Численность
свыше 10 000 человек
Местоположение
Россия