Refactor Cloud applications in Go 1.18 with generics

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] CAMERON BALAHAN: Hi. I'm Cameron, and today I'm going to talk about the latest release of the Go programming language Go 1.18. This is a huge release that includes new features like fuzzing, workspace mode, performance improvements, and our biggest change ever to the language, generics. Supporting generics has been our most-requested feature since Go 1. In Go 1.18, we've delivered the generic support for the majority of our users need today. And there's more coming. In future releases, expect us to introduce more generics in the Go standard library and continue to iterate and provide additional support for some of the more complicated generic use cases. We want your feedback, which we'll use to learn what we can do better. In fact, that's our approach to this whole release. We huge changes are hard to get right. So in Go 1.18, we've laid the foundation for features that we anticipate we can make better in the releases to come. Before generics, writing generic code could be tedious. It often involved having multiple methods that did exactly the same thing but with different types of names. Many in the community heavily relied on Go generate to make this a little easier. But even then, you were usually left with huge blocks of type switches for calling your generic functions. Other times, libraries made design decisions about using a type that was a least common denominator type. For example, take a look at the map package. Common methods like Min and Max were forced to operate on a float 64. But what if you wanted to figure out the Max of two ints? Pre 1.18, you would have to cast your types to floats to call the functions and cast the result back to an int after you get the result. The solution to the Go generics problem needed to solve two issues. One, it needed a way to provide parameterized types for strucks functions and methods. Two, it needed a way for parameterized types to use common operators, like equals, less than, and greater than. I would also like to point out that this example makes use of the currently experimental constraints package that is being considered for adoption into the standard library. As you would expect, generics gives us a way to create parameterized types and function signatures. Rather than write out multiple functions for the same behavior on different types, you can write one function and use it across many types. But this does introduce some new complexity, which we strive to avoid in Go. Because of this, the implementation tries to take advantage of some concepts that most Gophers are already familiar with. The two most important concepts are constraints and type sets. Constraints are just interfaces. They can also include a new concept called type sets. And type sets are the union of types allowed to be used by the constraint. Next, my colleague Cody will take a closer look at how generics work in Go. CODY OSS: Thanks, Cameron. As Cameron mentioned, I am Cody, and I would like to walk you through how generics, also referred to as parameterized types, work and how they are different from implementations in other languages that you may be familiar with. With the 1.18 release, Go now supports providing parameterized types in a couple of different circumstances, functions, structs, and/or methods. Here, we can see that this function has one parameterized type T that is constrained by the constraints ordered. Constraining by this value not only allows us to pass in different types like floats and strings into this function, it also allows us to use the operators that work on those types, like the less than operator in this example. Parameterized types also work on structs and methods. The struct here specifies one parameterized type T that is constrained to the types defined by the any, which is equivalent to the empty interface, meaning, all types. Notice that the corresponding method we don't need to provide a constraint for T again as it's already defined by the struct. Let's break down what an interface in Go is prior to 1.18. Historically, interfaces define method sets. Any type that implements all of the methods defined by an interface implicitly implements the interface. Let's present that same information in another way, in relation to the types and not method sets. Let's say that an interface defines a set of types, which are the types that implement the methods defined by the interface. Any type that is a member of the interfaces type set implements the interface. In the end, both of these views of interfaces lead to the same outcome. But this new way of describing an interface has an advantage that we can explicitly add types to the set of interface describes, allowing us to control the typeset in new and different ways. With Go 1.18, the syntax for interfaces has been expanded to optionally allow specifying a set of types. If this type set is provided, the interface can only be implemented by types that specified in the type set. As we saw in the previous example, parameterized values must also define constraints that scope how the type may be used. In Go, we already have a concept that can scope how a type can be used. Interfaces, type constraints are interfaces that may contain some extra information. All interfaces can, but not necessarily should, be used as type constraints. The inverse is not true though, not all things that can be used as constraints can be used as interfaces. Here's an example of how constraints package define the sign interface. The sign interface defines a set of all the built in signed types. The pipe symbol between the types is used to express that we are taking the union of the type set. Also note that we don't need to provide methods for this interface. Since operators like plus, minus, and less than all work for the types defined by this interface, any parameterized type that is constrained by this interface may also use those operators. We don't always want to restrict an interface to a particular type. Sometimes, we want the definition to be a little more fuzzy. This is where the new tilde token comes in. When put in front of a type, it means the set of all user defined types whose underlying type is that type. This means that the type myInt64 in the example also satisfies the signed interface. Now that we have learned the concepts behind Go's generics, let's take a quick look at how they compare to using generics in Java. One of the biggest differences is that Go does not have type erasure. This means that at runtime, Go retains all of the type information associated with the calling function or method. Here we can see, by using the reflect package, that the type information of the parameterized type is preserved at runtime. Another difference is that Go also does not have the idea of wildcards. Since Go is not a traditional object-oriented language, as it does not have type hierarchies, there is no need for wildcards. Like the rest of the language, Go's generics are designed with simplicity in mind. This means some of the more complicated features associated with generics in other languages are not present in Go. These include, no specialization that allows writing multiple versions of a generic function designed to work with specific types, no metaprogramming, allowing code to be written at compile time to generate code to be executed at runtime, no contravariance or covariance, for the same reason Go doesn't support wildcards, no operator overloading for things like accessing a map with the bracket syntax. Now that we have learned what Go generics are and are not, let's take a look and see where using generics in Go make sense. One example of a good use case is when you are writing functions that work with container types that can be used to process elements of any types. For example, here we have a function that checks if two slices are equal. To do this operation, we only need to be able to compare the elements of the slice. So we use the built-in constraint comparable. That allows us to do just that. Another case you might consider using generics is when different types need to implement some common logic and the implementation looks the same for all of the different implementing types. For example, let's consider the case where we need to sort a slice. The standard library defines a sort interface that requires three methods, len, swap, and less. This example shows the generic struck that implements the methods for all splice types. This can be done because the logic for all types is exactly the same. Here, we can see how the previously-defined slice func type is used to sort any slice that can provide a comparison function. Here, type parameters can be used to make it easy for any type to implement the sort interface. Type parameters worked well here because the implementation looks the same for all types. We have seen some examples of where to use generics. Now let's take a look and see where to not use generics. As discussed earlier, basic interfaces already provide a generic programming in Go prior to 1.18. If you were only calling methods to find on an interface, it is better to not use type parameters. For example, this function defines a parameterized type T that is constrained by io.Reader. io.Reader does not define a typeset, just to read method. Adding a type parameterization here just adds complexity and provides no extra value. A function instantiated with a type argument will most likely not be any faster than code that uses interface methods. Avoid using generics in cases like these. Removing the previous parameterized as type helps make this function signature more readable. Another place we should not use generics is if the implementation is different for each type. In this case, just use different methods, and don't try to force generics into your code. For example, the implementation from reading from a file on disk and a network socket are quite different. So different methods should be defined for each operation. Also, there are times when using reflection might make more sense than using generics. An example in the standard library is the encoding JSON package. If we required the use of an interface, that would require every type passed in to define a Marshal JSON method. This would be quite cumbersome, and different types are encoded differently. Generics doesn't make sense in this case. Now let's take everything we've learned so far and see how we can apply what we've learned about generics to simplify a simple web app written for Go 1.16. Let's take a look at the web app I have deployed to Cloud Run. It's currently deployed with the Go 1.16 runtime, which just stopped receiving security updates with the release of 1.18. So it's time to update to the latest runtime. The first step is to change the version declared in the go.mod to 1.18. Next, let's take a look at our main.go and see what's going on in our web app. The first thing I notice right away is that we seem to be generating some generic types with a tool called genny. Let's take a look at that code. Here, it looks like we're defining a generic number type that will later be generated into concrete functions for each type. The function here is taking the Max of the elements in a slice. Let's go take a look at the generated code. It appears that the two functions are generated for the two different types that we are working with. This is a great example of a place we can refactor our code to use generics. So let's do it. I'm going to start by copy and pasting one of the implementations into our main.go. This function wants to be generic over the types int and float 64. So let's first define a constraint representing those types. I'm going to make use of the golang/x/exp library, which provides some common constraints. I run go mod tidy here to pull in the new dependency. Now let's substitute out our int type, the generic type T bound by our number constraint. Now we rename the function since its general purpose. Next, let's find the spots that call the old generated functions and replace them with our new generic function. You will notice that although I could provide a type, when calling the function, I don't need to because the compiler is smart enough to infer the type for us in this instance. Now that we have replaced the old call sites, we get to do my favorite part, delete some code. Let's clean up the code that is no longer being used. It also looks like we no longer need the dependency on genny. So let's run go mod tidy to simplify our dependency tree. Sorry, I got a little excited and jumped straight into refactoring some code. Let's take a step back and see what else is going on in main.go. Let's start with the main function. It looks like what's happening here is we are registering to HTTP handlers, one that processes ints and one that processes floats, then we are just starting up a server. Scrolling down a little more, it looks like we have a response type that works with float 64s. Next, we have our process ints handler. It looks like this decode some JSON into a slice events, then takes the summoned Max of that slice, and finally, marshals the data back to JSON and writes the response to the calling client. When creating the response type, it appears we are casting the int and some Max to a float 64. Maybe instead of this, we can just make some response generic as well. Let's do that. I again, will make use of the number constraint we defined earlier. And now we just need to refactor where the type is used. When instantiating types, we need to provide the parameterized type we are generic over. It looks like the sum variable returned from Sum function is not the right type. Let's take a closer look at the Sum function to see if we can do some more refactoring there. It looks like a lot is going on here. The first thing I noticed is that the function takes an empty interface, which is a good first signal that we may be able to refactor it. Next, I notice that we cast the input parameter to a slice then type switch on the element type, then it appears we take the sum of either an int or a float 64. This code can definitely benefit from a refactor. We will again use the number constraint we defined earlier and make the function generic over T. I know that this function should expect a slice of T. So let's change the input parameter to match that? Let's also change the return value to T so that the function operates only on one type. Now we just clean up the rest. That looks much better. Scrolling back up to our int handler, it looks like the issue we saw earlier is now resolved. No more red squiggles. I think we are good with this handler for now. Let's move on to the float handler. This appears to be almost identical to our ints handler above, but it operates on float 64s. First we decode some JSON, we process the input, and then marshal the data back to JSON and write the result. Besides the business logic and types these two handlers use, these seem very similar. Let's see if we can do some more generic refactoring here and make a general HTTP handler. Let's start by copying one of the existing handlers. We will start refactoring by making this function return in http handler func. This will let us turn the function into a middleware we can wrap our int and float handler with. Now let's figure out how we can abstract away our business logic. Let's have this middleware be generic over a function that takes the input I and returns the output O. Here, I'm making use of the any constraint, that is a new keyword, and as mentioned earlier, is just an alias for the empty interface. Meaning we can pass any type to it. Next, let's refactor out the business logic out of the generic handler and have just those bits in the int and float handlers. In our original handlers, we can delete a lot of the common code and adjust the input and output types accordingly. We can actually take things one step further and reduce this down to one generic process handler that is constrained by the number again. Let's see what that would look like. Finally, we just need to wrap our tight parameterized generic handler in the generic middleware. We just did quite a bit of refactoring in a short amount of time. Let's try starting up the server locally and testing it with the little client I wrote. Hey, we did it. It works. I have this workload deployed to Cloud Run on Google Cloud. So our last step is to ship it, gcloud, run deploy. And in a few moments, we would have our new generic app up and running on the Cloud. In this small example, we were able to reduce our code base by over 40% and get rid of a dependency, all by adopting generics. I hope that this helped reinforce some of the ways that adopting generics can simplify your code base. Back to you, Cameron. CAMERON BALAHAN: Thank you, Cody. And thank you to everyone who tuned in. In recap, generics are a powerful way to simplify your code, and we hope that you'll use them to be more productive. Tell us what you think. We're eager to incorporate your feedback to be sure we get it right. Thank you. [MUSIC PLAYING]
Info
Channel: Google Cloud Tech
Views: 7,667
Rating: undefined out of 5
Keywords: Google Go, Cloud Applications, Generics in GO, Google Generics Go, Google Go Language, go 1.18, Google I/O, Google IO, I/O, IO, Google I/O 2022, IO 2022
Id: -F2t3oInqKE
Channel Id: undefined
Length: 23min 24sec (1404 seconds)
Published: Thu May 12 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.