Skip to content

Commit

Permalink
Add Generics presentation
Browse files Browse the repository at this point in the history
  • Loading branch information
Merovius committed Mar 8, 2022
1 parent 5f7829a commit 891811b
Show file tree
Hide file tree
Showing 19 changed files with 581 additions and 0 deletions.
6 changes: 6 additions & 0 deletions 2022-03-08_generics/comparable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package set // OMIT

type Set[T any] map[T]struct{} // Error: T is not comparable
type Set[T comparable] map[T]struct{} // OK
// INTERFACE OMIT
var s Set[any] // Error: any does not implement comparable
19 changes: 19 additions & 0 deletions 2022-03-08_generics/constraints_pkg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package constraints // OMIT

// Signed is a constraint that permits any signed integer type.
type Signed interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 }

// Unsigned is a constraint that permits any unsigned integer type.
type Unsigned interface { ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr }

// Integer is a constraint that permits any integer type.
type Integer interface { Signed | Unsigned }

// Float is a constraint that permits any floating-point type.
type Float interface { ~float32 | ~float64 }

// Complex is a constraint that permits any complex numeric type.
type Complex interface { ~complex64 | ~complex128 }

// Ordered is a constraint that permits any type that supports the operators < <= >= >.
type Ordered interface { Integer | Float | ~string }
256 changes: 256 additions & 0 deletions 2022-03-08_generics/generics.slide
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
Generics in Go 1.18
Introduced using a practical example

Axel Wagner
Software Engineer, Infront Quant
https://blog.merovius.de/
@TheMerovius

* The example

* Providers

A service has its business logic split into _providers_

.code provider.go

: ID is used to route messages to the right provider
: RequestType/ResponseType are used for allocating messages and dynamic type checking
: Message is an interface embedding json.Marshaler etc.
: Publisher allows to send multiple responses
: ResponseIterator allows receiving multiple responses

* Implementing providers

An implementation of a provider is roughly

.code usage.go

: New might take dependencies etc.
: dep is a different provider, this one depends on
: Note the type-assertions for Request and subresp

* Problems

- Possible to pass wrong request type to Call/Stream/Publish
- Request/Responses need type-assertions and extra variables
- Slice request/responses use pointers
- `RequestType()`/`ResponseType()` is boilerplate

: For simplicity, the reflect logic just assumes request/responses are pointers

* Generics

* Type parameters

.code provider_generic_1.go

: Package-scoped declarations can have an extra argument list using square brackets
: These can be used as types in the declaration
: The declaration has to be instantiated, substituting the specific types used
: Publish now checks that ts argument matches the type declared by the provider
: We don't need type-assertions anymore
: No more need for Request/ResponseType methods

* Type parameters (cont)

Now an implementation is

.code usage_generic_1.go ,/INFER/

: Request/ResponseType methods are gone
: We don't need a temporary variable for the sub response anymore
: We can instantiate using non-pointers, if we want, for slices
: Publish can infer its type argument
: We could still accidentally instantiate Call with the wrong arguments

* Type-inference

It is a bit unwieldy having to add the instantiation to every type. Luckily,
the compiler can sometimes _infer_ these types, allowing us to omit them:

.code usage_generic_1.go /INFER/,/NOINFER/

*Limitations*

- Type-inference only works based on the *arguments* of a function call:

.code usage_generic_1.go /NOINFER/,$

- Thus it only works on *calls*, not for generic types or storing in a variable

* Making Call safe

We can make `Call` even more type-safe, by using a little trick:

.code provider_generic_2.go

: Even though ID is just a string, we can add parameters to it
: Now the ID also carries information what Request/Response is needed
: That information can then be used by Call/Stream to type-check their instantiation and arguments (next slide)

* Making Call safe (cont)

And on the implementation side:

.code usage_generic_2.go ,/SPLIT/

.code usage_generic_2.go /SPLIT/,/INFER/

Bonus: `dep.ID` is an argument and "carries" request/response types. So we can
now infer type arguments:

.code usage_generic_2.go /INFER/,$

: Now, if we instantiate Call with the wrong arguments, it can tell based on dep.ID and the compiler complains
: The fact that type-inference only considers arguments is another benefit of the ID trick.
: Otherwise, the response type would not appear in the call and could not get infered.

* Constraints

Remember the `Message` interface? In our new version, requests and responses no
longer need to comply with it, we can use `any` type. We can fix that by adding
it as a _constraint_:

.code provider_generic_3.go /type ID/,/func usage/

* Constraints (cont)

Constraints can be any interface type. At instantiation, the compiler checks
that the type-arguments implement that interface:

.code provider_generic_3.go /func usage/,/ID/

The compiler allows a function to call exactly the methods defined by the constraints:

.code provider_generic_3.go /CALL IMPL/,$

`any` is just a new, predeclared alias for `interface{}`.

* Type sets

So far, constraints only allow calling _methods_. For using _operators_, we
introduce _type_sets_:

- `T` is the set containing only `T`
- `~T` is the set containing all types with _underlying_type_ `T`
- `S|T` is the set of all types which are in the set `S` or the set `T`

An interface can now contain a type set:

.code type_sets.go

Interfaces containing such type-sets can _only_ be used as constraints.

* Type sets (cont)

The compiler allows *using*an*operation* in a generic function, if it is
supported by all types in the type set of the constraint:

.code type_sets_use.go /func Concat/,/^}/

* Type sets (cont)

The compiler allows an *instantiation*, if the type argument is in the type set of the constraint:

.code type_sets_use.go /func usage/,/^}/

* Type sets (cont)

It is also possible to use type-set elements _directly_ in a constraint:

.code join_example.go ,/SPLIT/

.code join_example.go /SPLIT/,$

* The constraints package

There is a new package `golang.org/x/exp/constraints`, for commonly used type sets:

.code constraints_pkg.go

* comparable

There is one special predeclared interface `comparable`, implemented by
anything that is (safely) comparable using `==` and `!=`:

- Any string/numeric/boolean/pointer/channel type
- Any struct-type with only comparable fields
- Any array-type with a comparable element type
- *Not* function, slice, map *or*interface*types*

It is needed to use `==` and `!=` or to use a type-parameter in a map:

.code comparable.go ,/INTERFACE/

Importantly, interface-types do *not* implement `comparable` (see [[https://github.com/golang/go/issues/51338][#51338]]):

.code comparable.go /INTERFACE/,$

* Pointer methods

Back to the example. There is a problem with our `Message` interface.

.code pointer_methods.go /type Message/,/END DEFINITION/

* Pointer methods (cont)

If we try to use this, we get into trouble, though:

.code pointer_methods.go /type Request/,/^}/

* Pointer methods (cont)

`Call` needs to accept/return the plain types, but call the methods on their pointers:

.code pointer_methods_2.go

* Pointer methods (cont)

We thus have to pass *both* the base and the pointer types and constrain the
pointer type to have the relevant methods:

.code pointer_methods_fix.go /type Message/,$

* Library changes

There are a couple of new packages, taking advantage of generics:

- `golang.org/x/exp/constraints`: A set of useful constraints to be used with
type parameters.
- `golang.org/x/exp/maps`: Various functions useful with maps of any type.
- `golang.org/x/exp/slices`: Various functions useful with slices of any type.
- `go/*` have been updated to be able to write tools for generic code.

* Limitations

* No higher abstractions

Every generic function/type must be fully instantiated before use:

.code higher_abstraction.go

One design goal was to allow [[https://research.swtch.com/generic][different implementation strategies]].

Allowing higher abstraction would require a boxing implementation.

* No extra type parameters on methods

It is not possible to add extra type parameters to methods:

.code method_parameters.go ,/SPLIT/

Use functions instead:

.code method_parameters.go /SPLIT/,$

This is because Go allows interface type-assertions, which would require
runtime implementation strategies:

.code method_type_assertion.go

* Other limitations

- No embedding of type parameters.
- A union element with more than one term may not contain an interface type with a non-empty method set.

This comment has been minimized.

Copy link
@changkun

changkun Mar 29, 2022

Thanks for the great talk! I wondered where I could find the discussion for this decision since the release note didn't provide an issue number?

This comment has been minimized.

Copy link
@Merovius

Merovius Mar 29, 2022

Author Owner

golang/go#45346 it's a long issue. Some interesting comments:

This comment has been minimized.

Copy link
@changkun

changkun Mar 29, 2022

Hmm. You also mentioned that we could somehow get around this restriction in the talk. What might be the alternative for this with the restriction?

type Stringish interface {
    ~string | fmt.Stringer
}

This comment has been minimized.

Copy link
@Merovius

Merovius Mar 29, 2022

Author Owner

Hm. I might've just been wrong about that and thinking of something else.
TBH this is all pretty complicated stuff. It's easy to get into a situation where, to solve a naturally arising problem, we get into NP-complete territory. I find it challenging to keep track of what restrictions exists, what the reasons for them are and how we could mayhaps solve them.

I think with the design as it is, Stringish can't be expressed. That's probably something that should be fixed. But I'm not sure how we could, without painting ourselves into a corner, because it's not that different from interface TertiumNonDatur { A() | A() int }, which is at the core of all the CNF-SAT reductions…

So, apologies for the mistake in the talk.

- A couple of minor limitations, to be addressed in Go 1.19
10 changes: 10 additions & 0 deletions 2022-03-08_generics/higher_abstraction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
func Sort[T constraints.Ordered](s []T) {
sort.Slice(s, func(i, j int) bool {
return s[i] < s[j]
})
}

func Example() {
f := Sort // Error: Sort must be fully instantiated
f := Sort[int] // OK
}
19 changes: 19 additions & 0 deletions 2022-03-08_generics/join_example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package strings

// Join is like strings.Join, but also works on defined types based on string.
// S is any type with underlying type string.
func Join[S ~string](parts []S, sep S) S {
p := []string(parts) // allowed conversion from []S to []string
joined := strings.Join(p, string(sep) // allowed conversion from S to string
return S(joined) // allowed conversion from string to S
}
// SPLIT OMIT
type Path string

const Sep Path = "/"

func Join(parts ...Path) Path {
// Infers strings.Join[Path], which has type
// func([]Path, Path) Path
return strings.Join(parts, Sep)
}
7 changes: 7 additions & 0 deletions 2022-03-08_generics/method_parameters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type Set[A comparable] map[A]struct{}

// Error: Can't have extra type parameters on methods
func (s Set[A]) Map[B comparable](f func(A) B) Set[B]

// SPLIT OMIT
func Map[A, B comparable](s Set[A], f func(A) B) Set[B]
7 changes: 7 additions & 0 deletions 2022-03-08_generics/method_type_assertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type X struct{}
func (X) F[T any](v T) {}

func FarAwayCode(x X) {
// Compiler did not know it might need to generate S.F[int]
fint := x.(interface{ F(int) })
}
37 changes: 37 additions & 0 deletions 2022-03-08_generics/pointer_methods.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package x

import (
"encoding/json"
)

type Message interface {
json.Marshaler
json.Unmarshaler
}

// Illustrative implementation of the relevant parts of Call.
func Call[Req, Resp Message](req Req) (resp Resp, error) {
b, err := req.MarshalJSON()
if err != nil { return resp, err }
// Send bytes over network, get response back
err := resp.UnmarshalJSON(b)
if err != nil { return resp, err }
return resp, nil
}

// END DEFINITION OMIT

type Request struct { /* … */ }
func (m *Request) MarshalJSON() ([]byte, error) { /* … */ }
func (m *Request) UnmarshalJSON(b []byte) error { /* … */ }

type Response struct { /* … */ }
func (m *Response) MarshalJSON() ([]byte, error) { /* … */ }
func (m *Response) UnmarshalJSON(b []byte) error { /* … */ }

func instantiation() { // OMIT
// Error: Request/Response do not implement Message, methods have pointer receivers
resp, err := Call[Request, Response](req)
// Panics: resp.UnmarshalJSON(b) tries to unmarshal into a nil-pointer
resp, err := Call[*Request, *Response](req)
} // OMIT
16 changes: 16 additions & 0 deletions 2022-03-08_generics/pointer_methods_2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package x

import (
"encoding/json"
)

func Call[TODO](req Req) (resp Resp, error) { // HL
// Call MarshalJSON on the pointer // HL
b, err := (&req).MarshalJSON() // HL
if err != nil { return resp, err }
// Send bytes over network, get response back
// Call UnmarshalJSON on the pointer // HL
err := (&resp).UnmarshalJSON(b) // HL
if err != nil { return resp, err }
return resp, nil
}
Loading

0 comments on commit 891811b

Please sign in to comment.