プログラミング
CおよびC++における型パンニング
Type Punning in C and C++ (blog.pwkf.org)
要約
この記事は、CおよびC++における型パンニング、すなわちメモリを異なる型として解釈する手法について解説しています。ポインタキャストは一般的に使われますが、厳密なエイリアシング規則により未定義の動作(UB)となる可能性があり、特に最適化レベルが高い場合に予期せぬバグを引き起こすことがあります。安全な型パンニングの方法として、ユニオン(union)やmemcpyが推奨されています。
全文翻訳
あるバグの特定に時間がかかりました。問題は型パンニングでした。ポインタキャストは-O0では問題なく動作しましたが、-O2では静かに壊れました。CとC++の区別は本当に危険で、このトピックに関するほとんどのブログ記事は間違っています。
型パンニングとは、読み書きの間でメモリを異なる型として解釈することです。これは、シリアライゼーション、ネットワークプロトコル、および低レベルのハードウェアアクセスに不可欠です。問題は、「実際には機能する」ことと、「定義された動作を持つ」ことは異なるということです。
セーフからUBへのスペクトラム
C言語では、安全な型パンニングの方法はユニオン(union)とmemcpyです。ポインタキャストは、厳密なエイリアシング規則の下では技術的に未定義の動作ですが、遭遇するすべてのコンパイラで機能します。
ユニオン(Union)
ユニオンを使用すると、ある型として書き込み、別の型として読み取ることができます。これはC言語では定義された動作です。
union { float f; uint32_t bits; } pun;
pun.f = 3.14f;
uint32_t exp = (pun.bits >> 23) & 0xff; // IEEE-754指数を抽出
これは、構造体を分解するのにも美しく機能します。
union { struct color { float r, g, b, a; } c; float as_array[4]; } u;
u.c = (struct color){ .r = 1, .a = 1 };
float a = u.as_array[3];
memcpy
ユニオンを使いたくない場合は、memcpyが安全であり、コンパイラはそれをレジスタ移動に最適化します。
float f = 3.14f;
int i;
memcpy(&i, &f, sizeof(f)); // 定義された動作、単一の命令にコンパイル
ポインタキャスト — 便利だがUB
これはコンパイルされ、実行され、すべてのプラットフォームで「正しい」結果をもたらします。
float f = 3.14f;
int *p = (int *)&f;
int i = *p;
しかし、これも未定義の動作です。厳密なエイリアシング規則は、オブジェクトはその実効型、その修飾バージョン、または文字型へのlvalueを通じてのみアクセスされるべきであると述べています。無関係な型へのポインタキャストはこれを違反します。
CとC++が異なる理由
C言語では、型はメモリを解釈する方法です。C++では、型はファーストクラスの市民です — コンパイラは異なる型が決して互いにエイリアスしないと仮定することが許可されています。これには具体的な結果があります。
以下を検討してください。
struct c { uint32_t a; uint32_t b; };
uint32_t bar(uint64_t *u64, struct c *c) {
if (c->a == 2) {
*u64 = 4;
}
if (c->a == 2) {
return c->a;
}
return c->b;
}
int main() {
struct c c = { 2, 3 };
return bar((uint64_t *) &c, &c);
}
GCCまたはClangで-O2を使用すると、これは2を返します。-O1以下では、0を返します。コンパイラは、u64がuint64_t*であり、cがstruct c*である — 異なる型 — と認識するため、それらがエイリアスしないと仮定します。*u64 = 4の書き込みがc->aを変更できないという仮定に基づいて、2番目のc->a == 2のチェックは最適化によって削除されます。これは、型がメモリ内で重複しているにもかかわらず、標準の下では技術的に正しいです。
より深い説明は、「Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes」にあり、C++がこの方向に行った理由をカバーしています。
実践的なルール
C言語を記述していて型パンニングが必要な場合は、ユニオンまたはmemcpyを使用してください。ポインタキャストは、機能しなくなるまで「機能」します。そして「機能しなくなる」とは、コンパイラがあなたが実行されていると思っていたコードを静かに最適化してしまうことを意味します。
C++を記述している場合、同じことが適用され、さらにコンパイラは「as-if」規則の下で物事を壊すためのより多くの自由度を持っています。
私が始めたバグは何だったか?ホットループ内のfloat*からuint32_t*へのポインタキャストでした。-O2では、ループは厳密なエイリアシングの仮定の下で最適化され、私が書き込んでいた値は期待される場所には決して現れませんでした。ユニオンが10分でそれを修正しました。