Небольшой дисклеймер
Я не писал компилятор 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_EFFECT
c вложенной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.