<-- mermaid -->

stringerの出力コードに見る、Go言語の奥深さ

はじめまして。AlphaDrive Product Division の fmatzy です。

AlphaDrive では、新規プロダクトの開発に Go を採用しています。

Go はシンプルな言語機能によって高い生産性を実現するという思想の言語です。本ブログでは、Go のシンプルな言語機能ゆえの奥深さに感動した話を紹介します。

Go における enum (っぽいもの) を実現する仕組み

Go はシンプルな言語仕様ゆえに、他のプログラミング言語には見られるような機能がなかったりします。例えば、enum (列挙型) という機能は言語仕様として存在しません。その代わり、型定義や const 定義、iota を組み合わせて同様の目的を達成します。

type Fruit int

const (
    Apple Fruit = iota
    Banana
    Orange
)

上記の Apple はあくまで整数であるため、 fmt.Println(Apple) としても、0 としか表示できません。ここで使えるのが fmt.Stringer インターフェースで、このインターフェースが実装されていれば、fmt.Println() 内で自動的に String() メソッドが呼ばれ、文字列に変換できます。

func (i Fruit) String() string {
    switch i {
    case Apple:
        return "Apple"
    case Banana:
        return "Banana"
    case Orange:
        return "Orange"
    default:
        return "unkown value"
    }
}

func main() {
    fmt.Println(Apple) //Output: Apple
}

Go では、上記のようなコードを自動生成するツールが公式に提供されており、それが stringer です。

pkg.go.dev

言語機能として取り込むのではなく、コード生成によってコード上に現れるようにするのが Go らしいアプローチですね。ツールが公開されたのは 2014 年 (最初のコミットは Rob Pike 氏) です。

stringer で出力されるコードの奥深さ

上記で示した Fruit 型を例に、stringer でコードを生成するには、下記のようにコマンドを実行します。

$ stringer -type Fruit

出力されるコードは下記のようになります。

// Code generated by "stringer -type Fruit"; DO NOT EDIT.

package main

import "strconv"

func _() {
    // An "invalid array index" compiler error signifies that the constant values have changed.
    // Re-run the stringer command to generate them again.
    var x [1]struct{}
    _ = x[Apple-0]
    _ = x[Banana-1]
    _ = x[Orange-2]
}

const _Fruit_name = "AppleBananaOrange"

var _Fruit_index = [...]uint8{0, 5, 11, 17}

func (i Fruit) String() string {
    if i < 0 || i >= Fruit(len(_Fruit_index)-1) {
        return "Fruit(" + strconv.FormatInt(int64(i), 10) + ")"
    }
    return _Fruit_name[_Fruit_index[i]:_Fruit_index[i+1]]
}

コードの後半は、String() メソッドで文字列変換するための各種定数と、String() の実装となっています。特に注目したいのは、コードの前半部分です。

func _() {
    // An "invalid array index" compiler error signifies that the constant values have changed.
    // Re-run the stringer command to generate them again.
    var x [1]struct{}
    _ = x[Apple-0]
    _ = x[Banana-1]
    _ = x[Orange-2]
}

この関数宣言には blank identifier (_) が使われています。blank identifier の使用例は Effective Go でも紹介されている通りで、変数の代入や import で自分もお世話になっていますが、関数宣言に使えるというのはこのコードを見て初めて知りました。blank 宣言された関数の場合、コンパイル結果には一切含まれないようです。Go コンパイラ自身のコードでは、構文エラーをチェックするテストコードなどで使われていました。

また、定数の変化を検出するために、長さ 1 の配列の index が使われています。数値のバリデーションといえば等号・不等号での比較や if 文が思いつきますが、当然実行時のバリデーションになってしまいます。一方、配列の index であればコンパイル時に invalid index として検出することができます。少しハック的ですが、Go コンパイラが配列の index 範囲をチェックしてくれることを使った非常にクレバーな実装です。

出力コードにこの箇所が追加されたのは、2019 年のことです。元々の実装では、定数の値が変わった状態で stringer の再実行を忘れると、誤った String() の値が出力されるようになっていましたが、この修正によりコンパイルエラーとして検出されるため、stringer の再実行を忘れることがなくなりました。コードレビュー履歴を見ると、Rob Pike 氏がコンパイラの生成結果に影響を与えないことや、コンパイルエラーによってユーザーがバグと勘違いしないかを気にしており、パフォーマンスやシンプルさを重視する姿勢が伝わってきます。

さて、実際に、元となっている Fruit の値を下記のように変更すると、

type Fruit int

const (
    Apple Fruit = iota
    Banana
    Lemon // Added
    Orange
)

実行時に下記のようにコンパイルエラーとなります。エラーになるコードを見ると Re-run the stringer command to generate them again. とあるので、stringer の再実行が必要なことが明白ですね。

$ go run .
# stringer-example
./fruit_string.go:13:8: invalid argument: index 1 out of bounds [0:1]

おわりに

Go の言語機能はシンプルですが、使い方によってコンパイル結果に影響を与えずにコンパイルエラーを検出するという問題解決ができるのは奥深いですね。まだまだ知らない言語機能の使い方があるというのは非常に学びになりました。

実際に開発をする上でも、言語やライブラリに理解を深めて生産性の高い開発に繋げていければと思っています。

AlphaDrive では、Go を用いてプロダクトを開発する仲間を募集中です!

www.wantedly.com

Page top