Hacker News new | ask | show | jobs
by gizmo686 2521 days ago
Inheritance makes generics difficult.

For instance, if A < B (B extends A), What is the relationship betwern Array[A] and Array[B]?

If you are just reading the array, you would want Array[A] < Array[B]. If you are writing to the array, you would want Array[B]<Array[A]. If you are doing both, you want Array[A] to have no relation to Array[B].

This problem doesn't come up in ML style languages because they do not make use of inheritence.

5 comments

It comes up in Scala. The solution is to have notation to specify the variance of types.
I think it is Gilad Bracha that said, when the decision was made to include only covariance in Dart, that variance flies in the face of programmer's intuition.

He's right. It's easy to understand one level of variance: you can replace a return type by a subtype and a parameter type by a supertype. (I wouldn't be surprised that many programmer don't undestand this.)

Two levels already requires some deep thinking (assuming definition-site variance: `List<Object>` or `List<String>` as a return type / parameter type, was is allowed to replace it?

More than that? (`List<List<String>>`) Hahaha, good luck.

And by the way, Java has notations to specify the variance of type, but only at the use-site, which is different from doing it at the definition site (both enable expressing things the other can't do... but you can actually have both, as I think is the case in Kotlin, though there are some limitations).

I'm not really sure what you mean. Variance just changes what is and is not a subtype/supertype. If List is covariant then List<T> is a subtype of List<U> if T is a subtype of U. So then, by induction, List<List<T>> is a subtype of List<List<U>> if T is a subtype of U. I'm not sure what's hilariously hard to follow here...
Good point, it's a bad example because we assume we're just combining covariance which is intuitive.

Nevertheless, I have a point because:

1. Things get harder with contravariance. 2. Things get harder when you mix covariance and contravariance.

The prototypical covariance class is Producer<T> (with method produce() returning T) while for contravariance that's Consumer<T> (with method consume taking a T as parameter).

Assume each class has a superclass (Consumer0<T> and Producer0<T>) and a subclass (Consumer2<T> and Producer2<T>). Assume V extends U extends T.

Can you list the subclasses and superclasses of Consumer<Producer<U>>?

Personally, I have to think carefully about this for a minute or so, and I've been there before a couple times.

This is not an especially complex scenario either. I've seen things get worse in practice.

Ah, I can figure it out pretty easily for that example too but probably only because I am pretty familiar with variance and you chose really generous names. I personally find the reasoning about variance becomes a lot easier if you stop thinking about the definitions and start thinking about what you should be able to do. A producer of a subtype is technically also producing the supertype. A consumer of a supertype can totally consume the subtype.
Yes, though Scala can hardly be an example of simplicity.
I don't think it's the simplest language out there but its parametric polymorphism is quite straightforward
Once you figure out co- and contravariance, yes. But neurosurgery is straightforward, too. It's just getting from here to there.
99% of the time Scala programmers don't have to worry about variance. I've been using Scala for over 5 years and it's never been more than a cursory concern, the defaults usually work fine. You just have the flexibility to work with it how you want if you need to. To compare it to neurosurgery is simply disingenuous
I think you are generalising. But variance is specifically important for library developers where you have different uses for your code
And said notation causes the generic type system to be Turing complete, as in the Java case.
That would be the crazy experimental weird feature.
They've been in Scala for at least a decade (iirc), with few alterations. How long does it take for "experimental weird feature" to become "reasonable solution to a problem that should be copied". Another five years?
That seems like a problem with inheritance + mutability, not inheritance on its own. After all, if B is a subtype of A, then we can make List[B] a subtype of List[A] as long as lists are immutable. Appending an A to List[B] returns List[A], what's the problem? :-) In fact some ML style languages (like OCaml) do have inheritance and it works fine with generics. Some generic types (like lists) will be covariant, others (like comparators) will be contravariant, combinations of the two will be invariant, and it all can be automatically inferred.

Which of course doesn't change the fact that imperative languages trying to combine generics + inheritance + mutability are in for a world of hurt.

The same problem happens with immutable types. Does a function of type Int -> A extend a function type Int -> B? How about A -> Int extending B -> Int? The answers to these are opposite.
It's not so difficult. It's just covariance and contravariance. C# has long solved this issue. https://docs.microsoft.com/en-us/dotnet/csharp/programming-g...
> in ML style languages because they do not make use of inheritence.

Except for OCaml and Scala (or any other ML supporting subtyping), where you could simply define type's variance.

Why in the case of writing to the array would you want Array[B] < Array[A]?

  def writeFirst: (xs: Array[B], x:B): xs[0]=x;

  var as: Array[A] = ???
  var b:B = ???
  var a:A = ???

  a=b;        //This is fine, since B extends A.
  as[0]=a   //Obviously fine, since a:A
  as[0]=b   //This better be fine, otherwise the above 2 lines just punched a hole in our type system.
  writeFirst(as,b); //If I am allowed to do the above, I should be allowed to do this.
For completeness, the opposite example:

  def getFirst(xs: Array[B]):B = xs[0];

  var as:Array[A]
  var b:B = as[0] //This shouldn't work. Not all A's are B's
  var b:B = getFirst(as) //Simmilarly, this shouldn't work.
Thanks for the extra detail, but in your `writeFirst` example I don't see a case of `Array[B]` < `Array[A]` (which in your notation is said to be `Array[A]` inherits from `Array[B]`).

I must be missing something.

Using the word "inherits" might be misleading (as might "extends", which I used). We don't particuarly care that the any actual behaviour/implementation gets re-used.

The core meaning of A < B is the is-a relationship. That is to say, a value of type B is also a value of type A.

I assume you agree agree that my first example ought to type-check (although, as the second example shows, there is an argument to be made that it shouldn't). The question is how does it typecheck?

In the last line, we call writeFirst(as,b). Here, the first parameter has type List[A].

However, writeFirst is declared as taking a first parameter of type List[B]. The fact that this works means that List[A] is-a List[B], which is the defining feature of List[B] < List[A].

If this were not the case, then we would have needed to define writeFirst with a type along the lines of:

writeFirst[X < B]( xs:List[X], x:B): xs[0] = x

Where we explictly declare that the type parameter of the list is a subtype of B. In this case List[A] could be used not because of the languages decision on variance, but because the program explicitly typechecks with X=A.

Note that this actually changes the return type. In my original example writeFirst(as,b) would have a natural return type of List[B]. However, in this new example, the natural return type would be List[A].

This second example is closer to how ML style languages work.

EDIT: It occurs to me that I was thinking a bit too functional in this. The natural return type of writeFirst is void in (most?) OOP langauges because arrays are mutable. What I wrote assumed that the natural meaning of writeFirst was to construct a new list which replaces the first element.