この記事は 弁護士ドットコム Advent Calendar 2022 の3日目の記事になります。
Goでの列挙型どうしてますか?
こんにちは。
私は弁護士ドットコム株式会社のクラウドサイン事業本部で、Goを使ったバックエンドの開発に携わっています。
ところで、Goでの列挙型が欲しくなったとき、皆さんはどうしてますか?
私的には iota が便利なんでよく使ってきました。
例題:フルーツバスケットを作ろう
列挙型を使う例題として、フルーツの種類を列挙型でコード化して、それを集めてバスケットに詰めるということをやってみたいと思います。
今回使ったGoのバージョンは以下のものです。
% go version
go version go1.19.2 darwin/amd64
ファイルを以下のような配置にします。
.
├── fruit
│ └── code.go
├── go.mod
└── main.go
モジュール名は、今回サンプルということでローカルのみで使用する fruitbasket
にしてみます。
module fruitbasket
go 1.19
fruit/code.go でフルーツの種類を列挙型として定義します。
package fruit
// Code はフルーツの種類を表す型
type Code int
const (
Apple Code = iota + 1
Banana
Cherry
)
// Basket は Code を格納するバスケット
type Basket []Code
Apple
などの定義には iota を使うが、ゼロ値は未使用という形にするところも完璧ですね。
フルーツの定数定義ができたので main
でバスケットを作り、中に入っているものをプリントします。
package main
import (
"fmt"
"fruitbasket/fruit"
)
func main() {
basket := fruit.Basket{
fruit.Apple,
fruit.Banana,
fruit.Cherry,
}
for _, c := range basket {
fmt.Println(c, "in the basket.")
}
}
実行します。
% go run .
1 in the basket.
2 in the basket.
3 in the basket.
以上でフルーツの種類を列挙型で定義するという目的は一応果たせました。
iotaを使う場合の困りどころ
このように iota はコード化して列挙したいものを簡単に定義できて便利なものです.
ところが使っていると困ったと感じることも出てきました。
その1つは、上記の例の fruit.Code
の値のように定義された値が整数値なので、それをそのままログに出したりDBに保存したものを見た場合に、何を表すのかぱっとわからないことがあるということです。
もう1つは、一度 iota で定義した定数は、その定義の順序を変えることができないということです。定義順を変えてしまうと、それぞれの定数の値が変わってしまってプログラム全体に影響を及ぼしてしまうので、それは避けたいところです。
例えば上の例に Avocado
を追加したいと思ったら、一番最後に追加せざるを得ません。さもなくばプログラム全体の書き替えになってしまいます。
const (
Apple Code = iota + 1
Banana
Cherry
Avocado
)
定義順はアルファベット順にしておきたいと思っても、致し方なしということもあるわけです。
fruit.Code を文字列ベースにしてみよう
fruit.Code
の値が意味するところをわかりやすくして、定義順も自由にしたいと思ったら、文字列ベースの型に変更するというアイデアが一つあります。
ではやってみます。
...
// Code はフルーツの種類を表す型
type Code string
const (
Apple = Code("Apple")
Banana = Code("Banana")
Cherry = Code("Cherry")
)
...
% go run .
Apple in the basket.
Banana in the basket.
Cherry in the basket.
いいですね。プリントの結果もわかりやすくなりました。
ちなみに以下のような定義の書き方をしてしまうと Apple
は Code
型ですが、 Banana
と Cherry
は string
型になってしまいます。
注意が必要なところです。
...
// Code はフルーツの種類を表す型
type Code string
const (
Apple Code = "Apple" // Code
Banana = "Banana" // string
Cherry = "Cherry" // string
)
...
ここでAvocado を追加してみよう
さて、fruit.Code
型の定数がiotaを使わない定義になったので、 Apple
の行をコピペして修正すれば好きなところに Abocado
を追加定義できますね。
...
const (
Apple = Code("Apple")
Avocado = Code("Apple")
Banana = Code("Banana")
Cherry = Code("Cherry")
)
....
func main() {
basket := fruit.Basket{
fruit.Apple,
fruit.Avocado,
fruit.Banana,
fruit.Cherry,
}
for _, c := range basket {
fmt.Println(c, "in the basket.")
}
}
% go run .
Apple in the basket.
Apple in the basket.
Banana in the basket.
Cherry in the basket.
あれあれ? Apple
が2つ。
実はわざと Avocado
の定義の右辺を、コピペしたまま修正忘れという状態にしていました。
列挙型のベースに文字列型を使った場合の問題
列挙型のベースに文字列型を使うと自由度が増すのですが、その分、値を一つずつ手作業で定義する必要が出てきます。
また、その手作業のため typo の可能性があり、これはGoの文法チェックでは防げません。
そこで: stringer を使ってみる
stringer は整数定数に、その定数名と同じ文字列を返す String メソッドを定義するファイルを生成してくれるコマンドです。
これならば定義値となる文字列の typo を防げるのでは?
ということでstringer を使うような改造を施していきます。
fruit.Code
型の定数は元のiotaの定義に戻し、 stringer コマンドで fruit.Code.String
メソッドの定義ファイルを生成します。
stringer の実行には、go install golang.org/x/tools/cmd/stringer
でコマンドをインストールしておいてそれを実行すればいいのですが、今回は go generate
経由で、またコマンドのインストールをやらずにやってみます。
そのため fruit/code.go
に //go:generate
の行を差し込みます。
また、fruit.Backet
が保持する型を string
に変更しておきます。
package fruit
// Code はフルーツの種類を表す型
type Code int
//go:generate /usr/bin/env bash -c "echo generating strings for fruit.Code 1>&2"
//go:generate go run golang.org/x/tools/cmd/stringer -type=Code
const (
Apple Code = iota + 1
Banana
Cherry
)
// Basket はフルーツの種類を表す文字列を格納するバスケット
type Basket []string
もとのディレクトリで go generate ./...
などとやると、 //go:generate
で指定されたコマンドが実行されます。
//go:generate /usr/bin/env bash -c "echo ..."
の行はファイル生成のためには不要ですが、何やってるかわかるようにメッセージを出すためだけのものです。
ではやってみます。
% go generate ./...
generating strings for fruit.Code #<-- echo による出力
no required module provides package golang.org/x/tools/cmd/stringer; to add it:
go get golang.org/x/tools/cmd/stringer
fruit/code.go:7: running "go": exit status 1
エラーになりました。 golang.org/x/tools/cmd/stringer
のパッケージがないと言われてます。
メッセージ通り go get golang.org/x/tools/cmd/stringer
をすればいいんですが、今回はそうせずに、stringerへの参照を含む tools.go
を置いておくことにします。
//go:build tools
package tools
import (
_ "golang.org/x/tools/cmd/stringer"
)
//go:build tools
は tools.go ファイルをビルド対象に含めないためのタグの指定です。 tools
部分は何でもよくてビルド時に使わないタグを指定することでビルド対象から除外しておきます。
ここまでファイル構成がこうなりました。
.
├── fruit
│ └── code.go
├── go.mod
├── go.sum
├── main.go
└── tools.go <- New
tools.go の設置が終わったらパッケージの依存情報を更新するため go mod tidy
を実行します。
% go mod tidy
go: finding module for package golang.org/x/tools/cmd/stringer
go: found golang.org/x/tools/cmd/stringer in golang.org/x/tools v0.3.0
なにやらstringerが取り込まれたようです。
では、再度 go generate
をやってみます。
% go generate ./...
generating strings for fruit.Code
これで fruit/code_string.go
というファイルができてます。
.
├── fruit
│ ├── code.go
│ └── code_string.go <- New
├── go.mod
├── go.sum
├── main.go
└── tools.go
ここまで準備が整ったところで main
を修正します。 fruit.Basket
には fruit.Code.String
が返す文字列を追加します。
ちなみに、fruit.Code.String
の定義は先ほど生成されたfruit/code_string.go
の中にあります。
package main
import (
"fmt"
"fruitbasket/fruit"
)
func main() {
basket := fruit.Basket{
fruit.Apple.String(),
fruit.Banana.String(),
fruit.Cherry.String(),
}
for _, c := range basket {
fmt.Println(c, "in the basket.")
}
}
実行します。
% go run .
Apple in the basket.
Banana in the basket.
Cherry in the basket.
できてます。
ここでAvocado
の定数を好きな行に追加して、
...
const (
Apple Code = iota + 1
Avocado
Banana
Cherry
)
...
% go generate ./...
generating strings for fruit.Code
main
でもfruit.Avocado.String()
を追加します。
func main() {
basket := fruit.Basket{
fruit.Apple.String(),
fruit.Avocado.String(),
fruit.Banana.String(),
fruit.Cherry.String(),
}
for _, c := range basket {
fmt.Println(c, "in the basket.")
}
}
% go run .
Apple in the basket.
Avocado in the basket.
Banana in the basket.
Cherry in the basket.
うまくいきました。
文字列ベースの新しい列挙型を作る
さて文字列型を使った定数定義がなんとかできたところですが、 fruit.Backet
の定義をもう一度見てみましょう。
// Basket はフルーツの種類を表す文字列を格納するバスケット
type Basket []string
fruit.Basket
には文字列がなんでも入ってしまうので、この定義だと fruit.Code
という型を定義したことでの型チェックの恩恵を受けてないように思います。
そこで文字列ベースの列挙型として fruit.Type
を定義して、 fruit.Backet
の定義を fruit.Type
を集めたものに修正します。
そして fruit.Code
にもfruit.Type
型の値を返す Type
というメソッドを追加します。
そんなこんなで、ちょっとした工夫も追加して最終的にできたものは以下になります。
package fruit
import "fmt"
// Type はフルーツの種類を表す型
type Type string
// Code はフルーツの種類に対応したコード
type Code int
//go:generate /usr/bin/env bash -c "echo generating strings for fruit.Code 1>&2"
//go:generate go run golang.org/x/tools/cmd/stringer -type=Code
const (
invalid Code = iota
Apple
Avocado
Banana
Cherry
endOfCode
)
// Type は c を Type に変換したものを返す
func (c Code) Type() Type {
if c <= invalid || c >= endOfCode {
return Type(fmt.Sprintf("BadCode(%d)", c))
}
return Type(c.String())
}
// Basket は Type を格納するバスケット
type Basket []Type
package main
import (
"fmt"
"fruitbasket/fruit"
)
func main() {
basket := fruit.Basket{
fruit.Apple.Type(),
fruit.Avocado.Type(),
fruit.Banana.Type(),
fruit.Cherry.Type(),
}
for _, c := range basket {
fmt.Println(c, "in the basket.")
}
}
% go generate ./...
generating strings for fruit.Code
% go run .
Apple in the basket.
Avocado in the basket.
Banana in the basket.
Cherry in the basket.
おわりに
文字列の列挙型を作るべく、 const と iota を使うことで重複のない定数を定義し、それを stringer コマンドの結果で文字列化したものを、列挙用の型に変換して使うという試みをやってみました。
上記の例では fruit.Code
型の定数を直接使わず、かならず fruit.Code.Type
メソッドの結果を使うという約束事を守る注意点はあります。
それにしても stringer が生成するコードはコンパクトで素晴らしいと思います。さらにファイル生成後に元ファイルだけを変更してしまうとコンパイルエラーになり、これによってファイルの不整合が防がれる仕組みになっていて、こんな事ができるんだと本当に感心します。
説明がくどくなっちゃったかな、と思いつつ、ここまで長々とお付き合いいただきましてありがとうございました。こうしたほうがいいよとかありましたら教えていただけると嬉しいです。
明日は @Sympatisk_Hedgehog3 さんです。お楽しみに。