Небольшой дисклеймер
Я не писал компилятор Kotlin и, как и любой живой человек, мог что‑то упустить или неверно истолковать. Моя задача — не расписать каждый винтик в механизме Contracts, а поделиться теми любопытными моментами, которые удалось накопать при чтении исходников. Если что‑то покажется неточным или спорным — пишите в комментариях, всегда рад открытому диалогу и коллективному «докапыванию до сути».
Примечание. Это вторая часть «эпопеи» про контракты в Kotlin. Рекомендую прочитать первую часть по ссылке ниже:
Очень краткий экскурс в тему 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 компилятора, рекомендую начать с этой статьи:
Ну что ж, пристёгивайтесь, нас ждёт увлекательное путешествие в недры Kotlin контрактов!
Не только fun: где ещё компилятор ждёт контракт?
Основа Kotlin K2 компилятора — это FIR‑дерево (Frontend Intermediate Representation).
Вкратце: FIR — это AST (абстрактное синтаксическое дерево), обогащённое семантической (смысловой) информацией. Подробнее про FIR — по ссылке ниже.
Оказывается, что у этой основополагающей технологии есть своя небольшая документация: fir‑basics.md. Давайте в неё заглянем и прочитаем что в ней написано про контракты. А написано там следующее (в моём вольном переводе):
Компилятор разрешает использовать контракты в свойствах, функциях и конструкторах классов

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

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

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

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

В интерфейсе содержится нулабельное поле contractDescription типа FirContractDescription, которое отвечает за полное описание контракта. Не у каждой функции реализован контракт, но если он реализован, то это поле будет заполнено.
Давайте посмотрим у каких «сущностей» реализован интерфейс FirContractDescriptionOwner, чтобы понять где на самом деле на уровне компилятора есть поддержка контрактов:
-
FirSimpleFunction — обычная функция.
-
FirConstructor — конструктор.
-
FirAnonymousFunction — анонимная функция.
-
FirPropertyAccessor — свойство класса.
В очередной раз убедились, что на уровне компилятора можно использовать контракты не только на уровне функций.
Давайте разбираться дальше….
Где находится то самое ограничение на использование контрактов вне функций?
Вернёмся к методу 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’е есть информация как о том, с какой версии ожидается фича (с версии Kotlin 2.3), так и тикет KT-27090 в рамках которой реализуется данная функциональность.
Получается, что ориентировочно с версии 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
Если вы попробуете использовать новый синтаксис контрактов, то скорее всего получите ошибку компиляции.
ContractSyntaxV2Новый Contract API сейчас спрятан за Feature Flag’ом ContractSyntaxV2. Чтобы его включить, добавьте в опции Kotlin компилятора аргумент "-XXLanguage:+ContractSyntaxV2", а затем обязательно пересинхронизируйте Gradle.
ContractSyntaxV2После этого вы сможете попробовать новый синтаксис контрактов в своём проекте.
ContractSyntaxV2
Как работает новый Contracts API изнутри
Теперь посмотрим, как под капотом Kotlin парсит новый синтаксис контрактов. Для начала парсер сканирует исходный код и разбивает его на лексемы (KtTokens). Например:
-
[→KtTokens.LBRACKET -
]→KtTokens.RBRACKET -
contract→KtTokens.CONTRACT_KEYWORD
Обратите внимание: в новом API contract — это уже зарезервированное слово, выделенное в отдельный токен. В старом API конструкция contract { ... } была обычным вызовом метода, и компилятор на этапе лексического анализа вообще не знал, что это какой‑то специальный контракт — для него это был просто идентификатор (название метода) и фигурные скобки.
После лексического анализа компилятор переходит к синтаксическому, где из списка лексем строится дерево токенов. Этот этап начинается в классе KotlinParsing.
При парсинге сущности «функция» вызывается метод parseFunction(boolean), внутри которого дважды происходит вызов parseFunctionContract() (почему так, обсудим чуть позже).
Далее parseFunctionContract() передаёт управление парсинга блока контракта, вызывая метод parseContractDescriptionBlock() у объекта KotlinExpressionParsing.
В методе parseContractDescriptionBlock() парсер сначала сдвигает курсор на следующий токен с помощью advance(), а после этого приступает к разбору списка эффектов контракта.
Кстати, заметили, что мы нигде явно не передаём информацию о текущей позиции парсера? Тем не менее, парсер всегда «в курсе», где он находится в коде. Вся магия в том, что состояние курсора хранится внутри специального свойства myBuilder — это экземпляр класса SemanticWhitespaceAwarePsiBuilder.
Теперь давайте посмотрим как в методе 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_EFFECTc вложеннойBINARY_EXPRESION
В итоге, выражение returns() implies (value != null) в дереве PSI будет выглядеть так:
№4. block.done(CONTRACT_EFFECT_LIST)
block.done(CONTRACT_EFFECT_LIST);
-
Закрывает маркер, созданный на первом шаге, и объявляет:
«Всё, что мы только что разобрали между скобками — это узел типаCONTRACT_EFFECT_LIST.» -
Этот узел появится в PSI‑дереве и будет представлять весь список эффектов контракта.
В результате этих этапов у нас получается PSI‑дерево, которое затем преобразуется в RawFIR, обогащается семантикой и превращается в ResolvedFIR.
Если хотите посмотреть, какое именно PSI‑дерево строит компилятор, попробуйте установить плагин PsiViewer для IntelliJ IDEA — после установки его иконка появится в правой панели IDE.
Просто кликните по иконке PsiViewer — и перед вами откроется сгенерированное PSI‑дерево для текущего файла.
Двойной вызов парсинга контрактов
Помните в функции parseFunction(boolean) дважды вызывался метод parseFunctionContract(): до и после вызова parseTypeConstraintsGuarded(boolean)?
Вызов parseTypeConstraintsGuarded(boolean) как раз отвечает за парсинг ограничений generic типов (where). Это значит, что контракт можно объявлять как до, так и после блока where в функции — получилось довольно гибко и удобно.
Можно объявить контракт до блока where ✅
whereМожно объявить контракт после блока where ✅
whereНо нельзя объявлять контракт одновременно и до, и после where ❌
whereЗаключение
Спасибо, что дочитали до конца! Надеюсь, после этой статьи внутренности Kotlin Contracts и компилятора в целом стали для вас чуть понятнее и ближе. Как видите, не так уж страшно заглядывать «под капот» языка — иногда там можно найти ответы на свои вопросы и даже кое‑какие сюрпризы.
Если остались вопросы или хочется что‑то обсудить — пишите в комментариях, всегда рад диалогу и новым находкам. Спасибо за внимание и удачных раскопок в мире исходников!
Дополнительные материалы
-
Книга: «Компиляторы. Принципы, технологии и инструментарий». Авторы: Ахо, Ульман, Лам.
-
Статья: Как новый компилятор K2 ускоряет компиляцию Kotlin на 94%.
-
Статья: Корутины с точки зрения компилятора.
-
Цикл статей Crash course on the Kotlin compiler: K1 + K2 Frontends, Backends, Frontend: Parsing phase, Frontend: Resolution phase.
ссылка на оригинал статьи https://habr.com/ru/articles/917998/
Добавить комментарий