Design experiment using Traits/Composition/Delegation, would constructor "variables" reduce boilerplate?

Hello everyone I’m experimenting with a design exploring using composition over inheritance to combine reusable behaviors. These components have some shared and component specific config. Leveraging kotlin traits with interface delegation, delegated properties and default values.

The idea is that we have n-types of concrete Car types, here: Sedan, RaceCar and FancyRaceCar that are composed from reusable traits, this code is a toy example analog to a system I’m building and wondering if there is an opportunity to further reduce *Base-class boilerplate.

I’m stuck on constructors lacking the ability to create local “variables” to pass instances to the superclass constructors and its interface delegates, but perhaps I’m missing something as I’m new to kotlin.

also on gist: Kotlin Composition/Trait Experiment · GitHub

abstract class Car(open val engine: Engine, open val entertainment: Entertainment) :
    Engine by engine,
    Entertainment by entertainment

interface Engine {
    fun speed(): Int
}

interface Entertainment {
    fun volume(): Int
}

// Global config potentially used by all components/behaviors, imagine config varying by base vs luxury vs sport model
class CarConfig(config: Any) {
    val speedLimit: Int by ConfigValue(config, "global.speedLimit")
    val soundLimit: Int by ConfigValue(config, "global.soundLimit")
}

// Config lookup mechanism, implementation is irrelevant, contains global and component specific config properties
class ConfigValue<T, R>(config: Any, key: String) : ReadOnlyProperty<T, R> {
    override fun getValue(thisRef: T, property: KProperty<*>): R = TODO()
}

class DefaultEngine(config: Any, private val baseConfig: CarConfig) : Engine {
    private val speed: Int by ConfigValue(config, "engine.speed") // component specific config
    override fun speed(): Int = min(speed, baseConfig.speedLimit)
}

class RaceEngine(config: Any, private val baseConfig: CarConfig) : Engine {
    private val speed: Int by ConfigValue(config, "engine.speed") // component specific config
    override fun speed(): Int = min(2 * speed, baseConfig.speedLimit)
}

class DefaultEntertainment(config: Any, private val baseConfig: CarConfig) : Entertainment {
    private val volume: Int by ConfigValue(config, "entertainment.volume")
    override fun volume(): Int = min(volume, baseConfig.soundLimit)
}

// We don't want to do this obviously
class SedanWithDuplication(config: Any) :
    Car(DefaultEngine(config, CarConfig(config)), DefaultEntertainment(config, CarConfig(config)))

// We can do this, leveraging inheritance and default values to simulate having "variables" in our constructor
class Sedan(config: Any) : SedanBase(config, CarConfig(config))

abstract class SedanBase(
    config: Any,
    baseConfig: CarConfig,
    engine: Engine = DefaultEngine(config, baseConfig),
    entertainment: Entertainment = DefaultEntertainment(config, baseConfig)
) : Car(engine, entertainment)

// But this gets more annoying as we'd have to create an extra Base-type for every combination...

class RaceCar(config: Any) : RaceCarBase(config, CarConfig(config))

abstract class RaceCarBase(
    config: Any,
    baseConfig: CarConfig,
    engine: Engine = RaceEngine(config, baseConfig),
    entertainment: Entertainment = DefaultEntertainment(config, baseConfig)
) : Car(engine, entertainment)

// And it gets worse with a level of indirection for every layer of dependency, eg.
// adding a dependency on Engine from Entertainment:

class SportEntertainment(config: Any, baseConfig: CarConfig, private val engine: Engine) :
    Entertainment by DefaultEntertainment(config, baseConfig) {

    fun displaySpeed() {
        println(engine.speed())
    }
}

class FancyRaceCar(config: Any) : FancyRaceCarBase(config, CarConfig(config)) {
    init {
        entertainment.displaySpeed()
    }
}

abstract class FancyRaceCarBase(config: Any, baseConfig: CarConfig) :
    FancyRaceCarBaseBase(config, baseConfig, RaceEngine(config, baseConfig))

abstract class FancyRaceCarBaseBase(
    config: Any,
    baseConfig: CarConfig,
    override val engine: RaceEngine,
    override val entertainment: SportEntertainment = SportEntertainment(config, baseConfig, engine)
) : Car(engine, entertainment)

The answer really depends on what you want to do with these classes.

It seems that the only “important” classes are Car, Engine and Entertainment. All the rest seems can be replaced with functions returning those.

For example all of Sedan, SedanBase, RaceCar RaceCarBase have no logic in methods or fields, so they can easily be replaced by a function returning a Car configured to be a Sedan/RaceCar.

Thanks for looking into this @al3c!

This is obviously a toy example, in a real system there would be more logic in the varying implementations of Cars, in fact FancyRaceCar does just a little bit of that to demonstrate in a silly way. You are correct that the key top-level / visible components are the Car and Sedan, RaceCar, FancyRaceCar, the rest are implementation details / boilerplate. The other key (but internal) components to be reused to build various Cars are the implementations of Engine and Entertainment, which make up different kinds of Cars. The specialized Cars however can only be instantiated by passing in a config structure, the caller doesn’t really know what makes up a FancyRaceCar it just knows it needs one and has the config for it.

To clarify, it’s not just about behavioral differences, there may be extended APIs exposed on the specializations, so the outside world knows about them (not demonstrated in the toy example), so we’re interacting with more than the Car contract.

You can use public constructors to “build” arguments other private constructors.

class Sedan private constructor(
    config: Any, baseConfig: CarConfig
) : Car(
    DefaultEngine(config, baseConfig),
    DefaultEntertainment(config, baseConfig)
) {
    constructor(config: Any) : this(config, CarConfig(config))
}

Alternatively, you define top-level methods for constructing class. If you want to hide the the less-friendly constructor, you may consider exposing interfaces instead of classes.

interface Car : Engine, Entertainment

private abstract class AbstractCar(open val engine: Engine, open val entertainment: Entertainment) :
    Car,
    Engine by engine,
    Entertainment by entertainment

//Can drop Sedan interface if no additional methods or semantics
interface Sedan : Car 

fun Sedan(config: Any): Sedan {
    val baseConfig = CarConfig(config)
    return SedanImpl(
        DefaultEngine(config, baseConfig),
        DefaultEntertainment(config, baseConfig)
    )
}

private class SedanImpl(engine: Engine, entertainment: Entertainment) : AbstractCar(engine, entertainment), Sedan
1 Like

Ahh that may actually be a path forward, didn’t know this was possible, I guess I got stuck with tunnel vision.

Certainly an option, but this perhaps trades one kind of boilerplate for another. It is also a different approach that would be more difficult to integrate in the existing mechanism, but worth considering.

I’ll experiment with this, thx for your suggestions!

Feel silly for leaving out companion object factory methods. These can read like constructors too if they use the invoke operator.

class Sedan private constructor(
    config: Any, baseConfig: CarConfig
) : Car(
    DefaultEngine(config, baseConfig),
    DefaultEntertainment(config, baseConfig)
) { 
    companion object {
        operator fun invoke(config: Any) = Sedan(config, CarConfig(config))
    }
}

val sedan = Sedan(sedanConfig)
2 Likes

I knew about companion objects and the invoke operator but but never thought of using them together, nice one, thx for suggesting!

1 Like