Go の "Type Sets" proposal を読む

18 min read読了の目安(約16800字

Intro

proposal: spec: generics: use type sets to remove type keyword in constraintsというproposalが2021/4/2に出されました。(注意: acceptされたわけではないのでこの内容が実装されるとは限りません)

このproposalは、先日acceptされたType Parameters Proposalの文脈にあり、この中に現れる「型制約」の表現方法を改良するものです。

タイトルを読んでみると「type setsを用いて、型制約からtypeキーワードを取り除く」とのことです。このtype setsという概念が面白いため、その文脈から内容までを紹介するのがこの記事の目標です。

簡単に参照するため、以下このproposalをType Sets Proposalと呼ぶことにします。前提になっているすでにacceptされた型パラメータのproposalはType Parameters Proposalと呼ぶことにします。

読者の想定知識

  • Go言語でのプログラミングをある程度したことがある、もしくは、
  • A Tour of Goは一通りやったことがある

くらいの知識と経験があることを仮定します。

  • Type Parameters Proposalに詳しいことは仮定しません。
  • Go言語仕様に詳しいことも仮定しませんが、仕様書の重要用語はいくらか使うと思います。それらを事前に知らなくても読めるように努力します。いくつか参考資料をあげておきます。

この記事で出てくるGo言語仕様用語

サマリー

  • Type setsとは、ある型があるインターフェースを「実装する」条件を記述するための新しい概念です。
  • 従来からこの目的を果たしてきたmethod setsという概念と異なり、Type Parameters Proposal後の新しいインターフェースにも拡張可能になっています。
  • このType setsを記述するための新しい文法として、次の2つが導入されます。
    • ~T approximation element(近似要素)
    • T | U union element(合併要素)

Context

このproposalは、先日acceptされたType Parameters Proposalの文脈にあり、この中に現れる「型制約」の表現方法を改良するものです。そこで、まずType Parameters Proposalの内容を見ていきます。Type Sets Proposalの内容だけ知りたい人は、Contextセクションを丸ごと飛ばしていただければOKです。

Type Parameters Proposalについては以下の日本語記事も参考になります。

Type Parameters Proposalの要点

abstractから今回重要なことを抜き出すと次のようになります:

  • Go言語にオプショナルな型パラメタを導入する。これは型宣言と関数宣言で用いることができる。
  • 型パラメタはインタフェースで制約される。これを型制約という。
  • インタフェース型が型制約として用いられるときは、その型に代入可能な型のリストを含めることができる。これをtype listと呼ぶ。

新しく書けるようになるのは次のようなコードです:

  • func F[T any](p T) { ... }
  • type M[T any] []T
  • func F[T Constraint](p T) { ... }

ここでConstraintは必ずInterface Typeでなければいけません。このように定義されたGeneric functionのパラメータは型制約で許された演算しかできません。

https://go2goplay.golang.org/p/82D2VAi6ico
package main

import (
	"fmt"
	"strings"
)

type MyString string

func (s MyString) String() string {
	return string(s)
}

type Stringers[T fmt.Stringer] []T

func (ss Stringers[T]) Show() string {
	var vals []string
	for _, v := range ss {
		vals = append(vals, v.String())
	}
	return strings.Join(vals, ", ")
}

func main() {
	var ss Stringers[MyString] = []MyString{
		MyString("Hello"),
		MyString("Type Constraint"),
	}

	fmt.Println(ss.Show())
}

type Stringersはある型Tのスライスにより定義されますが、このTにはfmt.Stringerインタフェースによる型制約がついています。これはString() stringというシグネチャを持つメソッドを持つことを要求するインタフェースです。このようにして、型制約のついたgeneric typeであるStringersを定義することができました。

このStringersにメソッドを定義してみます。ここではShow() stringというメソッドで、スライスの要素のString()の結果を繋げて文字列として返すことにしました。型制約がついているおかげで、Stringersの要素に対してString()メソッドを呼び出すことができます。

Type listの必要性

とてもわかりやすい仕組みです。この上何か必要なものがあるのでしょうか?それは、「従来のGoのインタフェースによって表現できない型制約」です。つまり、「あるメソッドを呼び出せる」という制約ではなく、「ある演算子のオペランドになれる」という制約、例えば < によって比較することができるという制約を考えてみます。やりたいことは次のようなことです:

// Smallestは、引数のスライスから「最小値」を選ぶ
// Type Parameters Proposalの設例を改訂
func Smallest[T Constraint](s []T) T {
	r := s[0] // panic if slice is empty
	for _, v := range s[1:] {
		if v < r { 
			r = v
		}
	}
	return r
}

Smallestは、<で比較できる任意の型を受け取り、その(genericな)最小値を返したいです。しかし、このような演算を許すような型制約は従来のインタフェースでは表現できません。インタフェースとはすなわちメソッドセットであるからです。

そこで、Type Parameters Proposalはインタフェースの定義方法を拡張しました。それがtype listです。

// Ordered は順序付可能な全ての型にマッチする型制約です。
// 順序付可能な型とは、<, <=, >, >= 演算子をサポートする型のことです。
type Ordered interface {
	type int, int8, int16, int32, int64,
		uint, uint8, uint16, uint32, uint64, uintptr,
		float32, float64,
		string
}

interface{}型リテラルの中に、type int, int8, ...ともう1つtypeキーワードが書かれていて、その後に型の名前がリストされています。このように定義したインタフェース型を型制約として用いることができ、この型制約は、

  • 列挙されている型のいずれかと同一である
  • または、underlying typeが列挙されている型のいずれかと同一である

ときに満たされます。underlying typeとは、例えば次のようなものです。

type MyInt int // MyIntのunderlying typeはint
type MyMyInt MyInt // MyMyIntのunderlying typeはint

// intのunderlying typeはint自身

つまり、type A Bという型定義を遡ってゆき、それ以上遡れなくなるところにある型がunderlying typeです。詳しくはDQNEOさんの発表資料が大変わかりやすいのでそちらの一読をお勧めします。

この部分はわかりにくかったと思います。実際にこのわかりにくさはType Sets Proposalのモチベーションの1つになっているようなので、後に改めて触れたいと思います。

これを用いると、初めにやりたかったことは次のように実現できます。

https://go2goplay.golang.org/p/Q2FGpUQkJzr
package main

import (
	"fmt"
)

type MyInt int

func main() {
	numbers := []MyInt{3, 1, 2} // MyIntもOrdered型制約を満たす
	strs := []string{"z", "banana", "gopher"}

	fmt.Println(Smallest(numbers))
	fmt.Println(Smallest(strs))

}

// Ordered は順序付可能な全ての型にマッチする型制約です。
// 順序付可能な型とは、<, <=, >, >= 演算子をサポートする型のことです。
type Ordered interface {
	type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64, string
}

// Smallestは、引数のスライスから「最小値」を選ぶ
// Type Parameters Proposalの設例を改訂
func Smallest[T Ordered](s []T) T {
	r := s[0] // panic if slice is empty
	for _, v := range s[1:] {
		if v < r {
			r = v
		}
	}
	return r
}

暗黙的な"underlying type matching"の問題

さて、type listの使い方がわかったところで、先ほどわかりにくかったunderlying typeの問題をもう一度見てみます。

type listを持つinterfaceによる型制約Cがあるとします。ある型Tがあって、Tが型制約Cを満たすのは、

  • 列挙されている型のいずれかとTが同一である
  • または、Tのunderlying typeが列挙されている型のいずれかと同一である

ときです。この2番目の条件があるから、先ほどのコードでも出てきたMyInt型はOrdered型制約を満たすことができたのです。

type MyInt int // underlying typeはint

// Ordered は順序付可能な全ての型にマッチする型制約です。
// 順序付可能な型とは、<, <=, >, >= 演算子をサポートする型のことです。
type Ordered interface {
	type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64, string
}

このように、「その型自身が列挙されていなくても、その型のunderlying typeが列挙されていればOKですよ」、と判断することをこの記事では"underlying type matching"と呼ぶことにします。これは公式の用語ではありませんが、proposalのissue内で議論に使われていた言い方を借りてきたものです。

このunderlying type matchingのわかりにくさがType Sets Proposalのモチベーションになっているようです。このわかりにくさは、次のようなことを考えるとさらにはっきりしてきます。

type listからsum typeへ

実は、Type Parameters Proposalの段階では、「type listを含むインタフェース型は、型制約としてのみ用いることができて、通常の変数宣言や関数宣言において使うことはできない」とされています。

https://go2goplay.golang.org/p/Ft0laObIfrq
type C interface {
    fmt.Stringer
    type int,int64
}

var x C // これはだめ
/*
type checking failed for main
prog.go2:14:7: interface contains type constraints (int, int64)


Go build failed.
*/

これはあくまで今のところはできないというだけの話で、将来的にはtype listを含むインタフェースを従来のインタフェース型と同様に使えるようにすることが検討されており、実際にproposalも出されています。そのようなproposalが実装されれば、この文法を「型の和」(sum type)として使うことが可能になるでしょう。

underlying type matchingと代入可能性

そうなると、変数への代入可能性はどうなるのでしょうか?type listを含むインタフェース型へ代入可能な型は、それを型制約として使ったときに型制約を満たす型である、と定義するのが妥当でしょう。そうでなければあまりにもわかりにくくなってしまいます。

そのように決めておいて、次の例を考えます。

type MyInt int

type IntIF interface {
    type int
}

type MyIntIF {
    type MyInt
}

のように型を定義しておきます。色々なパターンで代入できるかどうか考えてみましょう。

var i int = 1 
var myInt MyInt = 1 

var a int = myInt // NG
var b MyInt = i // NG

var c IntIF = i // OK
var d IntIF = myInt // OK

var e MyIntIF = i // NG
var f MyIntIF = myInt // OK

混乱する人が多いのではないでしょうか?a, bの例を見るとわかるように、従来の代入可能性の判断においてはunderlying type matchingのようなことは行われません。(※行われるのは、どちらかがdefined typeではないときだけです。)

このように、type listを含むインタフェースが通常の型の世界に出てくると、従来の代入可能性の条件と異なる基準が使われるため、やや混乱を招くのではないかと筆者は思います。

underlying type matchingと型switchステイトメント

sum types using interface type listsで盛んに議論されたのは型switchステイトメントです。次のコードはどうなるでしょうか?

type Sum interface {
	type int, uint
}

type A int

func main() {
    var s Sum = A(1)
    F(s)
}

func F(x Sum) {
	switch s.(type) {
	case nil, uint, int:
		return
	}
	panic("missed type")
}

これはpanicするとproposalのauthorが述べています

これは、型switchステイトメントの仕様から、caseによるマッチが成功するのはあくまでも「型が同一である」ときだけであり、ここでもunderlying type matchingのようなことは行われていないからです。「列挙されている全ての型をcaseに書いているのにどのcaseにもマッチしないことがある」というのは親しみやすい挙動ではないかもしれません。

underlying type matchingの表現力の限界

ユーザー定義型type MyInt inttype MyString stringがあるとき、この2つのいずれかに「一致する」型は次のように定義できます。

type T interface {
    type MyInt, MyString
}

この型にマッチする型はMyIntMyStringの他にはありません。type MyMyInt MyIntというような型があったとしても、MyMyIntのunderlying typeはintであってMyIntではありませんから、underlying type matchingは成り立たないからです。要するに、MyIntMyStringをunderlying typeに持つような型は存在しないのです。

では、intstringのいずれかにマッチし、それ以外のいかなる型にもマッチしないインタフェースは定義できるでしょうか?

type T interface {
    type int, string
}

答えは、「できない」です。

上記のinterfaceは、intまたはstringをunderlying typeに持ついかなる型にもマッチしてしまいます。そのマッチを防ぐ方法はありません。

Type Sets Proposal

このようにみてくると、interface type listにおいて上記の問題(混乱しやすい動きと表現力の限界)を引き起こしているのは、次の2つではないか?と思えてきます。

  • underlying type matchingが
    • 暗黙的に行われること
    • 常に行われること

これらを解決するのが表題のType Sets Proposalです。それでは内容を見ていきましょう。

Type Sets Proposalは、Type Parameters Proposalにあったtype listを次のアイディアで置き換えます。(つまり、type listは廃止されます)

Type sets(型集合)

全ての型は、type sets(型集合)を持ちます。この集合というのは高校までの数学で出てくる「集合」のことだと考えて大丈夫です。

やりたいことは、ある型があるインターフェースを実装するとはどういうことか、という条件付けを、従来のmethod setsを用いる方法と一貫性を持たせながら拡張することです。

Tのtype setsは次のように決まります。

  • Tがinterface型でない場合は{T} (Tのみからなる集合)
  • Tが従来の(type listなどを持たない)インタフェース型である場合は、Tの全てのメソッドを実装している全ての型からなる集合
  • それ以外の場合は、後述

interface型のtype setは無限集合になります。したがって、type setに含まれる型を全て列挙することができるとは限りません。

例題: interface{}型のtype setsは何でしょうか?

Tがinterface ITを実装するための条件

従来のGo言語仕様書では、

A variable of interface type can store a value of any type with a method set that is any superset of the interface. Such a type is said to implement the interface.

としています。つまり、

Tがinterface ITを実装するための条件は、Tのmethod setがITのmethod setを含む集合であることです。

Type Sets Proposalでは、この条件を次のように言い換えます。

Tがinterface ITを実装するための条件は、TtがITのtype setの要素であることです。

要素を埋め込んだインタフェースのtype set

さらに、埋め込みインタフェースのtype setを次のように定義します。

type I1 interface {
    E
}

のようにEを埋め込んだI1のtype setはEのtype setです。さらに、

type I2 interface {
    E1
    E2
}

のようにE1,E2を埋め込んだインタフェースI2のtype setは、E1のtype setとE2のtype setとの共通部分(intersection)です。

typeset(I2) = typeset(E1) ∩ typeset(E2)

とも書けます。

ここまでの話は従来の言語仕様を何も変えていません。上記のtype setの定義とそれを用いた「実装」の再定義も、method setを用いた従来の定義と等価です。違いが出てくるのはここから先です。

interface elements(インタフェース要素)

型制約に用いられるインタフェース、もしくは型制約に用いられるインタフェースに埋め込まれるインタフェースには、「インタフェース要素(interface elements)」と呼ばれる新しい構成要素を埋め込むことができます。インタフェース要素として使えるのは次の3つです。

  • 任意の型。インタフェース型に限らない。
  • approximation element(近似要素)と呼ばれる新しい文法要素。
  • union element(合併要素)と呼ばれる新しい文法要素。

これによって、「型引数Aが型制約Cを満たすのは、ACを実装するときだ」もしくは「ACのtype setに属するときだ」という言い方ができるようになります。

この3つのインタフェース要素を順番に見ていきます。

任意の型(インタフェース型に限らない)

型制約として用いるinterface型は、interfaceではない型を埋め込むことができます。

type Integer interface {
    int
}

Integerのtype setはなんでしょうか?1つの要素を埋め込んだインタフェースのtype setは、埋め込まれた要素のtype setと等しいのでした。それでは、埋め込まれたintのtype setはなんでしょう?interface型ではない型のtype setは、それ自身のみからなる集合{int}でした。よって、Integerのtype setは{int}であり、intIntegerを実装します。

この時点で、interface type listと異なっていることがわかると思います。ここではunderlying type matchingが行われていません。書いてある型に一致すればよく、一致しなければだめ、という基準になっています。

approximation element

それでは逆に、underlying type matchingをさせたいときはどうすれば良いでしょう?そのためには、新しい文法要素~を明示的に用いよ、というのがこのproposalです。

approximation element ~Tのtype setは、「underlying typeがTに等しい全ての型からなる集合」です。ただし、~Tは、Tのunderlying typeがTに等しいときのみ有効です。そうでない場合、~Tのtype setは空集合になります。

例を挙げましょう。

type AnyInt interface {
    ~int
}

このAnyIntのtype setは、underlying typeがintである全ての型です。例えばtype MyInt intと定義されたMyIntAnyIntのtype setに属し、したがってAnyIntを実装します。

union element

最後の要素です。union elementは、「型もしくはapproximation elementを|で繋いだもの」です。

  • int | float32 // 型を繋いでいる
  • ~int8 | ~int16 | ~int32 | ~int64 // approximation elementを繋いでいる

名前から想像できるように、union elementのtype setは、union elementの要素として含まれている要素のtype setの合併集合(和集合)です。よって、int | float32のtype setは{int, float32}となります。

例題: ~int8 | ~int16 | ~int32 | ~int64 のtype setは?(言葉で説明してOK)

interface type listとの比較

以上がType Sets Proposalの内容です。現段階ではあくまで型制約として用いるインタフェースのみがインタフェース要素を埋め込み可能という制限になっていますが、これも将来的に通常のインタフェース型と同様に使えるようにすることが想定されています。そこで、その場合にinterface type listとどのような違いが出てくるか見てみましょう。

代入可能性

type listについて説明した時に次の例を出しました。

type MyInt int

type IntIF interface {
    type int
}

type MyIntIF interface {
    type MyInt
}

このとき、代入可能性が次のようになるのがわかりにくい点でした。

var i int = 1 
var myInt MyInt = 1 

var a int = myInt // NG
var b MyInt = i // NG

var c IntIF = i // OK
var d IntIF = myInt // OK

var e MyIntIF = i // NG
var f MyIntIF = myInt // OK

Type Sets Proposalの内容に従うと次のようになります。

type MyInt int

type IntIF interface {
    int
}

type MyIntIF interface {
    MyInt
}

var i int = 1 
var myInt MyInt = 1 

var a int = myInt // NG
var b MyInt = i // NG

var c IntIF = i // OK
var d IntIF = myInt // NG

var e MyIntIF = i // NG
var f MyIntIF = myInt // OK

このように、approximation elementを使わない限りは従来の代入可能性と同じ動きになります。逆にunderlying typeも代入させたい時は、次のように明示的に書くことになります。

type MyInt int

type IntIF interface {
    ~int
}

var i int = 1 
var myInt MyInt = 1 

var d IntIF = myInt // OK

underlying typeを理解しなければこの動きが理解できない点に変わりはありませんが、underlying type matchingが「暗黙的」に行われるというわかりにくさは~の導入によって解消されています。

型switchステイトメント

同様に型switchステイトメントも書き直してみます。

type Sum interface {
	int | uint
}

type A int

func main() {
    var s Sum = A(1) // ここで代入不可能
    F(s)
}

func F(x Sum) {
	switch s.(type) {
	case nil, uint, int:
		return
	}
	panic("missed type")
}

今度はA型の値がSum型の変数sに代入不可能なので混乱することはありません。

表現力

interface type listには、intstringだけにマッチする型を定義できないという問題がありました。もちろんこれはType Sets Proposalでは解決されています。

type IT interface {
    int | string
}

逆に代入可能性が一貫しなくなる例

逆に、Type Sets Proposalによって少しだけわかりにくくなる例もあります。

type IntSlice []int

var a IntSlice
var b []int

var c IntSlice = a // OK
var d IntSlice = b // OK

type IntSliceIF interface {
    IntSlice
}

var e IntSliceIF = a // OK
var f IntSliceIF = b // NG

最後に

筆者はこのType Sets Proposalはわかりやすくて良いな、と感じています。underlying typeを考えなくて良くなるわけではないですが、暗黙的にマッチングされるよりも、~をつけなければマッチングしないという仕組みの方がわかりやすいですし、「型制約を満たす」ことが「実装する」と同義になる点もスッキリして良いと思います。

この記事を読んで興味が湧いたら元のProposalも読んでみてください。筆者もまた読み直します。というか、昨日存在を知って勢いでこの記事を書いたので、読み直してこの記事の間違っているところを探さないといけません。皆様も何かこの記事に誤りや疑問点・わかりにくいところがあればお気軽にコメントいただければありがたいです。

この記事に贈られたバッジ