🦜

GoとEnumについて

2022/12/21に公開2

これはGo Advent Calendar 2022の21日目の記事です。

たびたび「Enumイディオム」がGoに欲しいという要望が話題になるのでその話をまとめてみる。
議論の中心はここが詳しい。

https://github.com/golang/go/issues/19814

「Enumイディオム」が解決する課題

  1. 文字列表現がつく
  2. 反復処理ができる
  3. 偽の列挙値を導入することを防ぐ
  4. 名前空間を分離する

などがあるらしい。上にあげた課題がGo標準では解決しにくいという理由がよく上がるのですが、Enumイディオムの追加方法は何パターンかあります。

  • Enum型プリミティブを言語仕様に加える
  • ライブラリとしてEnumオブジェクト実装を提供する
  • 現状の推奨「iotaによる手法+go-generate」による方法

「Enum型プリミティブを言語仕様に加える」について

  • 言語実装のアップデートが必要
  • reflectパッケージのアップデートが必要
  • 後方互換のためiota式とEnum式の2択がずっと残る
  • 周辺ツールのアップデートが必要(gofmt、gopls、その他静的解析系)

というような課題が残ります。

「ライブラリとしてEnumオブジェクト」について

  • メモリフットプリントの増大
  • コンパイラ支援がないとそんなに簡潔に使えない

というような課題が残ります。

現状の推奨「iotaによる手法+go-generate」について

  • 課題における1.や2.についてはこの方法で追加することができます。
  • 課題における3.はGoの仕様上暗黙の変換で意図せず偽の値を作ってしまうことは発生しにくい。
  • 課題における4.についてもGoの仕様上パッケージプレフィックスで名前空間は分離される。

つまり、今のGoはEnumイディオムが解決してくれるほとんどを現状のまま解決可能ではあるのです(ベストではないにしろベターに解決)。

iotaベストプラクティス

iota単体だと課題の1.と2.は支援がなく、3.と4.は言語仕様が支援してくれるというのが現状なんですが、以下のツールを使うことで補完できます。

stringerは課題の1.を解決します。enumerは1.と2.を解決します。

あと、iota値を定義するときに必ずdefined typeを定義しましょう。

stringerを使う例

> go install golang.org/x/tools/cmd/stringer@latest
// go:generate stringer -type Status
type Status int // defined type
const (
	NONE Status = iota
	HOGE
	MOGE
)

enumerを使う例

> go install github.com/alvaroloes/enumer@latest
// go:generate enumer -type Status -json
type Status int // defined type
const (
	NONE Status = iota
	HOGE
	MOGE
)

enumerの出力例

status_enumer.go
// Code generated by "enumer -type Status -json"; DO NOT EDIT.

package main

import (
	"encoding/json"
	"fmt"
)

const _StatusName = "NONEHOGEMOGE"

var _StatusIndex = [...]uint8{0, 4, 8, 12}

func (i Status) String() string {
	if i < 0 || i >= Status(len(_StatusIndex)-1) {
		return fmt.Sprintf("Status(%d)", i)
	}
	return _StatusName[_StatusIndex[i]:_StatusIndex[i+1]]
}

var _StatusValues = []Status{0, 1, 2}

var _StatusNameToValueMap = map[string]Status{
	_StatusName[0:4]:  0,
	_StatusName[4:8]:  1,
	_StatusName[8:12]: 2,
}

// StatusString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func StatusString(s string) (Status, error) {
	if val, ok := _StatusNameToValueMap[s]; ok {
		return val, nil
	}
	return 0, fmt.Errorf("%s does not belong to Status values", s)
}

// StatusValues returns all values of the enum
func StatusValues() []Status {
	return _StatusValues
}

// IsAStatus returns "true" if the value is listed in the enum definition. "false" otherwise
func (i Status) IsAStatus() bool {
	for _, v := range _StatusValues {
		if i == v {
			return true
		}
	}
	return false
}

// MarshalJSON implements the json.Marshaler interface for Status
func (i Status) MarshalJSON() ([]byte, error) {
	return json.Marshal(i.String())
}

// UnmarshalJSON implements the json.Unmarshaler interface for Status
func (i *Status) UnmarshalJSON(data []byte) error {
	var s string
	if err := json.Unmarshal(data, &s); err != nil {
		return fmt.Errorf("Status should be a string, got %s", data)
	}

	var err error
	*i, err = StatusString(s)
	return err
}
  • 「String() string」が実装されfmt.Stringerインターフェースを満たすのでfmt出力にて文字列化される
  • StatusValues()で列挙値のスライスを取得できる
  • JSONのマーシャラー、アンマーシャラーがあることでJSON上でヒューマンリーダブルな文字列として扱われ、未知の文字列を読もうとするときちゃんとエラーになる
  • 「StatusString(文字列)」により文字列を列挙値にパースできる

Enumイディオム+パターンマッチ

  • モダンな言語にはEnumイディオム+パターンマッチを備えたものがあります。
  • この場合、新しく列挙値を増やしたとき、対応側にてパターンマッチのケース記述が不足している場合にコンパイルエラーにできるという大きなメリットがあります。
  • 同様の検査を行うのに以下のツールが使えます。

https://github.com/elliotchance/switch-check

type Foo int

const (
	FooA Foo = iota
	FooB
)

const FooC = Foo(17)
const FooD = FooC
var FooE = Foo(-1)

func fooMissingSomeValues() {
	foo := FooB

	switch foo {
	case FooA:
	case FooC, FooD:
	}
}

以上のようにケース不足な実装に対しこのツールで検査をすると、

./foo.go:33:2 switch is missing cases for: FooB, FooE

というようにFooBやFooEに対応するケースが無いというエラーを得ることができます。

まとめ

  • Enumやパターンマッチを言語に追加するのはあちこちにコストがかかって現実的でない。
  • iotaベストプラクティスに沿って書けばベストではないがベターに列挙型を実装できます。
  • なので、Enumイディオムはおそらく今後もGo言語に追加されることはないと予想します。
  • Enum+パターンマッチは強力だけど同様の検査をするツールがGoにはあります。
  • なのでパターンマッチがGoに追加される未来も無さそう。
  • Goはコンパイルタイムよりも静的解析ツールに頼る傾向があり、CI/CDに静的解析ツールを組み込むことはわりと受け入れられている。
  • Goはコード生成におけるデメリットやわずらわしさをかなり削った設計を意図的に行っており、コード生成は他の言語処理系に比べ割と拒否感はありません。

最後の2点は他の言語処理系に慣れた人は初見でびっくりするポイント(僕もはじめのころにびっくりしました)でもありますが、Goに入らばGoに従っていくと「割と悪くはない」というのが僕の所感です。

Discussion