Go の "Type Sets" proposal を読む
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言語仕様用語
- underlying types ←一番重要です。これ以外は知らなくても一応この記事は理解できると思います。
- method sets
-
型の同一性(type identity)
- @syumaiさんの記事に詳しいです
-
代入可能性(assignability)
- yyh-glさんの資料で解説されています
-
defined type
- 筆者の発表資料で解説しています。この概念は仕様書だとわかりにくいと思います。
サマリー
- 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のパラメータは型制約で許された演算しかできません。
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つになっているようなので、後に改めて触れたいと思います。
これを用いると、初めにやりたかったことは次のように実現できます。
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を含むインタフェース型は、型制約としてのみ用いることができて、通常の変数宣言や関数宣言において使うことはできない」とされています。
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 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
混乱する人が多いのではないでしょうか?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(s 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 int
とtype MyString string
があるとき、この2つのいずれかに「一致する」型は次のように定義できます。
type T interface {
type MyInt, MyString
}
この型にマッチする型はMyInt
とMyString
の他にはありません。type MyMyInt MyInt
というような型があったとしても、MyMyInt
のunderlying typeはint
であってMyInt
ではありませんから、underlying type matchingは成り立たないからです。要するに、MyInt
とMyString
をunderlying typeに持つような型は存在しないのです。
では、int
とstring
のいずれかにマッチし、それ以外のいかなる型にもマッチしないインタフェースは定義できるでしょうか?
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
が従来の(メソッド定義のみを持つ)インタフェース型である場合は、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
がinterfaceIT
を実装するための条件は、T
のmethod setがIT
のmethod setを含む集合であることです。
Type Sets Proposalでは、この条件を次のように言い換えます。
型
T
がinterfaceIT
を実装するための条件は、T
が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
を満たすのは、A
がC
を実装するときだ」もしくは「A
がC
の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}
であり、int
はInteger
を実装します。
この時点で、interface type listと異なっていることがわかると思います。ここではunderlying type matchingが行われていません。書いてある型に一致すればよく、一致しなければだめ、という基準になっています。
approximation element
それでは逆に、underlying type matchingをさせたいときはどうすれば良いでしょう?そのためには、新しい文法要素~
を明示的に用いよ、というのがこのproposalです。
approximation element ~T
のtype setは、「underlying typeがT
に等しい全ての型からなる集合」です。
例を挙げましょう。
type AnyInt interface {
~int
}
このAnyInt
のtype setは、underlying typeがint
である全ての型です。例えばtype MyInt int
と定義されたMyInt
はAnyInt
の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)
解答例
その型のunderlying typeがint8, int16, int32, int64
のいずれかになるような全ての型からなる集合です。
例えば、type MyInt int64
のように定義したMyInt
がこのtype setの要素になります。
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(s Sum) {
switch s.(type) {
case nil, uint, int:
return
}
panic("missed type")
}
今度はA
型の値がSum
型の変数s
に代入不可能なので混乱することはありません。
インタフェース型Sum
の型セットは{int, uint}
ですが、A(1)
はA
型の値のため、Sum
型の変数であるs
には代入できません。
表現力
interface type listには、int
とstring
だけにマッチする型を定義できないという問題がありました。もちろんこれは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も読んでみてください。筆者もまた読み直します。というか、昨日存在を知って勢いでこの記事を書いたので、読み直してこの記事の間違っているところを探さないといけません。皆様も何かこの記事に誤りや疑問点・わかりにくいところがあればお気軽にコメントいただければありがたいです。
Discussion
こちらの最初の例、
interface
が抜けちゃってます!本当ですね!ありがとうございます、修正反映します!