Goのポカを減らす戦略
ソフトウェアの品質
通常ソフトウェアの品質を高く保つには以下の手続きを継続する必要があります。
- イディオムやアルゴリズムを適切なものを正しく選択してコードを記述する
- コンパイルにより誤りを検出・修正してコードの正確さを高める
- 静的解析により誤りを検出・修正してコードの正確さを高める
- 各種テストの実行により誤りを検出・修正してコードの正確さを高める
- レビューにより誤りを検出・修正してコードの正確さを高める
なぜ継続する必要があるのかというと、ソフトウェアをゼロバグでリリースするのは困難だからです。リリース後実運用にて発覚する問題点があれば上記の手続きをもう一度踏む必要があるからです。
ソフトウェアの品質を下げる要因のひとつが「ポカ」で、ざっくりいうとこれは「うっかり正しくない記述をしてしまったりする」ことです。
実は多くの種類があるポカ
ランタイム検出項目のうち、言語処理系によってはコンパイラが発見してくれるもの
- 未初期化メモリアクセス
- ヌルポインタ参照
- データアクセス競合
- デッドロックの発生
- 条件パターンマッチ漏れ
それ以外のランタイム検出には以下のようなものがあります。
- 範囲外インデックスアクセス
- ゼロによる割り算
- デッドロックの検出
以下の項目は静的解析で発見できる可能性があります。
- 分岐やループ条件のロジックミスや想定漏れ
- エラーや例外の不適切な握り潰し(無視)
- 外部からの入力データの検証漏れ
- 非推奨機能の利用
- 慣習のミスマッチ
レビューでないと発見しにくいポカ
- アプリケーションドメインの要求と挙動のミスマッチ
- チームメンバーの合意とのミスマッチ
- セキュリティ上望ましくない実装の選択
- 手順依存がある機能を異なる手順で利用してしまう
- 必要以上にレイテンシの悪化を招く実装
- 必要以上にスループットを損なう実装
- 必要以上に制約を増やしてしまう実装
- 冗長な依存(同じ役割のライブラリが多種取り込まれる)
- 不適切なコメント(コメントの内容と実際の挙動がミスマッチなど)
- 有効なアルゴリズムを選択できていない
- オーバーキルな実装の選択
- 計測を伴わない最適化
- etc...
つまり、コンパイラが発見してくれるのは良いことというのは理解できるのですが、コンパイラで発見できるポカというのはこれら全体のなかでとても限定的であり、特に後者よりのポカは静的解析ツールやテスト、レビューで発見することになります。
習熟が減らすポカ
- 前述の多様なポカのほとんどは理解不足により発生する
- なので習熟が進めば自然とこれらのポカは最初から発生しなくなっていく
- この習熟が不十分な人がレビューに参加すると理解が進む
- レビュアーたちはその中で最も習熟した人に近い習熟度に近づく
- 特に「レビューでないと発見しにくいポカ」を減らすのは極力チームみんなでレビューに参加することが重要なのはGoに限らずどんな処理系でも同じ
モダンな言語処理系が採用する機能
- Nullable/Non-Nullableを区別して記述し、コンパイルが通る時点でNullセーフとするしかけ
- 型修飾などによりイミュータビリティやコピーオンライトを使ってアクセス競合を避けるしかけ
- コードの書き手にオーナーシップを意識させることでメモリの生成や破棄のタイミングをコンパイル時点で静的に決定させ、なおかつ並列アクセス時の排他処理も静的に解決するしかけ
- コンストラクタの強要および参照の概念により未初期化メモリへのアクセスを回避する仕掛け
- パターンマッチにより条件やEnum対応に漏れをなくすしかけ
これらはGoが採用しなかった手法でもあります。
Goが安全のために採用した機能
- ポインタの移動操作を禁止したこと
- 確保するメモリは必ずゼロ値で初期化されること
- 上記2つにより未初期化メモリの参照はできない
- 慣習として必須のエラーハンドリングが返値のヌルチェックを兼ねていること
- 以上によりポインタを採用したがメモリ安全だしヌル参照も習熟につれ発生頻度をほぼなくせる
- 変数に対し誤解を生みやすいconst修飾を使えなくした(const値は真の定数値)
- より狭いスコープで同じシンボル名に「:=」を使うと広域スコープの同名シンボルを隠蔽する(狭い側のスコープを抜けると隠蔽が解除される)これを「シャドウイング」というが、これは諸刃で、意図しない変数の変更をしないかわりに意図した変数の書き換え漏れをも産むこともあるが理解して使えば後者は避けられ、前者の利点が生きてくる
- GCを採用してコードの書き手にメモリのライフサイクルに関与させないという手法を採った
Goが重視しない手順
- 豊富なイディオムや型表現の網羅的なサポート
- 上記を使ったコンパイル時の不整合検出
Goが重視する手順
- 静的解析
- テスト
- レビュー
Goがイディオムや型表現を増やさない理由
- Go以外の言語処理系がどんどん流行のイディオムや型表現を模倣してどれも似た言語になっていく
- Goはそうではない言語処理系を目指して生まれたという出自がある
- ミニマルな機能セットでアプリケーションを組み立てるというのに特化した言語処理系
- あちこちに個人の趣味嗜好がコード表現に乗らない工夫がある
- イディオムや型表現が増えると、コードの書き手のスキル差が同じ目的のコードの差分を増大させる
- コードに趣味嗜好や個人のスキルによる差分が乗れば乗るほどレビューコストが増大する
- また、イディオムや型表現が増えると、新たにポカを生じやすいポイントが増えることがある
- Goは言語仕様自体にほどよく安全な設計がされていて完璧じゃないが十分という考え方
- ポカを減らすのには「静的解析」や「テスト」、「レビュー」の方が重要という考え方
- つまり本質的にポカを減らすには実装の隠蔽を極力控えてレビュアーがレビューの時に「実装をちゃんと追いやすい」ことの方が重要という考え方
静的解析サポート
- 「go vet」という組み込みの静的解析診断ツールがある
- 「go vet」はプラガブルに拡張できる
- 一般に静的解析は書く時に比べ詳細な型ハンドリングが要求され、その実装コストは高くなりがち
- 静的解析ツールを作るにも、その動作を良好に維持するにも、本来はコストが大きい。
- Goでは言語仕様がシンプルかつ10年に数点しか変更されないこと
- 静的解析支援フレームワークがあることでそれらのコストは最小化されている
テストサポート
- 組み込みのテストツールがバンドルされている
- ベンチマークやプロファイラ、ドキュメント生成ツールなどもバンドル
- プロファイリングの可視化ツールも豊富にバンドル
レビューサポート
- gofmtおよびその派生ツールによりコード整形をする
- 保存時に強制的に整形するのが一般的(PRにて整形されていないものはリジェクト)
- 記号の省略を許さない言語仕様が整形の矯正力を高くしている
- 整形コマンドはあらゆる設定を許さないことで必ず誰が書いても同じような整形となる
- 以上の特性によりVCSのコード差分は実際の変更部分だけがはっきり浮き彫りになる
- gofmtの良い効能は周知されており同様の矯正力の高い整形ツールはどんどん他の言語処理系に移植されている
- Goの公開ライブラリの有用なものは100%整形スタイルが統一されている(スタイルの分断がない)
- シンボルを追跡するのに目で追ったとしても間違うことがない(インポートパスがユニーク、pkg名を省略しない慣習、インポート文を書く場所が限定的、オーバーロードがないなど)
Goの採用する戦略
- Goは学習コストの低さとコンパイル時間の短さをウリにしている
- イディオムや型表現が増えることはそれらの双方を悪化させること
- Goはイディオムや型表現を強化するという手法を積極的に採らない方針
- ジェネリクスについてはもちろん双方を悪化させますが、
- ポカを減らすだけでなく様々なメリットが見込めることから採用された
- ポカを減らすという目的だけであれば静的解析で十分
また、Goは静的解析ツールの実装コストと維持コストが他の言語処理系に比べ非常に低いというのもGoの特徴の一つであり、Goの狙いがここにあることは明確です。
書き捨てではなくソフトウェアの品質が求められる時、静的解析やテスト、レビューはもはや必須です。レビューでなければ発見しにくいポカ以外であれば「コンパイラが発見する」のも「静的解析やテストで発見する」のもコストはそんなに変わらないというのがGoの考え方です。
まとめ
「ポカを減らす」ための戦略において、モダン言語ではイディオムや型表現を強化していく手法を採用する傾向がありますが、Goではそれらを積極的に採用せず、静的解析やテスト、レビューを重視します。
もちろん前者には論理的に一部のポカが入る余地を完全に除去できる旨味はあります。しかし、イディオムや型表現が増えることはまた別の種類のポカを産む余地が生まれたりするのです。Goではポカはシンプルな言語仕様で一部を取り除き、残りは習熟や静的解析、テスト、レビューで減らすという戦略を採っています。
また、レビューによるポカ発見はレビュアー全体の習熟度を向上させ、習熟度が上がればポカの発生頻度が下がります。ポカの見逃しが怖いなら静的解析やテストで機械的に発見できるようにすれば良いというのがGoの考え方です。
Go以外で採用されている新しいイディオムや型表現で減らせるのは結局のところポカ全体のうちのほんの一部でしかありません。もちろん、ポカを完全に除去できる領域を拡大していくこと自体は歓迎すべきことではあるのは間違いないのですが、その方向に傾倒して行き着く先はCoqのような言語に近づくことになります。Coqなどはコンパイルが通った時点で正しい挙動が証明(保証)されるとはいえ、これが生産性を発揮する分野はかなり限定的であって、正しさを正確に表現すること自体にコストがかかりすぎるのです。
つまり、結局のところコンパイルタイムチェックをどこまでするのかという「程度の違い」でしかありません。Goはモダンな言語に比べコンパイル時間や学習コストを大きくするようなしくみの採用には消極的です。Goは静的解析やテストやレビューをやりやすくすることに注力した設計やツールを採用しています。Go1.18に採用されるジェネリクスはコンパイル時間や学習コストを大きくするわけですが、採用された理由は他に相乗効果メリットが大きいと判断されたわけです。
Discussion