C++

Sebastian Aaltonen氏のプログラミング観

とても勉強になったので写経のつもりで翻訳しました。
普段自分でもわかっている気がするけど言語化するのが難しかったこと、自分でやっていて確かにあとから痛い目を見たかもしれないと反省を促される内容でした。
※ゲームプログラミング特有だなと感じた内容はきちんと理解できなかったので割愛しています。

他の人が「C++開発においてデバッガは使い物にならない」みたいな物議を醸しそうな発言をしてくれたし僕も20年来のC++プログラミングの経験を通して学んだ単体テスト、DRY、コピペ指向プログラミング、関数の長さ、その他諸々について思ってることを共有できそうな気がする。

まずはじめに僕はたくさんの間違いをして(あるいはチームにするように仕向けて)きたことを伝えておきたい:教科書通りのOOP(鳥は動物である)、テンプレートの怪物、コード生成のやりすぎ、オーバーエンジニアリング、過度に抽象的なコード、生同期プリミティブの使いすぎ...

単体テストは一つの依存だ。関数・クラス・データを使うもう一つの呼び出し元。依存ゼロのコード・データに依存を追加することはタダじゃない。重くなる。そのコードへの変更は遅くなるしいくつかのリファクタリング・最適化はできなくなる。

よくあるライブラリ系のコード(算数、コンテナなど)はもともとたくさんの依存があって変わらないよく定義されたAPIがあるから依存を追加すること(テストケース)によって重くなることはない。こういうコードのテストはとても有益でほとんど害がない。

いい慣例として三回同じコードをコピペしてそれでも同じ振る舞いをしているならリファクタリング(抽出)するといい。そこに至るまではコードに依存がないから単体テストに追加しないほうがいい。依存のないコードが一番いいコードだ。安全に改変できる。

大きいコードベースで一番大きな問題は複雑に絡み合ったコードの依存関係だ。早まった抽象化や抜き出しは依存関係を増やす。二箇所以上から呼び出されるコードはいずれ複雑な制御フローで溢れてしまう傾向にある。パフォーマンスは悪く、わかりにくくて改変が難しい。

世の中には二種類の長い関数がある。もつれたものとそうでないもの。前述のように依存を作ることを避ければ流れるように進行する長い関数を書くことができる。読みやすく、わかりやすく、改変しやすい。コードベース内を飛び回ることがない。脳にかかる負荷が小さい。

一見そういう長い関数は小さな関数として区切って共通するコードを使いまわしたほうがいいように見えるかもしれない。でもそうすることでそれらの関数の間の依存関係は増え、直線的にコードを読むことができなくなる。改変は多くの関数に影響を与えるからリスクが高い。

プログラムはデータを変換する。データの依存関係こそが真の依存関係であり、どのデータをどの順番で処理して、どうやって処理を並列化して、どの普遍性を維持しなければならないかを規定する。コードの依存関係は多くの場合見せかけの依存関係にすぎない。

前述の通り多くの場合関数が大きすぎることが問題にはならない。巨大なクラスや構造体が互いに関係のない大量のデータを含んでいる場合はその限りではないけれど(Baseobject症候群)。どこにどのデータを置くか決めるときに実世界の「モノ」の抽象化は絶対に使わないほうがいいよ...

代わりにそのデータに対して適用するすべての変換を元にデータを分けるといい。頻繁に一緒にアクセスされるデータは一緒にいるべきだし、ほとんどの呼び出し元で使われない属性は別の構造に切り分ける。こうすることで見せかけの依存関係はできなくなるしキャッシュの利用効率がよくなる。

クリティカルセクション(排他制御など)は同期について間違ったことを教えてしまう。コードを同期したいことはないはずだ。コードは不変だ。競合状態はすべて純粋にデータの問題だ。一つの関数・クラスを守る同期プリミティブが共有されていたらそれは臭いことが多い。

細かい同期プリミティブを使わずに見せかけの依存関係をへらすようにデータを分割したほうがいい。こうすることで単純な並列化が可能になるし同時にコードベースを維持・管理するのが簡単になる。キャッシュによるパフォーマンス改善にもなる。

データの競合状態がないことを補償できなければマルチスレッド化なんてできない。他のオブジェクトへのポインタや参照をたくさん持ったオブジェクトは非常に並列化しにくい。仮想関数に至ってさらにひどい。呼び出しのターゲットがわからないのにどのデータがアクセスされるかなんてわかるわけがない。

パフォーマンスのボトルネックはほとんどループの中にある。大量に呼ばない限りは遅い関数なんて存在しない。ループの中で使われているデータをきちんと理解していれば並列化によってコア数分までスケールさせられる。抽象化、仮想関数、そしてデータの依存はこれを難しくする。

単純なデータ構造を使ってさえいればループの並列化は最も安全な最適化だ。並列forループは関数の中に閉じ込めることができる。他のコードを改変する必要なんてない。タスク・ジョブスケジューラとも相性がいい。

「早まった最適化は諸悪の根源である」は計算機科学においてもっとも誤解された文だ。データ構造の計画はアーキテクチャ設計の一部であって最適化ではない。いいデータレイアウトはパフォーマンスを劇的に向上させるだけでなくコードの管理を楽にする。

言い換えるなら「プロファイラがボトルネックを発見するまで細かい最適化をするな」。プロファイリングツールは早いうちから使ってなるべく早めに問題を発見できるようにしよう。頻繁にプロファイルをとって自動プロファイリングで品質保証を。

未来に備えたコードを作る一番の方法:今の要求を満たす最小限のコードを作ること。コード・データの依存関係を最小化する。過剰に抽象的なコードがいいとは限らない。いくつか特殊ケースに対応してくれるかもしれないけど過剰に複雑だ。次に必要なことを実現するためにリファクタリングをしよう。

未来を予想するのは難しい。決断を下しコードを書くことをなるべく遅らせよう。こうすることで実際のニーズに合った判断をしてコードを書くことができる。誰も使わない機能を書くのは時間の無駄だ。早まった機能の実装は大きなリファクタリングに繋がりやすい。

オブジェクトのライフタイムや所有権が未知のコードは臭い(レファレンスカウント、共有ポインタ)。ほとんどの場合あらゆるオブジェクトの明確な所有者を定義できるはずだ。マルチスレッド環境ではカスタムのデストラクタとレファレンスカウントは致命的に相性が悪い。デストラクタがいつ呼ばれるのかがわかりづらい。

ユニークポインタを含むその他レファレンスカウントされないRAIIは僕も嫌いじゃない。でもメモリアロケーションにかかるコスト、メモリのフラグメンテーション(コンソールやモバイルなど)、そして間接参照のコスト(キャッシュミス)を忘れないこと。カスタムアロケータを使って別途メンバをアロケートするのではなく値を持ったメンバを使うことを好もう。

特定の型を持つオブジェクトをアロケートする一番の方法は大きなストレージ領域を確保しておいて全部隣通しに置くことだ。線形なメモリアクセスパターンはバラバラにアロケートするよりキャッシュフレンドリーだ。パフォーマンスに大きな差が出る。

手動new/deleteを使っている場合はメモリリーク検出ツールを使おう。グローバルなnew/deleteをラップすることで簡単にリーク追跡もできる。日頃の開発ビルドではリーク追跡を有効にしよう。メモリトラッカーを使い始めたらメモリの情報もダンプしよう。とても便利。

リファクタリングするときはローカルブランチ(git)もしくはシェルフ(P4)を作ろう。こうすると結果が良くなかったときに変更を簡単に捨てることができるし、あとでまた触りたくなったらまだコードが残ってる。変更に100%満足していない限りは主要ブランチにマージはしないほうがいい。

すべてのリファクタリングの試みは情報をくれる。失敗したからと言って無駄にはならない。でも開発が活発なコードにリファクタリングの労力を注ごう。きちんと動いていて近い将来に変更が入る予定のないコードを触る必要はない。

パフォーマンスがいいコードを書きつつ、プロファイリングをせずに細かい最適化を施さないようにしよう。最適化をするときはすべてのターゲットプラットフォームでベンチマークをとって結果が100%明瞭でコードの品質に満足している場合のみ主要ブランチにマージしよう。そうでなければブランチを切る。

履歴を残すためにすべてのプラットフォームのプロファイルのトレース・キャプチャファイルを保持しよう。最新の最適化・変更を元にファイルに名前をつけよう。最適化の前後ですべてのプラットフォームでビフォー・アフターを比較しよう。得たゲインについてメールやメモを残そう。品質保証のためにリグレッションテストを自動化しよう。

純粋なALU命令カウントはパフォーマンスボトルネックではないことが多い。間違った最適化のために時間を無駄にするのではなくプラットフォーム専用の低レベルプロファイリングツールを使って本当のボトルネックを探そう。CPUコードではメモリレイテンシとキャッシュが最もよくあるボトルネックだ。

現代のアウトオブオーダーCPUにとって最も毒なのはメモリload→addr→loadの依存チェーン(連結リストなど)だ1。CPUの並列化を促すために深さ優先ではなく幅優先探索を使おう。CPUがすべての小要素のメモリを同時に読み込むからレイテンシを帳消しにできる。

新しいC++の標準を採用する前にすべてのターゲットプラットフォームのコンパイラがサポートしていることを確認しよう。すべてのコンパイラで使いたい機能のコード生成やコンパイル時間を調べておこう。現状では使うのがリスキーだと思う機能は使用を禁止してもいい。


  1. ここの部分はきちんとわかっていません。ご指摘をいただけると幸いです。