Hacker News new | ask | show | jobs
by grumdan 2508 days ago
> because all existing implementations are bad

I'm also not sure why in OOP-land, generics are this crazy experimental weird feature, when in functional languages, people figured out how to implement parametric polymorphism (the original term for generics) in quite reasonable ways. I get that subtyping adds some complexity, but overall I don't understand why such a basic way to build abstractions is so controversial in (some) OOP languages. If anyone has some context on why this is more difficult to have in Java-like languages, I'd be curious to hear it.

9 comments

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.

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
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.

That's a really great question!

One other factor is that functional languages typically are theory first, implementation second, while non-functional languages tend to more often have the language features follow whatever the implementation admits. I know for Go they fretted a lot about how you'd efficiently compile generics, which is not something that comes up when you're designing System F or whatever.

But I believe that subtyping has a massive impact on generics, particularly in OOP languages where people expect to use lots of subtypes. There are all of these new questions around not only bounded polymorphism but also variance and mutability and how inference works.

Even TypeScript, which is following a lot of the design decisions already established by C# with the same person behind both, is still kinda just meandering around the design space and continuing to make changes to the semantics in new versions.

If go ever showed people the awesomeness of StandardML or Ocaml/ReasonML (and their amazing type systems), I think the language would lose a lot of users.
This is confusingly phrased. Do you mean "If Go people were ever shown ... ML"? I would totally drop Go for an ML language if any of them had a sane syntax, usable build tooling (including native, static compilation by default), and a (single) decent standard library.

A super awesome type system is worthless without the basic requirements for scalable software development.

If StandardML had even a fraction of the money behind go, there would simply be no contest.

Ocaml is billed as the pragmatic ML, but the syntax really sucks and nominally typed structs aren't nearly as good. They also have 3 competing standard libraries.

Haskell is too ivory tower. Most devs simply can't be bothered.

StandardML is that awesome middle. The language choices are pragmatic compared to haskell (mutable refs, side effects, and not lazy). The syntax is super simple and consistent (unlike ocaml). The concurrent ML extensions offer good multi-threading (still waiting on ocaml). The mlton compiler is very fast. There's only one standard library and it's decent. The big thing holding the language back is third-party libraries. If Google threw their millions at SML instead of go, SML really would be better in every way.

>nominally typed structs aren't nearly as good

OCaml structs are structurally typed.

> They also have 3 competing standard libraries.

There is only one standard library, stdlib.

You know what he meant, if you want functionality comparable to the Go stdlib you are going to have to choose between Core or Batteries, then for async do you choose Async or Lwt? And as said the build system (when you're used to Go) is poor. I like a lot of what OCaml offers, but there's a lot of friction.
> if you want functionality comparable to the Go stdlib you are going to have to choose between Core or Batteries

He is talking about SML here.

Care to elaborate, what Go has to do with Core or Batteries? Both are mostly container libraries, and you don't need them since most of the useful staff is in stdlib.

Check revdeps in OCaml, nobody uses batteries, and nearly nobody safe JS uses Core.

XML parsers, Lwt, servers are in separate packages, which is the only and right thing to do.

>then for async do you choose Async or Lwt

Lwt, it's a non-question. Async is for JaneStreet only.

> And as said the build system (when you're used to Go) is poor.

Dune [1] is far superior to Go's build system. Extremely fast, composable, supporting packaging and os-dependent configurations, extremely easy to config. No abomination like "import github.com/package" or "//go:" in your code.

Go build system doesn't have even a fraction of nice features dune have. For example I could `dune utop ./path/to/my/libs` to build my libs and run a nice repl to test them.

Go doesn't even let you to configure your warnings precisely.

[1] https://dune.build/

> OCaml structs are structurally typed.

You can read about Ocaml's nominal record typing in the [ReasonML docs](https://reasonml.github.io/docs/en/record) (it's more clear there IMO).

SML gets this right in my opinion. If I create a record `{foo = "abc", bar = 123}`, I can pass that record on to ANY function that needs a record that looks like {foo:string, bar:int} fields because it looks at the structure rather than the type of the record constructor.

Another nice property of the SML approach is that you don't need named arguments. Instead, pass a tuple with names (aka a record). One set of syntax rules covers both cases. I also rather like the ability to access record fields with the hash syntax (eg, `#foo myrecord`) when I don't want to destructure.

>You can read about Ocaml's nominal record typing in the [ReasonML docs](https://reasonml.github.io/docs/en/record) (it's more clear there IMO).

You are confusing records with structures, it seems. Structures exist in module language. Records are nominal in SML as well. Access functions for tuples and records are a dirty ugly hack build-in for convenience, they have nothing to do with structural typing, they are inferred in place.

OCaml has structural typed records, they are called objects.

   let obj = object method pi = 3.14; method name = "Pi" end
   val obj : < pi : float ; name : string >
is structurally typed record.

> I can pass that record on to ANY function

No, you can't.

    #foo { foo = 42 }
works, but

    fun f x = #foo x
doesn't. It's an ugly hack, typing is still nominal. A hack, just like SML's arithmetic op overload.

Compare it to OCaml, which have proper structural typing for objects and raw polymorphism

    let f x = x#foo
    f : < foo : 'a; .. > -> 'a
Edit: sorry, indeed typing is structural since you can write `{x:typ} -> typ`.

Anyways OCaml have structural typed records and raw polymorphism and subtyping support for them.

> Another nice property of the SML approach is that you don't need named arguments. Instead, pass a tuple with names (aka a record).

What's your point? You can use records as arguments in OCaml as well.

> I would totally drop Go for an ML language if any of them had a sane syntax, usable build tooling (including native, static compilation by default), and a (single) decent standard library.

F# seems to meet all of that except that native static compilation isn't the default (but is available).

I have found Rust to be a nice middle-ground for this. Sure, the borrow checker takes some getting used to, but it features an ML-style type system (though I still miss some extensions to Haskell's type system available in GHC), rock-solid tooling, and a comprehensive well-documented standard library. The high quality of third-party crates also surprised me. Whether the C-like syntax is sane is debatable though.
Yeah, Rust is the best ML for the things I care about, but after 5 years of on-and-off use, I still haven't adapted to the borrow-checker and a GC is just fine for the applications I write. And learning curve is important too--I need to be able to onboard new developers quickly. Go simply offers the better tradeoffs today for my apps. If someone built a "Rust-lite"--Rust with Go's runtime or Go with Rust's type system (less its lifetimes and borrowing semantics--insofar as those are considered a part of its type system), that would be my primary app dev language. But it's looking like Go is going to get there first with its proposal for generics and hope for sum types.
> a sane syntax

Define sane syntax. C-like abomination? At least unlike C it's unambiguous.

Easily visually scanned and parsed with little mental overhead.
> Easily visually scanned and parsed with little mental overhead.

Sure, I'm asking for actual traits, which makes something easily or hardly parsed. The only languages that I find more readable than OCaml are Ada, Pascal and SML. What causes mental overhead in OCaml/SML syntax for you?

Do you find

     func test (f func(a int, b int) int, x int) int
more readable than

     val test : (int -> int -> int) -> int
Maybe, you've just got used to it?
Yes, I do. Especially when you start considering multiline examples with flow control. There are probably a couple of factors at play:

1. Familiarity. Like (presumably) most programmers, I'm familiar with languages in the C syntax family. Of course you can protest that this is a subjective criteria, but little good that will do you as you try to convince your colleagues to adopt (what they perceive to be) your pointlessly cryptic language for the next project.

2. Visual structure is important and while OCaml's minimalism makes for elegant parser algorithms, it works against human psychology (or so I strongly suspect).

Look, I want to like OCaml/SML. I think the type system is a step in the right direction, but the type system is just gravy and the practical concerns--the fundamentals--are neglected (as much as you may protest to the contrary).

Just to be clear, I don’t think all existing implementations are bad, that was my (possibly unfair) caricature of the Go devs’ position.
I can't think of a popular OO language where they are considered a crazy experimental feature. One historical reason for a certain reticence early on was people were unhappy with C++ templates.
ad-hoc polymorphism is where it shines. Such as the typeclass in Haskell or modules in OCaml
> I'm also not sure why in OOP-land, generics are this crazy experimental weird feature

It's not, it's an essential, basic feature.

I’d say in Java they’re still not quite there, being implemented via compile-time type erasure and runtime type-assertions.
Depends who you ask I guess
Golang doesn't have subtyping.
It makes code unreadable.

   void lol<A, <B, C<D>>, E>()
Pretty much all syntax is unreadable if you don't know the lingo. For example, try presenting the ubiquitous

    for (int i = 0; i < 10; i++) {}
to ten random people without prior programming experience and see how many of them can correctly tell you what all that means. Similarly,

    { a, b in a > b }
is probably not very clear to people who don't write Swift and perfectly lovely closure syntax to people who do.

There's language syntax that's actually unreadable, for instance due to using names that obscure or otherwise don't clearly express what a construct is/does or using the same operator/keyword for too many different context-dependent purposes. I don't quite think the sheer presence of angle brackets makes code unreadable, any more than the sheer presence of curly braces or parentheses do.

You’re missing my point. While other syntactic elements can be learned, generics can be abused to make the code unreadable.
> generics can be abused to make the code unreadable

All features can be abused to make the code unreadable. E.g. all modern languages have regular expressions in their standard libraries, despite complex regular expressions are essentially write-only code.

> While other syntactic elements can be learned, generics can be abused to make the code unreadable.

Substitute "generics" for pretty much any bit of programming syntax, and this statement is still true.

That isn't even close to valid C++.
It is a valid template specialization declaration if you add template<> in front, like this:

template<> void lol<A, <B, C<D>>, E>()