unexportedなmethodを持つGoのinterfaceとsum type

何か書けと後輩に詰められたので、寝る前に雑に調べごとをしてメモを残しておくことにする。 これは8日目。

adventar.org

Goのinterfaceに書けるメソッド名にはidentifierが指定されているだけで特に制限が無い。 つまり、unexportedなメソッドを定義することができる。

InterfaceType      = "interface" "{" { ( MethodSpec | InterfaceTypeName ) ";" } "}" .
MethodSpec         = MethodName Signature .
MethodName         = identifier .
InterfaceTypeName  = TypeName .

( https://golang.org/ref/spec#Interface_types より )

周りのコードを見渡してみると、例えば reflect.Type なんかはこれに該当する。

type Type interface {
  ...
  common() *rtype
  uncommon() *uncommonType
}

( https://github.com/golang/go/blob/ac0ba6707c1655ea4316b41d06571a0303cc60eb/src/reflect/type.go#L27-L214 より )

ちなみにgo docは通常exportedな情報しか表示してくれないが、-uをつけるとちゃんとunexportedの情報も見える。

こういったinterfaceはunexportedなmethodを持つため、(もちろんunexportedな型を返り値に持っているというのもあるが)パッケージの外でこれを満たす型を定義することはできない。

package foo

type A interface {
    Hoge() int
}

type B interface {
    Hoge() int
    piyo() int
}
package main

import (
    "fmt"

    "github.com/taxio/playground/foo"
)

type X struct{}

func (x *X) Hoge() int { return 0 }

func NewA() foo.A { return &X{} }

type Y struct{}

func (y *Y) Hoge() int { return 0 }
func (y *Y) piyo() int { return 0 }

func NewB() foo.B { return &Y{} }

func main() {
    fmt.Println(NewA())
    fmt.Println(NewB())
}
❯ go build
# github.com/taxio/playground
./main.go:20:28: cannot use &Y literal (type *Y) as type foo.B in return argument:
        *Y does not implement foo.B (missing foo.piyo method)
                have piyo() int
                want foo.piyo() int

調べてみるとこういうのをSealed Interfaceというらしい。 続けて「sum typeをエミュレートするのにも使える」と記述されている。

-- https://blog.chewxy.com/2018/03/18/golang-interfaces/
Sealed interfaces can only be discussed in the context of having multiple packages. A sealed interface is an interface with unexported methods. This means users outside the package is unable to create types that fulfil the interface. This is useful for emulating a sum type as an exhaustive search for the types that fulfil the interface can be done.

sum typeというのはどうやら代数的データ型というトピックの中で出てくるものらしく1、Tagged Unionなど様々な呼ばれ方があるらしい2 (wikipedia調べ)。

Tagged Unionで適当にggってると、TypeScriptの記事がよく目につく。

これはTypeScript 2.0で入った機能らしく、これを応用することでそれぞれのメンバが条件的に網羅されているかを検証できるらしい3。 確かに言われてみれば、switch文のdefault節の中にneverで網羅性をチェックするコードを書いた記憶がある。

凄い、知らない間に代数的データ型の恩恵を受けていたのか。 数学的なバックグラウンドは全く分からないので、今度会社の人に優しそうな型の入門書を聞いてみよう4

さて、こんな便利な機能がGoでも使える可能性があると分かったのであれば試すほかない。 先の記事で紹介されていたgo-sumtypeのREADMEを眺めながら手元で動かしてみる。

github.com

❯ go get -u github.com/BurntSushi/go-sumtype

サンプルのコードを少しいじって以下のようにしてみた。 構造体A,BはインターフェースXを満たす。

package main

import "fmt"

//go-sumtype:decl X
type X interface {
    sealed()
}

type A struct{}

func (a *A) sealed() {}

type B struct{}

func (b *B) sealed() {}

func NewXA() X {
    return &A{}
}

func main() {
    x := NewXA()
    switch x.(type) {
    case *A:
        fmt.Println("A!")
    case *B:
        fmt.Println("A!")
    default:
        panic("unreachable")
    }
}

この状態ではgo-sumtypeは怒らない。

❯ go-sumtype main.go

しかし*Aのcaseを除くと、

...
    switch x.(type) {
    // case *A:
    //     fmt.Println("A!")
    case *B:
        fmt.Println("A!")
    default:
        panic("unreachable")
    }
...
❯ go-sumtype main.go
~/go/src/github.com/taxio/playground/main.go:24:2: exhaustiveness check failed for sum type 'X': missing cases for A

しっかり条件が網羅されていないことを報告してくれた。

これを応用すればもしかして網羅的なerrorチェックみたいな面白いことができる...??? ( 眠い頭で適当なことを言っています。)

おわりに

パッと調べただけだったが、思ったより面白い話が見つかった。 もうちょっとちゃんと調べて年末の技術書典で記事を出そうかな。知らんけど。

ではおやすみなさい。