10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Goでの列挙型を文字列ベースで作ってみる?

Last updated at Posted at 2022-12-02

この記事は 弁護士ドットコム 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 にしてみます。

go.mod
module fruitbasket

go 1.19

fruit/code.go でフルーツの種類を列挙型として定義します。

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 の値が意味するところをわかりやすくして、定義順も自由にしたいと思ったら、文字列ベースの型に変更するというアイデアが一つあります。

ではやってみます。

fruit/code.go
...
// 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.

いいですね。プリントの結果もわかりやすくなりました。

ちなみに以下のような定義の書き方をしてしまうと AppleCode 型ですが、 BananaCherrystring 型になってしまいます。
注意が必要なところです。

fruit/code.go
...
// Code はフルーツの種類を表す型
type Code string

const (
	Apple  Code = "Apple"  // Code
	Banana      = "Banana" // string
	Cherry      = "Cherry" // string
)
...

ここでAvocado を追加してみよう

さて、fruit.Code型の定数がiotaを使わない定義になったので、 Apple の行をコピペして修正すれば好きなところに Abocado を追加定義できますね。

fruit/code.go
...
const (
	Apple  = Code("Apple")
	Avocado = Code("Apple")
	Banana = Code("Banana")
	Cherry = Code("Cherry")
)
main.go
....
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 に変更しておきます。

fruit/code.go
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 を置いておくことにします。

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の中にあります。

main.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の定数を好きな行に追加して、

fruit/code.go
...
const (
	Apple Code = iota + 1
	Avocado
	Banana
	Cherry
)
...
% go generate ./...
generating strings for fruit.Code

mainでもfruit.Avocado.String()を追加します。

main.go
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 の定義をもう一度見てみましょう。

fruit/code.go
// Basket はフルーツの種類を表す文字列を格納するバスケット
type Basket []string

fruit.Basketには文字列がなんでも入ってしまうので、この定義だと fruit.Code という型を定義したことでの型チェックの恩恵を受けてないように思います。

そこで文字列ベースの列挙型として fruit.Type を定義して、 fruit.Backet の定義を fruit.Type を集めたものに修正します。
そして fruit.Code にもfruit.Type型の値を返す Type というメソッドを追加します。

そんなこんなで、ちょっとした工夫も追加して最終的にできたものは以下になります。

fruit/code.go
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
main.go
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.

おわりに

文字列の列挙型を作るべく、 constiota を使うことで重複のない定数を定義し、それを stringer コマンドの結果で文字列化したものを、列挙用の型に変換して使うという試みをやってみました。
上記の例では fruit.Code 型の定数を直接使わず、かならず fruit.Code.Type メソッドの結果を使うという約束事を守る注意点はあります。

それにしても stringer が生成するコードはコンパクトで素晴らしいと思います。さらにファイル生成後に元ファイルだけを変更してしまうとコンパイルエラーになり、これによってファイルの不整合が防がれる仕組みになっていて、こんな事ができるんだと本当に感心します。

説明がくどくなっちゃったかな、と思いつつ、ここまで長々とお付き合いいただきましてありがとうございました。こうしたほうがいいよとかありましたら教えていただけると嬉しいです。

明日は @Sympatisk_Hedgehog3 さんです。お楽しみに。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?