New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: go 2: a simpler approach to generics with "concrete" interfaces #33627
Comments
Disclaimer: I read only summary section, keep that in mind. Overall this looks really interesting except few things
|
@target-san I'm not exactly sure to understand what you mean, but "concrete" was just a word I came up with to describe the property of a generic interface to be associated to one of its type parameters. How it translates into an actual implementation is still to be worked on. In particular, it would be nice to be able to write EDIT. More on concrete interfaces: I see that I wrote they "should be read in code as an assertion on a type", but this is just a hint in how the programmer should think when they see one, not an actual rule on how they should be implemented. It's easier to remember that On the "interface fields" part I anticipated it would be hard to accept. This is why I suggested forbidding to export interfaces using them in order to sort of keep the original spirit of interfaces (which is in my opinion, as I state in my presentation, a good design decision from the original authors). Or, we could still set this aside and maybe make a decision in a later version of Go 2; and be content with generic structures for now. Maybe this would require a deeper analysis of the existing real-world codebase to decide whether it should be added or not. |
Your discussion about contracts appears to refer to the earlier draft. Please take a look at the most recent draft (https://go.googlesource.com/proposal/+/master/design/go2draft-contracts.md) as it has significant differences. It would help if you could explain how to implement the Graph/Nodes/Edges idea with your suggested syntax. Thanks. |
@ianlancetaylor @kantbell |
Overall, I like the ideas in here. Very well thought-out.
This is false. A field only requires two binary operations, conventionally I personally like the idea of pseudo-interfaces to access operators, but I am skeptical the goteam will ever implement it, as it effectively amounts to operator overloading =D. |
I think too much () can be confusing Just like:
When call the function just like:
or
I personally think it will be very simple to drive up |
I think that this is a matter of parsing. For example, consider @neroBB
This is talked about in the draft. |
This comment was marked as off-topic.
This comment was marked as off-topic.
@john-wilkinson This issue is about a specific proposal for a change to Go. It's not a good place to discuss the language in general. Please ask your question on golang-nuts or in a forum. See https://golang.org/wiki/Questions. Thanks. |
@ianlancetaylor Can this be closed now that Go 1.18 includes generics? (and uses some of these ideas) |
Thanks, yes, this proposal is no longer applicable. |
A summary is available at the end for the less patient of you!
The problem with contracts
Much constructive criticism has already been expressed against contracts. Here is a brief list of good arguments I've read:
It does not provide a clean, systematic way of indicating which operations are allowed on a type. For example, two different contracts may describe the same idea in two very different ways (taken from this blog post):
They can also describe very different things even though they look similar. Consider this example:
It looks like both would refer to numeric types, right? Not quite:
add
is also compatible withstring
.But there is more:
mult
implicitly allow operations that would most likely not produce a compilation error:The whole point of contracts was that you state what you can do with a type outside of the function body. Will programmers be thorough enough in explicitly listing all the operations they need if some can be omitted? I don't think so.
On the contrary, it can and will end up as a game of one-lining constraints, akin to the
(c = getchar()) != EOF && isalpha(c) ? toupper(c) : '\0'
follies in C and C++:Is it clear that
pointer
could be a slice or a map as well? How aboutmagic
, what could it even be? Will the compiler produce an error if a contract is impossible? The horrors of real-world code should not be underestimated.A contract exposes the internals of types to the users: it can be both exported and require a given type to be implemented in some way only, which is for example a huge step back from the decision of leaving fields out of interfaces.
A substantial amount of the standard library and existing code from the outside world may end up being rewritten with contracts to only mimic what was done with interfaces until now, leading to projects with a random mixture of contracts and interfaces depending on each contributor's habit on the matter.
It is overall too clever, as formulated by Chris Siebenmann. He also adds:
Operators can only be used on the very small set of primitive types anyway: the amount of distinct, meaningful contracts that can be written with operators is thus very small. It means that in general, a contract will describe the methods and fields of a composite type, making it a lot like an extended, generic version of interfaces.
This is exactly what I am going to present next: an extended, generic version of interfaces.
A case study
Let's say we want to write a generic
Min()
function. It should probably look like this:It is ambiguous whether
T
refers to an existing type, or any type. We still want the compiler to report errors when mistakenly using non-existent types, so at least we need a way to indicate thatT
is a parameter as well. The syntax proposed in the current draft design is almost just fine:See how the position of
type
differs from the draft: by switching it at the end, it now looks like a function definition — which it is, since we use generic functions just like functions on types:It eliminates the cognitive burden of using
Min(types.Int)
just like a function, but defining it a different way. In short, it just reads better.But this is not all, it also enables two possible additions to the language in the future that would then fit naturally:
"Pseudo-"partial applications, by extending the idea of "chaining" parameter lists to build higher order functions:
I say "pseudo-"partial because technically this wouldn't be allowed:
That is, Go wouldn't have full currying. Which is good, as forgetting an argument would raise an error at compilation, instead of silently producing a partial application.
type
reads a lot like the "type" ofT
. Why not add new "types" of types, then? They could be used to introduce new semantics on type parameters:In my opinion, it would look much better than the current draft design:
Note that in the current draft design
ordered
would be a contract, which means that we could very well still add contracts as "types" of types in the future if what I suggest next is implemented.Now back to our example. We want to indicate that
T
should have a method calledLess()
, which takes an argument of typeT
and returns a boolean. IfT
was known, then what we would need is exactly an interface; it makes perfect sense to first attempt to extend the semantics ofinterface
to something that works for anyT
. By reusing the same syntax, we could try:This is actually wrong: we don't know that
a
is of typeT
! What I suggest is extremely simple... EmbedT
to indicate thatOrdered(T)
should behave likeT
:It only makes sense when you think of it with other interfaces:
I call this a "concrete" interface (hence the title): it specifically refers to a given type in contrast with "normal" interfaces, referring to any type matching their description.
I feel that a number of points need to be addressed before continuing any further:
Writing it differently, such as:
much like how it is suggested for contracts would not only look weird, but would also become unnecessarily repetitive, and would allow to write impossible declarations:
The last case is possible if, and only if
T == S
which has no reason to occur in real-life situations and most probably is a bug, so it should raise an error at compilation anyway. The proposed syntax avoids this error, and repetitions as well.For the same reason, the following code:
should produce an error at compilation since, in general,
T != S
. That means a concrete interface must be associated with exactly one type parameter. If there are zero associated types, then basically it is a normal generic interface. If there are more than one, then it is an error.If we have, for example:
What is the type of
c
? Of course, it should behttp.Client
. That means a concrete interface acts like an assertion on type properties at compilation: it takes a type as an argument, and returns it if it corresponds to the specifications, otherwise the compilation fails. This is why I strongly advise naming concrete interfaces with the prefixWith
to indicate that a particular property is expected. Our initial example would be better rewritten as:Embedding other concrete interfaces should be possible, but recursion on the associated type forbidden:
Note that embedding concrete interfaces is equivalent to chaining their application:
A first major drawback is that you can't specify fields with an interface, which is a good design decision in my opinion. I assume that the initial philosophy was that when exporting an API, only how you can interact with an object matters, not its inner working. Contracts, on the other hand, allow exposing the internals to the users, and may restrict how a structure is implemented.
If yet, such a possibility is desired, we could naively think at first of some way using generic structures. Let's say, for a new example, that we want to write a generic
ValueOf()
function that takes a structure with aValue
field, and return its value:Following the same logic than with interfaces, we would think of something similar to this:
... This time,
WithValue(Integer, int)
is not the same type asInteger
! Indeed, it has an extraInteger
field representing the embedded structure.At this point, there are two solutions:
Abandon the possibility to specify fields, in accordance to the principle that the internals should not be fiddled with by the outside world
Extend the semantics of concrete interfaces again to indicate which fields are required, with the exception that exporting such an interface, or a function using such an interface is an error at compilation:
I strongly support this second option. Since interfaces containing field requirements cannot be exported, there is good practice to write separate, unexported interfaces containing field requirements only. It would still be very useful inside of a package to indicate constraints, and would prevent exposing the internals of the package when used by someone else.
The second drawback is what probably motivated the idea of contracts in the first place: operators. In our example with
Min()
I took great care of writinga.Less(b)
instead ofa < b
, and for a very good reason: for as long as Go refuses to implement operator methods (I don't hold an opinion on that), there will be no choice but to write two versions of every function working on both composite and primitive types:Whether contracts are implemented or not in the end, code duplication will still occur with primitive types. Since composite types are more frequent than primitive types, a generic function will most likely end up using methods rather than operators; therefore using methods should be preferred. Given this perspective, we should try to find a suitable solution with interfaces one more time, using what the language already permits.
As stated before in the preliminary criticisms of contracts, some operators are implicitly allowed (
t * t
impliest / t
), others don't make sense when used together (for examplet && t
andt + t
), and overall the set of meaningful contracts using operators is very small. An excellent idea brought forward by Matt Sherman, and named by Axel Wagner are "pseudo-interfaces". Instead of adding a way to list which operations are possible, we add a small set of builtin identifiers grouping operators that only make sense together:comparable
==
,!=
ordered
<
,<=
>
>=
boolean
||
,&&
,!
bitwise
^
,%
,&
,|
,&^
,<<
,>>
arith
+
,-
,*
,/
concat
+
complex
real(z)
,imag(z)
nilable
v == nil
pointer
*v
As the name suggests, they can be used like interfaces:
A number of derived interfaces could be predeclared as well:
num
interface { comparable; ordered; arith }
integral
interface { num; bitwise }
With a set of sane, predeclared pseudo-interfaces, developers would only need to refer to the standard Go documentation to find out what they mean, instead of tediously trying to guess what a contract implies:
A small issue arises when you think about how to write a generic
Min()
function again. We still need to know what type we're referring to, asordered
in our case is merely an interface. The code would then look like this:Of course, it works. But we'd rather write the function this way:
I suggest we might as well just do that: pseudo-interfaces (and them only) can also be used as "types" of types as it was referred to earlier:
At last, we still have the problem that every generic function will have to be written twice: one for the primitive types, one for the composite types. As a convenience, and for performance reasons, I would encourage the introduction of a new "pseudo-"package named
types
, which provides a number of structures and interfaces wrapping the operations on the primitive types:This way we can write a definitive, generic
Min()
function that also appliesto integers:
Using dark magic with the compiler, we could natively translate
types.Int
intoint
at compilation, avoiding the extra overhead of wrapping everything into an actual structure with methods. The package don't really exist as source code, hence the prefix "pseudo". A number of generic structures could be provided bytypes
as well:If methods from
types
are assigned (for examplef := m.Get
) or the types fromtypes
are embedded to create substructures, the compiler could fall back to an actual implementation of the structures in pure Go. Since these types would be standard and supposedly widely used, we still may find ways to optimize such situations in the future, to the benefit of everyone.types
could also include concrete interfaces, being the general counterpart of the aforementioned pseudo-interfaces:comparable
==
,!=
types.Comparable
interface(T type) { T; Equal(T) bool }
That is, if type
x
is compatible with pseudo-interfacep
, then typetypes.X
(notice the capital letter) is compatible with concrete interfacetypes.P
. This would break the rule of naming concrete interfaces with the prefixWith
, but at least it is memorable and coherent with everything else. We can write at last:Summary
Invert the syntax to declare type parameters, and make it look like a function declaration.
(type T)
becomes(T type)
:Exactly one type argument can be embedded to a generic interface, turning it "concrete":
A concrete interface should be read in code as an assertion on a type, returning the type if the assertion succeeds:
A normal interface embedding a concrete interface turns into a concrete interface, because of the rule stated above:
Fields can be specified by an interface, but then the interface cannot be exported:
A function using an interface specifying fields cannot be exported either:
A number of identifiers, called "pseudo-interfaces", are added to access operators: they can be used like interfaces, and group operators together in a meaningful and documented way:
Pseudo-interfaces can also be used as "types" of types to concisely refer to type parameters on which operators can be used:
A new package named
types
is added to provide a standard interface to primitive types and ease the writing of generic functions:types
exports the composite counterpart of primitve types and pseudo-interfaces by using a capital letter:The current draft proposal on generic structures is just fine, with the extra care that structures using concrete interfaces specifying fields cannot be exported either:
EDIT. Just to be clear,
type Name(T type) keyword { ... }
is sort of (but not exactly) syntactic sugar forName := keyword(T type) { ... }
, pretty much howfunc Name(a Type) { ... }
is sort of (but not exactly) syntactic sugar forName := func(a Type) { ... }
. I see no reason to forbid writing things like this:What do you all think?
The text was updated successfully, but these errors were encountered: