The Finatext Tech Blog

THE Finatext Tech Blog

Follow publication

Go with Generics: なぜ Interface Union が使えないのか

--

シカゴの象徴「Cloud Gate」、通称「豆」 / 筆者撮影

こんばんは。 Finatext のクレジット事業でエンジニアをしています、東郷です。

先日、シカゴの GopherCon 2024 に参加してきたのですが、「Advanced Generics Patterns」を発表した Axel Wagner さんに飲み会で「なんで generics で interface Union を使えないの?」と相談したところ興味深い回答をいただけたので、ここに紹介させていただきます。

大切なお話: なにぶん飲み会の立ち話であり、もしかすると筆者が盛大に勘違いしている可能性もあります。 Axel さんの公式なご見解ではなく、あくまで筆者の認識となりますことをご承知おきください。

Axel による GopherCon 2024 の Axel の登壇 / 筆者撮影

Generics

Go 1.18 で generics が登場してからというもの、我々の生活は一変しました。

分かりやすい例でいうと builtin の slices.Equal のような、非常に便利な関数を型に囚われることなく実装・利用できるようになりました。

import "slices"

func main() {
println(slices.Equal([]int{1, 2, 5}, []int{1, 2, 5})) // true
println(slices.Equal([]int{1, 3, 5}, []int{1, 2, 5})) // false
println(slices.Equal([]int32{1, 2, 5}, []int64{1, 2, 5})) // compile error: type []int64 of []int64{…} does not match inferred type []int32 for S
}

slices.Equal の signature は下記のようになっており、比較可能な任意の型の要素を持つスライスを渡せるようになっています (ソースコード)

func Equal[S ~[]E, E comparable](s1, s2 S) bool

Interface Union Type

これらの generics を使って便利に開発を進められるようになった一方、一つの壁にぶつかりました。 「いずれかの interface」を示す型を union で定義できなかったのです。

import "fmt"

type Byteser interface {
Bytes() []byte
}

// cannot use fmt.Stringer in union (fmt.Stringer contains methods)
func Stringify[T interface{ fmt.Stringer | Byteser }](v T) string

Type Sets

Interface union とは何ぞやという点ですが、まずは下記のコードを御覧ください。

import "golang.org/x/exp/constraints"

func Sum[T interface{
constraints.Integer | constraints.Float
}](vs ...T) T {
var sum T
for _, v := range vs {
sum += v
}
return sum
}

func main() {
println(Sum(1, 2, 5)) // 8
println(Sum(1.2, 1.5)) // 2.7
println(Sum(int(1), 1.2)) // cannot use 1.2 (untyped float constant) as int value in argument to Sum
}

ここでの constraints.Integer および constraints.Float はそれぞれ下記のようになっています (ソースコード)

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

type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Integer interface {
Signed | Unsigned
}

type Float interface {
~float32 | ~float64
}

ここで示されているのは、たとえば constraints.Integer ならば「任意の整数型を受け取れる」型が定義されています。これを「Type Set」といいます。

これにより、前述の例の Sum 関数は、任意の整数型または浮動小数点方を引数で受け取れるようになっていて、かつ同じ型で合計値を返せるようになっています。

なぜ Type Set はよくて Interface Union がだめなのか

これが疑問でした。 type set で primitive は複数の値を union にできるのに、 interface の組み合わせができないのか。

Axel さんに伺った話では、その理由はずいぶんとシンプルなものでした。

Generics は Type Switch を前提とした機能ではない

冒頭で挙げた Stringify 関数を例に挙げると、その中身を仮に実装するなら下記のようなコードになります (interface union が使えないのでコンパイルエラーになります)

import "fmt"

type Byteser interface {
Bytes() []byte
}

// cannot use fmt.Stringer in union (fmt.Stringer contains methods)
func Stringify[T interface{ fmt.Stringer | Byteser }](v T) string {
switch v := any(v).(type) {
case fmt.Stringer:
return v.String()
case Byteser:
return string(v.Bytes())
default:
panic("unreachable") // compiler requires return-equiv statement
}
}

ここでは内部で type switch を行っており、関数の signature 以外は any と何ら変わらない処理となっています。

そもそも generics が導入された動機はこういった type switch や reflection を削減することにメリットがあり 「type switch を前提とするユースケースはそもそも generics の目的に合致しないのでは?」ということでした。

なるほど言われてみれば確かにその通り。 type sets は互換のある演算子のみを使っており、 type switch 等は一切不要となっています。

回避策

とはいえ「じゃあ any で」としてしまうのは型安全性の不便さが残ってしまうので回避策を考えると、このようなケースで generics が向かないならば、いっそ generics を使わず関数を分離してしまうという手もあるかもしれません。

import "fmt"

type Byteser interface {
Bytes() []byte
}

func StringifyStringer[T fmt.Stringer](v T) string {
return v.String()
}

func StringifyByteser[T Byteser](v T) string {
return string(v.Bytes())
}

あるいは Axel さんの登壇でも例示されたように、 builtin の関数のように func を渡す形の別関数を用意するのもよさそうです ()

func Equal[S ~[]E, E comparable](s1, s2 S) bool

func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool

おまけ

実は type switch による performance downside を計測して付記しようとしたのですが、思った結果が得られず「type switch しても速度がほぼ変わらない」という、思ってたんと違う形になりました。が、その謎も思わぬ形で早々と解決しました。

GopherCon 2024 の最終日、 Google Go team の Keith Randall さんが登壇「Interface Internals」にて「Go 1.22 で interface type switch のパフォーマンス改善しといたよ!」と仰られていたのです。

試してみると、たしかに Go 1.21 では type switch のほうが遅い結果となりました。1.22 ではほぼ差がなくなっており、しっかりと改善の効果を実感できます。

$ asdf local golang 1.21.5
$ go test -benchmem -bench .
goos: darwin
goarch: arm64
pkg: github.com/ktogo/benchtest
BenchmarkStringifyAnyStringer-12 178512208 6.670 ns/op 0 B/op 0 allocs/op
BenchmarkStringifyAnyByteser-12 50326334 23.01 ns/op 5 B/op 1 allocs/op
BenchmarkStringifyStringe-12 494067585 2.430 ns/op 0 B/op 0 allocs/op
BenchmarkStringifyByteser-12 248494794 4.812 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/ktogo/benchtest 6.380s

$ asdf local golang 1.22.1
$ go test -benchmem -bench .
goos: darwin
goarch: arm64
pkg: github.com/ktogo/benchtest
BenchmarkStringifyAnyStringer-12 393826981 2.943 ns/op 0 B/op 0 allocs/op
BenchmarkStringifyAnyByteser-12 82505652 13.98 ns/op 5 B/op 1 allocs/op
BenchmarkStringifyStringe-12 493270286 2.414 ns/op 0 B/op 0 allocs/op
BenchmarkStringifyByteser-12 250246922 4.802 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/ktogo/benchtest 6.000s

We’re Hiring!

Finatext グループでは一緒に働く仲間を募集中です!様々なエンジニア系のポジションがあるので気軽に覗いてみてください!

--

--

No responses yet

Write a response