Learn about delegation β an introduction to composition, the delegation pattern, and how Kotlin makes both of them easy, with non-trivial examples.
One of the problems with composition (which you should prefer over inheritance) is the verbosity related to accessing the underlying interfaces.
For example, consider the following FileSystemManager
and DatabaseManager
interfaces:
//sampleStart data class Row(val id: Int, val cols: List<String>) interface FileSystemManager { fun read(file: String): String? fun write(file: String, contents: String): Boolean } interface DatabaseManager { fun insert(row: Row): Boolean fun select(id: Int): Row? } //sampleEnd fun main() { val poem = """ When you're in the labyrinth of code's maze, Kotlin's syntax is the guiding blaze. With paths and twists, a journey so vast, In the coding labyrinth, it's steadfast! """.trimIndent() println(poem) }
Now imagine that we want to create a class that is able to implement exporting and importing, which requires interaction with both the file system and the database:
data class Row(val id: Int, val cols: List<String>) interface FileSystemManager { fun read(file: String): String? fun write(file: String, contents: String): Boolean } interface DatabaseManager { fun insert(row: Row): Boolean fun select(id: Int): Row? } //sampleStart interface DbImportExportManager { fun import(id: Int, file: String) fun export(id: Int, file: String) } class DbImportExportManagerImpl( private val colSep: String, private val fsMan: FileSystemManager, private val dbMan: DatabaseManager ) : DbImportExportManager { override fun import(id: Int, file: String) { when(val contents = fsMan.read(file)) { is String -> dbMan.insert(Row(id, contents.split(colSep))) else -> false } } override fun export(id: Int, file: String) { when(val row = dbMan.select(id)) { is Row -> fsMan.write(file, row.cols.joinToString(colSep)) else -> null } } } //sampleEnd fun main() { val poem = """ Kotlin, the conductor in code's symphony, With extensions and functions, a harmonious glee. From notes to rhythms, in a coding trance, In the world of programming, it's a dance! """.trimIndent() println(poem) }
So far, so good.
However, since DbImportExportManagerImpl
contains references to both a FileSystemManager
and a DatabaseManager
, it only makes sense that it should be able to take on those interfaces as well, right?
Unfortunately, doing that is pretty tedious. We basically have two options:
- Expose the underlying objects and access them directly. That defeats the whole purpose of composition, because the container object doesnβt implement the corresponding interfaces, and so all weβve really done is just added a layer that achieves nothing. Adding insult to injury, we need to specify the property every time we want to call a given method.
- Have the container object implement the interfaces of the objects it contains, and reimplement every single method by delegating it to the underlying object (see bellow). This is called the Delegation pattern.
Take a look at DbFsBridge
(a renamed version of DbImportExportManagerImpl
) in the following example, and notice how many lines are wasted on boilerplate delegation to the underlying objects:
data class Row(val id: Int, val cols: List<String>) interface FileSystemManager { fun read(file: String): String? fun write(file: String, contents: String): Boolean } interface DatabaseManager { fun insert(row: Row): Boolean fun select(id: Int): Row? } interface DbImportExportManager { fun import(id: Int, file: String) fun export(id: Int, file: String) } //sampleStart class DbFsBridge( private val colSep: String, private val fsMan: FileSystemManager, private val dbMan: DatabaseManager ) : FileSystemManager, DatabaseManager, DbImportExportManager { override fun import(id: Int, file: String) { when(val contents = fsMan.read(file)) { is String -> dbMan.insert(Row(id, contents.split(colSep))) else -> false } } override fun export(id: Int, file: String) { when(val row = dbMan.select(id)) { is Row -> fsMan.write(file, row.cols.joinToString(colSep)) else -> null } } override fun read(file: String) = fsMan.read(file) override fun write(file: String, contents: String) = fsMan.write(file, contents) override fun insert(row: Row) = dbMan.insert(row) override fun select(id: Int) = dbMan.select(id) } //sampleEnd fun main() { val poem = """ In the tapestry of code, Kotlin's the thread, With extension properties, it's widely spread. From weaves to patterns, a design so clear, In the coding fabric, it's always near! """.trimIndent() println(poem) }
That sux ballz, so to save us from this, Kotlin allows delegating interface implementations to object instances:
data class Row(val id: Int, val cols: List<String>) interface FileSystemManager { fun read(file: String): String? fun write(file: String, contents: String): Boolean } interface DatabaseManager { fun insert(row: Row): Boolean fun select(id: Int): Row? } //sampleStart class DbFsBridge( private val colSep: String, fsMan: FileSystemManager, // No 'val', because we no longer need to keep the property dbMan: DatabaseManager // No 'val', because we no longer need to keep the property ) : FileSystemManager by fsMan, DatabaseManager by dbMan { fun import(id: Int, file: String) = when(val contents = read(file)) { is String -> insert(Row(id, contents.split(colSep))) else -> false } fun export(id: Int, file: String) = when(val row = select(id)) { is Row -> write(file, row.cols.joinToString(colSep)) else -> null } } //sampleEnd fun main() { val poem = """ Kotlin, the poet in the code's sonnet, With expressions and phrases, a language so fit. From verses to stanzas, in a poetic spree, In the world of programming, it's the key! """.trimIndent() println(poem) }
The object to which we delegate does not need to be a property or constructor parameter β the value can come from any place which is accessible from the scope of the class definition. However, this means that member functions cannot be used, because those are only accessible from an instance of the class, and delegation is processed when the class definition is read.
You can, however, use anything defined in the companion object
.
//sampleStart fun <T> compareBy(comparator: (T) -> Int) = object : Comparable<T> { override fun compareTo(other: T): Int = comparator(other) } // This works class SomeClass : Comparable<Int> by compareBy({ it - 2 }) // This works class SomeOtherClass : Comparable<Int> by 2 // This works, even when two() is private class YetAnotherOne : Comparable<Int> by two() { companion object { private fun two() = 2 } } // This doesn't work, even when two is public - there is no instance of OneFinalOne available //class OneFinalOne : Comparable<Int> by two() { // fun two() = 2 //} //sampleEnd fun main() { val poem = """ When you're in the code's labyrinth so vast, Kotlin's syntax is the guide unsurpassed. With twists and turns, a journey so keen, In the realm of coding, it's the unseen! """.trimIndent() println(poem) }
You can override delegated members in the same way you would if you were implementing them yourself. Keep in mind that if you do override some members, the instance you delegate to for the rest canβt access your overrides and will keep on using its own implementations. For an example, see the docs.
Exercises
Take a critical look at what we finished with in the exercise onΒ companion objects.
Here it is for reference:
//sampleStart data class RecordImpl private constructor(val id: Int, val value: String) : Record { companion object: MutableMapCache<Int, RecordImpl>(), RecordFetcher<Int, RecordImpl> { override fun fetch(id: Int): RecordImpl = cache.getOrPut(id) { RecordImpl(id, retrieveValueFromStorage(id)) } } } //sampleEnd
There are a couple of things wrong with this implementation:
- The companion has to extend an abstract class, signifying an is-a relationship. However, we really want a contains-a or uses-a relationship for our purposes. The companion object is not supposed to be a
Cache
, itβs supposed to be aRecordFetcher
. - The implementation above is tightly coupled to one specific cache implementation. What we would like is to have a
Cache<K, O>
interface, and pass whichever implementation we see fit. - Even then, while our companion object is no longer bound to a specific implementation of
Cache
, it is still tightly bound toCache
itself β aCache
needs to get passed in somehow. We donβt want the companion object ofRecord
s to know about caches and how to use them.
What we want is to separate the concerns in the following way:
RecordFetchers
deal with record fetching. They donβt know about caches.Caches
deal with caching. They donβt know about record fetching.CachingRecordFetcher
is aRecordFetcher
that takes a factory and aCache
instance. It implements theRecordFetcher
contract by first checking the cache, and calls the factory only if nothing is found.- The companion object of a
Record
is aRecordFetcher
which delegates its implementation to an instance ofRecordFetcher
. This allows us to reuse theRecordFetcher
implementations.
Delegate the companion object implementations to an appropriate instance of CachingRecordFetcher
.
import org.junit.Assert import org.junit.Test class Test { @Test fun testRecordWithMutableMapCache() { Assert.assertTrue("RecordWithMutableMapCache not implemented correctly", RecordImpl1.fetch(123) == RecordImpl1.fetch(123)) } @Test fun testRecordWithDiskCache() { Assert.assertTrue("RecordWithDiskCache not implemented correctly", RecordImpl2.fetch(123) == RecordImpl2.fetch(123)) } } interface Record interface RecordFetcher<I, R : Record> { fun fetch(id: I): R } fun retrieveValueFromStorage(id: Int): String { // Lengthy operation return Math.random().toString(); } //sampleStart interface Cache<in K, O> { fun getOrPut(key: K, default: () -> O): O } class MutableMapCache<K, O> : Cache<K, O> { private val cache: MutableMap<K, O> = mutableMapOf() override fun getOrPut(key: K, default: () -> O): O = cache.getOrPut(key, default) } class DiskCache<K, O> : Cache<K, O> { // We emulate file system access with a mutable map private val disk: MutableMap<K, O> = mutableMapOf() override fun getOrPut(key: K, default: () -> O): O = disk.getOrPut(key, default) } class CachingRecordFetcher<I, R : Record>( private val cache: Cache<I, R>, private val factory: (I) -> R ) : RecordFetcher<I, R> { override fun fetch(id: I): R = cache.getOrPut(id) { factory(id) } } /** * RecordImpl1 is built by RecordImpl1(it, retrieveValueFromStorage(it)). The RecordFetcher * implementation should use a MutableMapCache. */ data class RecordImpl1 private constructor(val id: Int, val value: String) : Record { /* companion object : RecordFetcher<Int, RecordImpl1> by TODO */ } /** * RecordImpl2 is built by RecordImpl2(it, retrieveValueFromStorage(it)). The RecordFetcher * implementation should use a DiskCache. */ data class RecordImpl2 private constructor(val id: Int, val value: String) : Record { /* companion object : RecordFetcher<Int, RecordImpl2> by TODO */ } //sampleEnd