2

Java Arrays are not fully type-safe because they are covariant: ArrayStoreException can occur on an aliased array. Java Collections, on the other hand, are invariant in their type parameter: e.g., List<Thread> is not a subtype of List<Runnable> (which may be somewhat counterintuitive). The motivation seems to do with Lists and other collections being mutable, so to keep the type system sane, their type parameters necessarily have to be invariant.

If a programming language only supported immutable types, could a type system where type parameters were either covariant or contravariant (but never invariant) work? In other words, to use Scala's way of expressing variance, one would have List[+E], Function[-T, +R], Map[+K, +V], etc. I know that there are some older languages (e.g., GNU Sather) that seem to get away with supporting just co-/contravariant parameter types.

My general question is: in a world of completely immutable data types, is there a case where one would specifically need an invariant parameter type (as opposed to either co- or contravariant)? Are there some examples for immutable data structures that would only be correct with an invariant type parameter?

raner
  • 1,175
  • 1
  • 11
  • 21
  • This question is going to get closed as opinion based... (IMO, subtyping is a bad idea - a class should implement exactly one interface, but that's opinion.) – Tom Hawtin - tackline Mar 26 '20 at 03:12
  • Thanks for your comment, @TomHawtin-tackline. I guess I'm a little surprised, since I'm not asking for opinions, but maybe a hard counter-example with an explanation. Is there a specific part that makes this question opinion-based or that would invite opinion-based answers? I'm happy to rewrite/edit my question. I'm coming at this from a compile-time type safety perspective, which, at least in my mind, is a pretty hard science that doesn't really leave much room for opinion-based answers. Should I maybe clarify that? – raner Mar 26 '20 at 03:19
  • 1
    I'm surprised that you write `Map[-K, +V]`; surely that should be `Map[+K, +V]`? (Can't allow `-K` without breaking iteration. But `+K` would be fine, because missing keys implicitly map to null.) – ruakh Mar 26 '20 at 03:29
  • 1
    The only opinionated part is the first sentence and a half. If you get rid of the stuff about Java lists, the general question is interesting and unopinionated. – StriplingWarrior Mar 26 '20 at 03:41
  • Thanks, @StriplingWarrior, I rewrote it a little bit. The list versus array stuff somewhat sets the stage for the question, in my mind, so I didn't want to remove that. But I reworded the initial part. – raner Mar 26 '20 at 03:58
  • @ruakh, you may have a point there; I'm still working on fully understanding your comment. You definitely uncovered a couple of implicit assumptions that I didn't write down in the question. One of them is that there are no `null` pointers, so this fictional version of `Map` that I have in mind would return an `Optional` or a union or sum type like "`V|Nothing`" (if that makes sense). Can you elaborate on breaking iteration? Another unstated assumption of mine is that there would not be anything like `Iterator` at all, since it relies on mutable internal state. – raner Mar 26 '20 at 04:07
  • @ruakh, also a `Map` is basically a function for obtaining a value for a key, which is why I used the same type parameter variances for `Map` that I used for `Function`, but I may be missing something here. – raner Mar 26 '20 at 04:09
  • 1
    Re: "One of [my assumptions] is that there are no null pointers, so this fictional version of Map that I have in mind would return an Optional or a union or sum type like 'V|Nothing'": OK, but that's the same thing for this purpose. Re: "a Map is basically a function for obtaining a value for a key, which is why I used the same type parameter variances for Map that I used for Function": A map is sort of like a *partial* function, in that its set of keys is a subset of the set of instances of its key-type. It compensates for this by mapping missing keys to a default value, *[continued]* – ruakh Mar 26 '20 at 04:19
  • 1
    *[continued]* which means that it's fine to allow checks for mappings where the key is of the wrong type. Re: "that there would not be anything like Iterator at all, since it relies on mutable internal state": I think iteration is an essential feature of collections; fortunately, you can use immutable iterators (e.g. with `head` and `tail` methods, where the latter returns the new iterator). – ruakh Mar 26 '20 at 04:23
  • Thanks for elaborating, @ruakh. I think I understand now, `Map[-K, +V]` would imply `Entry[-K, +V]`, etc., which would not make sense. So, it would have to be `Map[+K, +V]`. Incidentally, I had a look at Scala's immutable map type, which is defined as `Map[K, +V]` (invariant in `K`), so I'm wondering if I'm missing something else here, since `Map[+K, +V]` seemingly would have provided greater flexibility and yet the API designers opted for invariance in `K`. – raner Apr 02 '20 at 19:47
  • 1
    `List` not being a subtype of `List` is not so much to do with mutability, although the problem only expresses itself with mutable structures - an immutable structure is an edge case. At first glance and without any thought, it not being a subtype can seem counterintuitive, but when you consider the implication of being a subtype meaning you can assign a subtype to a variable of the supertype, it should be obvious why it isn't. – Bohemian Apr 02 '20 at 20:26
  • 1
    @Bohemian: I don't understand your comment, sorry. If `List` were immutable, what would be the harm in `final List runnables = existingListOfThreads;`? (That is: if `List` were immutable, what would `List` allow that it shouldn't, that `List extends Runnable>` doesn't?) – ruakh Apr 02 '20 at 20:44
  • @Bohemian, I'm struggling with the same questions as @ruakh does. It seems the affected methods are exactly the mutating methods, e.g., `add(E)`, `add(int, E)`, `set(int, E)` (in case of `List`), which, for an immutable type, would create a new object, rather than modifying an existing one. It appears that for an immutable type the assignment of a subtype object to a variable of a supertype would indeed work. Can you clarify which operations would be supported that shouldn't be? Also, you suggest that the underlying problem is not mutability but something else. Can you clarify what it is? – raner Apr 03 '20 at 05:26
  • @user1932890 yes, using its mutator methods show the problem, but an immutable collection doesn’t need typing: if it’s immutable you can’t do anything to it, so it doesn’t need protection against it containing objects of the wrong type, so immutability is an edge case where typing is irrelevant, unnecessary and not part of the real problem. Here’s the crux: if `List` is a subtype of `List`, then this assignment would be legal: `List list = mysubtypelist;` and then `list.add(anything); Subtype explodesWithClassCastException = mysubtypelist.get(0);` – Bohemian Apr 04 '20 at 02:06
  • 1
    @Bohemian: I think you've misunderstood something. There are plenty of languages that have strong type systems where immutability is the norm. Just as it makes perfect sense for a `final` variable to have a type so you can use it, it makes sense for an immutable list to have an element-type so you can use its elements. And your mutable examples are clearly irrelevant, since the whole point of this question is about how things could/should/work in the absence of mutability. – ruakh Apr 04 '20 at 02:36

1 Answers1

2

So, every type system either allows some unsound programs or forbids some sound programs or both (this is a consequence of Rice's theorem), so a good working assumption is that yes, any stricture you come up with is bound to rule out some sound programs that would otherwise have been allowed. On the other hand, humans are infinitely clever, so in another sense the answer is no: if you add a stricture like you describe, that's OK, people will figure out a way around it when they need to. (Of course, sometimes the workaround they'll come up with will be one you don't like, such as abandoning your language.)

But I think what you're really asking for is a convincing case: a realistic example where, given the choice between supporting that example straightforwardly and sticking with your proposal to require all type parameters to be either covariant or contravariant, your gut will tell you to abandon the proposal so you can support that example straightforwardly.

Since you've already identified various cases where a type parameter can't be covariant and various cases where a type parameter can't be contravariant (for example, Function[-T, +R] is fine, but the reverse would be totally unsound), a good approach is to search for cases where the same type parameter is used twice, once in a way that can't be covariant and once in a way that can't be contravariant. A trivial example would be UnaryOperator[T] <: Function[T, T], analogous to Java's java.util.function.UnaryOperator<T>, whose 'apply' method returns the same type as it accepts. A UnaryOperator[String] can't be used as a UnaryOperator[Object] (because you can't pass it an arbitrary Object), but a UnaryOperator[Object] can't be used as a UnaryOperator[String], either (because even if you pass it a String, it might return some different Object).

For a more fleshed-out realistic example . . . imagine a binary search tree TreeMap[K, +V] <: Map[K, V], analogous to Java's java.util.TreeMap<K,V>. Presumably we want to support methods such as 'firstKey' and 'floorEntry' and 'iterator' and so on (or at least, some of them), so we can't make K contravariant: a TreeMap[Object, Foo] can't be used as a TreeMap[String, Foo], because when we retrieve a key, the key might not be a String.

And since it's a binary search tree, it needs a Comparator[K] internally, which immediately makes it tricky for K to be covariant: if you use a TreeMap[String, Foo] as a TreeMap[Object, Foo], then you're implicitly using a Comparator[String] as a Comparator[Object], which doesn't work. Now, since the map certainly only contains String keys, perhaps the 'get' method can work around this by pre-checking the type of the key before calling using Comparator[String]; but the 'floorEntry' and 'ceilingEntry' methods are still a problem: what entry comes "before" or "after" an arbitrary object that can't be compared to the keys in the map?

And even though you've said that your map is immutable, you probably still want some sort of 'put' method, just, a purely functional one that returns a modified copy of the map. (Purely functional red black trees support the same invariants and worst-case asymptotic time complexities as mutable ones, so type system aside, this is certainly a reasonable thing to do.) But if a TreeMap[String, Foo] can be used as a TreeMap[Object, Foo], then its 'put' method needs to support returning a binary search tree that contains a non-String key — even though its Comparator[String] doesn't define an ordering for such keys.

(In a comment, you mention that Scala actually defines Map[K, +V] with an invariant key type. I've never used Scala, but I bet that this is exactly why.)

ruakh
  • 175,680
  • 26
  • 273
  • 307
  • Thanks, @ruakh, very comprehensive answer! Especially your `UnaryOperator[T]`/`Function[T,T]` example and the problems with `Comparator[K]` and the 'floorEntry`/`ceilingEntry` methods that you described were exactly the counterexamples I was looking for! – raner Apr 04 '20 at 00:59