Issue
I come across a generics problem that I'm struggling with. I've distilled the code down to a simple example to show the problem, as follows
I have a simple hierarchy of types:
open class Fruit
class Apple : Fruit() {
fun crunch(): String = "CRUNCH!"
}
class Orange : Fruit() {
fun squish(): String = "SQUISH!"
}
and a corresponding hierarchy of consumers specific to those types, with a generic parent:
abstract class FruitConsumer<T : Fruit> {
abstract fun consume(fruit: T)
}
class AppleConsumer : FruitConsumer<Apple>() {
override fun consume(apple: Apple) {
println("consuming apple ${apple.crunch()}")
}
}
class OrangeConsumer : FruitConsumer<Orange>() {
override fun consume(orange: Orange) {
println("consuming orange ${orange.squish()}")
}
}
I need to be able to process a list of fruit of unknown type using one of these 'consumers' and currently have this:
fun processFruit(fruitList: List<Fruit>) {
fruitList.forEach {
val processor = consumerFactory(it)
processor.consume(it)
}
}
fun <T : Fruit> consumerFactory(theFruit: T): FruitConsumer<T> {
return when (theFruit) {
is Apple -> AppleConsumer() as FruitConsumer<T> <---- unchecked cast
is Orange -> OrangeConsumer() as FruitConsumer<T> <---- unchecked cast
else -> throw RuntimeException()
}
}
This reports unchecked casts for as FruitConsumer<T>
, but removing these means I have to consider bounds. This then comes with its own problems, e.g.
If I define the return of the factory method as an 'out' type (making it a 'producer' to the kotlin compiler), I don't need the casts:
fun processFruit(fruitList: List<Fruit>) {
fruitList.forEach {
val processor = consumerFactory(it)
processor.consume(it) <--- ERROR HERE
}
}
fun <T : Fruit> consumerFactory(theFruit: T): FruitConsumer<out Fruit> {
return when (theFruit) {
is Apple -> AppleConsumer() <--- no cast
is Orange -> OrangeConsumer() <--- no cast
else -> throw RuntimeException()
}
}
but then since the return type from the factory is considered a producer, it can't consume anything.
If I change the bounds using the 'in' keyword (to make the return type a 'consumer' to the kotlin compiler), then I can't return the specific consumer types:
fun processFruit(fruitList: List<Fruit>) {
fruitList.forEach {
val processor = consumerFactory(it)
processor.consume(it) <------ This compiles fine now
}
}
fun <T : Fruit> consumerFactory(theFruit: T): FruitConsumer<in Fruit> {
return when (theFruit) {
is Apple -> AppleConsumer() <--- Error type mismatch
is Orange -> OrangeConsumer() <--- Error type mismatch
else -> throw RuntimeException()
}
}
Am I missing something obvious here? Is there an elegant solution to this without using unchecked casts?
Solution
This is achievable in a type-safe fashion by:
- Completing the action on a
Fruit
without returning the respectiveFruitConsumer
- Defining a method on
Fruit
for the functionality, or - Using a visitor pattern to locate the functionality in one place.
Completing the action without returning
Here, you can adjust your loop to:
fun processFruit(fruitList: List<Fruit>) {
fruitList.forEach {
consumeFruit(it)
}
}
fun consumeFruit(theFruit: Fruit) {
return when (theFruit) {
is Apple -> AppleConsumer().consume(theFruit)
is Orange -> OrangeConsumer().consume(theFruit)
else -> throw RuntimeException()
}
}
By not returning we have not given ourselves the burden of having two compatible objects together 'in the wild'; then we can write a program that compiles fine1.
In addition, there are two other ways that are worthy of consideration in such a situation (although they are essentially using the same theme).
A method on Fruit
This is probably the most straightforward way, and is really classic OOP and the Hollywood principle2. Programs normally simplify when you locate functionality for the same 'thing' in the same place.
Here, define:
abstract class Fruit {
abstract fun beConsumed()
}
Then you can have on each Fruit
:
class Apple : Fruit() {
fun crunch(): String = "CRUNCH!"
override fun beConsumed() {
AppleConsumer().consume(this)
}
}
class Orange : Fruit() {
fun squish(): String = "SQUISH!"
override fun beConsumed() {
OrangeConsumer().consume(this)
}
}
And your loop simply becomes:
fun processFruit(fruitList: List<Fruit>) {
fruitList.forEach {
it.beConsumed()
}
}
Using a visitor pattern
It may not be possible for each Fruit
to know how it is consumed, or it may be inappropriate for other reasons. Such situations could be when the FruitConsumer
needs various dependencies3 or is in a different module.
Then the visitor pattern nicely still allows a type-safe approach. First we need to require Fruit
s to accept visitors:
abstract class Fruit {
abstract fun accept(visitor: FruitVisitor)
}
interface FruitVisitor {
fun visit(apple: Apple)
fun visit(orange: Orange)
}
We define our accept
methods in the usual way:
class Apple : Fruit() {
fun crunch(): String = "CRUNCH!"
override fun accept(visitor: FruitVisitor) = visitor.visit(this)
}
class Orange : Fruit() {
fun squish(): String = "SQUISH!"
override fun accept(visitor: FruitVisitor) = visitor.visit(this)
}
Then a visitor is given the knowledge of how to consume the different Fruit
types:
class FruitConsumerVisitor : FruitVisitor {
override fun visit(apple: Apple) {
AppleConsumer().consume(apple)
}
override fun visit(orange: Orange) {
OrangeConsumer().consume(orange)
}
}
And finally we update our loop:
fun processFruit(fruitList: List<Fruit>) {
val fruitVisitor = FruitConsumerVisitor()
fruitList.forEach {
it.accept(fruitVisitor)
}
}
To wrap it up, I've put this solution on a Kotlin playground with a main method so we can see it in action:
fun main() {
processFruit(listOf(Apple(), Orange(), Apple()))
}
// Prints:
// consuming apple CRUNCH!
// consuming orange SQUISH!
// consuming apple CRUNCH!
1For completeness, you might consider improving this further by making Fruit
a sealed class to avoid the else
in the when
. If this is not possible, then one of the other methods is more appropriate as the compiler will not allow you to extend the Fruit
family without prescribing how the new fruit is consumed.
2"Don't call us, we'll call you"
3Although that could be overcome by passing in the dependencies needed to construct any FruitConsumer
instance in the beConsumed
method.
Answered By - Simon Jacobs
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.