Golang Generics type constraints with interface and Tilda

eye-catch Golang
Sponsored links

Define multiple functions that have the same logic

In some cases, a function needs to handle a different data type for the same logic. We can easily define some functions in the following way.

func acceptUint(value uint) {
    fmt.Println(value)
}
func acceptUint32(value uint32) {
    fmt.Println(value)
}
func acceptUint64(value uint64) {
    fmt.Println(value)
}

func RunTilda() {
    acceptUint(uint(1))
    acceptUint32(uint32(1))
    acceptUint64(uint64(1))
}

We can process the same procedure for a different data type by using a different function. However, this is not a good idea because the code is duplicated.

Sponsored links

Using any (interface) data type to accept multiple data types

For a simple logic, any, a.k.a interface{}, data type can be used. But we have to add a switch case to handle only the desired data type.

func acceptAny(value any) error {
    switch v := value.(type) {
    case uint:
        fmt.Println(v)
    case uint32:
        fmt.Println(v)
    case uint64:
        fmt.Println(v)
    default:
        return errors.New("unsupported data type")
    }
    return nil
}

func RunTilda() {
    acceptUint(uint(1))
    acceptUint32(uint32(1))
    acceptUint64(uint64(1))
    acceptAny(uint32(22))   // 22
    acceptAny("sample text")    // unsupported data type: string
}

A coding error can’t be caught in the compile time but at runtime.

Using Generics to have type constrains

Generics can be used Go 1.18 or a later version. By using Generics, the previous situation can be improved in a good way.

Let’s check the code.

func acceptUintGenerics[T uint | uint8 | uint16 | uint32 | uint64](value T) {
    fmt.Println(value)
}

func RunTilda() {
    acceptUintGenerics(uint(5))
    acceptUintGenerics(uint8(6))
    acceptUintGenerics(uint16(7))
    acceptUintGenerics(uint32(8))
    acceptUintGenerics(uint64(9))
    // string does not implement uint|uint8|uint16|uint32|uint64 (string missing in uint | uint8 | uint16 | uint32 | uint64)compilerInvalidTypeArg
    acceptUintGenerics("sample")
}

The syntax is as follows.

func FunctionName[TYPE_NAME type1 | type2 | ...](param1 TYPE_NAME){

}

We can give any type name to TYPE_NAME but I often see K, T, U, V as a type name. T is used most often.

Handle multiple data types by interface

If we need to use the same data types in different functions, it’s useful to define an interface for the union data type. It can be defined in the following way.

type UnsignedInt interface {
    uint | uint8 | uint16 | uint32 | uint64
}

The function looks simpler than the previous one with this declaration.

func acceptUnsignedInt[T UnsignedInt](value T) {
    fmt.Println(value)
}

func RunTilda() {
    acceptUnsignedInt(uint(1))
    acceptUnsignedInt(uint8(1))
    acceptUnsignedInt(uint64(1))
    // MyUnsignedInt does not implement UnsignedInt (possibly missing ~ for uint in constraint UnsignedInt)compilerInvalidTypeArg
    // acceptUnsignedInt(MyUnsignedInt(1))
}

Type Definition cannot be accepted

Let’s dive into it deeper. We can define an alias for a data type. There are two ways to define it.

type UintAlias = uint   // Type Alias
type MyUnsignedInt uint // Type Definition

The type defined by “Type Alias” can be handled as the original data type, it’s unit here. On the other hand, the type defined by “Type Definition” is handled as a different data type. Therefore, the variable can’t be set to acceptUnsignedInt function.

func RunTilda() {
    acceptUnsignedInt(UintAlias(1))
    // MyUnsignedInt does not implement UnsignedInt (possibly missing ~ for uint in constraint UnsignedInt)compilerInvalidTypeArg
    acceptUnsignedInt(MyUnsignedInt(1))
}

An interface with Tilda to cover Type Definition

If it’s necessary to handle the type in the same way as the original data type, we need to tell it to the compiler. This can be done by adding a Tilda.

type UnsignedInt interface {
    uint | uint8 | uint16 | uint32 | uint64
}

// newly added
type UnsignedIntWithTilda interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

Adding a Tilda to the data type, the data type defined by “Type Definition” can also be used.

func acceptUnsignedIntWithTilda[T UnsignedIntWithTilda](value T) {
    fmt.Println(value)
}

func RunTilda() {
    acceptUnsignedIntWithTilda(uint(1))
    acceptUnsignedIntWithTilda(uint8(1))
    acceptUnsignedIntWithTilda(uint64(1))
    acceptUnsignedIntWithTilda(MyUnsignedInt(1))
}

An example to use Type Definition and Generics with Tilda

When is the combination of Type Definition and Generics useful? Assume that we have the following struct.

type Centimeter float64
type Kilogram float64

type productInTilda struct {
    Name   string
    Height Centimeter
    Width  Centimeter
    Length Centimeter
    Weight Kilogram
}

We want to show only 2 digits after the decimal point. So, we define the following function.

type Float64Value interface {
    ~float32 | ~float64
}

func toStringWithTwoDigitsAfterDecimalPoint[T ~float64](value T) string {
    return strconv.FormatFloat(float64(value), 'f', 2, 64)
}

It uses Generics with a Tilda. It means that it accepts our own data types that has float64.

The unit is respectively, cm and kg. We want to show the units when the struct needs to be converted to string. Of course, the units can be written in the String() function of the struct. However, if we always want to show the unit when the value is converted to string, we have to add it in all places. Likewise, toStringWithTwoDigitsAfterDecimalPoint needs to be called in those places. Let’s implement it in one place.

func (value Centimeter) String() string {
    return fmt.Sprintf("%s cm", toStringWithTwoDigitsAfterDecimalPoint(value))
}

func (value Kilogram) String() string {
    return fmt.Sprintf("%s kg", toStringWithTwoDigitsAfterDecimalPoint(value))
}

func (p productInTilda) String() string {
    return fmt.Sprintf("(Name: %s, Height: %s, Width: %s, Length: %s, Weight: %s)",
        p.Name, p.Height, p.Width, p.Length, p.Weight)
}

In this way, we don’t have to care about the unit in the code.

product := productInTilda{
    Name:   "Desk",
    Height: 15,
    Width:  40.3,
    Length: 100.123,
    Weight: 3.2,
}
fmt.Println(product)
// (Name: Desk, Height: 15.0 cm, Width: 40.3 cm, Length: 100.1 cm, Weight: 3.20 kg)

fmt.Println(product.Height) // 15.0 cm
fmt.Println(product.Width)  // 40.3 cm

We can keep the precision and the unit everywhere. It keeps the consistency in the project.

Comments

Copied title and URL