Go 1.21: Generic Functions Comprehensive Revisit

0 评论
/ /
317 阅读
/
17274 字
10 2023-08

Generics have long been a highly anticipated feature in the Go programming language. With the release of Go 1.21.

This comprehensive guide aims to provide a detailed exploration of generics in Go 1.21, shedding light on their benefits, syntax, implementation, and best practices.

Check out My Notes on upcoming go version: What’s New in Go 1.21 a Comprehensive Notes

Generics Basic Syntax in Go 1.21

To define a generic function or type, we use the type T keyword followed by the name of the generic parameter enclosed in square brackets []. For instance, to create a generic function that takes in a slice of any type and returns the first element, we can define it as follows:

func First[T any](items []T) T {
    return items[0]
}


In the example above, [T any] denotes the type parameter T, which represents any type. The any keyword indicates that the T type can be any valid type.

We can then call the First function with any slice type, and the function will return the first element of that slice. For example:

package main

func main() {
  intSlice := []int{1, 2, 3, 4, 5}
  firstInt := First[int](intSlice) // returns 1
  
  println(firstInt)
  
  stringSlice := []string{"apple", "banana", "cherry"}
  firstString := First[string](stringSlice) // returns "apple"
  
  println(firstString)
}


Note that when calling a generic function, we specify the type argument in square brackets []. This allows the compiler to generate specific code for that type during compilation.

We can also add constraints to the generic type parameter to restrict it to certain types. For instance, if we want to limit the type T to only types that implement the Stringer interface, we can use a constraint like this:

func PrintString[T Stringer](value T) {
    fmt.Println(value.String())
}


The Stringer constraint ensures that the type T must have a String() method. This constraint allows us to safely call the String() method on the value parameter within the function.

Generics with various types in Go 1.21

In another example, let’s write function SumGenerics that performs addition operations on various numeric types such as int, int16, int32, int64, int8, float32, and float64.

func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
    return a + b
}


Let’s see how we can utilize this generic function:

package main

func main() {
  sumInt := SumGenerics[int](2, 3) // returns 5
  
  sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
  
  sumInt64 := SumGenerics[int64](10, 20) // returns 30
}


In the code above, we can see that by specifying the type argument in square brackets [] when calling the generic function, we can perform addition operations on different numeric types. The type constraint ensures that only the specified types [T int, int16, int32, int64, int8, float32, or float64] can be used as type arguments.

Using generics in this manner allows us to write concise and reusable code without sacrificing type safety. The function can be called with various numeric types, and the compiler will generate specific code for each type, ensuring that the addition operation is performed correctly.

Generics with arbitrary data types in Go 1.21

generics can be used for serialization and deserialization of arbitrary data types, using the provided Serialize and Deserialize functions:

type Person struct {
 Name    string
 Age     int
 Address string
}

func Serialize[T any](data T) ([]byte, error) {
  buffer := bytes.Buffer{}
  encoder := gob.NewEncoder(&buffer)
  err := encoder.Encode(data)
  if err != nil {
    return nil, err
  }
  return buffer.Bytes(), nil
}

func Deserialize[T any](b []byte) (T, error) {
  buffer := bytes.Buffer{}
  buffer.Write(b)
  decoder := gob.NewDecoder(&buffer)
  var data T
  err := decoder.Decode(&data)
  if err != nil {
    return data, err
  }
  return data, nil
}


In this example, we have two generic functions, Serialize and Deserialize, which utilize Go’s gob package to convert arbitrary data types to bytes and vice versa.

func DeserializeUsage() {
  person := Person{
  Name:    "John",
  Age:     30,
  Address: "123 Main St.",
  }

  serialized, err := Serialize(person)
  if err != nil {
    panic(err)
  }
  
  deserialized, err := Deserialize[Person](serialized)
  if err != nil {
    panic(err)
  }
  
  fmt.Printf("Name: %s, Age: %d, Address: %s", deserialized.Name, deserialized.Age, deserialized.Address)
}


Output: Name: John, Age: 30, Address: 123 Main St.

In the above code, we create a Person instance with some data. We then use the Serialize function to convert the person object into a byte array. Later, using the Deserialize function, we convert the byte array back to the Person object.

By defining the Serialize and Deserialize functions as generic functions with the T any type parameter, we can serialize and deserialize any data type that supports encoding and decoding with the gob package.

Custom Validators in Go Using Generics and the Validate Function

let’s write a generic Validate function with custom validators.

type Validator[T any] func(T) error

func Validate[T any](data T, validators ...Validator[T]) error {
 for _, validator := range validators {
  err := validator(data)
  if err != nil {
   return err
  }
 }
 return nil
}


In this example, we have a generic Validate function that performs data validation using custom validators. The Validator type represents a function that takes in a value of any type T and returns an error.

func StringNotEmpty(s string) error {
 if len(strings.TrimSpace(s)) == 0 {
  return fmt.Errorf("string cannot be empty")
 }
 return nil
}

func IntInRange(num int, min, max int) error {
 if num < min || num > max {
  return fmt.Errorf("number must be between %d and %d", min, max)
 }
 return nil
}


Additionally, we have two example custom validators: StringNotEmpty and IntInRange.

StringNotEmpty ensures that a string is not empty, and IntInRange checks if an integer is within a specified range.

package main

func main() {
  person := Person{
    Name:    "John",
    Age:     30,
    Address: "123 Main St.",
  }
  
  err := Validate(person, func(p Person) error {
    return StringNotEmpty(p.Name)
  }, func(p Person) error {
    return IntInRange(p.Age, 0, 120)
  })
  
  if err != nil {
    println(err.Error())
    panic(err)
  }
  
  println("Person is valid")
}


in this example, we create a Person instance and pass it through the Validate function. We define two custom validators for the Person struct, checking that the Name field is not empty and the Age field is within a valid range. If any of the validators return an error, the validation process is halted, and the respective error is returned.

By using generics and custom validators, the Validate function allows for flexible and reusable data validation across different data types, enhancing code reusability and making it easy to add or modify validation rules.

Let’s write another example using the validator function:

type LoginForm struct {
    Username string
    Password string
}

func (f *LoginForm) Validate() error {
    return Validate(f,
        func(l *LoginForm) error {
            return StringNotEmpty(l.Username)
        },
        func(l *LoginForm) error {
            return StringNotEmpty(l.Password)
        },
    )
}

func ValidateUsage2() {
    loginForm := LoginForm{
        Username: "John",
        Password: "123",
    }

    err := loginForm.Validate()
    if err != nil {
        println(err.Error())
        panic(err)
    }

    println("Login form is valid")
}


In this example, the LoginForm struct implements a Validate method that utilizes the Validate generic function we defined earlier.

The Validate method calls the generic Validate function and provides it with two custom validators specific to the LoginForm type. The validators, represented as closure functions, check if the Username and Password fields are not empty using the StringNotEmpty validator function.

To validate the LoginForm instance, we simply call the Validate method on the instance itself.

type LoginForm struct {
    Username string
    Password string
}

func (f *LoginForm) Validate() error {
    return Validate(f,
        func(l *LoginForm) error {
            return StringNotEmpty(l.Username)
        },
        func(l *LoginForm) error {
            return StringNotEmpty(l.Password)
        },
    )
}

func ValidateUsage2() {
    loginForm := LoginForm{
        Username: "John",
        Password: "123",
    }

    err := loginForm.Validate()
    if err != nil {
        println(err.Error())
        panic(err)
    }

    println("Login form is valid")
}


If any of the validators return an error, the validation process is halted, and the respective error is returned. In such cases, we handle the error accordingly.

Conclusion

the examples showcased the power and versatility of generics in Go 1.21. Generics enable us to write reusable and type-safe code that can handle different data types and structures without sacrificing code clarity and maintainability.

Generics bring significant benefits to the Go programming language, enhancing code reuse, reducing redundancy, and improving code organization. With generics, developers have the ability to write more expressive, concise, and flexible code that adapts to different data types and structures, paving the way for more extensible and maintainable software development.

To access the source code used in the examples discussed above, please visit the following GitHub Gist: Source Code Or Go Playground

Additionally, if you are interested in learning more about the upcoming Go release version 1.21 and further insights on the discussed examples, please take a look at my comprehensive notes on the release: Go Upcoming Release 1.21

Go、Golang、Golang Tutorial、Golang Development、Golang Tools