Ever since I started learning Kotlin I have been intrigued by what is going on behind the scenes in the build.gradle.kts
file. At first glance, the file looks confusing, and for a newbie like me, it is hard to comprehend how the snippet below even has Kotlin-valid syntax.
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.1"
}
To fully understand how is this Kotlin there are a couple of mechanisms at play here that you must understand.
- Lambdas with receivers
- Infix functions
What is plugins
?
Internally, plugins
is a function whose signature looks somewhat like this:
fun plugins(block: PluginDependenciesSpecScope.() -> Unit): Unit
This tells us that plugins
is a function that takes a single argument named block
which known as a lambda with receiver.
A lambda with receiver is a special kind of lambda that allows you to call methods on a specific object (the receiver) within the lambda's body without explicitly referencing it.
For example, in our function signature above, we can see that the receiving type is PluginDependenciesSpecScope
from the package org.gradle.kotlin.dsl
. This lambda function does not receive any arguments, as indicated by the empty parentheses after the receiver type specification .()
, and it does not return anything meaningful, as indicated by the -> Unit
.
A potential implementation of the plugins
function could be:
fun plugins(block: PluginDependenciesSpecScope.() -> Unit): Unit {
val scope = PluginDependenciesSpecScope()
scope.block()
}
While Gradle is way more complex, this is just a very simple example of how the function could be implemented. The key part is that behind the scenes, an instance of PluginDependenciesSpecScope
is created for you, and block
, being an extension method, can be called directly on this instance.
What is kotlin
and id
?
Both kotlin
and id
are extension methods of PluginDependenciesSpecScope
, the reason behind one being able to call these methods directly without specifying the receiver is because this receiver is implicit, but if you wanted to, you reference it directly using the this
keyword:
plugins {
this.kotlin("jvm") version "1.9.25"
this.kotlin("plugin.spring") version "1.9.25"
this.id("org.springframework.boot") version "3.4.1"
}
Both these methods follow the fluent pattern, meaning they return the same object they acted on. For example, the signature of the kotlin
method looks like this:
fun PluginDependenciesSpec.kotlin(module: String): PluginDependencySpec
The difference between kotlin
and id
Behind the scenes, kotlin
is just a shorthand for id
, when you call kotlin("jvm")
, this methods calls id
but appends the prefix org.jetbrains.kotlin.
to whatever string you passed, so kotlin("jvm")
is equal to id("org.jetbrains.kotlin.jvm")
.
So, what is version
?
The missing piece in my understanding of the Kotlin Gradle DSL is the version
in the plugin definition, at first I thought this was a keyword, however, it turns out is an extension method, but not any kind of method but an infix one.
The signature of the version
method looks like:
infix fun PluginDependencySpec.version(version: String): PluginDependencySpec
As you can see, it takes operates on a PluginDependencySpec
but takes in a String
and returns a PluginDependencySpec
.
An infix function can be called without using dot notation and parentheses. It's declared using the infix
keyword and must have a single parameter. This allows for more readable, natural language-like expressions in code.
So we could further change our plugin block definition to:
plugins {
this.kotlin("jvm").version("1.9.25")
this.kotlin("plugin.spring").version("1.9.25")
this.id("org.springframework.boot").version("3.4.1")
}
Conclusion
On Lambdas with receivers
While the approach of using lambdas with receivers is powerful for creating DSLs, it can initially be confusing for those new to Kotlin or Gradle. This technique allows for a more natural, declarative syntax, enabling code that looks like special language constructs when it's actually just method calls on an implicit receiver.
Once grasped, this results in more readable and expressive code that closely resembles the domain it's describing - in this case, Gradle build configurations. However, the implicit nature of the receiver can be a source of confusion for beginners trying to understand what's happening "behind the scenes.”
On fluent interfaces and infix methods
The fluent pattern and infix methods aim to create a chain of method calls that read almost like natural language. In theory, this should enhance readability and make the build script more intuitive. However, for those not deeply familiar with Gradle, Kotlin, or the concept of DSLs, this syntax can initially seem magical or confusing.
It's not immediately obvious that version
is a method, for instance, or why we can omit parentheses in some places but not others. While it allows for a clear separation of concerns, each method in the chain responsible for a specific aspect of plugin configuration requires some learning and adjustment to fully appreciate and utilise effectively.
As for me…
While I'm still getting accustomed to the Gradle Kotlin DSL and occasionally find it confusing, I've come to appreciate its power and elegance. Building DSLs is indeed one of Kotlin's strengths, and understanding these concepts opens up exciting possibilities for creating expressive and maintainable code.
By exploring the mechanics behind Gradle's Kotlin DSL, I feel I have demystified a bit of its syntax and gained insights into advanced Kotlin features that can be applied in various contexts. When mastered, these patterns and techniques can significantly enhance our ability to write clean, readable, and powerful code.
I hope this dive into the Gradle Kotlin DSL has been as enlightening for you as it has been for me. Whether you're a seasoned Kotlin developer or just starting out, understanding these concepts can greatly improve your grasp of Gradle and Kotlin's capabilities.
Top comments (0)