Goのジェネリクスを今さら学ぶ

目次

はじめに

Go 1.18 で追加されたジェネリクスについて特に学ばないままここまで来てしまった。反省。

ほぼ公式のAn Introduction To Genericsの通りですが、基本から見ていきます。

従来のインターフェース型との違い

従来のインターフェース型はそのインターフェース型のメソッドすべてを実装していればインターフェースを実装している、と見なしていた。

type Stringer interface {
  String() string // メソッドの集合を定義可能
}

func Print(s Stringer) {
  fmt.Println(s.String())
}

Go 1.18 ではインターフェース型を型のセットと捉えるようにし、インターフェース型はその型のメソッドのセットを実装している、と見なすようになった。

そのためインターフェース型の構文が拡張されており、型のセットを明示的に指定できるようになった。

type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

インターフェース型の新構文

| (縦棒)

  • 型の和集合を表す
type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

~ (チルダ)

  • 指定した型を基底型とする全ての型のセット
  • 例のPrint関数は string型 と Defined type を受け取ることができる
type StringLike interface {
	~string
}

func Print[T StringLike](s T) {
	fmt.Println(s)
}

type CustomString string

Print("hello!")
Print(CustomString("hello!"))

型エイリアス

関数の型パラメータ

関数にで型パラメータを指定できます。

  • 例では比較演算子が利用可能なスライスから最大値を返します
func Max[E constraints.Ordered](s []E) E {
	if len(s) == 0 {
		panic("Max: empty slice")
	}

	max := s[0]
	for _, v := range s[1:] {
		if v > max {
			max = v
		}
	}
	return max
}

型の型パラメータ

型にで型パラメータを渡すことができ、フィールドの要素に指定できます。

  • 例では任意の型のStack型を実装しています
type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(item T) {
	s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	item := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return item, true
}

Defined type と型推論の注意点

Defined type を扱うジェネリクスの実装においては型の推論の振る舞いで少し気を付けるポイントがあります。

型パラメータで slice を扱う場合、[]E のように要素型のみを指定すると、Defined typeを渡した場合に戻り値の型が維持されなくなります。

  • 例では任意の型のsliceをコピーして返す関数
  • Point型をClone関数に渡したところ、戻り値の型が[]int32と解釈されている
type Point []int32

func (p Point) String() string {
	return ""
}

func Clone[E any](slice []E) []E {
	result := make([]E, len(slice))
	for i, v := range slice {
		result[i] = v
	}
	return result
}

func someFunction(slice Point) {
	r := Clone(slice)

	// コンパイルエラー:
    // r.String undefined (type []int32 has no field or method String)
	fmt.Println(r.String())
}

これは []E というパターンに Point をマッチさせる際、コンパイラは Point の基底型 []int32 から E = int32 と推論するため、Point という型情報自体は保持されない。

型パラメータに [S ~[]E, E any] と指定することで、S がスライス型そのものを表すようになる。

この場合、S = PointE = int32 と推論されるため、戻り値の型も Point として保持される。

func Clone[S ~[]E, E any](slice S) S {
	result := make(S, len(slice))
	for i, v := range slice {
		result[i] = v
	}
	return result
}

func someFunction(slice Point) {
	r := Clone(slice)

	// コンパイルできる
	fmt.Println(r.String())
}

ジェネリクスの良さそうな使い方

ユーティリティ関数などはよくお見かけするので割愛。

個人的に興味のあるものを書いています。

ジェネリクスを用いたバリデーション実装

構造体のバリデーションの課題は複数チェック項目があるうち、違反しているエラーを列挙して返したいが、やや複雑な実装が求められるところ。

また、通常はその複雑な部分を外部のライブラリに依存することが多い。

ジェネリクスを用いてバリデーションを実装することにより、ライトにその機構を実現できるのでは?と感じた。

  • 基本的なバリデーション用の構造体
type ValidationError struct {
	Field   string
	Message string
}

type ValidationErrors []ValidationError

func (ve ValidationErrors) Error() string {
	var messages []string
	for _, e := range ve {
		messages = append(messages, fmt.Sprintf("%s: %s", e.Field, e.Message))
	}
	return strings.Join(messages, ", ")
}

type Validator[T any] struct {
	value  T
	errors ValidationErrors
}

func Validate[T any](value T) *Validator[T] {
	return &Validator[T]{value: value}
}

func (v *Validator[T]) Rule(field string, check bool, message string) *Validator[T] {
	if !check {
		v.errors = append(v.errors, ValidationError{field, message})
	}
	return v
}

func (v *Validator[T]) RuleFunc(field string, fn func(T) error) *Validator[T] {
	if err := fn(v.value); err != nil {
		v.errors = append(v.errors, ValidationError{field, err.Error()})
	}
	return v
}

func (v *Validator[T]) Result() error {
	if len(v.errors) == 0 {
		return nil
	}
	return v.errors
}

事例としてUser構造体にバリデーションを実装する

  • かなりシンプルに実装できた
type User struct {
	Name  string
	Email string
	Age   int
}

func ValidateUser(u *User) error {
	return Validate(u).
		Rule("name", u.Name != "", "name is required").
		Rule("name", len(u.Name) >= 2, "name must be at least 2 characters").
		Rule("email", u.Email != "", "email is required").
		RuleFunc("email", func(user *User) error {
			if !isValidEmail(user.Email) {
				return errors.New("invalid email format")
			}
			return nil
		}).
		Rule("age", u.Age >= 0 && u.Age <= 150, "age must be between 0 and 150").
		Result()
}

func isValidEmail(email string) bool {
	// 簡易的なチェック
	return strings.Contains(email, "@") && strings.Contains(email, ".")
}