Interface Delegation in Kotlin
Last week I introduced the delegated properties feature that is part of the Kotlin language and can be used by using the by
keyword. That is only half the truth; there is an additional delegation built-in the language - interface delegation, also working with the by
keyword. So to give a complete overview about delegation in Kotlin, today I want to explain interface delegation.
Interface delegation in Kotlin allows a class to delegate the implementation of an interface to another object. Instead of the class implementing the interface methods directly, it forwards the calls to a delegate object that provides the actual implementation.
How It Works
Consider an interface Printer
with two methods:
interface Printer {
fun print(value: String)
fun print(value: Int)
}
Instead of a class implementing Printer directly, it can delegate the implementation to another object:
class ConsolePrinter : Printer {
override fun print(value: String) {
println("Printing String '$value' to console")
}
override fun print(value: Int) {
println("Printing Int '$value' to console")
}
}
class Document(printer: Printer) : Printer by printer
fun main() {
val document = Document(ConsolePrinter())
document.print("Hello World!")
document.print(42)
//Output:
//Printing String 'Hello World!' to console
//Printing Int '42' to console
}
Here, the Document
class implements Printer
by delegating all Printer
methods to the printer object provided during its instantiation. The Document
class can optionally overwrite one of the methods but this is not necessary.
Benefits of Using Interface Delegation
-
Code Reusability: Promote reuse of existing implementations without inheritance.
-
Flexibility: Easily swap out delegate implementations without altering the delegating class.
-
Maintainability: Reduce code duplication and simplify class hierarchies.
-
Separation of Concerns (SoC): Delegate specific functionalities to dedicated classes, enhancing modularity.
Implementing Interface Delegation
Let’s explore interface delegation through various examples to understand its practical application.
Basic Delegation Example
Consider a simple scenario where a class needs to implement an interface but wants to delegate the implementation to another object.
interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) {
println("Console Logger: $message")
}
}
class FileLogger : Logger {
override fun log(message: String) {
// Imagine writing to a file here
println("File Logger: $message")
}
}
class Application(logger: Logger) : Logger by logger {
fun run() {
log("Application started")
// Application logic here
// ...
log("Application finished")
}
}
fun main() {
val consoleApp = Application(ConsoleLogger())
consoleApp.run()
val fileApp = Application(FileLogger())
fileApp.run()
}
//Output:
//Console Logger: Application started
//Console Logger: Application finished
//File Logger: Application started
//File Logger: Application finished
The Application
class implements Logger
by delegating to the logger object.
Depending on the Logger implementation passed via constructor parameter(ConsoleLogger
or FileLogger
), the log method behaves accordingly. This approach allows Application
to use different logging strategies without changing its code. Together with dependency injection, this allows changing the behavior of the Application
class for different environments, also improving testability - the functionality of the different Logger
implementations can be tested separately.
Delegation with Additional Functionality
Delegation doesn’t mean that the delegating class is limited to only forwarding method calls. It can also add its own behavior before or after the delegation call.
class TimestampLogger(private val logger: Logger) : Logger by logger {
override fun log(message: String) {
val timestampedMessage = "${System.currentTimeMillis()}: $message"
logger.log(timestampedMessage)
}
}
fun main() {
val logger = TimestampLogger(ConsoleLogger())
logger.log("This is a timestamped log.")
}
//Output:
//Console Logger: 1701267582383: This is a timestamped log.
The TimestampLogger
delegates to logger
but overrides the log method to add a timestamp before delegating the method call. This showcases how delegation can be combined with method overriding to enhance or modify behavior.
Delegation and Lazy Initialization
Together with the property delegation, I’ve explained in the last post, I can use interface delegation to write a simple version of the lazy
- delegate by my own (don’t use this in production and stay with the built-in version).
import kotlin.reflect.KProperty
class LazyDelegate<T>(private val initializer: () -> T) : Lazy<T> {
private var _value: T? = null
override val value: T
get() {
if (_value == null) {
_value = initializer()
}
return _value!!
}
override fun isInitialized(): Boolean = _value != null
}
class Config {
val configValue: String by LazyDelegate {
println("Initializing configValue")
"Config Data"
}
}
fun main() {
val config = Config()
println("Before accessing configValue")
println("configValue: ${config.configValue}")
println("Accessing configValue again")
println("configValue: ${config.configValue}")
}
//Output:
//Before accessing configValue
//Initializing configValue
//configValue: Config Data
//Accessing configValue again
//configValue: Config Data
LazyDelegate
implements Kotlin’s Lazy<T>
interface, providing a custom lazy initialization mechanism.
The Config
class uses property delegation to lazily initialize configValue
.
The delegate ensures that configValue is initialized only once when accessed for the first time.
Multiple Delegations
A class can delegate to multiple interfaces, enabling it to conform to multiple types seamlessly.
interface Reader {
fun read()
}
interface Writer {
fun write()
}
class SimpleReader : Reader {
override fun read() {
println("Reading data")
}
}
class SimpleWriter : Writer {
override fun write() {
println("Writing data")
}
}
class ReadWriteDevice(reader: Reader, writer: Writer) : Reader by reader, Writer by writer
fun main() {
val device = ReadWriteDevice(SimpleReader(), SimpleWriter())
device.read()
device.write()
}
//Output:
//Reading data
//Writing data
ReadWriteDevice
delegates Reader
and Writer
interfaces to reader and writer objects respectively.
This allows ReadWriteDevice
to support both reading and writing functionalities without implementing the methods directly.
Comparison with Inheritance
While both delegation and inheritance allow code reuse, they differ fundamentally:
Aspect | Inheritance | Delegation |
---|---|---|
Relationship |
Is-a relationship |
Has-a relationship |
Flexibility |
Rigid, single inheritance |
Flexible, multiple delegations possible |
Coupling |
Tightly coupled to superclass |
Loosely coupled via delegates |
Reusability |
Limited to superclass capabilities |
Can compose multiple behaviors |
When to Use Delegation Over Inheritance:
-
Multiple Behaviors: When a class needs to exhibit multiple behaviors from different interfaces.
-
Runtime Flexibility: When behaviors need to be swapped or changed at runtime.
-
Avoiding Hierarchical Complexity: To prevent deep and complex inheritance hierarchies.
-
Encapsulation: To better encapsulate and manage responsibilities.
Best Practices
-
Favor Composition Over Inheritance: Use delegation to compose behaviors rather than inheriting from base classes.
-
Interface Segregation: Design small, focused interfaces to make delegation more manageable and meaningful.
-
Immutability: When possible, delegate to immutable objects to avoid unintended side effects.
-
Clear Responsibilities: Ensure that delegate objects have well-defined responsibilities to maintain code clarity.
Common Pitfalls
-
Overusing Delegation: While delegation is powerful, excessive use can lead to fragmented code that’s hard to follow.
-
Circular Delegation: Avoid scenarios where delegates refer back to the delegating class, causing infinite loops.
-
Delegate State Management: Managing mutable state within delegates can introduce complexity and bugs.
Conclusion
Together with property delegation, interface delegation in Kotlin is a useful feature that empowers me to write cleaner, more modular, and maintainable code. It follows the principle of composition over inheritance, allowing me to break down tasks and responsibilities into smaller, manageable parts. This makes the code easier to understand, change, and extend.
The main advantages of interface delegation are:
-
Flexibility: I can easily add or change behaviors without modifying the main logic of your application.
-
Separation of Concerns: By delegating specific tasks to separate classes, I keep the code well-organized and easier to maintain.
-
Reusability and Scalability: Delegated classes can be reused in different parts of the application. I can add new features without breaking the old ones.
Comments