strict aliasing rules, type punning解説 その1

strict aliasing rules つまり元の型のオブジェクトを別の型のオブジェクトとして使用すること(type punning)は基本的にできないというC/C++におけるルール。
つまり以下のようなことはできず、未定義動作を引き起こします。

float value = 0;
int i = *(int*)&value; //undefined behavior

これはfloatとintに互換性(aliasingの仮定)がないからです。ここではアライメントや型の大きさの違いといった環境依存の別の問題も存在しますが、これをクリアしても依然として未定義動作です。

static_assert(sizeof(float) == sizeof(int), "");
alignas(float) alignas(int) float value = 0;
int i = *(int*)&value; //undefined behavior

例ではポインタを用いましたが参照でも同じです。

以下この記事ではstrict aliasing ruleに焦点を絞って解説します。アライメントや型の大きさ、effective type、ポインタ型のキャスト、パッティング、内部表現(エンディアン、2の補数?、IEEE754?)、C言語におけるtrap representation、trivially copyableかどうかといったその他のことについてはあまり考慮していません。つまりこの記事でOKとしてあっても上記の理由により未定義動作や未指定動作となることが十分にあり得ます。実際にtype punningを行う際にはこれ等にも注意する必要があります。またこの記事における未定義動作という用語は規格で述べられたもの、個々のコンパイラのマニュアルで述べられたもの、個々のコンパイラが期待とは異なるコード生成をする事などの意味を混同して使用していますのでその点をご留意ください。

このルールの背景にあるのはaliasing(別名の存在)を仮定しないことによりある意味では普通の最適化をしたいという動機です。
下記例は strict aliasing rules の存在によって最適化が可能な一例です。

void fill(int* dest, double* src, size_t count) {
for (size_t i=0; i<count; ++i) {
 int value = *src;
 *dest++ = value;
}
}

destの指す先が更新されてもsrcの指す先に変更は及ばない(intとdoubleに互換性はないからint*とdouble*は同じ領域を指してはいないだろう)と考えられるため int value=*src; をforループに入る前の位置へ移動する最適化が可能です。もし同じ領域を指すと仮定すると(aliasingが存在していたら)最適化はできなくなります。


通称 strict aliasing rule について規格では大よそ次のように述べられています。
左辺値の元の型のオブジェクトを以下に述べる型以外でアクセスした場合は未定義動作である。

  • 元の型に対してsigned,unsignedの関係にある型。
  • const等で修飾された型。
  • type similarな(cv修飾だけが違うようなポインタ)型(C++11)。
  • 継承関係にある型。
  • 上記の型を含んだ集成体(配列、条件付きの構造体)、union。
  • char, unsigned char, std::byte(C++17)型。C言語ではsigned charも含まれるがC++では含まれない。

つまりこれらがaliasingの存在が考慮される型であり互換性があると言えます。特にchar,unsigned char,std::byte型はどんな型に対してもaliasingが仮定されます。また特に集成体とunionについては誤解が多い所で注意が必要です。左辺値と条件が付いていますがルールのイメージを掴む上で必要なものではないのでとりあえず忘れてもらって構いません。

unsigned value = 0;
int i = *(const int*)(void*)&value; //OK

unsigned intとconst intは互換性がある(ただし非constからconstへの一方向です)。このときストレージのサイズとアライメントについては心配いりません。標準のsigned型とunsigned型はどちらも同じです。

float value = 0;
char* tmp = (char*)&value;
int i = *((int*)tmp); //undefined behavior

途中でchar*型を経由しても意味はありません。元の型とそれをアクセスする型はそれぞれfloatとintであり互換性がありません。


aliasingを仮定した適切なコードにするにはどうするかというとchar,unsigned char型としてアクセスし用意した別の領域へコピーするようにします。memcpyやmemmoveを使用するのが一番汎用的な方法でしょう。

static_assert(sizeof(float) == sizeof(int));
float value=0;
int out;
memcpy(&out, &value, sizeof(out));

//もしくは
char* src = (char*)(void*)&value;
char* dest = (char*)(void*)&out;
for (int i=0; i<sizeof(out); ++i)
    *dest++ = *src++; 

memcpyの仮引数はvoid*型であり一見aliasingは仮定されないように思うかもしれませんが標準ライブラリの<string.h>ヘッダの全ての関数は内部でバッファをunsigned char型として扱うと決められているのでこれらの関数は常にaliasingを仮定しているため安全です(ちなみにこの記事におけるaliasingとは型同士の話であって、memmoveとmemcpyの差異というのは少し違った話です)。逆に言えばコンパイラがmemcpyやmemmoveを積極的にinline化してくれるとしてもaliasingの存在を仮定しているという点においては遅くなる可能性があります。また単にchar,unsigned char型を文字列としてしか扱っていなくてもその個所では最適化が効きづらくなっていると考えられます。


char,unsigned char配列型のストレージを単独の別の型(T)として扱う場合は規格のstrict aliasing ruleに該当する文面を見る限りでは保証されていないと考えられます。が結果的にchar,unsigned char型は任意の型Tと相互にaliasingが仮定されることになるためおそらく最適化に絡む問題が発生する可能性は少ないでしょう(もちろんアライメント等の別の問題が発生する余地は十分にあります)。しかし互換性のない2種類以上の型として使用した場合は明らかにundefined behaviorでしょう。
(規格ではchar,unsigned char配列型へtrivially copyableな型の値をコピーしてからまた元に戻せることは保証していますがここでは直接関係なさそうです)
未定義動作の避け方ですがバイト列のデータをT型のオブジェクトとして読み取りたいならmemcpyを使い、char配列のストレージ上にT型のオブジェクトを構築したいなら 配置new を使用するのが良いでしょう。
規格ドラフトN4140から抜粋した以下の文には"reused"や"with side effects"など思わせぶりな単語が見えるので

3.8 Object lifetime
The lifetime of an object of type T ends when:
— if T is a class type with a non-trivial destructor (12.4), the destructor call starts, or
— the storage which the object occupies is reused or released.

3.7.3 Automatic storage duration
If a variable with automatic storage duration has initialization or a destructor with side effects, it shall not
be destroyed before the end of its block,

配置newによってreusedされ以前のcharオブジェクトが破棄されたと考えればstrict aliasing rulesは回避できそうな気がします。

alignas(T) unsigned char storage[sizeof(T)];
*((T*)&storage) = T{}; // undefined behavior
T* p = ::new ((void*)&storage) T(); //OK
*p;// OK
p->~T();

ストレージ用途にstd::aligned_storage<Size>::type型が利用できますがstd::aligned_storage<S>::typeではなくstd::aligned_storage<S>を間違えて使ってしまうデメリットがあると思っています。

またstrict aliasing rulesとは別に古いオブジェクトへのポインタを使いまわしてはいけないという規則があるのですが、それに関してC++17からはstd::launderというなんだか使えそうで使えない感じの関数が入ります。
この関数は規格で前提条件がsimilarな型に限定されてしまっているのですが、コード例ではそれ以外の型に使用している場面があるなど現状意味がわからないです。


エイリアシングの考慮による最適化の違い clang3.3 -O2

void fill(int* dest, double& src, size_t count) {
for (size_t i=0; i<count; ++i) {
 int value = src;
 *dest++ = value; // #1 int,unsigned,char,unsigned charのみ考慮
 //memcpy(dest++, &value, sizeof(int)); // #2 あらゆる型を考慮
}
}
# 1
	cvttsd2si	(%rsi), %eax #呼び出されるのは最初だけ
.LBB0_2:
	movl	 %eax, (%rdi)
	addq	 $4, %rdi
	decq	 %rdx
	jne	 .LBB0_2

# 2
.LBB0_1:
	cvttsd2si	(%rsi), %eax #ループ内で何回も呼び出される
	movl	 %eax, (%rdi)
	addq	 $4, %rdi
	decq	 %rdx
	jne	 .LBB0_1

またこれとは関係なく一般的に関数呼び出しをしていればその先でsrcの指す先が更新される可能性があるため最適化の難易度は上がります。

//ポインタの最下位ビットを立てる例(そもそも推奨できませんが)
typedef void* pointer;
static_assert(sizeof(pointer) == sizeof(uintptr_t), "");
alignas(max_align_t) pointer p = nullptr;

// undefined behavior
*(uintptr_t*)(&p) |= 1; 

// OK
uintptr_t tmp;
memcpy(&tmp, &p, sizeof(tmp));
tmp |= 1;
memcpy(&p, &tmp, sizeof(tmp));


unionを使用する方法はお勧めできません。strict aliasing ruleによれば未定義動作となります。strict aliasing rule の文面にunionとありますがこれはunionのメンバ同士のtype punningを意味するのではありません。

gccにおいてはunionのメンバを直接使用する分には問題がないが、ポインタ(参照も同様でしょう)を経由した場合はundefined behaviorであると記載されています。特にunionのメンバのポインタ(参照も)をとることは他のコンパイラにおいても少なからず制限されると考えられます。

C99以降ではunionを使用した際のstrict aliasing ruleによる未定義動作は解消されていますがこの自由度はgccと同程度のものと考えたほうがいいでしょう。

union uni{ float f; int i; } u;
u.i = 0;
float* p = &u.f;
*p = 0; //the behavior is undefined on gcc and probably on other compilers.

alignas(uni) int a=0;
((uni*)&a)->f = 0; //the behavior is undefined on gcc.

unionが利用できる場面は実際的にはかなり限定されていると考えたほうがいいでしょう。unionはtype-punningのためのものではないのです。


応急的な回避策としてはあらゆる型同士の間にaliasが存在すると仮定するgcc、clangのコンパイルオプション-fno-strict-aliasingが利用できます。
ただコード自体が規格に沿うわけでもありませんし、strict aliasing rulesに基づく最適化もされなくなります。それでもundefined behaviorよりはよっぽどましです。

strict aliasing rules, type punning解説 その2(union)へ続く。