How to create a factory with type-safe inputs using generics

525 Views Asked by At

I'm looking for a way to pass configuration input to a factory which is derived from a base class and holding different input parameters depending on that derived class for that factory.

I'm struggling to find a good way to implement this. So let me show what I've currently got and where the problem is:

class ExampleFragmentFactoryImpl @Inject constructor(
    private val providers: List<ExampleFragmentProvider<out ExampleInput>>
): ExampleFragmentFactory {

    @Suppress("UNCHECKED_CAST")
    override suspend fun <T: ExampleInput> create(
        pageType: T
    ): Fragment {
        providers.forEach { provider ->
            try {
                val typesafeProvider = provider as? ExampleFragmentProvider<T>
                typesafeProvider?.let {
                    return it.provide(pageType)
                }
            } catch (e: ClassCastException) {
                // This try-except-block shall be avoided.
            }
        }
        throw IllegalStateException("could not create Fragment for pageType=$pageType")
    }
}

Here the factory interface...

interface ExampleFragmentFactory {

    suspend fun <T : ExampleInput> create(
        pageType: T
    ): Fragment
}

Now the provider interface...

interface ExampleFragmentProvider<T: ExampleInput> {

    suspend fun provide(
        pageType: T
    ) : Fragment
}

the input class...

sealed class ExampleInput {

    object NotFound : ExampleInput()

    object WebView : ExampleInput()

    data class Homepage(
        val pageId: String
    ) : ExampleInput()
}

and finally a provider implementation:

internal class ExampleHomepageProvider @Inject constructor() :
    ExampleFragmentProvider<ExampleInput.Homepage> {

    override suspend fun provide(pageType: ExampleInput.Homepage): Fragment {
        TODO()
    } 
}

As commented above, it's really bad that try-except is necessary in the factory. There should be nice way of how to achieve this without try-except. Unfortunately, due to type erasure, it's not possible to check the type before casting. Working with reified types afaik is not possible using polymorphic code.

Another possible solution could be to avoid using generics and casting to the required input type within the providers provide() method -- but that's not really nice, too.

Do you have any suggestions how I can improve this kind of factory?

1

There are 1 best solutions below

3
broot On

To do this we need to acquire KType / KClass / Class of related ExampleInput of a provider. There is no direct and straightforward way to acquire it due to type erasure, but still there are some ways to get hold of it.

Solution #1: capture within reified param

We could register providers one by one using a function with reified type. However, I guess this is not possible in your case as you use dependency injection to acquire providers.

Solution #2: provide by provider

We can make providers responsible for providing their related input types. This is pretty common solution in cases like this.

First, we create additional property in ExampleFragmentProvider to expose its associated T type:

interface ExampleFragmentProvider<T: ExampleInput> {
    val inputType: KClass<T>
    ...
}

internal class ExampleHomepageProvider ... {
    override val inputType = ExampleInput.Homepage::class
    ...
}

Alternatively, we can use KType or Class here.

Then, we use this exposed type/class to search for a matching provider in the factory:

class ExampleFragmentFactoryImpl @Inject constructor(
    providers: List<ExampleFragmentProvider<*>>
): ExampleFragmentFactory {
    private val providersByType = providers.associateBy { it.inputType }

    override suspend fun <T: ExampleInput> create(
        pageType: T
    ): Fragment {
        @Suppress("UNCHECKED_CAST")
        val provider = checkNotNull(providersByType[pageType::class]) {
            "could not create Fragment for pageType=$pageType"
        } as ExampleFragmentProvider<T>
        return provider.provide(pageType)
    }
}

Note that contrary to your original solution, it searches for the exact type. If your ExampleInput has deep subtypes structure, then ExampleHomepageProvider won't be used when asked for e.g. ExampleInput.HomepageSubtype.

Solution #3: reflection voodoo

Generally speaking, type parameters in Java/Kotlin are erased. However, in some cases they're still obtainable. For example, ExampleHomepageProvider was defined as a subtype of ExampleFragmentProvider<ExampleInput.Homepage> and this information is stored in the bytecode. So wouldn't it make sense to use this info to acquire T? Yes, it makes sense, and yes, it is possible with some crazy reflection voodoo:

fun <T : ExampleInput> ExampleFragmentProvider<T>.acquireInputType(): KClass<T> {
    @Suppress("UNCHECKED_CAST")
    return this::class.allSupertypes
        .single { it.classifier == ExampleFragmentProvider::class }
        .arguments[0].type!!.classifier as KClass<T>
}

Then, we can use this function in the factory as a replacement of inputType:

private val providersByType = providers.associateBy { it.acquireInputType() }

Note, this is pretty advanced stuff and it is good to have some low-level understanding of generics in JVM. For example, if we create a generic provider then its T may be actually erased for good and above function will throw an exception:

ExampleHomepageProvider().acquireInputType() // works fine
GenericFragmentProvider<ExampleInput.Homepage>().acquireInputType() // error

Solution #4: 2 + 3 = 4

If we like to go with reflection voodoo, it probably makes sense to still make providers responsible for acquiring their T. This is good for OOP and is more flexible as different providers could decide to use different ways to get their type. We can provide default implementation of inputType at the interface and/or provide abstract implementation:

interface ExampleFragmentProvider<T: ExampleInput> {
    val inputType: KClass<T> get() = acquireInputType()
    ...
}

abstract class AbstractExampleFragmentProvider<T: ExampleInput> : ExampleFragmentProvider<T> {
    override val inputType = acquireInputType()
}

There is important difference between them. Default implementation on interface has to calculate everything each time inputType is accessed. Abstract class caches inputType when initializing.

Of course, providers can still override the property and e.g. provide the type directly, as in earlier examples.