laitimes

Generics in Go: Exciting Breakthroughs

Author | Marko Milojevic

Translated by | Wang Qiang

Planning | Liu Yan

One feature changes everything.

How often do we experience a fundamental change in the programming language of our choice? Some languages will change more frequently, but others will be more conservative than Wimbledon.

The Go language falls into the latter category. Sometimes it's just too old-fashioned for me. "That's not how Go was written!" It was the sentence I dreamed about the most. Most of the new versions of Go are just incremental improvements to existing directions.

At first, I didn't feel like that path. If there is nothing new to stimulate, it will be annoying to always use a tool sooner or later. Sometimes I'd rather watch the boring "Walking with the Kardashian Sisters" than touch Go.

(Just kidding.) One reason I didn't install a TV was to escape the TV shows that might contaminate my beautiful eyeballs. )

And then...... Fresh blood is finally here. At the end of last year, the Go team announced that version 1.18 would begin to support generics, which is not a small improvement of the past, nor is it a suggestion and constraint on developer behavior.

Raise your spirits, the revolution is coming.

So, what is generics?

Generics allow us to parameterize types when defining interfaces, functions, and structures. Generics are not a new concept. We've been using it since the first version of the ancient Ada language, and later templates in C++ have generics, and modern implementations in Java and C# are common examples.

Regardless of the complicated definitions, let's look at a real-world example - in the following code, generics allow us to avoid many Max or Min functions and write them instead:

Declare only one method, as follows:

Wait, what just happened? Instead of defining a method for each type in Go, we used generics — we used generic types with parameter T as the parameter to the method. With this small tweak, we can support all orderable types. The parameter T stands for any type that satisfies the Ordered constraint (we'll discuss the constraint topic later). So, in the beginning we need to define what type T is.

Next, we define where to use this parameterized type. Here, we determine that both the input and output parameters are of type T. If we define T as an integer to execute the method, then everything here is an integer:

There's more to it than that. We can provide as many parameterized types as possible. We can assign them to different input and output parameters, depending on our preference:

Here we have three parameters, R, S, and T. As we can see from the constraint any (which behaves like interface{}), these types can be anything. So now we should be clear about what generics are and how we can use them in Go. Let's talk about the exciting impact it has.

How do I enable generics in my on-premises environment?

A stable version of Go 1.18 is not yet released. So we need to make some adjustments to test it locally.

To enable generics, I used Jetbrains' Goland. I found a useful article on their website for setting up the environment in which code runs in Goland.

The only difference from that article was that I used the Go source code with the master branch (https://go.googlesource.com/go) instead of the branch in the article.

On the master branch, we can enjoy a new package from the standard Go library, Constraints.

Speed, I want speed

Generics in Go are not the same as reflections. Before going into some complex examples, it is necessary to first check the benchmark scores of generics. Logically, we don't expect its performance to be close to reflection, because in this case we don't need generics.

Of course, generics are not like reflections, and it is not intended to be like that. But at least in some use cases, generics are an alternative to generating code.

So, this means that we want to see generic-based code with the same benchmark results as code that executes "classic". Let's examine a basic case:

Here's a small way to convert one Number type to another. Number is a constraint we built on top of the Integer and Float constraints in the Go Standard Library (we'll discuss this later). Number can be any numeric type in Go: any derivation from int to uint, float, and so on. Method Trasforms converts a slice with the first parametric numeric type S as the slice base and converts it to a slice with the second parametric numeric type T as the slice cardinality.

In short, if we want to convert an integer slice to a floating-point slice, we will call this method as we did in the main function.

The non-generic alternative to our function requires an integer slice and returns a floating-point slice. So, here's what we'll test in the benchmark:

No surprises. The execution time of the two methods is almost the same, which means that using generics does not affect the performance of our application. But does it have an impact on the structure (struct)? Let's try it. Now we will use the structures and attach methods to them. The test task remains the same - converting one slice to another:

Still no surprises. Either using a generic or classic implementation has no impact on the performance of your Go code. Yes, we don't have tested overly complex use cases, but if there are significant differences we've definitely seen them. So, we can rest assured.

Constraints

If we want to test more complex examples, it is not enough to add arbitrary parameterized types and run the application. If we decide to make a simple example of some variables without any complicated calculations, then we don't need to add anything special:

Aside from the fact that our method Max doesn't calculate the maximum value of its input and returns them all, there's nothing strange about the example above. To do this, we use a parameterized type T defined as interface{}. In this example, we should not think of interface{} as a type, but as a constraint. We use constraints to define rules for our parameterized types and to provide the Go compiler with some background knowledge about expectations.

To repeat: we are not using interface{} as a type here, but as a constraint. We define various rules for a parameterized type, in which case the type must support anything that interface{} does. So actually, we can also use any constraint here.

(Honestly, in all the examples, I prefer interface{} to any because my Goland IDE doesn't support the new reserved word (any, comparable), and then I get a lot of error messages in my IDE and autocomplete doesn't work.) )

At compile time, the compiler can accept a constraint and use it to check whether the parameterized type supports the operators and methods that we want to execute in the following code.

Since the compiler does most of the optimization work at runtime (so we don't affect the runtime, as we saw in benchmarks), it only allows operators and functions defined for specific constraints.

So, to understand the importance of constraints, let's complete the implementation of the Max method and try to compare the a and b variables:

When we try to trigger this application we get an error – operator >not defined on T. Because we define the T type as any, the final type can be anything. From here on, the compiler doesn't know what to do with the operator. To solve this problem, we need to define the parameterized type T as some kind of constraint that allows such operators. Thanks to the go team's great performance, we already have the Constraints package, and it has such constraints.

The constraint we're going to use is named Ordered, and the adjusted code is so elegant:

By using the Ordered constraint, we get the result. The advantage of this example is that we can see how the compiler interprets the final type T, depending on the value we pass to the method. We don't need to define the actual type in square brackets, just like in the first two cases, the compiler can recognize the type used for the arguments — in Go it should be int and float64.

On the other hand, if we want to use certain types that are not the default, such as int64 or float32, we should pass those types strictly in square brackets. Then we do exactly what the compiler should do.

If we want, we can extend the functionality in the function Max to support searching for the maximum value in the array:

In this example we can see two interesting points:

After defining type T in square brackets, we can use it in a function signature in a number of different ways: simple type, slice type, or even part of the map.

When we want to return a zero value of a particular type, we can use T(0). The Go compiler is smart enough to convert the zero value to the desired type, such as an empty string in the first case. We can see what kind of constraint it is to compare a certain type of value. With the Ordered constraint, we can use any operator defined on integers, floating-point numbers, and strings.

If we want to use the operator ==, we can use a new reserved word comparable, which is a unique constraint that only supports such operators:

In the example above, we can see what the usage of the comparable constraint should look like. Similarly, the compiler recognizes actual types even if they are not strictly defined in square brackets. One thing to mention in the example is that we used the same letter T for both parameterized types in two different methods, Equal and Dummy.

Each T type is defined only in the scope of this method (or structure and its methods), and we will not talk about the same T type outside of its scope. We can repeat the same letter in different ways, and the types are still independent of each other.

Custom constraints

We can customize the constraints, which is easy. Constraints can be any type we want, but the best option might be to use an interface:

We define a Greeter interface to use it as a constraint in the Greetings method. Not for demonstration purposes, here we can use a variable of type Greeter directly instead of a generic.

Type set

Each type has an associated set of types. The set of types of a normal non-interface type T is simply a collection that contains T itself. The set of types for an interface type (this section only discusses normal interface types, there is no list of types) is a collection of all types that declare all the methods of an interface. The above definition comes from a proposal for a type set. It's already included in go's source code, so we can use it anywhere we want.

This significant change opens up a lot of new possibilities: our interface types can also be embedded with the original types, such as int, float64, byte and not just other interfaces. This feature allows us to define more flexible constraints.

Examine the following example:

We defined a Comparable constraint, and that type looks a bit weird, right? The new method of using a set of types in Go allows us to define an interface that should be a union of types. To describe the union between the two types, we should put them in the interface and put an operator between them: |.

So in our example, the Comparable interface is the following types of unions: rune, float64, and... I guess it's int? Yes, it is indeed an int, but here defined as an approximate element.

As you can see in the proposal for a type set, a set of types that approximate the element T is a set of types of type T and all underlying types of T.

Therefore, just because we use the ~int approximation element, we can provide a variable of type customInt to the Compare method. As you can see, we define customInt as a custom type, where int is the underlying type.

If we don't add the operator ~, the compiler complains that the application won't execute.

How far can we go?

We can soar freely. Seriously, this feature has revolutionized the Go language. I mean, there's a lot of new code that keeps coming up. Probably this will have a significant impact on packages that depend on the code to build, such as Ent.

Starting with the Standard Library, I've seen a lot of the code be refactored in a future release to use generics. Generics may even drive some ORMs, as we saw in Doctrine.

For example, consider a model from a Gorm package:

Imagine implementing the repository pattern in Go for two models (ProductGorm and UserGorm). In the current stable version of Go, we can only choose one of the following solutions:

Write two separate repository structures

Write a code generator that should use a template to create both repository structures

Deciding not to use a repository Now that we have generics, we can move on to a more flexible approach that can do this:

So, we have the Repository structure, which has a parameterized type T, which can be anything. Note that we define T only in the Repository type definition and simply pass its assigned function to it. Here we can only see the Create and Get methods, just for demonstration purposes. To make the presentation a little simpler, let's create two separate methods to initialize different Repositories:

Both methods return repository instances with predefined types. Here's the final test of this applet:

Yes, it can. An implementation of a Repository that supports two models. Zero reflection, zero code generation. I thought I'd never see anything like this in Go. I was so happy that tears were about to flow

Summary

There's no doubt that generics in Go are a huge change that can quickly change the way Go is used and will soon spark a lot of refactoring in the Go community.

While I play with generics almost every day to see what good things we can do with it, I can't wait to see them in the stable go version. Long live the revolution!

https://levelup.gitconnected.com/generics-in-go-viva-la-revolution-e27898bf5495

Read on