0

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?

ceedee
  • 371
  • 1
  • 11
  • Just to make sure I get it right: `ExampleFragmentFactory` is supposed to work with different kinds of `T`, it holds providers for various `T` types and when we invoke `create(MyExampleInputType)` it should search for providers related to this type, correct? I think this is not possible without storing `Class` / `KClass` / `KType` of providers. You can create a property like: `val ExampleFragmentProvider.pageType: KType` or add providers to the factory using reified function (but I see it is injected, so may be not possible). Maybe there is a better way. – broot Oct 25 '21 at 10:26
  • Yes, you got the requirements right -- and yes, I'd like to inject the available providers. Question regarding your approach: how to I get an instance of KType with the provider class? – ceedee Oct 25 '21 at 10:44

1 Answers1

2

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.

broot
  • 21,588
  • 3
  • 30
  • 35
  • Thanks for the clarification regarding usage of the KClass! This works for me! It's certainly not perfect that every interface implementation needs to provide the KClass, but as you said, there's most likely no better solution. I like the map approach for finding the right provider in O(1). Taking an iterative approach, it would be possible to work without the KClass using this nullable casting helper: ``` inline fun castIfPossible(any: Any?) : T? = any as? T? ``` – ceedee Oct 25 '21 at 14:56
  • If you mean to use this `castIfPossible()` to not store/use `KClass` at all then I believe this is not possible. Reified means that the caller has to know the type of `T`, so we really still have the problem of somehow acquiring `T` of a provider. Anyway, I updated my answer. As it was partially imprecise and misleading, I rewritten some of its parts even from the beginning, so you probably need to re-read it all. Sorry for that. – broot Oct 25 '21 at 19:37
  • I tried the approach with castIfPossible. It compiles, but fails at runtime. Looks I was wrong :-) I'll read through your code, thank you! – ceedee Oct 29 '21 at 15:07