Go style: 読みやすく高速に
目標は、メンテナが上から下へ読めるコードであり、パフォーマンスはコード全体に散らばったトリックではなく、APIとデータの形状から生まれることです。設計が適切であれば、読みやすさと速度が衝突することはほとんどありません。衝突した場合のみ、計測されたホットパス上で読みやすさを犠牲にし、その理由をコメントに記述します。
これはDave Cheneyの「API Design as a Performance Primitive」に従っています。ある点を超えると、パフォーマンスを後から追加することはできないため、設計段階でパフォーマンスを考慮し、それ以外はすべてシンプルに保ちます。
出典: Cheneyのトーク (https://dave.cheney.net/paste/gophercon-uk-2026.html)、コードはgithub.com/pkg/jsonおよびgithub.com/davecheney/bytereaderにあります。John Ousterhout、「Define errors out of existence」。
優先順位
- 正確性。 速度や簡潔さと引き換えに絶対にしない。
- APIの形状とbig-O。 メモリの所有権、何が返されるか、各呼び出しがどれだけの作業を行うか。これらが後からのどのようなチューニングよりもパフォーマンスを決定づける。
- ホットパス上のアロケーション。 アロケーションごとに、割り当てとガベージコレクタの2回のコストがかかる。コレクタは高速かもしれないが、「アロケーションしないことは常に高速である」。
- 入力単位あたりの作業量。 各バイトを1回だけ触り、呼び出しはバイト単位ではなく、トークンまたはレコード単位で行う。
- マイクロ最適化(手動インライン化、ビットトリック、境界チェックの回避)。プロファイルでホットと示されたパスのみで行い、かつ利益を証明するベンチマークがある場合のみ。
変更が上のレベルで正当化されない場合は、行わないでください。
ホットパスとコールドパスを分離する
どのコードがイベントやリクエストごとに実行されるか(ホット)と、セットアップ、ビルド、設定、または更新時に実行されるか(コールド)を決定します。
- コールドパス: 最もシンプルなコードを書く。共有する代わりにクローンし、マップや
fmtを使用し、自由にアロケーションする。ホットパスのイディオムを持ち込まないでください。読みやすさを犠牲にし、何も得られません。 - ホットパス: 再利用可能なバッファ、世代カウンター、フラット配列などのテクニックを使用してもよい。各テクニックに名前(小さな型やヘルパー)と、それが存在する理由を説明するコメントを付け、読者がリバースエンジニアリングする必要がないようにします。
パフォーマンスのためにAPIを設計する
APIによって、呼び出し元が節約できる上限が決まります。APIがすべての結果をヒープに強制する場合、「ガベージコレクタのベンチマークを取っている」ことになります。高速パスが自然なものになるように設計してください。「異なるAPIを受け入れる用意があれば」、2〜10倍の利得が得られることがよくあります。
- エスケープしなければならない値を返さない。 プリミティブを
interface{}/any、新しい文字列、マップ、またはアイテムごとの新しい構造体へのポインタを介して返すと、呼び出しごとにアロケーションが強制されます(encoding/json.Decoder.Tokenは古典的な「ガベージファクトリー」です)。具体的な型、または[]byteのようなビューを返し、呼び出し元が必要なものだけを変換できるようにします。 - コピーではなくビューを返す。 呼び出し元が読み取りのみを必要とする場合。ビューが有効な期間を文書化します(「次の呼び出しまで有効」、
bufio.Scanner.Bytesのように)。生の入力のビュー(トークンのバイト、その最初のバイトが種類を示す)は、呼び出し元が無視するかもしれないデコードされた値よりもコストが低いです。 - 呼び出し元が再利用可能なメモリを所有できるようにする。 スクラッチやバッファ引数を受け入れるか、呼び出し元が保持する構造体内に保持し、呼び出しごとにアロケーションしないようにします。呼び出し元が初期バッファを提供できるようにし、スタック上やプールから取得できるようにします:
var buf [8 << 10]byte; d := NewDecoderBuffer(r, buf[:])。 - バッファ管理を少数のメソッドの背後に隠す。
io.Readerは呼び出し元管理のバッファにコピーし、補充をすべての呼び出し元に任せます。小さなウィンドウAPIはデータをその場に保持します: 参照にはWindow() []byte、消費にはRelease(n)、さらに読み取るにはExtend() int。「ウィンドウはReleaseまたはExtendによって無効化される」ことをドキュメントコメントに記述します。 - エラーをホットループの外に定義する。 ブール値または空の結果を返し、エラーを後で報告するイテレーションを使用します:
for s.Next() { ... }その後s.Err()。スローパスでセンチネル結果を使用し(Extendは0を返し、その後呼び出し元がエラーを問い合わせる)、内部ループにエラーブランチがないようにします。io.EOFを他の終端条件と同様に扱います。 - 入力単位あたりの作業量をカウントする。 各バイトを1回処理し、バイト単位ではなくトークンまたはレコード単位で関数呼び出しを行い、中間コピーを排除します。バイト単位からトークン単位の呼び出しへの移行は、アロケーションの除去に次いで2番目に大きな効果があることがよくあります。
- APIを階層化する。 低レベルでアロケーションのないビルディングブロック(生のトークン、ビュー、スクラッチを所有するイテレータ)を提供し、その上に便利なAPIを構築します。便利でアロケーションを行うAPIのみを提供しないでください。
- 所有権とライフタイムをドキュメントコメントに記述する: 何が借用され、何が保持され、何が同時使用に対して安全か、ゼロ値が何を意味するか。
- ゼロ値を有用にする。 それが自然な場合。
- 変更可能な共有状態のロックよりも、不変の共有値と新しいスナップショットの公開を優先する。 読み取りが支配的な場合。
APIを測定し、実装だけでなく、置き換えるAPIと比較してallocs/opとスループットを比較します。references/examples.mdには、これらの形状が並べて示されています。
ホットループのチューニングは最後に
APIとbig-Oが適切であり、プロファイルがループがホットであることを示した後でのみ:
- 状態に対する
switchは、比較の連鎖にコンパイルされます。funcフィールド(d.state = (*Decoder).stateValue)を介して選択される、状態ごとの小さなメソッドにより、各状態を読みやすく保ち、そのディスパッチを除去できます。 - 値を使用する関数にチェックを移動することで、コンパイラが境界チェックを不要と証明できるようにし、エントリポイントをインライン化予算(80;呼び出しには約52のコストがかかる)内に収めます。
go build -gcflags=-m=2で確認します。 - 呼び出しを避けるために2つの関数をマージすると、トークン単位のパスで5〜15%の利得が得られることがありますが、ネストが深くなります。ベンチマークがある場合のみ行い、その理由をコメントに記述します。
Cheneyが述べるように、「APIのbig-O効果に取り組まずにこれらのトリックに手を出してはいけない。それは針を動かさない。」
関数を小さくフラットに保つ
各関数が1つのことを行い、最大1〜2つのブランチを持つことを目指します。ガイドとして、循環的複雑度を10以下、認知的複雑度を10以下に保ちます(gocyclo、gocognit)。各ケースを1つの呼び出しに委譲するフラットな型スイッチは、それを超えても問題ありません。ネストは禁止です。
- 複数条件に名前を付ける。
if len(a)*len(b) > maxN || size(a)*len(b)+size(b)*len(a) > maxMをif !fitsProduct(a, b)に置き換えます。名前が意図を文書化し、関数が説明を担うことができます。 - ディスパッチしてから委譲する。 ノードやメッセージの種類に対するスイッチは、各ケースのロジックを含むのではなく、ケースごとに1つのメソッドを呼び出すべきです。
- 早期リターン。 ガード節を最初に、メインパスはインデントなしで。
- ケースを繰り返さない。 2つのブランチがオペランドの順序のみ異なる場合、1つのヘルパーを書き、それを2回呼び出します。
- 混合関心事を分割する。 「見つける」、「新しい値を構築する」、「整理する」は3つの関数であり、1つではありません。
- ブール型フラグパラメータを避ける。 それが最も明確なオプションである場合、意味のある名前を付け、呼び出しサイトで説明します。
- ルックアップテーブルを使用する(
map[string]Kind、[256]bool)。文字列やバイト比較の連鎖の代わりに。
読者とCPUのためにデータを整形する
- 並列スライスを構造体のスライスにグループ化する。 同じIDでインデックス付けされた4つのスライスは、1つの構造体です。読みやすくなり、通常は局所性が向上します。
- 繰り返されるメカニズムに独自の小さな型を与える。 同じカウンターラップ、ビットマップ、バッファロジックが2回出現する場合、それは2つまたは3つのメソッドを持つ型です。
- ビット詰めよりも明示的なフィールドを優先する。 詰め込みが測定上重要でない限り。シフト演算を伴うフラグの
uint64よりも、[]segment{name, each}の方が優れています。 - 表現を少なく保つ。 状態や種類が少なければ、重複するスイッチも少なくなります。測定可能なコストがない場合、特殊なケースを一般的なケースに統合します。
パフォーマンスの主張を証明する
測定せずに、変更が高速、低速、またはアロケーションなしであると主張しないでください。パフォーマンスに敏感なコードを変更する前に:
- 関連するベンチマークの
benchstatベースラインを保存します。 - 変更を行い、比較します。有意な差異を数値で報告します。
go build -gcflags=-mの出力をベースと比較し、新しいヒープエスケープを確認します。- ゼロアロケーションを約束するパスに対して、
testing.AllocsPerRunテストを維持または追加します。 - プロファイルを作成してから、リグレッションの原因を推測します。
簡略化によりホットパス時間が測定可能なほど失われ、それをきれいに回復できない場合、停止し、トレードオフを報告します。静かに複雑さを追加したり、静かにリグレッションを受け入れたりしないでください。
正確なコマンド、複雑度の測定方法、および一般的にリグレッションを引き起こす落とし穴については、references/verify.mdを参照してください。スタイルを示すリファクタリングの前後については、references/examples.mdを参照してください。
Goの慣例に従う
gofmt、go vet、およびプロジェクトのリンターが合格する必要があります。コードを修正し、リンタールールを弱めたり除外したりしないでください。名付けたルールと理由がある場合のみ、1行を抑制します。- 短いレシーバー名、型のメソッド間で一貫性を保ちます。理由なく1つの型でポインタレシーバーと値レシーバーを混在させないでください。
- ドキュメントコメントは識別子の名前で始め、契約を述べます。
- 何ではなく、なぜをコメントします。周囲のコメント密度に合わせます。
- 標準ライブラリ(
slices、maps、strings、math/bits)を手書きのヘルパーより優先します。 - 生成されたコードを決して手動編集しないでください。
レビューまたはリファクタリング時
- 何かに触れる前に、ホットパスとそのベンチマークを特定します。
- 複雑度を測定して最悪の関数を見つけ、それらを最初に修正します。
- 小さなステップでリファクタリングし、各ステップの後に関連テストを実行します。
- 動作を同一に保つ: 同じ結果、同じ予算とフォールバック、同じアロケーション保証。動作の変更は、別個の明示的な決定としてのみ行います。
- 何が変わったか、変更前後の複雑度とベンチマーク数値、および検証されていないものを報告します。