3
votes

I have a generic sealed class, used to represent either single values or pairs of values (split before and after a certain event):

sealed class Splittable<T>

data class Single<T>(val single: T) : Splittable<T>()

data class Split<T>(val before: T, 
                    val after : T) : Splittable<T>()

I would like to define data classes that are generic (parameterizable) over Splittable, so that the properties of the class must either be all Single or all Split. I thought this would do it:

data class Totals<SInt : Splittable<Int>>(
    val people : SInt,
    val things : SInt
)

val t1 = Totals(
    people = Single(3),
    things = Single(4)
)

val t2 = Totals(
    people = Split(3, 30),
    things = Split(4, 40)
)

But I was wrong, because it allows invalid combinations:

val WRONG = Totals(
    people = Single(3),
    things = Split(4, 40)
)

Moreover, what if my class has more than one basic type, for instance both Int and Boolean? How do I write the generic signature?

data class TotalsMore<S : Splittable>(
    val people : S<Int>,
    val things : S<Int>,
    val happy  : S<Boolean>
)

val m1 = TotalsMore(
    people = Single(3),
    things = Single(4),
    happy  = Single(true)
)

val m2 = TotalsMore(
    people = Split(3, 30),
    things = Split(4, 40),
    happy  = Split(true, false)
)

The data class declaration gives errors:

error: one type argument expected for class Splittable<T>
    data class TotalsMore<S : Splittable>(
                              ^
error: type arguments are not allowed for type parameters
        val people : S<Int>,
                      ^
error: type arguments are not allowed for type parameters
        val things : S<Int>,
                      ^
error: type arguments are not allowed for type parameters
        val happy  : S<Boolean>
                      ^

So it appears I cannot pass a higher-kinded type as a type parameter. Bummer.

I can decouple the two generics:

data class TotalsMore<SInt : Splittable<Int>,
                      SBoolean: Splittable<Boolean>>(
    val people : SInt,
    val things : SInt,
    val happy  : SBoolean
)

This works, but it makes it even more obvious that you can mix and match Single and Split, which I want to forbid:

val WRONG = TotalsMore(
    people = Single(3),
    things = Single(4),
    happy  = Split(true, false)
)

I would like every object of those data classes to either be made of all Single values, or all Split values, not a mix and match.

Can I express it using Kotlin's types?

1

1 Answers

2
votes

Your Totals class requires a generic type parameter, but you don't specify one in the constuctor your example. The way that works is with type inference: the Kotlin compiler figures the generic type out from the other parameters. The reason you can mix Single and Split in your first WRONG example is that the compiler sees the two arguments and infers the common supertype from them. So you're actually constructing a Totals<Splittable<Int>>.

If you'd specify a subtype explicitly, you wouldn't be able to mix:

val WRONG = Totals<Single<Int>>(
    people = Single(3),
    things = Split(4, 40) /**  Type inference failed. Expected type mismatch: inferred type is Split<Int> but Single<Int> was expected */
)

So what you want to do is to accept the subclasses of Splittable but not Splittable itself as a generic parameter.

You can achieve that with an additional interface for your subclasses and an additional generic constraint with a where-clause:

sealed class Splittable<T>

interface ConcreteSplittable

data class Single<T>(val single: T) : Splittable<T>(), ConcreteSplittable

data class Split<T>(val before: T, 
                    val after : T) : Splittable<T>(), ConcreteSplittable

data class Totals<SInt : Splittable<Int>>(
    val people : SInt,
    val things : SInt
) where SInt : ConcreteSplittable

val t1 = Totals<Single<Int>>(
    people = Single(3),
    things = Single(4)
)

val t2 = Totals(
    people = Split(3, 30),
    things = Split(4, 40)
)

val WRONG = Totals( /** Type parameter bound for SInt in constructor Totals<SInt : Splittable<Int>>(people: SInt, things: SInt) where SInt : ConcreteSplittable is not satisfied: inferred type Any is not a subtype of Splittable<Int> */
    people = Single(3),
    things = Split(4, 40)
)

As for the second part, I don't think that's quite possible. As you noted, type arguments are not allowed for type parameters.

Unfortunately, you can also not introduce a third type parameter S and restrict SInt and SBool to both the common type S and to Splittable<Int> or Splittable<Bool> respectively.

data class TotalsMore<S, SInt, SBool>
(
    val people : SInt,
    val things : SInt,
    val happy  : SBool
) where S : ConcreteSplittable, 
  SInt : S, 
  SInt : Splittable<Int>, /** Type parameter cannot have any other bounds if it's bounded by another type parameter */
  SBool : S,
  SBool : Splittable<Boolean> /** Type parameter cannot have any other bounds if it's bounded by another type parameter */

What you could do, is to create "safe" type aliases, like this:

data class TotalsMore<SInt : Splittable<Int>, SBool : Splittable<Boolean>> (
    val people : SInt,
    val things : SInt,
    val happy  : SBool )

typealias SingleTotalsMore = TotalsMore<Single<Int>, Single<Boolean>>

typealias SplitTotalsMore = TotalsMore<Split<Int>, Split<Boolean>>

val s = SingleTotalsMore(
    people = Single(3),
    things = Single(4),
    happy  = Single(true) )

Creating a mixed TotalsMore would still be possible though.