Kotlin Symbol Processing (KSP) — это упрощенный API для создания плагинов к компиляторам, который позволяет использовать возможности Kotlin, сохраняя при этом минимальную кривую обучения. По сравнению с KAPT процессоры аннотаций, использующие KSP, могут работать в 2 раза быстрее.
Плагины компилятора и почему KSP
Плагины компилятора — это мощные средства метапрограммирования, которые могут значительно улучшить процесс написания кода. Плагины компиляторов вызывают компиляторы непосредственно как библиотеки для анализа и редактирования исходного кода программ. Эти модули также могут генерировать выходные данные для различных целей. Например, они могут генерировать шаблонный код, и даже генерировать полные реализации для специально помеченных элементов программы, таких как Parcelable. Плагины имеют множество других применений и даже могут использоваться для реализации и тонкой настройки функций, не предусмотренных непосредственно в языке.
Хотя плагины компилятора очень мощные, за эту мощь приходится платить. Чтобы написать даже простейший подключаемый модуль, необходимо обладать определенными знаниями о компиляторе, а также знать детали реализации конкретного компилятора. Другая практическая проблема заключается в том, что плагины часто тесно связаны с конкретными версиями компиляторов, а это означает, что вам придется обновлять свой модуль каждый раз, когда вы захотите поддержать новую версию компилятора.
Kotlin Symbol Processing спроектирован таким образом, чтобы скрыть изменения в компиляторе, что минимизирует усилия по обслуживанию процессоров, использующих его. KSP не привязан к JVM, что позволяет в будущем легче адаптировать его к другим платформам. KSP также призван минимизировать время сборки. Для некоторых процессоров, например Glide, KSP сокращает время полной компиляции до 25% по сравнению с kapt.
Сама платформа реализована в виде плагина компилятора. В репозитории Maven компании Google имеются готовые пакеты, которые можно загрузить и использовать, не прибегая к самостоятельной сборке проекта.
Обзор
KSP API обрабатывает код на языке Kotlin на идиоматическом уровне. KSP понимает специфические для Kotlin особенности, такие как функции расширения, дисперсия деклараций и локальные функции. Кроме того, он явно моделирует типы и обеспечивает базовую проверку типов, такую как эквивалентность и совместимость по назначению.
API моделирует структуры Kotlin-программ на уровне символов в соответствии с грамматикой Kotlin. Когда плагины на базе KSP обрабатывают исходники, такие конструкции, как классы, члены классов, функции и связанные с ними параметры, становятся доступными для процессоров, а такие вещи, как блоки if и циклы for, — нет.
Концептуально Kotlin Symbol Processing похож на KType в рефлексии Kotlin. API позволяет процессорам переходить от объявлений классов к соответствующим типам с определенными аргументами типа и наоборот. Также можно заменять аргументы типа, указывать вариации, применять проекции звезд и отмечать нулевые возможности типов.
Еще один способ думать о KSP — как о препроцессорном фреймворке для программ на языке Kotlin. Если рассматривать плагины на базе KSP как символьные процессоры, или просто процессоры, то поток данных при компиляции можно описать следующими шагами:
- Процессоры читают и анализируют исходники программы и ресурсы.
- Процессоры генерируют код или другие формы вывода.
- Компилятор Kotlin компилирует исходные программы вместе со сгенерированным кодом.
В отличие от полноценного плагина компилятора, процессоры не могут модифицировать код. Плагин компилятора, изменяющий семантику языка, иногда может быть очень запутанным. KSP позволяет избежать этого, рассматривая исходник программы как доступный только для чтения.
Обзор KSP можно также получить из этого видеоролика:
Как KSP смотрит на исходные файлы
Большинство процессоров ориентируются по различным программным структурам входного исходного кода. Прежде чем перейти к использованию API, давайте посмотрим, как может выглядеть файл с точки зрения KSP:
KSFile packageName: KSName fileName: String annotations: List<KSAnnotation> (File annotations) declarations: List<KSDeclaration> KSClassDeclaration // class, interface, object simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration classKind: ClassKind primaryConstructor: KSFunctionDeclaration superTypes: List<KSTypeReference> // contains inner classes, member functions, properties, etc. declarations: List<KSDeclaration> KSFunctionDeclaration // top level function simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration functionKind: FunctionKind extensionReceiver: KSTypeReference? returnType: KSTypeReference parameters: List<KSValueParameter> // contains local classes, local functions, local variables, etc. declarations: List<KSDeclaration> KSPropertyDeclaration // global variable simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration extensionReceiver: KSTypeReference? type: KSTypeReference getter: KSPropertyGetter returnType: KSTypeReference setter: KSPropertySetter parameter: KSValueParameter
В этом представлении перечислены общие вещи, которые объявлены в файле: классы, функции, свойства и т.д.
SymbolProcessorProvider: точка входа
KSP ожидает реализацию интерфейса SymbolProcessorProvider для инициирования SymbolProcessor:
interface SymbolProcessorProvider { fun create(environment: SymbolProcessorEnvironment): SymbolProcessor }
SymbolProcessor определяется как:
interface SymbolProcessor { fun process(resolver: Resolver): List<KSAnnotated> // Let's focus on this fun finish() {} fun onError() {} }
Resolver предоставляет SymbolProcessor доступ к деталям компилятора, таким как символы. Процессор, который находит все функции верхнего уровня и нелокальные функции в классах верхнего уровня, может выглядеть следующим образом:
class HelloFunctionFinderProcessor : SymbolProcessor() { // ... val functions = mutableListOf<KSClassDeclaration>() val visitor = FindFunctionsVisitor() override fun process(resolver: Resolver) { resolver.getAllFiles().forEach { it.accept(visitor, Unit) } } inner class FindFunctionsVisitor : KSVisitorVoid() { override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { classDeclaration.getDeclaredFunctions().forEach { it.accept(this, Unit) } } override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) { functions.add(function) } override fun visitFile(file: KSFile, data: Unit) { file.declarations.forEach { it.accept(this, Unit) } } } // ... class Provider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = TODO() } }
Примеры использования KSP
Получить все функции члена:
fun KSClassDeclaration.getDeclaredFunctions(): List<KSFunctionDeclaration> = declarations.filterIsInstance<KSFunctionDeclaration>()
Проверка того, является ли класс или функция локальной:
fun KSDeclaration.isLocal(): Boolean = parentDeclaration != null && parentDeclaration !is KSClassDeclaration
Найти реальное объявление класса или интерфейса, на которое указывает псевдоним типа:
fun KSTypeAlias.findActualType(): KSClassDeclaration { val resolvedType = this.type.resolve().declaration return if (resolvedType is KSTypeAlias) { resolvedType.findActualType() } else { resolvedType as KSClassDeclaration } }
Сбор подавленных имен в аннотации к файлу:
// @file:kotlin.Suppress("Example1", "Example2") fun KSFile.suppressedNames(): List<String> { val ignoredNames = mutableListOf<String>() annotations.filter { it.shortName.asString() == "Suppress" && it.annotationType.resolve()?.declaration?.qualifiedName?.asString() == "kotlin.Suppress" }.forEach { val argValues: List<String> = it.arguments.flatMap { it.value } ignoredNames.addAll(argValues) } return ignoredNames }