Hacker News new | ask | show | jobs
by iainmerrick 2514 days ago
Maybe this is unfair, but it’d be great if the Go devs could just say “generics will work similarly to [C# / Java / Swift / D / whatever], except that we’ll address [problems] with [adjustments]”.

Rather than going through this whole rigmarole of resisting adding generics too early because all existing implementations are bad, then slowly reinventing the wheel from scratch, then finally ending up with something pretty similar to one of those other languages anyway.

It’s OK not to make something completely new and different. It’d be useful to explicitly say which language(s) you’re borrowing from because you can then more clearly call out the differences and explain the reasons for them.

7 comments

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

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.

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

> 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?
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>()

What is your point? Serious question.

If you tell me, for example, that Go generics are going to be like C# generics, then I have to be familiar with the full semantics of C# generics. Essentially you tell me that in order to understand X I have to understand Y first, that's not good. Consumers of your language are not, for the MOST part, PLT nerds.

I’d compare it to e.g. the work on async/await in Rust, where the discussion seems more directly “we like this feature that C# pioneered and JS has adopted, here’s how we plan to adapt it to make it work well with Rust.”

Admittedly, the Rust async/await RFC and the Go contracts proposal both discuss prior art in sections towards the end, so they are actually similar in that respect. Maybe it’s really just a question of tone and messaging, and the particular discussions that tend to end up on HN.

Fair point, however I think that you're mistaking language design discussion in a closed group of PLT invested people, and a blog post targeted at general audience.

Visiting https://rust-lang.github.io/async-book/ I don't see any mentions of neither C# nor JS. The key difference here is the audience.

The async book is in the process of being re-written; I wouldn't be surprised if a comparison to JS ends up in there, given that we have some significant differences and it really trips up a lot of people who come from JS.
>then I have to be familiar with the full semantics of C# generics

Or you could research the very detailed discussion and documentation available on the topic of C# generics.

How is your suggestion not equivalent to "be familiar with full semantics of C# generics"? How does that help me with being productive using Go's implementation of generics, which has fundamentally different type system and syntax to begin with?
You're asking why general knowledge of generics and the pros and cons of various implementations would give you better understanding into the specific trade offs made by Go's implementation? You can't imagine why?
The big advantage is that PLT nerds (Who else are you going to learn a programming language from?) already know the ugly, hairy bits of Y and how to work around them. The alternative is for X to invent new ugly, hairy bits.
> then I have to be familiar with the full semantics of C# generics.

Presumably, by the time the feature ships, the golang.org/doc entry on generics will not consist of just 'lol, they are just like C# generics, docs.microsoft.com, chum'

It seems like you’re objecting to the messaging (not referencing an existing language, which doesn’t work for people not familiar with the implementation of that language) and also that, at its inception, Go didn’t pick (randomly?) a language off of which to model its generic implementation. Am I misreading?
Well, there doesn’t seem to be a lot of discussion of alternatives at all, so I can’t really see what design and implementation decisions are actually being made. Most likely I’m just looking in the wrong place, and those discussions are taking place elsewhere.

I guess my complaint is that this talk, like all the other updates on their progress on generics, implies that Go generics exist in a vacuum, whereas in reality there’s a ton of prior art that could usefully be referenced.

Edit to add: this particular talk is focused on syntax details. Those aren’t unimportant but they’re a small part of the whole picture. As I commented on the detailed Contracts proposal, the decision to add contracts rather than simply using interfaces (as in Java and C#) seems significant but isn’t explained.

It was beaten unto death in github repo issues, mailing list topics, and wiki pages soliciting real-world examples.

This post is basically a conference talk transcript. More stuff is in: https://go.googlesource.com/proposal/+/4a54a00950b56dd009648... including several callouts to other languages.

It didn't all hit HN, or perhaps more accurately, it probably did all hit HN but didn't all get upvoted because how many times does HN need to chew on it in a month?

I think HN would chew on it every day if it could! Luckily it doesn’t bubble up to the front page quite that often...

Edit to add: thanks for the link! I now see that the full Contracts proposal includes some sections towards the end that address my concerns, eg “Why not use interfaces instead of contracts?”

As jerf mentioned, there has been lots of talk; every single time the question of generics is raised here, /r/programming, or any Go-specific forum, it's addressed.

With respect to interfaces vs contracts; interfaces are about runtime polymorphism while contracts are about compile time polymorphism. This is an important difference when you consider []interface{Foo()} vs Slice(contract{Foo()})--an instance of the former can contain elements of varying concrete types while an instance of the latter can only contain elements of the same type. The other important detail is that interfaces only abstract over a single type while contracts support multiple type parameters (a single contract could specify a visitor type and a vistee type, for example).

Referencing existing work is not an rare or fanciful concept. If you're not familiar with prior work that's referenced in a paper then you can research that prior work as well. If prior work has not been referenced or addressed its usually considered a flaw.
>objecting

Reads more like suggesting to me. And, a helpful one.

> if the Go devs could just say “generics will work similarly to [C# / Java / Swift / D / whatever], except that we’ll address [problems] with [adjustments]”.

This is basically how C++ was designed, and it turns out not to work very well; the [adjustments₁] for [feature₁] turn out to introduce not only unanticipated [problems₁] with [feature₁] itself but also new and previously unimagined [problems₂] with [feature₂]. So the Golang designers prefer to take a much more cautious approach than the Lumbergh approach you're suggesting. So far it seems to have worked out well — the language is not without its compromises, and it's substantially more complicated than it was at first, but it's a very reasonable compromise.

I don’t agree with your point, but I applaud your use of Unicode.
Well, consider C++ inheritance combined with object nesting; you get slicing copies.

Or constructors combined with static objects; you get the static initialization order fiasco.

Or constness with template functions; you get two, four, or eight copies of each generic function in your source code according to which things are const.

Or (compile-time) overloading with (run-time) overriding; you get C++’s weird “hiding” rule about the other overrides you didn't override.

Or separate compilation with implicitly instantiated templates; you get geological build times as the compiler instantiates the same templates in every .C file and then throws away all but one of the identical instantiations at link time. (To be fair, this is far from the only reason C++ compiles slowly.)

Overriding combined with type conversions through implicitly invoked constructors (and implicit referencing and implicit casting to const) gives you annoying bugs that are unnecessarily hard to figure out.

Cleanup from exceptions via RAII combined with C’s unspecified argument evaluation order led to a situation where resource leaks during certain kinds of operations couldn't be avoided reliably, a bug in the language definition that wasn't noticed for several years, though I think it's fixed now.

The grammar is undecidable because of the number of different things that have been added, which sounds like a hyperbolic joke but is actually literally true, and a significant obstacle to implementing something like gofmt for C++.

The combination of template parameters using <> for parameters, the traditional longest-leftmost tokenization rule, and the >> operator for bitshifting made foo<bar<baz>> an unexpected syntactic pitfall, one that is now fixed.

There wasn't an exception-safe version of the STL for a number of years, which isn't really an interaction between templates and exceptions —a non-template container had to deal with the same exception-safety problems— but it did mean that fit quite a while you could use the STL or exceptions but not both.

This is far from the extent of the problem. The C++ FAQ consists largely of affirmations of the form, “Doing [reasonable thing 1] works, and [reasonable thing 2] works too, but if you do them both, you will die horribly for your immorality.” It's exaggerating about the death part but the surprising problems are quite real.

I don't hate C++, and I think it's the best existing language for some problem spaces, but it's very much the poster boy for unexpected problems arising from interactions between features.

As far as the keyboard goes, https://GitHub.com/kragen/xcompose and http://canonical.org/~kragen/setting-up-keyboard are your friends.

I generally agree with that characterisation of C++, but it’s not at all what I was trying to get at by suggesting that Go generics should copy ideas from other languages.

It’s good that they’re being careful and conservative; but I feel that they’re going out of their way to avoid all existing paths, whereas it would actually be safer to use some existing system(s) as a starting point, as both the advantages and disadvantages -- in practice, not just theoretically! -- are known.

I definitely agree that interactions between features need to be carefully thought through. One likely wart with the current proposal is that user-defined generic types still won’t look or behave quite like the built-in ones, as those have special syntax and special operators. Will it be considered good practice to expose raw slices, maps etc in APIs? Or will people start wrapping them (just as in Java, an array would most often be wrapped in an ArrayList for convenience)? This might have been easier to resolve if generics had been thinkable much earlier in the language design.

Oh, to that point I agree. I feel like that's not too far from what they're doing, really.

I agree that, with this design, it won't be as comfortable to use generic containers from a library as to use the built-in containers. That's been a persistent problem with C++ templates and Java generics, too, though (I think reasonable initializers for vector finally landed in C++17?) so maybe the lesson they took was that the base language should have slices and maps because there was no known reasonable design that allowed them to be seamlessly defined in a library while supporting static typing and object nesting? Maybe there is a solution if you design the language around generic containers from the beginning—have you tried D?

If you're just going to copy another language there really isn't much of a point in making a new language. Go is not java, or c# or rust.

Part of the explicit goal stated by the go team is that generics must still feel like go. If you slapped java generics onto go it would not feel like go.

For my own benefit, I agree with this. I just want a more usable language now. Something like ad-hoc structural typing as in TypeScript with aliases seems close.

For the future of languages I appreciate the clean slate effort. Go has been about scoping out a use area sticking a leg there and coming up with something that works well there. At the same time I dont think there will be anything revolutionary, just something that seems simple and compact. I wish them great success and us a short wait.

Probably not as much as you'd like but I think the detailed proposal does talk about borrowing from Ada.