It Made My Day

Acutally, my whole life is just one big yak shaving exercise.

(Go) 設定ファイルのパスを相対パスにした場合、設定を使用する他のレイヤでの単体テストに困った話

書いた後にこの↓記事と言いたいことが丸被っていたことに気づいた。

zenn.dev

この記事の内容はディレクトリ構成によって挙動が変わるため、実際にどう動くのか検証しやすいよう、サンプルコードを公開したので、よかったらcloneして動かしてみてください。 READMEに遊び方を記載しておきました

xxx.toml: no such file or directory

業務で、ヘキサゴナルアーキテクチャでGoのアプリを開発し、設定ファイルをTOMLで管理することが多い。

ディレクトリ構成(イメージです)

├── adapter
│   ├── driven
│   │    └── db.go                configパッケージを呼び出す
│   └── driver
│       ├── get_something_test.go driven adapterを初期化する処理を含む単体テスト。設定ファイルがないと言われ失敗する
│       └── get_something.go      driven adapterを初期化し、処理を行う
...
├── config
│   ├── environmtnes               設定ファイル
│   │    ├── dev.toml
│   │    ├── local.toml 
│   │    ├── prd.toml
│   │    └── stg.toml
│   └── config.go                 tomlファイルをパースして、構造体にしている
...
└── main.go                       エントリポイント

config ディレクトリの設定ファイルを環境準備し、環境変数で指定された環境名を使ってファイルを読み込む処理を下記のように書いている。
docker の中だけで動かす場合には、Dockerfile内でTOMLファイルをコピーしてGoでは絶対パス指定でファイルをOpenすれば良いのかもしれないが、Goのコードをローカル環境でシュッと動かしたいとき(特に単体テスト実行時など)があるので、相対パスでファイルを指定している。

config/config.go の、tomlファイルをパースする処理

type Config struct {
    xxx string `toml:"xxx"`
}

func NewConfig() Config {
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }
    filepath := "./config/environments/" + env + ".toml"
    file, err := os.Open(filepath)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    conf := &Config{}
    _ = toml.NewDecoder(file).Decode(conf)
    return *conf
}

このconfigパッケージのコンストラクタである NewConfig 関数は、adapterディレクトリにあるパッケージから呼び出される。

このコードの場合、ルートディレクトリからmain.goを実行すると正しく設定ファイルを読み取れるが、adapterディレクトリ下に単体テストを書いた場合、単体テストにおいてはそんなファイルないよ、というエラーになってしまう。

これはファイルパスの指定が相対パスであるために起こっており、カレントディレクトリがどこなのかが実行場所によって変わることが原因。 Goカレントディレクトリをどこだと認識しているのかは、下記のようなコードで確認できる。

   currentDir, _ := os.Getwd()
    fmt.Printf("current directory: %s\n", currentDir)

go run main.go時と単体テスト時で、出力されたcurrent directoryが異なるのがわかる↓

単体テストのカレントディレクトリを移動させた

Goでは、カレントディレクトリが実行ファイルを起動した場所に固定される。 そのため、アプリケーションのエントリポイントと単体テストの実行パッケージでカレントディレクトリが異なる。 そこで、TestMainでテスト実行時のカレントディレクトリを移動させることで解決した。

adapter/driver ディレクトリ下に、下記のTestMainを定義した。

import (
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    repoRoot := "../.."
    err := os.Chdir(repoRoot)
    if err != nil {
        os.Exit(1)
    }

    exitCode := m.Run()
    os.Exit(exitCode)
}

冒頭の記事でも言及されていたが、カレントディレクトリを元に戻したい時はそのような処理を書いておくと良さそう。

Go で API をキャッシュする 4 つの実用的な方法

この↓記事を見かけたので、キャッシュの方法をまとめる。

www.freecodecamp.org

本記事の半分程度はこの↑記事の内容を参照しているが、調べて追加・修正している部分があるのと、最近 singleflight が気になっていたのでそこは動くようにサンプルコードを書いた。

キャッシュ、大事だよね

API利用が増えたとき、すべてのリクエストがデータベースにアクセスし、同じデータを処理したり、同じ JSON を何度もシリアライズしたりすると、レイテンシが徐々に増加し、スループットが低下する。キャッシュは、既に完了した処理を保存し、将来のリクエストで即座に再利用できるようにすることで、高いパフォーマンスを維持するツール。

以下、freeCodeCampの記事 How to Cache Golang API Responses for High Performance で紹介されていた4つの方法を紹介し、その内容について調べたことを多少補足する。

方法1: ローカルおよびRedisストレージによるHTTPレスポンスのキャッシュ

API レスポンスを生成するプロセスのコストが高くなる場合、最も速い解決策はレスポンス全体を保存すること。Redis を使用すると、複数の API サーバーで同じキャッシュされたレスポンスを共有できる。 Goでは、この2層構成は通常、キャッシュアサイドパターンに従う。 つまり、まずローカルメモリを参照し、必要に応じてRedisにフォールバックし、両方の層で結果が見つからない場合にのみ結果を計算する。 計算が完了すると、値はRedisに保存され、すべてのユーザーとメモリに保存され、次の呼び出しですぐに再利用される。

疑似コード

val, ok := local.Get(key)  // ローカルキャッシュ
if !ok {
    val, err = rdb.Get(ctx, key).Result()  // Redis
    if err == redis.Nil {
        val = computeResponse() // expensive DB or logic
        _ = rdb.Set(ctx, key, val, 60*time.Second).Err()
    }
    local.Set(key, val, 1)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(val))

出処:How to Cache Golang API Responses for High Performance

上記のコードでは、まずローカルキャッシュからレスポンスを取得しようとする。 キーまたはデータが存在する場合は、即座にレスポンスが返される。 見つからない場合は、第2層としてRedisにクエリを実行する。 Redisからも何も返されない場合は、高負荷な計算が実行され、その結果はRedisに保存される。 これは他のサービスがアクセスできるように60秒の有効期限が設けられている。 その後、ローカルキャッシュに格納され、すぐに再利用できるようになる。 その後、レスポンスはJSON形式でクライアントに書き戻される。

これにより、繰り返しの呼び出しに対する超高速応答と、すべての API サーバーにわたる一貫したキャッシュという、両方のメリットが得られる。

方法2: Redisストレージによるデータベースクエリ結果のキャッシュ

2つ目は、APIの処理の中でもDBに対するクエリのパフォーマンスのボトルネックとなっている場合を前提とし、HTTPレスポンスではなく、DBクエリの結果をRedisにキャッシュする。 次のリクエストが来たら、Redisから結果を取得し、データベースを経由せずに応答を高速化する。

key := fmt.Sprintf("q:UserByID:%d", id)
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
    var u User
    _ = json.Unmarshal(b, &u)
    return u
}

u, _ := repo.GetUser(ctx, id) // real DB call
bb, _ := json.Marshal(u)
_ = rdb.Set(ctx, key, bb, 2*time.Minute).Err()
return u

ここでは、ユーザーIDを使用してクエリを一意に識別するキャッシュキーを作成し、Redisからシリアル化された結果の取得を試みる。 キーが存在する場合、バイト列をUser構造体にデシリアライズし、データベースにアクセスすることなく即座に戻り値を返す。 キャッシュミスが発生した場合は、リポジトリを介して実際のデータベースクエリを実行し、UserオブジェクトをJSONにシリアル化し、2分間の有効期限でRedisに保存して結果を返す。

このパターンにより、読み取り負荷の高い API のデータベース負荷と応答時間が大幅に削減されるが、データが変更されたときにエントリをクリアまたは更新することや、結果を適度に最新の状態に保つために有効期限の値を短く設定することを忘れないようにする必要がある。

方法3: ETag と Cache-Control を使用した HTTP キャッシュ

HTTP標準には、クライアントやCDNがレスポンスを再利用するためのツールとしてETagCache-Controlのようなヘッダーがある。 これを設定することで、レスポンスに変更があったかどうかをクライアントに通知できる。 リソースに変更がない場合、サーバーは軽量の304(Not Modified)レスポンスのみを送信する。

Go では、レスポンス本文から ETag を計算し、それをクライアントが送信した内容と比較し、ペイロード全体を返すか、304 だけを返すかを決定する。

etag := computeETag(responseBytes)
if match := r.Header.Get("If-None-Match"); match == etag {
    w.WriteHeader(http.StatusNotModified)
    return
}

w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=60")
w.Write(responseBytes)

上記のコードは、レスポンスコンテンツのフィンガープリントまたはハッシュであるETagを生成し、クライアントがIf-None-Match以前のリクエストで送信したETagと一致するヘッダーがあるかどうかを確認する。 ETagが一致する場合、コンテンツは変更されていないため、サーバーは304 Not Modifiedステータスを返してレスポンス本文を送信せず、帯域幅を節約できる。 ETagが一致しない場合、またはクライアントがキャッシュバージョンを持っていない場合、サーバーは新しいETagと、Cache-Control60秒間のパブリックキャッシュを許可するヘッダーを付加し、レスポンス全体を送信する。

このアプローチにより、帯域幅が削減され、CPU 使用率が低下し、応答を直接キャッシュして提供できる CDN とうまく連携する。

方法4: バックグラウンド更新付きStale-While-Revalidate

APIの高速性を維持するのであれば、多少古いデータを提供しても許容されるケースがある。 株価ダッシュボード、アナリティクスサマリー、フィードエンドポイントなどは、このモデルによく当てはまるだろう。 ユーザーにリクエストごとに最新のデータを待たせる代わりに、キャッシュされた値を即座に提供し、バックグラウンドで自動的に更新することができる。この手法はStale-While-Revalidateと呼ばれる。

Goでは、キャッシュデータだけでなく、データがいつ最新の状態か、いつ古いデータとして提供できるか、そしていつ再計算する必要があるかを定義するタイムスタンプも保存することで、これを実現できる。

疑似コード

entry := getEntry(key) // {data, freshUntil, staleUntil}
switch {
case time.Now().Before(entry.freshUntil):
    return entry.data
case time.Now().Before(entry.staleUntil):
    go refreshSingleflight(key) // キャッシュをバックグラウンドでリフレッシュ
    return entry.data
default:
    return refreshSingleflight(key) // キャッシュを同期的にリフレッシュ
}

ここで、コードはキャッシュエントリを取得し、その中にデータと、鮮度と古さの境界を示す2つのタイムスタンプが含まれている。 現在時刻が鮮度しきい値より前であれば、データは完全に新鮮であるとみなされ、即座に返される。 鮮度しきい値は過ぎているものの、staleの期間内であれば、コードはわずかに古いデータを即座に返すと同時に、バックグラウンドのゴルーチンを起動して非同期的に更新することで、次のリクエストで最新の情報を取得できるようにする。 さらに、時間が古さの境界を超えると、データは古すぎて提供できないため、コードはブロックし、同期更新を実行してから返す。

これにより、キャッシュが定期的に更新され、鮮度とパフォーマンスのバランスが保たれながら、レイテンシが低く抑えられる。

ただ、これ↑だけだとsingleflightパッケージの使い方や実際何やってんのかが微妙にわからんので、動くコードを書いてみた。 できるだけコメント内容から処理を追えるようにしてみたので、興味があれば動かしてコメントから処理の流れを追ってみてほしい。

私が書いたサンプルコードでは、refreshSingleflight関数を下記のように定義している。

var sg singleflight.Group
...

func refreshSingleflight(key string) (string, error) {
    v, err, _ := sg.Do(key, func() (any, error) {
        newData := expensiveCompute(key)
        entry := &cacheEntry{
            data:       newData,
            freshUntil: time.Now().Add(freshDuration),
            staleUntil: time.Now().Add(staleDuration),
        }
        setEntry(key, entry)
        return newData, nil
    })
    return v.(string), err
}

Go Playground

singleflightパッケージは、重い処理を1つのgoroutineのみで実行するようにし、同時にリクエストされた他の処理にはキャッシュを返すことで処理の重複を防ぐ。 例えば、上記のサンプルコードのexpensiveCompute関数に数秒かかったとして、その処理の間に5回リクエストが来たとしても、実際にexpensiveComputeを実行するのは最初の1リクエストのみとなる。 Go Playgroundのサンプルコードでは、sg.Doの関数内にコメントを打ち、実行回数がわかるようにしておいた。

まとめ

キャッシュは単一の戦術ではなく、様々なニーズに対応する複数の戦略から成る。 フルレスポンスキャッシュは、トップレベルでの繰り返し処理を排除し、クエリ結果キャッシュは、データベースへの繰り返しロードを防ぐ。 HTTPキャッシュは、プロトコルを活用してデータ転送量を削減する。 Stale-While-Revalidateは、データの古さを長期間残すことなく、速度を優先する妥協策。

実際には、これらのアプローチは階層化されることが多い。Go API では、レスポンスにローカルメモリと Redis を使用し、ホットテーブルにはクエリレベルのキャッシュを適用し、クライアントが不要なダウンロードを回避するために ETag を設定するといったことが考えられる。

参考資料

pkg.go.dev

キャッシュアサイドとその他のキャッシュ戦略についてわかりやすく書かれている。

zenn.dev

developer.mozilla.org

HTTPのCache-Controleで、stale-while-revalidate をレスポンスディレクティブに指定することができる。

developer.mozilla.org

GoのGenerics の内部実装の設計:辞書 と Gcshape Stenciling

この設計書→Go 1.18 Implementation of Generics via Dictionaries and Gcshape Stenciling が最終的に採用されたもの(2022年3月までコミットされている)。 本記事はこの設計書の概要を極力わかりやすく説明し、読者がGo で Generics がどのような設計で実現されているか大枠を理解することを目的とする。そのため、詳細な設計や具体的な実装については(複雑になりすぎるため)割愛し、やや概念的な説明になっている。

型パラメータごとに異なるメソッドや演算をどう扱うか

Go の Genericsは、バイナリサイズを膨張させず、パフォーマンスも確保する方法として "辞書" と "Stenciling" のプロポーザルのハイブリッドな形式である、「辞書とGCShape Stenciling」 もしくは「GCShape Stenciling」と呼ばれる方式を採用した。

Genericsコンパイラ実装は、主に具体的な型を持つ引数で実行されるジェネリック関数およびメソッドのインスタンス化を行う。 ここで単純に型引数ごとに具象型を用いたコードを生成すると、コンパイル時間やバイナリサイズが増大してしまう。 そこで、純粋なステンシル処理を避け、辞書を使って1つの関数インスタンスを複数の型引数に対して実行できるようにし、コードサイズの節約とパフォーマンスの両立を実現している。

私がこの「ステンシル処理」という言葉の意味を正確に理解しているか怪しいのだが、 Stenciling のプロポーザル Generics implementation - Stenciling には「この手法では、ジェネリック関数の本体を、インスタンス化される型セットごとにインスタンス化することで、ジェネリック関数の複数の実装を生成する」と書かれており、つまり実際に利用できるコンポーネントを生成するような処理のことをステンシル処理と呼んでいると認識している(製図の分野では、同じ形を描くために使用する型をステンシルテンプレートと呼んでいるようだ)。

Genericsが具体的な型を把握するための方法は言語によって異なるため、他言語の方針について外部記事(Rustコンパイラ開発ガイド Monomorphization - Rust Compiler Development Guide など)から引用する。

例えば、Java などの一部の言語では、実行時まで値の最も正確な型がわからない場合がある。Java の場合、(ほぼ)すべての変数が参照値(つまり、ヒープに割り当てられたオブジェクトへのポインタ)であるため、これは問題ない。この柔軟性は、オブジェクトへのアクセスはすべてポインタを参照解除する必要があるため、パフォーマンスを犠牲にしている。 Rust は異なるアプローチを採用しており、すべてのジェネリック型をモノモーフィズム化する。つまり、コンパイラは必要な具体的な型ごとに、ジェネリック関数のコードの異なるコピーを作成する。例えば、コード内で Vec と Vec を使用すると、生成されるバイナリには Vec 用のコードが 2 つ含まれることになる。結果としてプログラムは高速になるが、コンパイル時間(すべてのコピーの作成には時間がかかる可能性がある)とバイナリサイズ(すべてのコピーが大量のスペースを占める可能性がある)が犠牲になる。 C++ におけるテンプレートのインスタンス化も実質モノモーフィゼーションであり、広く使われているがコードが肥大化するとして悪名高い。

辞書とGCShape Stenciling の概要

辞書

型引数に関する関連情報を提供し、これにより、単一の関数のインスタンス化を複数の異なる型引数に対して正しく実行できるようになる。 この辞書は、型のメソッドや演算子などの情報を含み、実行時に動的に参照される。 これにより、実行時に渡される型に応じた処理が可能となる。 実態は、ジェネリック関数/メソッドの具体的な型引数のリストやitab(interfaceの定義)などが含まれる。

GCShape Stenciling

型パラメータに対して、同じメモリレイアウト(GCShape)を持つ型同士でコードを共有する手法。 これにより、同じ GCShape を持つ型に対しては、同じコードを再利用できる。 上述したstencilingのプロポーザルでは「インスタンス化される型セットごと」に実装を生成する方針だったが、最終的にはこのGCShapeを用いることで、型セットよりも広い範囲で関数実装を再利用できるようにしている。

GCShapeは、型のサイズ、必要なアライメント、そして型のどの部分にポインタが含まれるかによって決まる(内部実装的にはshapeと呼ばれ、コンパイラの中間表現(IR)で指定される)。 GCShapeごとに単一のアセンブリチャンクが生成され、それぞれが引数として辞書を受け取る。 2 つの具象型が同じ GCShape グループに属するのは、それらの基底型が同じであるか、両方がポインタ型である場合のみ。

この実装方針における課題

ジェネリック型を使用した再帰関数

辞書はコンパイル時に作成されるため、ジェネリック型をネストした再帰関数を処理することができない(Issue#48018)。たとえば、このようなコード↓をGo1.25でビルドしようとしても失敗する。

package main

type Box[A any] struct {
    value A
}

func Nest[A any](b Box[A], n int) any {  // ERROR instantiation cycle
    if n == 0 {
        return b
    }
    return Nest(Box[Box[A]]{b}, n-1)
}

func main() {
    Nest(Box[int]{0}, 10)
}

Go Playground

ハイブリッドアプローチの恩恵はどの程度あったのか

また、Generics implementation - GC Shape Stenciling では、ハイブリッドなアプローチの成果もしくは課題として下記のような観点が挙げられているが、具体的にどの程度メリットがあるかについて言及されている記事などは見当たらない。

  • 完全にステンシル化した場合と比べて、辞書を使用した場合にコードサイズがどれくらい削減できるか、また実行時間がどれくらい遅くなるか。
  • GCシェイプが分かれば、ジェネリック型の項目を操作するコードはすべて単純化されるため、アセンブリコードはほとんどの場合、GCステンシル実装と完全ステンシル実装で同じになる。唯一の例外は、メソッド呼び出しがコンパイル時に完全に解決できないことであり、これはエスケープ解析で問題となる可能性がある。ジェネリック型で呼び出されるメソッドはすべて慎重に解析する必要があり、完全ステンシル実装よりもヒープ割り当てが増える可能性がある。
  • 完全ステンシル実装ではインライン化が行われるような状況でも、インライン化が行われない(コンパイラの最適化に関係すると思われる)。

参考記事

Go の Generics はいつ、どのように使うべきなのか

この記事は、Go Conference 2025のセッション「GoのinterfaceとGenericsの内部構造と進化」の補足記事の一つです。

turbofish.hatenablog.com

interface と Generics

まずは Generics 誕生前後での型の抽象化方法を簡単に整理する。 Generics 誕生前はダックタイピングをする手段は interface のみだったが、Generics の登場によって型安全にダックタイピングができるようになった。

これについて、具体的に interface と Generics を用いたコードの書き方の違いから説明したい。ケーススタディとして、数字の加算を行う Sum 関数の実装方法を考える。なお、このセクションにおける諸々のメリデメの説明は、拡張性を必要とするアプリケーションを実装する前提であり、著者の感想も含まれる。

実装例1: interface{} を用い、具象型によって処理を分岐させる

まず、空の interface (interface{}) を用いて、全ての型を受け取れるSum関数を作ってみる。素朴に実装すると下のようなコードになりやすいが、このサンプルコードはランタイムで panic するリスクがある。

// interface{}で何でも受け取る
func Sum(nums interface{}) int {
    v := nums.([]int) // 返り値の2つめをハンドリングすればpanicはしない
    sum := 0
    for _, n := range v {
        sum += n
    }
    return sum
}

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))     // 6
    fmt.Println(Sum([]string{"a", "b"})) // panic: interface conversion: interface {} is []string, not []int

}

Go Playground

実装はわかりやすいけど、ランタイムでの panic リスクはぜひ減らしたい。そこで、型スイッチで加算ができない場合のデフォルトの挙動を定義し、引数の具象型が数値以外だった場合にはエラーを返すように実装することにする。

// interface{}で何でも受け取る
func SumInterface(nums interface{}) (int, error) {
    switch v := nums.(type) {
    case []int:
        sum := 0
        for _, n := range v {
            sum += n
        }
        return sum, nil
    default:
        return 0, errors.New("unsupported type")
    }
}

func main() {
    fmt.Println(SumInterface([]int{1, 2, 3}))     // 6 <nil>
    fmt.Println(SumInterface([]string{"a", "b"})) // 0 unsupported type
}

Go Playground

エラーハンドリングが面倒だし、何よりコードが冗長でわかりづらくも見える。これが大規模なコードベースに散在していたらちょっと嫌だなと感じるような気はする。

実装例2: interface を用い、個別に処理を定義する

ここまでの実装方針の最も大きな問題点としては、interface{} に格納された値の具体的な型情報はランタイムで解決されるため、型安全性の担保はコンパイル時ではなく実装者の責任に委ねられていた。

そこで、定義型と interface を用いて、型の分岐をせずに処理を実装することにする。Goでは関数のオーバーロード(同じ関数名で、引数の数や型が異なる複数の関数を定義すること)ができないため、Generics登場前は、型ごとに別関数を用意するのではなく定義型と共通のメソッドを持つ interface を組み合わせてダックタイピングを実現していた。

// Summable インターフェース
type Summable interface {
    Sum() float64
}

type IntSlice []int

func (s IntSlice) Sum() float64 {
    sum := 0
    for _, n := range s {
        sum += n
    }
    return float64(sum)
}

type FloatSlice []float64

func (s FloatSlice) Sum() float64 {
    sum := 0.0
    for _, n := range s {
        sum += n
    }
    return sum
}

// Summableを受け取る関数
func SumSlice(s Summable) float64 {
    return s.Sum()
}

func main() {
    ints := IntSlice{1, 2, 3, 4}
    floats := FloatSlice{1.1, 2.2, 3.3}

    fmt.Println(SumSlice(ints))   // 10.0
    fmt.Println(SumSlice(floats)) // 6.6
    // fmt.Println(SumSlice([]string{"compile", "error"}))  // コンパイルエラー
}

Go Playground

これなら、型アサーションも必要なくコンパイル時にエラーを検出することができるため、panicのリスクは低くなる。 ただし、(実際にどのようなデータを使用するかにもよるが)プリミティブ型を定義型でラップすることが増えたり、似たような実装を何度も定義することになり、実装上の見通しの悪さは出てくる。

実装例3: Generics を使用する

これが、Generics を使用すると下記のように書くことで、簡潔かつ型安全に実現できるようになった。コード内に登場する実装の概念については後述する。

// 型引数Tに制約をつけて、数値型だけ受け取れるようにする
func SumGeneric[T int | int64 | float64](nums []T) T {
    var sum T
    for _, n := range nums {
        sum += n
    }
    return sum
}

func main() {
    fmt.Println(SumGeneric([]int{1, 2, 3}))      // OK
    fmt.Println(SumGeneric([]float64{1.1, 2.2})) // OK
    fmt.Println(SumGeneric([]string{"a", "b"})) // コンパイルエラー
}

Go Playground

Genericsとは

※このセクションの内容は、大体が Tutorial: Getting started with generics - The Go Programming Language から説明を抜粋・要約している。

Generics を使用すると、呼び出し元のコードで提供される一連の型のいずれかに対応するように記述された関数や型を宣言して使用できる。

例:Generics を使用した関数と型定義の実装

// ノードが持つvalueがany型の木構造のデータを定義
type Tree[T any] struct {
    left, right *Tree[T]
    value       T
}

// FYI:処理は省略しているが、Generics型にはオペレータは使用できないため、 >や<での数値比較などはできない
func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

// 型パラメータ(後述)を使った型定義の例
// string の value を持つノードで構成される木構造 stringTree を定義している
var stringTree Tree[string]

Go Playground

関係する概念:

  • 型引数: Genericsを用いた関数やデータ構造のインスタンス化に使用される具体的な型。こういう→ x := GMin[int](2, 3) ところに出てくる、[] で囲まれた型(要は GMin[int](2, 3) の int)。
  • 型パラメータ:[]で囲まれている記述のやつ(要は GMinint[int]func (t *Tree[T]) Lookup(x T) *Tree[T]の中の[T])。インターフェースで定義される。型パラメータ自体も型である。
  • 型制約:型パラメータの型によって定義された型の集合のこと(メタ型)。Goでは、型制約はインターフェースでなければならない(出処:An Introduction To Generics - The Go Programming Language)。つまり、インターフェース型は値型としても、メタ型としても使用できるということになる。
  • 型集合(型セット):型パラメータのインターフェースを実装する型の集合のこと(インターフェースではなく具体的な型の集合)。

Generics 関数に型引数を与えることをインスタンス化と呼ぶ。インスタンス化の具体的な処理は2ステップから成り立っており、コンパイラが全ての型引数をそれぞれの型パラメータに置き換え、その各型引数がそれぞれの制約を満たしているかどうかを検証する。この2つ目のステップで失敗すると下図のようにコンパイルエラーが検出される。

また、上図を見てお気づきの方もいらっしゃるだろうが、Go1.18 から interface を型集合として定義できるようになった(それまでは、interface はメソッドの定義しかできなかった)。 例えば、下記のような Generics を用いた関数を実装するサンプルコードでは、型パラメータに constraints.Ordered interface が用いられている。

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

この constraints.Ordered interface は下記のように, cmpパッケージのOrderedで様々なプリミティブ型を内包する形で定義されている。

constraints/constraints.go

type Ordered = cmp.Ordered

src/cmp/cmp.go

// godocの和訳:Orderedは、順序付け可能な型を対象とする制約。
// つまり、<、<=、>=、> などの比較演算子をサポートする型であればすべて対象となる。
// Go言語の将来のバージョンで新たな順序付け可能な型が追加された場合、この制約もそれに合わせて変更される。
// なお、浮動小数点型にはNaN(Not-A-Number)値が含まれる場合がある。
// ==や<などの比較演算子でNaN値を他の値(NaN値以外)と比較した場合、常にfalseが返される。
// NaN値を正しく比較するには、[Compare]関数を使用すること。
type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

この、~ は 「基底型(underlying type)を含む」 ことを表し、例えば ~int はintの定義型(例:type MyInt int)を含む。 「基底型」についてはこちらの↓記事などをご参考されたい。

zenn.dev

Generics の進化

かなりざっくりだが、まずは登場してから今までの Generics 周りのトピックスをまとめてみた。

  • Go 1.18(2022年): 型パラメータと constraints 導入、any 登場
    • interface に型を並べて型集合として定義できるようになった
    • インターフェースはメソッドの集合から型の集合(つまりそれらのメソッドを実装する型を定義している)という考え方に変化
  • Go 1.19–1.21: 標準ライブラリの Generics 対応拡大
  • Go 1.21–1.22: 制約の表現力強化 (~, comparable など)、型推論の改善
  • Go 1.23以降: コンパイル効率改善、エラーメッセージ強化、ジェネリックエイリアス完全サポート
  • Go 1.25(2025年): コア型(Genericsの内部的に使用されている型)の削除
    • 将来的な言語仕様の柔軟性向上の可能性

パフォーマンスや既存のコードの最適化の面での進化は見られるものの、2025年夏時点では、Go の Generics は他言語のものと比較すると機能としてはかなりシンプルであると言える。 これには賛否両論あると思われるが、ユースケースが少ないとの声はGo 開発者アンケート 2024 で回答者の 8%を占めた。

そこで、Go の Generics では具体的に何ができなくて、何が今後実現されそうなのかを一部独断と偏見でピックアップして紹介する。

過去に拒否された仕様案の例(大量にありそうなので特筆すべきものがあれば更新します):

  • イシュー #21659:関数のオーバーロード
    • 導入が複雑ではないとの意見もあるものの、開発チーム側では「仕様に大幅な複雑さを増すことになる」として拒否している
    • メソッドを参照するメソッド式のようなパターンを考えた際に型の指定が複雑になるとのこと
  • イシュー #49085:メソッド内で型パラメータを定義する
    • イメージ:func (si *stream[IN]) Map[OUT any](f func(IN) OUT) stream[OUT]
    • これができたらストリーム処理パイプラインの実装時などに便利、と言う提案
    • 拒否理由はこちら。型パラメータがinterfaceである以上、関数内部で生成された型パラメータの詳細(メソッドなど)を外部の関数が知ることが困難になる場合がある、と言うことのようだ

議論中の仕様案の例:

  • イシュー #48522):型集合に共通するフィールドを参照可能にする仕様変更
    • 動的ディスパッチでは対応できないパフォーマンス要件を持つシステムにも対応できるようになる
    • Go開発チームから、コア型の削除により実現可能性が高まったと言及されている

Generics 利用の一般的なガイドライン

公式ブログの When To Use Generics (The Go Blog)という2022年の記事で、Genericsを使うべき時と使うべきではないときが例とともに挙げられている。 これについては今後機能追加などにより変更される可能性もあるかもしれないが、少なくとも現時点では守っておいてよさそうに思うため、ここでは概要をまとめておく。 詳細は記事が非常にわかりやすいためそちらをぜひ読んでもらいたい。

Generics を使うべき状況

  • 言語定義のコンテナ型(スライス、マップ、チャンネル)を使用する操作
  • 汎用データ構造
    • 連結リストなど、言語に組み込まれていないものを実装する場合
  • 異なる型で共通のメソッドを実装する必要があり、異なる型の実装がすべて同じように見える場合
    • 例:slices パッケージでの要素のソートなど

Generics を使うべきではない状況

  • ある型の値のメソッドを呼び出すだけで済む場合は interface 型を使用する
  • メソッドの実装が型ごとに異なる場合は、interface 型を使用する
  • メソッドを持たない型で操作をサポートする必要がある場合
    • 例:encoding/json パッケージ

簡単に言うと、「まったく同じコードを複数回記述していて、コピー間の唯一の違いがコードで異なる型を使用していることである場合、型パラメータを使用できるかどうかを検討してください」とまとめられていた。 この1文が Go の Genericsユースケースを最も的確に表しているように思う。

まとめ

Generics 導入前はダックタイピングをするために interface が用いられていたが、Generics の導入により型安全かつ簡潔に複数の型を扱えるようになった。

まったく同じコードを複数回記述しているような汎用性のある処理の場合は Generics を使おうね。処理が異なる場合や、データが持つ意味合いが違うなどの場合には普通に(愚直に)関数 or メソッドを実装するといいよ。

参考記事

go.dev

interfaceが当初メソッドの集合と定義されていたことがわかる。

research.swtch.com

↓インターフェースはメソッドの集合から型の集合、つまりそれらのメソッドを実装する型を定義していると言う考え方になった旨の記述がある。

これを実現するために、インターフェースを新しい方法で検討します。

最近まで、Go の仕様では、インターフェースはメソッドセット、つまりインターフェースに列挙されたメソッドの集合を定義するとされていました。これらのメソッドをすべて実装する型は、そのインターフェースを実装します。

しかし、別の見方をすれば、インターフェースは型の集合、つまりそれらのメソッドを実装する型を定義していると言えます。この観点から見ると、インターフェースの型集合の要素であるあらゆる型は、そのインターフェースを実装します。

go.dev

型パラメータの当初の設計で、却下された案とその理由も記載されている。

go.googlesource.com

Go の Typed-nil その2:error interfaceを実装した定義型のnil値は、nilとイコールではないことがある

注意:この記事の内容は挙動の内部実装をGoのコード上で追い切ることができません。内部実装の挙動ついては極力公式ドキュメントの説明を引用していますが、ややふわっとしている(要は「そういうもんなんだ」みたいな感覚になる)かもしれません。

この記事↓の続きです。

turbofish.hatenablog.com

この記事では、Goの内部実装のソースコードも引用している。引用時のGoのバージョンは1.25.1

nil errorを返しているのにnilとイコールにならない?

レイヤードアーキテクチャで開発していたりすると、独自に定義した構造体をerror(errorはinterface)として返す関数を作ることも多いはず。 そんな定義型のエラーを返す意図の関数であっても、例えば、下記のコードでは返り値のerrorがnilになることはない。

type MyError struct{}
// MyError 型は error interfaceを実装している
func (e MyError) Error() string { return "error" }

func main() {
    fmt.Printf("returnsNilError() == nil: %v\n", returnsNilError() == nil) // false
}

func returnsNilError() error {   // error は interface
    var p *MyError
    fmt.Printf("p == nil: %v\n", p == nil)
    return p
}

出力 --------
p == nil: true
returnsNilError() == nil: false

Go Playground

出処:Go Documentation Frequently Asked Questions (FAQ): Why is my nil error value not equal to nil?

上記のサンプルコードにおいて、returnsNilError 関数から返される変数pは、定義された時は MyError 構造体の nil ポインタだったものの、呼び出し側で受け取った返り値は error interface を実装する interface 値になる。その際、構造体の型Tの情報を持つが値Vを持たないデータになる。

内部実装については後述するが、ここのデータの変換はコンパイラで行われており、Goのコードで追うことができない。 この記事でGoアセンブリを解読してみようかとも考えたが、記事が無駄に長くなりそうなのでやめておく。

ここからは余談だが、var p = *MyError のpに reflect.TypeOf(p) をするとどうなるのか?という話をすると、結果として(p == nil はtrueになるにも関わらず) TypeOf(p) でちゃんと型(*MyError)は出力される。これは、ポインタ型自体は型を持っているため。 ポインタ型は、下記の構造体で表現される。

src/internal/abi/type.go

type PtrType struct {
    Type
    Elem *Type // pointer element (pointed at) type
}

一方でランタイムにおけるポインタ変数は、明示的な初期化が行われない場合にはゼロ値がであるnilとなるという仕様になっている(出処:The Go Programming Language Specification - The Go Programming Language)。つまり、p == nil はPtrType構造体のElemが持つポインタが示すアドレスがゼロ値のものか、を見ていると思われる。が、これについてもGoアセンブリを読まないとわからなそうだった。

interface の内部実装

iface 構造体

interface 値は、内部的にはインターフェースが持つメソッドの情報(itab)と値の2つの要素を持つ構造体として実装されている。

src/runtime/runtime2.go

type iface struct {
    tab  *itab         // interface テーブル(後述)
    data unsafe.Pointer
}

ところで、interface の実装について、公式のFAQでは以下のように説明されている。

Go言語のインターフェースは、内部的には2つの要素、型Tと値Vで実装されている。Vは、int、構造体、ポインタなどの具体的な値で、インターフェース自体ではない。そして、その型はT。例えば、インターフェースにint値3を格納した場合、そのインターフェース値は、形式的には(T=int, V=3)となる。

値Vは、プログラム実行中に同じインターフェース変数に異なる値V(および対応する型T)が格納される可能性があるため、動的値とも呼ばれる。

つまり、Tは iface 構造体の Type フィールド、Vは data フィールドのことを指していると言えるだろう。以下、型と値それぞれをTとVと呼ぶ。

以下、FAQよりinterfaceのTyped-niについて説明した箇所を抜粋する。

インターフェース値がnilとなるのは、TとVの両方が未設定(T=nil, Vは未設定)の場合のみ。特に、nilのインターフェースは常にnilの型を持つ。int型のnilポインタをインターフェース値に格納した場合、ポインタの値に関わらず、内部の型はintになる(T=*int, V=nil)。そのため、内部の値Vがnilであっても、そのようなインターフェース値はnilではない。

ご参考まで、Typed-nil を引き起こす変換処理は、コンパイラから呼ばれる、型 t の値を interface に詰めるときの変換処理convT関数(src/runtime/iface.go))。 この関数は実装を追っても何をやっているかよくわからないためgodocコメントを要約すると、convT関数は、引数vが指す型tの値を取り、それを「インターフェース値の2番目の要素」(これがiface構造体のdataフィールドを表していると思われる)として使用できるポインタに変換する。この関数は v が nil でも成功すると書かれている。つまり、型t の nil 値を interface に変換する際にもランタイムでは interface 値が生成されるということであり、この時値はないが型情報があるために Typed-nil となる。

itab

以下、 iface 構造体の1つ目のフィールドである itab について見てみるが、ちょっと細かい話になるので、「ある具象型があるインターフェースをどのように実装しているかという情報を持っている構造体で、動的ディスパッチを実現するものなんだな」、もしくはもう少し細かく言うなら、「ランタイムでメソッドの実装が入ってるメモリの場所を探す時に使うテーブルなんだな」くらいの認識で良さそう。すでに離脱しそうだったら次のセクションに進んでしまってください(土下座)。

ちなみに、どうもこのitabという仕組み自体が、プログラミング言語として結構珍しいものらしい。以下Russ Cox著research!rsc: Go Data Structures: Interfacesより抜粋して和訳したもの。

メソッドを持つ言語は、一般的に2つのグループに分かれます。1つは、すべてのメソッド呼び出しのテーブルを静的に準備する(C++Javaなど)、もう1つは、呼び出しごとにメソッドを検索し(Smalltalkやその多くの模倣言語、JavaScriptPythonなど)、さらに高度なキャッシュを追加して呼び出しを効率化する方法です。Go言語は、この2つの中間に位置します。メソッドテーブルはありますが、実行時に計算します。Go言語がこの手法を採用した最初の言語かどうかは分かりませんが、一般的ではないことは確かです。

話を内部実装に戻すと、itab はメソッドポインタの配列になっており、実体型と interface のメソッドセットを対応付けする。メソッド呼び出しは itab 経由で実行時に動的ディスパッチ(実行時に多態的な操作(メソッドまたは関数)のどの実装を呼び出すかを選択するプロセスのこと)を行う。itabフィールドの型である itab 型は abi の ITab 型のエイリアスであり、定義は下記のとおり。

src/internal/abi/iface.go

type ITab struct {
    Inter *InterfaceType // 実装しているインターフェース型
    Type  *Type      // 基底となる具象型
    Hash  uint32     // Type.Hash のコピー。型スイッチに使用される型のハッシュ値
    Fun   [1]uintptr // メソッドの実行リスト的なもの。実際には可変長サイズ。fun[0]==0 の場合、Type が Inter を実装していないことを意味する
}

Funフィールドは、インターフェースで定義された各メソッドが、実際にプログラムのどこにあるかを示す。このフィールドには、(構造体の定義としてはサイズ1の配列になっているものの)コンパイラによってインターフェースが要求するメソッドの数だけアドレスのポインタを持つ配列になり、ランタイムがその先にある可変長のメソッドアドレスのリストを読み取れる。この配列に含まれるポインタが、実行すべきメソッドが保存されているメモリアドレスを示している。 インターフェースの値が nil でないとき、Goは「この ITab を参照して、Fun リストにあるアドレスへジャンプしなさい」という命令を実行することで、正しいメソッド(例えば Error())を呼び出すことができる。

インターフェースを使用する際の注意点

公式FAQでは「インターフェースに具体的な値が格納されている場合、インターフェースは nil にならないことに注意してください。」と記載されている。

具体的にどう注意すれば良いか、特に言及している記事などは見当たらないが、インターフェースの初期値を変数に入れるような実装を避け、nilになって欲しい場面ではnilと書くのが良いと個人的には思う。 例えば先ほどの例で言うと、下記のようなイメージ。短いサンプルコードで書いてしまうとやや当たり前感は出るが、コードが長くなってくるととりあえず宣言だけしておくみたいなの結構やりがちなのではないだろうか。

func returnsNilError(ok bool) error {   // error は interface
    if !ok {
        return MyError{}
         }
    return nil
}

もちろん、nilが返るパターンとそうでないパターン両方が単体テストで担保される場合の方が多いと思うので、このTyped-nilが見落とされるケースは少ないだろう。それを置いておいても、コードを読む上でもnilになるべき箇所はnilと記載した方が理解しやすいと個人的には思う。 Goではinterfaceなのか具体型なのかぱっと見わからないこともあるため、できるだけコードを追わなくてもデータが理解できるシンプルな書き方を心がけたい。

参考記事

go.dev

zenn.dev

go.dev

この論文↓個人的にめっちゃ好き

Goのインターフェース(静的、コンパイル時にチェック、要求に応じて動的)は、言語設計の観点から見て、私にとってGoの最も魅力的な部分です。もしGoの機能を一つだけ他の言語に移植できるとしたら、それはインターフェースでしょう。

research.swtch.com

動的ディスパッチとは

en.wikipedia.org

Go Conference 2025に登壇したので補足記事書きます

「GoのinterfaceとGenericsの内部構造と進化」というタイトルで登壇しました。

gocon.jp

資料

使用した資料と参考にした記事のリンク集を公開しています。 よろしければご覧ください。

スライド speakerdeck.com

GitHubリポジトリには、資料のマークダウンファイル(Marpでスライドを作成しました)と参考情報のURLのリンクなどがあります。 github.com

補足記事一覧

  1. Go の Typed-nil その1: any型(=interface{}型)を、「どんな型でも入れられる」型として扱う場合には nil チェックに注意 - It Made My Day

  2. Go の Typed-nil その2:error interfaceを実装した定義型のnil値は、nilとイコールではないことがある - It Made My Day
  3. Go の Generics はいつ、どのように使うべきなのか - It Made My Day
  4. GoのGenerics の内部実装の設計:辞書 と Gcshape Stenciling - It Made My Day

わかりやすさ重視して補足記事短めに書いたつもりではあるけど、それでも4本になってしまった...。 よくこれを20分に収めようと思ったな...ほんとすいませんでした...。

補足記事書きます!

セッションをお聞きいただいた方から、「面白かった...けど....よくわからなかった…」という声を複数キャッチしたので(感想メチャクチャありがたいです!絶対むげにはしません!!!)、より実用的にセッション内容を理解してもらうべく、補足記事を書きます。

個人的に振り返ってみて、ハイコンテキストかつ早口で具体的な内部実装と抽象の説明を行き来してしまったため、ただでさえ伝えたいことが多かったのにさらに情報の関連がわかりづらく、聞き手にとって難解になっていたと反省しています。20分しかないのに詰め込みすぎたのもあるので、次回登壇することがあれば、もっとカバー範囲を絞って、理解を深めるための説明にもう少し多く時間を使うようにしようと思っています。

でも...言い訳していいですか?今回のテーマ、調べるのが楽しかったんですよ...すっごく楽しくて...なんならスライドのレビューを友人に頼めばいいものを直前まですっかり忘れて調査に没頭し(なんならスピーカー控え室でアセンブリ読んでた)、登壇直前になって私にマイクを渡してくれた運営の友人の顔をみて、「あ、てかレビュー頼んだらよかったやん」って気づいたくらいなんです(ちなみに、それ気づいた時自分で驚きすぎて口に出して言ったんですけど、その友人には冷静に「せやな」って言われましたw)。Day2のセッションでとある登壇者の方が「僕すごい頑張って資料作ったんですよ!懇親会にも行かずに!!」っておっしゃってて、死ぬほど共感しましたよ...。というか、なんなら本当はもっと喋りたいことあったんですよ...!

そこで(閑話休題)、登壇テーマを小分けにしてテーマを絞って記事にして連載にしようと思います。関連記事を出したら、この記事の上部のところにリンクを貼っていきます。 20分では話しきれなかった詳細をstep by stepで説明することを意識しますが、分かりづらい点などあれば遠慮なくコメントいただければ、記事を改善していきます!

全部描き揃えるまで少し時間はかかるかもしれませんが、Xで #gocon タグをつけてツイートしますので、気長にお付き合いいただけますと幸甚です。

最後に

倍率が非常に高い人気イベントにも関わらず登壇というチャンスに恵まれ、本当に良い経験をさせていただき幸運でした。正直自分が喋りたいことを喋りまくった感があり、自由にやりすぎて終わってからもひどく不安になりましたが、セッション中にガヤを入れてくれたりうなずきながら聞いてくれた人たち、Ask The Speakerや懇親会などで話しかけてくれた人たち、tweetしてくれたりもしくはそんなことしなくても普通に聞いてくれた人たちも、本当に心の支えになりました。ありがとうございました。

自分がコミュ障なのもありこれまではイベントは極力オンライン参加を決め込んでいたのですが、今回Go Conferenceに参加して純粋にセッションが面白く、ワークショップなども充実していて勉強して交流する楽しさを満喫することができ、初めてコミュニティの素晴らしさを理解した気がします。 今回はささやかながら個人協賛もさせていただいたのですが、来年以降私も何かしらコミュニティに貢献したいと考えています。

運営のみなさま、関係者や参加者のみなさま、本当にありがとうございました。

お疲れ様でした!

(Google Cloud) Datastoreを理解する

この記事は、大体の文章を主に公式ドキュメントから拝借してまとめています。

Datastoreの特徴

Google CloudのNoSQL ドキュメント データベース。 ダウンタイムなしのフルマネージドサービス。

可用性が高く、読み取り時のみならず書き込み時にも自動でスケールする。 スキーマレスだが、アプリケーションコードでそのような要件を強制できる。

非常に大規模なデータセットに対して自動的にスケールできるように設計されているため、アプリケーションが大量のトラフィックを受信しても高いパフォーマンスを維持できる。大規模な構造化データに対して可用性の高いアクセスを必要とするアプリケーションに最適。

RDBよりも制限はあるが)SQLライクなクエリ言語も提供しており、全てのクエリが強整合性を持つことができる(後述)。

ACID トランザクションのサポートが不要な場合、またはデータが高度に構造化されていない場合は、Bigtable を検討するとよい(出処:Datastore の概要  |  Cloud Datastore Documentation  |  Google Cloud)。

コンセプト

RDBとの概念の比較

出処:Datastore の概要  |  Cloud Datastore Documentation  |  Google Cloud

クエリ

クエリの種類と整合性モデル

  • グローバル クエリ:祖先を指定しないクエリのこと。結果整合性モデルで機能するように設計されており、強整合性は保証されない
  • 種類を指定しないクエリ:アプリケーションのすべてのエンティティを取得する。プロパティ値に対するフィルタや並べ替え順序を含めることはできないが、エンティティ キーを基準としたフィルタリングの実行や祖先フィルタの使用は可能
  • 祖先クエリ:指定されたエンティティとその子孫に結果が制限される
  • 射影クエリ:エンティティのプロパティの中で本当に必要なものだけをクエリできるため、エンティティ全体を取得する場合よりもレイテンシとコストを低く抑えられる
  • キーのみのクエリ:エンティティ自体ではなく、エンティティのキーだけが結果として返される

出処:Datastore での強整合性と結果整合性のバランス  |  Cloud Datastore Documentation  |  Google Cloud

フィルタリング

Datastore では、結合オペレーションや複数のプロパティに対する不等式フィルタリング、サブクエリの結果に基づいたデータに対するフィルタリングがサポートされていない。

ベストプラクティス

ホットスポットが起こらないようにキーを設定する

レイテンシに影響することがあるため、1 回の commit に同じエンティティ(キーが同一のエンティティ)を複数回含めないこと クエリがプロパティを必要としない場合は、インデックスからプロパティを除外すること。不必要なインデックス作成は、レイテンシの増加、インデックス エントリのストレージ費用増加につながる可能性がある

ホットスポットが起こらないよう、小さい範囲のキーに対する書き込み頻度が急に上昇しないようにする

辞書順が近い一連のドキュメントに対して、頻繁に読み取りや書き込みを行わないようにする、プロパティにインデックスを付ける際、単調に増加する値(NOW() タイムスタンプなど)を使用しない、など

ホットスポットは、エンティティ キーとインデックスの両方が使用するキー範囲で発生することがある

ホットスポットに対処するには、シャーディングまたはレプリケーションを使用する インクリメンタルなIDを使わないこと。allocateIds() メソッドから数値 ID を取得することができるので代わりにこれを使うこと

参考:ベスト プラクティス  |  Cloud Datastore Documentation  |  Google Cloud

TTLポリシーを使って古いデータを削除する

ドキュメント:TTL ポリシーでデータ保持を管理する  |  Cloud Datastore Documentation  |  Google Cloud

TTL プロパティとしてマークできるのは、1 種類につき 1 つのプロパティのみ。合計で 200 個の TTL ポリシーが可能 TTLに設定したfieldの値の時間を過ぎてから24時間以内にエンティティが削除される