Reflekt: a Library for Compile-Time Reflection in Kotlin

02/12/2022
by   Anastasiia Birillo, et al.
JetBrains
0

Reflection in Kotlin is a powerful mechanism to introspect program behavior during its execution at run-time. However, among the variety of practical tasks involving reflection, there are scenarios when the poor performance of run-time approaches becomes a significant disadvantage. This problem manifests itself in Kotless, a popular framework for developing serverless applications, because the faster the applications launch, the less their cloud infrastructure costs. In this paper, we present Reflekt - a compile-time reflection library which allows to perform the search among classes, object expressions (which in Kotlin are implemented as singleton classes), and functions in Kotlin code based on the given search query. It comes with a convenient DSL and better performance comparing to the existing run-time reflection approaches. Our experiments show that replacing run-time reflection calls with Reflekt in serverless applications created with Kotless resulted in a significant performance boost in start-up time of these applications.

READ FULL TEXT VIEW PDF

Authors

page 1

page 2

page 3

page 4

12/04/2017

Introspection for C and its Applications to Library Robustness

Context: In C, low-level errors, such as buffer overflow and use-after-f...
01/30/2013

Evaluating Las Vegas Algorithms - Pitfalls and Remedies

Stochastic search algorithms are among the most sucessful approaches for...
04/07/2021

Top Score in Axelrod Tournament

The focus of the project will be an examination of obtaining the highest...
02/14/2020

Sub-method, partial behavioral reflection with Reflectivity: Looking back on 10 years of use

Context. Refining or altering existing behavior is the daily work of eve...
07/30/2018

Comparison of Production Serverless Function Orchestration Systems

Since the appearance of Amazon Lambda in 2014, all major cloud providers...
12/18/2015

The interface for functions in the dune-functions module

The dune-functions dune module introduces a new programmer interface for...
02/20/2020

LibrettOS: A Dynamically Adaptable Multiserver-Library OS

We present LibrettOS, an OS design that fuses two paradigms to simultane...
This week in AI

Get the week's most popular data science and artificial intelligence research sent straight to your inbox every Saturday.

1. Introduction

Kotless (tankov2021infrastructure) is a framework created within JetBrains to facilitate the development of serverless applications in Kotlin. Serverless computing is a concept of developing and deploying cloud applications as a set of stand-alone functions, which are executed on demand and automatically scaled if needed (castro2017serverless). Such an approach suits a wide range of applications, and compared to developing them as traditional server-side applications it enables better resource management while being scalable, reliable, and cost-effective (castro2017serverless). Kotless aims to simplify the process of developing and deploying such serverless applications. The framework focuses on reducing the routine of serverless deployment by automatically generating deployment code straight from the source code of the application itself. Kotless

is fully open-source 

(kotlesslink) and has an active and rapidly growing community.

Being a Kotlin-specific framework, Kotless is also written in Kotlin, an open-source statically typed programming language that targets JVM, JavaScript, and native platforms via LLVM (llvm). Kotlin is fully interoperable with Java, enabling to reuse all Java features and existing libraries.

Implementation-wise Kotless heavily relies on the reflection mechanism. In general, reflection can be defined as the ability of a program to manipulate as data something representing the state of the program during its own execution (bobrow1993clos), and it is a common functionality in many programming languages, including but not limited to Python, Ruby, Go, R, Java, and, therefore, Kotlin. While reflection provides developers with a variety of possibilities, including the ability to manipulate with the program structure, one of its useful features is introspection — the ability of a program to examine its own state and structure (maes1988meta). One of the examples of program introspection is type identification, which is implemented via the instanceof operator in Java and the is operator in Kotlin, respectively.

However, such a powerful mechanism as reflection in Java, along with all the possibilities, brings its own drawbacks and is usually advised to be used carefully (li2019understanding). Among them are security restrictions, coming from the opportunity to load and execute classes at run-time. Some virtual machines, e.g., used in Android development, do not provide a secure environment for code supplied dynamically (zhauniarovich2015stadyna), and Google is strongly opposed to using this feature in Android applications (androidsecurity). Reflection also hampers the usage of GraalVM (GraalVM), a virtual machine that supports ahead-of-time compilation (GraalVMAOT) for faster program start-up and lower memory consumption (GraalVMNativeImage). Also, there are challenges with static analysis of the code, since it is fundamentally hard to predict the behavior of the code that uses reflection, which can be done only under significant assumptions (landman2017challenges). Last but not least is performance degradation. That is caused by the fact that reflection-based operations require types that must be dynamically resolved, and to do that, JVM should load all the necessary classes at run-time, slowing down the application. Poor performance of Java reflection has been widely studied (landman2017challenges; tudose2013java; li2019understanding) and this is one of the main reasons developers try to use reflection only when it is strictly necessary.

In Kotless, reflection is used to handle the event-driven way serverless applications are written in. In this case, it is limited to the task of collecting all the classes, objects (Kotlin classes that can have only one instance), and functions that satisfy some search condition across all the application’s source code. Classes that implement a specific interface or functions with a specific list of arguments and return types might serve as an example of such conditions.

The default way of doing this in Kotlin is Java Reflection API and libraries such as reflections (reflections), which provide a convenient domain-specific language. This approach allows collecting all required entities at run-time by scanning the whole classpath and checking the classes against the given search constraint. However, as mentioned above, iterating over all the accessible entities during the program run might be very inefficient (landman2017challenges; tudose2013java; li2019understanding). Compile-time approaches, such as Java Annotation Processor (annotationProcessing), are another possible option. Such tools search for the required entities only once during the compilation stage, and the result of this search can be stored somewhere to make the fetching of the required entities at run-time much faster. However, to make such a compile-time search possible, all the entities in the code should be marked in advance with specific annotations, which often makes this approach rather inconvenient to use. The tedious work of annotating entities along with the code becoming cluttered with annotations prevented Kotless developers from using this approach in the first place.

Therefore, there is a need for a fast and at the same convenient way to employ reflection methods. The former can be achieved by moving the process to compile-time, and the latter may be solved by developing a user-friendly DSL that requires minimum efforts to switch from a run-time library, such as reflections, to the new, compile-time approach. Unlike Java, in Kotlin all the above is possible thanks to the capability to write compiler plugins.

In this paper, we present a Kotlin reflection library called Reflekt (reflekt) that overcomes the flaws of the standard Java reflection approach and provides the means to find classes, object expressions, and functions by a given condition without degrading the application’s performance. Instead of relying on the JVM reflection infrastructure, Reflekt performs compile-time resolution of reflection queries using the Kotlin compiler analysis infrastructure, providing a convenient reflection API without actually using reflection. The search condition might be specific supertypes, annotations, and signatures of code elements, which is a typical use case for Kotless, as well as a custom user condition.

We evaluated Reflekt on two applications that utilize Kotless and achieved significant acceleration in their start-up. For the first project, replacing the run-time reflection approach with Reflekt decreases its start time by 13.8%, and for the second one, the results are even better with the overall improvement of 17%. At the same time, the compile-time approach of Reflekt did not affect the compilation time very much, only increasing it by about 1% for both applications.

The rest of this paper is organized as follows. Section 2 describes existing approaches to the problem of searching for entities in the source code, motivates the development of Reflekt, and briefly introduces the Kotlin compilation process. Section 3 presents the pipeline of Reflekt and its implementation details, while in Section 4 we discuss the practical evaluation of Reflekt. Finally, Section 5 sums up the work and discloses our future plans.

2. Background

In this section, we provide a motivational example of a reflection-based Kotless feature and explore several ways it can be implemented with the existing run-time and compile-time reflection approaches. We also describe the approach to finding classes and functions in the code proposed in this paper and highlight existing real-world projects that could benefit from such an approach.

2.1. Motivating Example

Kotless (tankov2021infrastructure) is a framework for developing and deploying serverless applications to Amazon Web Services (AWS) and Microsoft Azure clouds. From the user’s point of view, Kotless is almost invisible — it is integrated as a Gradle plugin and generates all the necessary deployment code automatically. To do that, Kotless introduces a DSL based on annotations and marker interfaces, that allow users to define how their application should be deployed to the cloud (tankov2019kotless). The performance of Kotless fully depends on the ability to find such annotations and marker interfaces in the users’ code, and this task is a perfect example of what reflection does. At the same time, serverless applications should start and work as fast as possible since they could be launched hundreds of times a day, and the longer they work, the more their end-users wait, the more CPU time is consumed, and the more money is eventually spent on cloud infrastructure (adzic2017serverless). Hence, in such a case, using run-time reflection, which is known for its poor performance (tudose2013java; landman2017challenges), can be quite costly.

Let us look closer at a concrete example of how reflection is used in Kotless. One of the features of Kotless is the ability to define events that will be repeatedly executed after a given time interval. To do this, a developer should mark the desired function with the @Scheduled annotation:

The job of Kotless is to find such annotations in the application code and generate appropriate cloud infrastructure code to make this repeated execution possible. Now, having this specific example in mind, let us discuss in detail how this task of finding entities in code could be implemented in Kotlin.

2.2. Existing Reflection Approaches

2.2.1. Run-time reflection

The built-in approach to solving this task is Java Reflection API (javaReflectionApi), which consists of two main parts: objects that represent various parts of the program and tools for extracting these objects. To fetch information about a class, Java introduces the Class class with a set of methods to introspect it, e.g., to find a required method or check for specific annotations. Class loaders scan the classpath for a needed Class and load it dynamically on demand. Therefore, the task of finding classes, objects, and methods by specific conditions is already entirely covered by the Java reflection infrastructure. Employing it for our motivating example, the solution will result in the following code fragment:

For each Class from the classpath we should extract the set of its methods’ annotations and check whether @Scheduled is among them.

Several libraries utilize Java Reflection API or employ a similar approach to achieve this task at run-time as well. For example, the aforementioned reflections (reflections) is a popular open-source library, which comes with a clear and intuitive DSL. Getting back to the motivating example, the task of finding all the @Scheduled functions is currently solved in Kotless with the reflections library like this:

Nevertheless, such a convenient tool comes with several disadvantages inherent to all approaches of this type, with the poor performance being the most notable. This is caused by the necessity to scan the whole classpath at run-time every time the program runs, which results in significant performance overhead. In fact, the inefficiency of Java Reflection API is a well known problem (tudose2013java). Historically, one of the main reasons this API was added in Java was the ability to introspect programs specifically at run-time. But in certain tasks, such as ours, where run-time execution is not a must, an alternative might be more practical.

2.2.2. Compile-time approaches

Another option would be to use approaches that search for the entities at compile-time and store the information about the entities of interest to use it later at run-time. Here, the scan of the classpath is performed at compile-time, not slowing down the application runs.

One of the examples of such an approach is Java Annotation Processing (annotationProcessing) and several libraries (classindex; scannotation; classgraph), which are based on it or follow the same strategy. When the compiler encounters a specific annotation, it runs an annotation processor created to handle this specific annotation. The annotation processor can then handle the annotated entity and get the same information Java Reflection API provides out of it. This information is stored for later use at run-time.

Therefore, for this approach to work, one needs to (1) mark all the desired entities in code with an appropriate annotation (e.g., if a developer wants to find all classes with a given supertype, this should be expressed with an annotation), and (2) for each annotation, implement a custom annotation processor with the desired behavior and register it in the compiler. Usually, this results in a lot of extra work and makes the code cluttered with endless annotations.

The task of finding all the @Scheduled functions from the motivating example could be solved using Java Annotation Processing as well. For that, a subclass of AbstractProcessor needs to be created with a custom implementation of the process() method. It defines which annotations to search, which conditions to satisfy, and how to handle the entity afterwards:

In addition to the annotation processing technique, there are several libraries that optimize the time-consuming classpath scanning inherent to run-time approaches. For example, the ClassGraph library (classgraph) allows to scan the classpath searching for classes that match some criteria, be it a specific annotation or a supertype. The idea here is just to remember the found classes at compile-time, thus reducing the size of the classpath needed to be scanned at run-time with Java reflection. Nevertheless, the need to implement serialization and deserialization of results manually for every search significantly increases the amount of code the users have to write.

Therefore, the existing compile-time approaches, while being much faster than run-time ones, cannot offer a way to solve the given task in just a few lines of code. They either require putting additional annotations everywhere in the source code and implementing annotation processors for every concrete annotation, or spending time on storing and loading the scan results in order to make them visible at run-time. Besides, the existing libraries often do not cover the more general task of finding classes, functions, and objects satisfying a custom search condition.

Description Reflection context
Kotlin Faker (kotlinFaker) is a tool for generating realistically looking fake data such as names, addresses, banking details, and many more, that can be used for testing purposes. The tool performs ahead-of-time compilation to a GraalVM (GraalVM) native image that speeds up the start-up and reduces the memory usage.
Kotlin Faker uses Java Reflection API to find and access classes and functions properties, as well as to collect code entities by specific annotations. However, the only way to make reflection work with GraalVM is to manually create a configuration file, listing a pre-written description of the entities that are needed to be found (GraalVMReflection).


Rawky (rawky) is an editor for the pixel art graphics (silber2015pixel). It has a graphical user interface (GUI) to interact with the image, e.g., repeat or cancel actions, add layers to the picture, etc.

Run-time reflection in this project is used to initialize the editor’s GUI, e.g., getting the options for the windows and panels. Here, it is done using the reflections (reflections) library.


Detekt (detekt) is a static code analysis tool for Kotlin. It finds various code smells (fowler2018refactoring), code style violations, calculates code quality measures, e.g., code complexity. To perform such analyses, Detekt operates with the syntax tree, provided by the Kotlin compiler.


Detekt implements its analyzers as sets of rules. Each of them is represented by a class implementing a specific interface. Reflection is used in this project during testing (via the reflections library) to collect all such classes and validate the analyzers.


Kotest (kotest) is a flexible testing tool for Kotlin with the multiplatform support (JVM and JavaScript). Kotest consists of several different parts: a framework that allows to define and execute tests, an assertion library with a diverse set of over 300 assertions, and a module for property-based testing (fink1997property).

Kotest is supposed to be used as a library in projects to define and run tests. It employs the ClassGraph (classgraph) reflection library to set up custom user configurations, as well as to search and apply additional automatic configurations to the project.

Ktor (ktor) is an asynchronous flexible framework for creating microservices and web applications. The framework provides a special DSL to create web applications of different scale and complexity.

Ktor does not use reflection to avoid the corresponding slowdown, however, it can also benefit from using Reflekt. The framework provides an HTTP API, which is based on Spring (springBoot) annotations. In large projects, it could be quite inconvenient to define hundreds of such functions and call them one by one from the main application context. With Reflekt, it is possible to find all Spring annotations and construct Ktor functions automatically at compile-time.
Table 1. Several existing projects from different domains that can benefit from using Reflekt.

2.2.3. The Reflekt approach

Run-time and compile-time approaches complement each other, with the former providing a convenient DSL but being slow, and the latter being fast but requiring the user to write a lot of extra code. Fortunately, there is a way to combine the advantages of both approaches.

Unlike Java, Kotlin provides a way to write plugins for its compiler, thus extending its functionality. It is possible to intervene into the compilation process to scan the compiling code for the required entities, filter them to match the search criteria, and collect them. Thus, such an approach preserves the efficiency of existing compile-time methods. Also, there is no need to mark the code with annotations, since we can use all the information available to the compiler. Moreover, it is possible to relieve the users of the obligation to store search results between the application compilation and running. The Kotlin compiler allows us to modify the compiled code, therefore, all the found entities can be directly written into the code’s intermediate representation, without the users even noticing it. All of the above allows us to implement a Kotlin compiler plugin with a concise and convenient DSL to search for classes, objects, and functions satisfying custom search conditions just in a few lines of code similar to run-time approaches.

Returning to our motivating example and the task of finding all the functions with the @Scheduled annotation, with the Reflekt approach it could be solved like this:

2.3. Use Cases of Reflekt

Although the original motivation behind the development of Reflekt was to overcome the performance slowdown caused by run-time reflection in Kotless, Reflekt can be useful outside of Kotless as well. In a recent study, Landman et al. (landman2017challenges) reported that among the 461 open-source projects they looked at, more than 96% of them contained Java Reflection API calls. Almost 70% of these projects used reflection to search for specific classes, which is one of the Reflekt’s primary use cases. We should note though that not all of these reflection calls could be substituted with Reflekt since there could be just not enough information at compile-time to do what is required (for example, when classes are needed to be created and used dynamically at run-time).

Table 1 highlights several popular (with at least 20 stars on GitHub) open-source Kotlin projects that can benefit from using Reflekt. These projects come from different domains (graphical editors, static analyzers, test frameworks) and cover different contexts of the reflection usage, such as initializing the project or running tests. The way reflection is applied also differs from project to project, including Java Reflection API itself as well as the reflections and ClassGraph libraries. In these projects, all the cases of using reflection do not actually require run-time calculations, and therefore, they could be handled by Reflekt.

2.4. Kotlin Compilation Process

2.4.1. Stages of the compilation process

This subsection briefly summarizes key points of the Kotlin compilation process.

Figure 1 presents the overall workflow of the Kotlin compilation process. The Kotlin compiler has three main parts: parser, frontend and backend. The first part is responsible for parsing source files and building a rich syntax tree — Program Structure Interface (PSI) (psi). Next, the frontend part resolves the necessary dependencies, inferences all types, and runs compiler diagnostics to verify the correctness of the compiled code (e.g., checks that the code does not contain two functions with the same signature and name). The backend part compiles the source code into a special intermediate representation (IR) and optimizes it. Next, a platform-specific generator (JVM, JavaScript, or Kotlin Native) finishes the compilation process, e.g., generates JVM bytecode.

Figure 1. The main parts of the Kotlin compilation process.
Figure 2. Examples of Reflekt’s core DSL:
a) a Reflekt query to find all classes with supertype A, annotation C, cast all of them to B, and put the result into a set.
b) a Reflekt query to find all functions without arguments, return type Unit, and annotation A. The result is returned as a list.
Figure 3. Examples of Reflekt’s extended DSL:
a) a SmartReflekt query to find all companion objects with supertype Any.
b) a SmartReflekt query to find all top-level functions with the name foo, return type Unit, and without any arguments.

2.4.2. Kotlin compiler plugins

The Kotlin compiler allows to write plugins, thus extending the internal functionality for all the supported platforms at any compilation stage. It has an incredibly powerful API with the access to all compilation stages, including intermediate representation of code, and the ability to modify internal classes and functions. For example, the kotlin-serialization library (serialization) is implemented as a Kotlin compiler plugin to serialize classes.

3. Reflekt

Reflekt is a reflection library that provides the means to search for classes, object expressions, and functions in Kotlin source code at compile-time. It is written in Kotlin and implemented as a Kotlin compiler plugin to integrate itself into the compilation process.

Reflekt consists of three main parts:

  • a domain-specific language (DSL) supporting two usage scenarios;

  • a Gradle plugin that facilitates the import of Reflekt in end-users’ projects;

  • a Kotlin compiler plugin that implements compile-time resolution of reflection queries.

Let us further discuss all these components in more detail.

3.1. Dsl

One of the Reflekt’s main features is a concise and convenient way of its usage, which is not typical for other existing compile-time approaches (see Section 2.2.2 for an example). This is achieved by providing a DSL that developers can use to find classes, object expressions, and functions in just one line of code. In this context, object expressions are classes that implement the Singleton (gamma1995design) pattern, and thus have only one instance.

Due to the varying use cases and implementation differences, Reflekt separates search conditions and DSLs provided for them into two types: core and extended.

3.1.1. Core DSL

The most basic features of Reflekt are:

  • finding classes and object expressions with specific annotations and supertypes;

  • finding functions with specific annotations and signatures.

Examples of the core DSL queries are illustrated in Figure 2.

To use this DSL in a project, a developer should address the Reflekt object calling one of three methods: classes(), objects(), or functions() depending on the search intent. Then a search condition should be formed specifying needed annotations, supertypes, or function signatures accordingly. The call chain concludes with a toList() or toSet() cast, which forms the result.

To specify annotations and supertypes, KClass objects should be used as parameters. The KClass type is Kotlin’s counterpart to Java’s java.lang.Class and is used to represent Kotlin classes. It can be obtained from any class by calling ::class (for example, MyClass::class).

Function signatures are defined using the FunctionN<*> type, which is a Kotlin way to refer to functions. For example, a function that takes an Int as an argument and returns a Unit (which acts as Java’s void), would have a (Int) -> Unit signature.

3.1.2. Extended DSL

To support more advanced search conditions, we offer an extended DSL. It expands the core DSL with the ability to:

  • find classes and object expressions with a custom filter, including properties of the collected entities;

  • find functions with a custom filter, including function properties.

Examples of the extended DSL are presented in Figure 3.

This DSL is available through referencing the SmartReflekt object, where, similarly to the core DSL, one of the three methods can be called: classes(), objects(), or functions(). In the type parameters of these methods, a supertype for object expressions or classes or a signature for functions can be specified. Then there should be a lambda function (an analogue of anonymous functions in Java) defining a custom search condition, for instance, being a companion for objects (Kotlin’s counterpart for Java static functions) or being top-level for functions. Finally, a concluding resolve() call is needed to return the found entities.

The supertype and the signature parameters should be of the same KClass and FunctionN<*> types as in the core DSL. As for the subsequent filtering conditions, the filter() function accepts parameters of KtClass (KtClass) for classes, KtObjectDeclaration (KtObjectDeclaration) for objects, and KtNamedFunction (KtNamedFunction) for functions. These are the Kotlin compiler’s built-in types, which have a variety of properties, holding practically all known information about these entities. All this information can be used in a filtering predicate with the only limitation of not capturing any external variables. This restriction is required to guarantee that all condition parameters are available at compile-time.

Implementation details of the extended DSL and the limitations they impose are discussed in Section 3.3.2.

Figure 4. Setting up Reflekt via Gradle in the build.gradle.kts file

3.2. Gradle Plugin

Gradle is an open-source build automation tool that is designed to be flexible enough to build projects, in particular, written in Java and Kotlin (gradle). Reflekt as a library could be used quite easily using the Gradle plugin, which provides an entry point from a build.gradle.kts script and has a dependency on the module with Reflekt and SmartReflekt DSLs, allowing developers to use them in their projects. So, to use Reflekt in a project, one simply needs to enable its Gradle plugin as shown in Figure 4.

Figure 5. Two possible usage scenarios of Reflekt:
a) Reflekt is used within a standalone project.
b) Reflekt is used in a library A, which is used as a dependency in another project B.

3.3. Compiler Plugin

This component implements the main Reflekt functionality and is designed as a Kotlin compiler plugin. Compiler plugins have an ability to intervene into the compilation process, including access to the intermediate representation (IR) of code: it is possible to analyze IR similarly to how abstract syntax trees are traversed and to even modify it, changing the code’s final behavior.

There are two scenarios in which Reflekt can be used (Figure 5). The first one assumes a developer uses Reflekt in some project to find entities within it. The second and more complicated one considers a case when the developed project is a library, and therefore the search should happen not only among the project itself, but also in other projects that add this library as a dependency. This section describes how these scenarios differ and what implementation decisions we made to support both use cases.

Figure 6. The general workflow of Reflekt.

3.3.1. Using Reflekt within a Standalone Project

This use case implies adding Reflekt to the dependencies of some project to enable the search of entities within this project’s source files. In this scenario, both Reflekt and SmartReflekt calls are supported.

The overall pipeline of Reflekt is presented in Figure 6. The workflow starts with finding Reflekt and SmartReflekt calls, then all of their parameters, such as types of instances to search, their supertypes, signatures, annotations, or custom lambda conditions are extracted. It happens on the diagnostics stage of the compiler’s frontend, since we need all qualified names already resolved for the subsequent search.

Next, all project’s source files are scanned for the required entities that satisfy the search conditions. One of the major limitations here comes from the nature of the Kotlin compilation process in multi-module projects: the compilation is performed for each module separately, so source files are not shared between modules during compilation. Therefore, in multi-module projects, Reflekt can find entities only within each module.

The check for classes and object expressions’ supertypes and function signatures is based on the Kotlin type system and subtype relations between them. For example, the () -> Int function signature satisfies the () -> Any search query, since the latter is more general than the former. To do such type comparisons, Kotlin introduces the KotlinType class, full specification of the Kotlin types system can be found online (kotlinTypeSystem).

However, for other conditions that do not involve type checks, the matching is implemented differently for Reflekt and SmartReflekt DSLs since the latter contains more complex filtering conditions. If for the core DSL we can simply get all the information about entities from their internal representations (e.g., get all annotations for a specific KtClass), we cannot evaluate custom user condition in the same way since they are unknown in advance. To support them, we employ the KotlinScript interpreter (kotlinScript), which allows running lambda conditions from user queries at compile-time and filter out the entities that way.

Since the KotlinScript interpreter is running during the compilation stage, no external variables should be used inside lambda conditions because they are unknown at this stage. However, external dependencies in the current project, which are available at compile-time and are included in Gradle using a compileClasspath configuration, are allowed in lambda conditions. Figure 7a shows an example with the correct lambda body, which does not require any additional context. However, the filtering condition displayed in Figure 7b is incorrect if the implementation of the getName() function is inaccessible to the KotlinScript interpreter.

Figure 7. Example of a correct and incorrect SmartReflekt calls.
a) a correct call without any external context inside lambda-conditions.
b) an incorrect call if the getName() function comes from some external context.

When all the instances that satisfy the search constraints are found, they should be returned from the Reflekt and SmartReflekt calls. To achieve that, the previous intermediate representation of these calls is substituted with the resulting list of the found entities. It happens on the IR generation compilation stage of the Kotlin compiler backend.

3.3.2. Using Reflekt within a Library

Another common use case assumes that Reflekt is used within a library, so it is required to search for entities not only within the current project, but also among the entities of other projects that will later use this library. That is the exact scenario Kotless falls under since it is developed as a library that uses Reflekt to search for entities in the projects of Kotless’ end-users.

This scenario is fundamentally different from the previous one, since libraries are often developed and used by different people, and when a library is being compiled, there is no information about the code that will use it some time in the future. Therefore, there is a need to process Reflekt calls during the compilation of the target project that uses the library, not the compilation of the library itself. Another problem comes from the inability to substitute the library’s IR, therefore, another way of changing the program’s behavior should be implemented.

Figure 8. The pipeline of Reflekt for the projects that will be used as libraries.

In this scenario, we provide support only for the core DSL with Reflekt calls due to the overall complexity of handling SmartReflekt calls. We plan to support the extended DSL for this scenario as well in the future.

The workflow of Reflekt in such cases is presented in Figure 8. First, similar to the previous scenario, we start by gathering all Reflekt calls when compiling the library. But now these calls should be saved in order to be restored and used properly during the future project compilation to search among this project’s entities. We save all the information about these calls in a separate ReflektMeta file. The fact that such a file should be created for a given library is indicated in a Gradle configuration file for this library.

The workflow continues when some project starts to use the developed library containing Reflekt calls. During the compilation of this project, the ReflektMeta file should be found and used. So to prevent from scanning all the libraries, those that are using Reflekt calls should be explicitly listed in the project’s Gradle configuration file. Search conditions are then restored from the ReflektMeta file along with fully qualified names of the calls’ parameters. The Kotlin compiler provides the means to get type descriptors from such names and then build appropriate KotlinType objects. After that, the search for the entities within the current project is performed similarly to the previous scenario.

Figure 9. Using the ReflektImpl file to store the results of the search in the case when Reflekt is used within a library.

Another difficulty is that besides the current project, the search for the entities should be performed within the library as well, and at this point for this library there could be only compiled binaries present without any source code. However, this problem can be efficiently solved by compiling a list of the existing packages in the library, which is also stored in the ReflektMeta file, utilizing the Kotlin compiler’s ability to get all type descriptors from a given package. The received descriptors then turn into KotlinTypes, and the search continues in its original way among the library entities.

The last difference in the workflows of the two scenarios is the final step of substituting the initial behavior of Reflekt calls to the found list of entities (see Figure 9). Reflekt DSL is designed in a way that definitions of Reflekt methods, which should return a list of the found entities, are stored in a separate file called ReflektImpl.kt. By default, all these methods return an empty list. It does not affect the first scenario (Section 3.3.1), because there all Reflekt calls are substituted with the actual found entities via changing the intermediate representation of code during compilation. However, there is no intermediate representation of code in the already compiled library. Nevertheless, we can substitute the whole ReflektImpl.kt file in the library with the new one, containing an appropriate implementation of methods, once this library gets used by some project. First, the original ReflektImpl.kt is excluded from the Reflekt’s .jar file. Then, during the compilation of the current project, once the result lists for every Reflekt call are collected, a new ReflektImpl.kt is generated. It provides an appropriate implementation for every Reflekt call, returning the list of actual entities that exist in the source code. By including it back into the Reflekt’s .jar file, we make the library use the newly generated implementations instead of original reflection calls.

4. Evaluation

We evaluated Reflekt on the Kotless framework (tankov2021infrastructure) which is intended to simplify the development and deployment of serverless cloud applications written in Kotlin. The task of searching for objects and functions by specific conditions serves as a fundamental way to support code generation in this framework: Kotless scans user’s project files, filters found entities by supertypes and annotations, and generates the necessary code for serverless deployment. Moreover, Kotless is designed to be used as a library, so using Reflekt in it would follow a scenario depicted in Figure 8.

Figure 10. Examples of two Kotless reflection calls and their replacement with Reflekt:
a) Get all objects with a particular supertype.
b) Get all methods with a specific annotation and signature.
Project Compilation (ms) Application start (ms)
Old approach The Reflekt approach Old approach The Reflekt approach
All Reflection All Reflection All Reflection All Reflection
Kotless - (1.2%) - - - -
Kotless website - (1.1%) (83%)
URL shorter - (1.2%) (81%) (80 %)
Table 2. Comparison of compilation and application start time for old and Reflekt approaches together with the percentage of how much time the reflection calls take.

4.1. Replacement of Reflection Calls

Currently, Kotless uses the reflections library (reflections) for reflection queries. While some of them are meant to be performed at run-time due to the requirement of dynamic loading, others can be moved to compile-time. We replaced the latter with Reflekt calls to measure the performance improvement. In total, reflection calls were replaced, examples of two of them are shown in Figure 10.

As can be seen, such a replacement does not require much effort due to the similarity of DSLs of Reflekt and the reflections library.

4.2. Evaluation Setup

The repository of Kotless (kotlesslink) provides a set of example projects. Two of them use only Kotless DSL (tankov2019kotless), while others require additional frameworks such as Ktor (ktor) to create web applications. For our experiments, we used only these two projects to eliminate possible interference with other libraries and frameworks. The first project represents a static website containing Kotless’ documentation (kotlesSite). The second one is a web service for shortening web URLs (kotlesShort). Both of them rely on searching for objects by supertypes to initialize their web pages and searching for functions by annotations to construct the pages’ routes.

For each example project, we measured the time spent on the compilation process and the application start together with the percentage of how much time it takes to process all the reflection calls. The same measurements were performed after the reflection calls were replaced with Reflekt queries. Some of the reflection calls could not be replaced with Reflekt calls and therefore they still remain in the implementation of Kotless, affecting the application’s start-up time. Additionally, since Reflekt increases the compilation time, we also study how the compilation time of Kotless itself has changed. For each setting, we repeated the experiment 10 times, cleaning all the caches in between runs.

To perform our experiments, we used an off-the-shelf MacBook laptop with the following characteristics: 2,4 GHz 8-Core Intel Core i9 processor and 32 GB 2667 MHz DDR4 RAM.

4.3. Results

The evaluation results are presented in Table 2. It contains the measurements for both example projects, Kotless website and URL shortener, performed before and after embedding Reflekt.

Initially, there are no reflection queries at compile-time. However, Reflekt moves the aforementioned reflection queries from run-time to compile-time. Thus, the compilation time slightly increases by about 1% for both applications, as well as for the compilation of Kotless itself.

The most interesting part is the differences in applications’ start times. First of all, we observe that reflection queries take a significant percentage of the start-up time, spanning from 80% to almost 85%. However, the overall time of the applications’ start decreases from 320 ms to 265 ms on average for the first project, and from 290 ms to 250 ms on average for the second one, achieving an improvement of 13.8% and 17% respectively. This performance boost is happening since the reflections library takes considerable time to perform the classpath scanning for functions and objects. Reflekt does not perform any scanning during run-time, so all applications are initialized faster, reducing cloud infrastructure costs for their end-users.

Moreover, the execution time of the reflections library directly correlates with the number of classes it scans. So, the larger the application is, the more improvement we can get when migrating from standard run-time reflection libraries to the proposed compile-time Reflekt approach.

5. Conclusion and future work

In this paper, we present Reflekt: a plugin for the Kotlin compiler for compile-time reflection. It allows searching for classes, object expressions, and functions by a given search condition at compile-time, overcoming the drawbacks of existing approaches. It combines the efficiency of compile-time approaches with the convenient and concise DSL that these approaches lack. At the same time, Reflekt wins over the existing run-time approaches, which despite providing a convenient DSL, suffer from the poor performance. Reflekt is implemented as a Kotlin compiler plugin which allows reusing the compiler internals to provide a compile-time reflection approach.

We evaluated the Reflekt’s performance on the Kotless framework, which has previously used the run-time reflections library. The measurements were performed on two serverless applications built using Kotless. The results show an improvement in the applications’ start time of 13.8% and 17% respectively.

The future work on Reflekt will be aimed at overcoming its existing limitations. First, we plan to support the search for entities in multiple modules at once, which is currently prohibited due to the nature of the Kotlin compilation process. We plan to achieve this by extracting descriptors of entities from the whole project and performing the search among them. In addition, we plan to optimize the analysis stage of the plugin to make it work even faster by improving the scanning mechanism and caching intermediate results. We also intend to support incremental compilation (incrementalcompilation) once the required compiler API becomes available. Another feature we want to implement is searching for entities among Java files, expanding Reflekt usage to Java libraries and to multi-language projects. Other plans include collecting the feedback from the end-users and implementing the requested features. We already have several submitted requests asking for the ability to search for the sealed classes along with the other entities and to support SmartReflekt calls inside the libraries’ files.

Acknowledgements

We thank Dmitriy Novozhilov and the rest of the Kotlin compiler team at JetBrains for their invaluable help and collaborative efforts.

References