Kotlin Custom Operators
One of the most powerful features of Kotlin is the ability to create custom operators through operator overloading. When building domain-specific languages (DSLs), custom operators can make your code more expressive, intuitive, and readable. In this tutorial, we'll explore how to define and use custom operators in Kotlin to enhance your DSLs.
Introduction to Custom Operators
In Kotlin, operators like +
, -
, *
, and others are actually functions with special names. By implementing specific functions in your classes, you can overload these operators to give them custom behavior when working with your own types.
This capability is the backbone of many elegant Kotlin DSLs, allowing you to create code that reads almost like natural language.
Basic Operator Overloading
Let's start with some basic operator overloading in Kotlin.
Binary Operators
Binary operators work with two operands. Here's how to implement the plus (+
) operator for a custom class:
data class Money(val amount: Int, val currency: String) {
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Cannot add different currencies" }
return Money(amount + other.amount, currency)
}
}
fun main() {
val fiveDollars = Money(5, "USD")
val tenDollars = Money(10, "USD")
val fifteenDollars = fiveDollars + tenDollars
println(fifteenDollars) // Output: Money(amount=15, currency=USD)
}
In this example, we've defined a Money
class and implemented the plus
operator, allowing us to add two Money
instances together using the +
symbol.
Unary Operators
Unary operators work with a single operand. Here's how to implement the unary minus (-
) operator:
data class Temperature(val degrees: Double, val scale: String = "Celsius") {
operator fun unaryMinus(): Temperature {
return Temperature(-degrees, scale)
}
}
fun main() {
val temp = Temperature(25.0)
val negatedTemp = -temp
println(temp) // Output: Temperature(degrees=25.0, scale=Celsius)
println(negatedTemp) // Output: Temperature(degrees=-25.0, scale=Celsius)
}
In this example, we can negate a temperature using the -
operator.
Common Kotlin Operators and Their Function Names
Here's a reference table of common operators and the function names you need to implement:
Operator | Function Name |
---|---|
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a / b | a.div(b) |
a % b | a.rem(b) |
a++ | a.inc() |
a-- | a.dec() |
+a | a.unaryPlus() |
-a | a.unaryMinus() |
!a | a.not() |
a == b | a.equals(b) |
a > b | a.compareTo(b) > 0 |
a[i] | a.get(i) |
a[i] = b | a.set(i, b) |
a() | a.invoke() |
Building a DSL with Custom Operators
Now let's see how custom operators can be used to build a more complex DSL. We'll create a simple time calculation DSL:
class TimeUnit(val value: Int) {
infix fun hours(dummy: Unit) = Duration(value, 0, 0)
infix fun minutes(dummy: Unit) = Duration(0, value, 0)
infix fun seconds(dummy: Unit) = Duration(0, 0, value)
}
data class Duration(val hours: Int, val minutes: Int, val seconds: Int) {
operator fun plus(other: Duration): Duration {
var totalSeconds = (this.seconds + other.seconds) % 60
var carryMinutes = (this.seconds + other.seconds) / 60
var totalMinutes = (this.minutes + other.minutes + carryMinutes) % 60
var carryHours = (this.minutes + other.minutes + carryMinutes) / 60
val totalHours = this.hours + other.hours + carryHours
return Duration(totalHours, totalMinutes, totalSeconds)
}
override fun toString(): String {
return "$hours hours, $minutes minutes, $seconds seconds"
}
}
val Int.time get() = TimeUnit(this)
fun main() {
val duration1 = 2.time hours Unit + 30.time minutes Unit
val duration2 = 1.time hours Unit + 45.time minutes Unit + 15.time seconds Unit
val totalDuration = duration1 + duration2
println("Duration 1: $duration1")
println("Duration 2: $duration2")
println("Total Duration: $totalDuration")
}
Output:
Duration 1: 2 hours, 30 minutes, 0 seconds
Duration 2: 1 hours, 45 minutes, 15 seconds
Total Duration: 4 hours, 15 minutes, 15 seconds
This DSL allows us to write time expressions in a very readable way. The Int.time
extension property, along with the infix functions and operator overloading, creates a fluent interface for working with time durations.
Using Invoke Operator for Function-Like Objects
The invoke
operator allows objects to be called like functions, which can be powerful in DSLs:
class HtmlTag(val name: String) {
private val children = mutableListOf<HtmlTag>()
private val attributes = mutableMapOf<String, String>()
operator fun invoke(setup: HtmlTag.() -> Unit): HtmlTag {
this.setup()
return this
}
infix fun String.equals(value: String) {
attributes[this] = value
}
operator fun String.unaryPlus() {
children.add(HtmlTag("text").apply { attributes["content"] = this@unaryPlus })
}
operator fun HtmlTag.unaryPlus() {
children.add(this)
}
override fun toString(): String {
val attributeString = if (attributes.isEmpty()) "" else
attributes.entries.joinToString(" ", " ") { (k, v) -> "$k=\"$v\"" }
return if (name == "text") {
attributes["content"] ?: ""
} else if (children.isEmpty()) {
"<$name$attributeString/>"
} else {
val childrenString = children.joinToString("")
"<$name$attributeString>$childrenString</$name>"
}
}
}
fun html(setup: HtmlTag.() -> Unit): HtmlTag = HtmlTag("html").apply(setup)
fun HtmlTag.body(setup: HtmlTag.() -> Unit): HtmlTag = HtmlTag("body").apply(setup).also { +it }
fun HtmlTag.div(setup: HtmlTag.() -> Unit): HtmlTag = HtmlTag("div").apply(setup).also { +it }
fun HtmlTag.p(setup: HtmlTag.() -> Unit): HtmlTag = HtmlTag("p").apply(setup).also { +it }
fun main() {
val htmlContent = html {
"lang" equals "en"
body {
div {
"class" equals "container"
p {
+"Hello, this is a paragraph!"
}
p {
+"Another paragraph here."
}
}
}
}
println(htmlContent)
}
Output:
<html lang="en"><body><div class="container"><p>Hello, this is a paragraph!</p><p>Another paragraph here.</p></div></body></html>
In this example:
- We use the
invoke
operator to make ourHtmlTag
class callable as a function. - We use the unary plus operator (
+
) to add text and child tags to a parent tag. - The
equals
infix function sets attributes on tags.
This results in a clean, readable HTML-building DSL.
Indexed Access Operators
The get
and set
operators allow you to implement indexed access syntax ([]
):
class Matrix(private val rows: Int, private val cols: Int) {
private val data = Array(rows * cols) { 0 }
operator fun get(row: Int, col: Int): Int {
require(row in 0 until rows && col in 0 until cols) { "Index out of bounds" }
return data[row * cols + col]
}
operator fun set(row: Int, col: Int, value: Int) {
require(row in 0 until rows && col in 0 until cols) { "Index out of bounds" }
data[row * cols + col] = value
}
override fun toString(): String {
return (0 until rows).joinToString("\n") { row ->
(0 until cols).joinToString(" ") { col ->
this[row, col].toString().padStart(3)
}
}
}
}
fun main() {
val matrix = Matrix(3, 3)
// Use the set operator
matrix[0, 0] = 1
matrix[0, 1] = 2
matrix[0, 2] = 3
matrix[1, 1] = 4
matrix[2, 2] = 5
// Use the get operator
println("Value at [1, 1]: ${matrix[1, 1]}")
// Print the whole matrix
println(matrix)
}
Output:
Value at [1, 1]: 4
1 2 3
0 4 0
0 0 5
This example uses custom indexing operators to create a simple matrix class that can be accessed using matrix[row, col]
syntax.
Best Practices for Custom Operators
When using custom operators in your Kotlin DSLs, follow these best practices:
-
Keep it intuitive: Use operators that make sense for your domain. Don't redefine operators to do something unexpected.
-
Document well: Since operators can be cryptic, document what each overloaded operator does.
-
Maintain consistency: Ensure operators behave consistently with other similar operators.
-
Don't overuse: Just because you can overload operators doesn't mean you should. Use them only when they genuinely improve readability.
-
Consider alternatives: Sometimes extension functions or infix functions may be a clearer choice than operator overloading.
Common Pitfalls
-
Precedence issues: Remember that operator precedence is fixed in Kotlin, so your custom operators inherit the precedence of the underlying operator.
-
Side effects: Avoid side effects in operator functions, as they're expected to be pure operations.
-
Performance: Be mindful of creating unnecessary objects in operator implementations.
Summary
Custom operators are a powerful feature in Kotlin that allows you to create expressive DSLs. By overloading operators, you can make your code more intuitive and domain-specific. We've covered:
- Basic operator overloading for binary and unary operators
- Creating a time calculation DSL using custom operators
- Using the
invoke
operator for function-like objects - Implementing indexed access with
get
andset
- Best practices and common pitfalls
When used judiciously, custom operators can transform your Kotlin code into a clean, readable domain-specific language that's a joy to use.
Exercises
-
Create a
Vector2D
class that supports addition, subtraction, and scalar multiplication. -
Extend the HTML DSL with additional tags and attributes.
-
Implement a
Money
class that handles different currencies and exchange rates. -
Create a simple mathematical expression DSL that supports basic operations and variables.
-
Build a scheduling DSL that uses custom operators to express time intervals and constraints.
Additional Resources
- Kotlin Official Documentation on Operator Overloading
- Kotlin DSLs in Action by Venkat Subramaniam (Book)
- Type-Safe Builders in Kotlin
- Advanced Kotlin: Building DSLs
By mastering custom operators, you'll be able to create more expressive and intuitive DSLs in Kotlin, making your code not just functional, but also a pleasure to read and work with.
If you spot any mistakes on this website, please let me know at feedback@compilenrun.com. I’d greatly appreciate your feedback! :)