Home > Software design >  Java Type Variance Too Permissive?
Java Type Variance Too Permissive?

Time:01-24

In a modern functional language like Scala, type variance is inherent in the type. Here's e.g. Scala's Function1:

trait Function1[-T1,  R] { ... }

contravariant in parameter type and covariant in return type. And here's java's counterpart:

interface Function<T,R> { ... }

Now, to express variance relationship, the "wildcard capture" special syntax is used. For example, the stream's map function is declared as

<R> Stream<T> map(Function<? super T, ? extends R> mapper);

Here, Java shifts the declaration of variance relationship from the type itself to its use as a param in some method signature.

Here's my question. Would I be amiss to say that there cannot be any legitimate usages of Function<T,R> that are not contravariant in T and covariant in R? In other words, does Java's way offer useful extra flexibility not found in Scala, or is it just a lot of repetitive unwieldy boilerplate?

CodePudding user response:

In other words, does Java's way offer useful extra flexibility not found in Scala, or is it just a lot of repetitive unwieldy boilerplate?

Suppose that class Foo extends class Parent and is in turn extended by class Child.

Then, as you know, we can pass an instance of ArrayList<Foo> to a method that takes a List<? extends Parent>, or to a method that takes a List<? super Child>.

A reason that a method might take List<? extends Parent> is if it only reads from the list (it never writes to it), and can happily support any element that's an instance of Parent (because it doesn't need anything specific to Foo or another subtype).

A reason that a method might take List<? super Child> is if it only writes to the list (it never reads from it), and the elements that it writes are always instances of Child (so it doesn't care whether the list can accept arbitrary instance of Foo or another supertype).

That said, yes, it is a lot of repetitive unwieldy boilerplate! As a result, it's not uncommon to come across a method that could take a List<? extends Parent> or a List<? super Child> but instead just takes a List<Parent> or a List<Child> (respectively).

CodePudding user response:

I am not an expert on this but it can be argued that the use of wildcard capture syntax in Java's Function interface allows for more flexibility in expressing variance relationships compared to Scala's Function1 trait, where variance is inherent in the type. The wildcard capture syntax allows for variance to be declared specifically in the context of a method's parameter or return type, rather than being inherent to the type itself. However, it can also be seen as repetitive and unwieldy boilerplate. It depends on the perspective of the developer and their use case.

CodePudding user response:

For Function specifically, no. Function defines exactly one abstract method, apply, which uses T contravariantly and R covariantly. But Function isn't what they had in mind when they designed that feature.

When the Java devs designed call-site variance, they were imagining classes that had both covariant and contravariant uses. For instance, in principle, the E in List<E> must be invariant. It appears in covariant position in get and in contravariant position in add.

So the rationale was this. Suppose we have a type hierarchy X <= Y <= Z. That is, X is a class that subclasses Y, and Y in turn subclasses Z. A List<Y> can do anything with type Y. It can have Ys added to the end, and a user can retrieve elements of type Y from it. But it can never be a List<Z> or a List<X>, since adding to a List<X> would be unsound, and so would retrieving as a List<Z>.

But we can express our intention. List<? extends Y> is a type we can only ever read from. It actually can take a List<Z> under the hood, since a list of Z elements is genuinely still (at least for covariant methods) a list of Y elements. We can get elements from this list, but we can't add to the end of it, since we said we're using the type argument in covariant position but add uses the type argument contravariantly. Essentially, List<? extends Y> is a smaller interface that includes some of the methods from the actual interface List.

The same is true of List<? super Y>. We can't read from it, since we don't know that every element is of type Y. But we can add to it, since we know that the list at least supports elements of type Y. We can use all of the contravariant methods, like add, but none of the covariant ones.

For a type like List that uses its type arguments in different ways, the call-site variance makes some amount of sense. For a special-purpose interface like Function that does one thing, it makes little sense.

That was the Java developers' rationale some twenty years ago when generics were added to Java. A lot has happened since then. If someone wrote an interface like List in today's world, an interface with upwards of 20 abstract methods, half of which have "this method may not be supported and might just throw UnsupportedOperationException" built-in to the contract, they'd rightly be laughed off the stage.

Today's world is one of small, tight interfaces. We follow the SOLID principles. An interface does one thing and does it well. If an interface defines more than two or three (non-defaulted, non-inherited) methods, we give pause and ask if we can make it more modular. And we try to design systems that are more immutable by design, to support scaling and concurrency. We have records, or data classes or whatever your favorite language calls them, that are immutable by default.

So twenty years ago, the idea of a massive super-interface that does twenty things and that can be narrowed down dynamically via type projections seemed pretty cool. Today, it makes far more sense to specify the variance at the declaration site, since most interfaces are small and have a clear use case in mind.

The scala.collection.Seq trait defines three abstract, non-inherited methods (apply, iterator, and length), and all of those use the type argument covariantly, so Seq is defined with a covariant type. The corresponding mutable trait adds one more method (update), which uses its type argument contravariantly, so it has an invariant argument.

In Scala, if you want to modify a sequence, you take a scala.collection.mutable.Seq. If you want to read, you take a scala.collection.Seq. And those interfaces are small enough and narrow enough in purpose that the fact that there are several of those doesn't affect the code quality (and the fact that traits and classes in Scala are cheap to write, compared to the boilerplate necessary in Java to make even a simple class).

CodePudding user response:

Actually, Scala supports both declaration and use site variance. Specifically, you can specify bounded wildcards just like in Java.

This already hints that declaration site variance can not replace use site variance in all cases. The reason is that a declaration can only be variant if it is variant in all possible uses. If some uses are variant, but other uses are not, we can't use declaration site variance, but we can use use site variance.

For instance, class Array[A] can not be declared variant, but the method appendedAll from ArrayOps only uses covariant methods of suffix, and can therefore employ use site variance:

def appendedAll[B >: A](suffix: Array[_ <: B])(implicit arg0: ClassTag[B]): Array[B]
  • Related