C, C++の標準規格に存在するStrict aliasing rules。これについて、プロでもなかなか理解されにくい(現状、自分でも理解できているかどうか怪しい)ので、極力わかりやすく書いていきたいと思う。
- TL;DR
- はじめに
- 未定義動作とは
- Strict aliasing rulesとは
- mallocやmmap、共有メモリの扱いについて
- Strict aliasing rulesに従いつつ、別の型のメモリとしてアクセスする方法
- 終わりに
- 参考
TL;DR
原則として、別の型のメモリとしてアクセスするようなコードを書いてはならない。別の型のメモリとして使う場合はmemcpy
を使う方法が最も無難である。
はじめに
Strict aliasing rulesは、個人的に非常に難しい規則であり、知らず知らずのうちに未定義動作(以下UB; Undefined behavior)を起こすコードを書いてしまう恐れがある。特に「あるメモリを別の型として扱うようなコード」を書き慣れている人にとっては、とても辛く、理解しがたいものと思われる。
しかしUBコードはそもそも書いてはいけないコードであるので、故にStrict aliasing rulesは原則として守らなければならない。
そこで、UBとStrict aliasing rulesについて、極力誰でも理解できるように書いていきたい。
未定義動作とは
UBとは、意図した動作となることが保証されないプログラムのことである。
UBとなるコード以外は問題ないように見受けられるかもしれないが、実際にはUBを含むコードは、それ以外にも悪影響を及ぼす。つまり、問題のないコードも、書いた通りに動かなくなる可能性がある。
これの真に恐ろしいところは、一見何も問題ないように動く可能性もある、という点である。これは一見何も問題がないから安心なのではなく、後々大きなバグが見つかる可能性もあり、そしてUBを含むプログラムは、本当はロジックも間違っていて、その間違ったロジックで動いている保証もないので、原因を突き止めることも困難になる。
したがって、実務コードではUBを含むコードは書いてはならない。それを許して良いのは競プロやコードゴルフのみである。
Strict aliasing rulesとは
Strict aliasing rulesは、ある型T
とU
において、T
型の値v
に対して、1つでも満たしていると*(U *)&v
のようにアクセスすることが許される、以下の規則である。
T = U
であるU
がT
に修飾した型(const
,signed
,unsigned
)であるT
がstruct { U; ... }
,union { U; ... }
もしくはそれに修飾した型であるU
がchar
もしくはそれに修飾した型である
4.については、int8_t
, uint8_t
も使える場合があるが、基本使わないほうが良い。これらの型はsigned char
, unsigned char
の型エイリアスとして実装されていることがあるが、そうでない場合もあるからである。
これらを満たさないアクセスは、UBとなる。そのため、よくテクニックとして書かれる*(U *)&v
は、実はStrict aliasing rulesを満たさない場合は書いてはならないのである。
typedef struct { int m; } T1; typedef struct { T1 m1; long long m2; } T2; int main(void) { T2 v = {{42}, 42LL}; *(T2 *)&v; // OK *(const T2 *)&v; // OK *(T1 *)&v; // OK *(char *)&v; // OK *(float *)&v; // Undefined behavior return 0; }
Strict aliasing rulesは実のところ、C, C++プログラムを最適化するために作られた規則である。したがって、なぜこのような規則があるのかについてはプログラマ目線から見ても理解できない可能性が高いので、解説しない。
この規則はC, C++プログラムを高パフォーマンスなものにする手助けをしているということだけ覚えておけば良いだろう。
malloc
やmmap
、共有メモリの扱いについて
Strict aliasing rulesを見て、malloc
やmmap
、共有メモリを生成したときも、常にvoid *
でなければならないと思った方もいるかもしれないが、そこは1度だけ任意の型のポインタとして扱えることが許されているので安心してほしい。
なぜなら、これらは型を持たないメモリに過ぎないからである。このような動的に割り当てられたメモリは、最初は型を持たない。動的割り当ての後に、そのメモリへのポインタを特定の型のポインタとして格納することで、初めてそのメモリは型を持つ。
そのため、動的割り当てされたメモリにおいては、異なる型のポインタへのキャストが許されているので、安心してキャストして良い(もちろん、そこで型が決定されるので、そこからは別の型のメモリとしてアクセスしてはいけないが)。
#include <stdlib.h> int main(void) { int *pInt = (int *)calloc(1, sizeof(int)); // OK (void)free(pInt); pInt = NULL; return 0; }
Strict aliasing rulesに従いつつ、別の型のメモリとしてアクセスする方法
(1) memcpy
を使う
Cにおいては、memcpy
を使うのが最もメジャーな方法である。memcpy
は中でchar *
に変換してコピーを行っている。Strict aliasing rulesにおいてどの型のメモリにおいてもchar *
を使ってアクセスして良いので、memcpy
を使ってもStrict aliasing rules違反とはならないのである。
「え?memcpy
は関数だから、呼び出しのオーバーヘッドが発生しちゃうじゃん」と思われた方もいるだろう。しかし実際のところ、モダンなCコンパイラではmemcpy
は最適化されて、あたかも関数が呼び出されていないかのように動作するアセンブリコードにコンパイルしてくれるので、これがボトルネックとなることは少ないはずである。
#include <stdint.h> #include <string.h> #include <stdio.h> int main(void) { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) uint32_t u32s[2] = {UINT32_C(0x00000000), UINT32_C(0x40450000)}; double d; // `uint32_t[2]`から`double`に変換 (void)memcpy((void *)&d, (void *)u32s, sizeof(uint32_t[2])); printf("%.1lf\n", d); // -> 42.0 return 0; }
42.0
どうしても不安なのであれば、いちいちfor
文を使ってchar *
の値をインクリメントして1byteずつ格納していくことになると思うが、可読性は落ちるし、結局生成されるアセンブリコードはmemcpy
を使う場合とあまり変わらないしで、そこまでしてmemcpy
の使用を避けるメリットは感じられない。
#include <stdint.h> #include <stdio.h> int main(void) { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) uint32_t u32s[2] = {UINT32_C(0x00000000), UINT32_C(0x40450000)}; double d; // `uint32_t[2]`から`double`に変換 char *p1 = (char *)u32s, *p2 = (char *)&d; for (size_t _ = 0; _ < sizeof(uint32_t[2]); ++p1, ++p2, ++_) { *p2 = *p1; } printf("%.1lf\n", d); // -> 42.0 return 0; }
42.0
(2) union
を使ったType punning(C99以降, 処理系依存)
C++ではUBとなるが、C99以降ではunion
を使ってType punningできることがある。ここでType punningとは「ある型のメモリを別の型のメモリとして使う」テクニックを指す。先ほどのStrict aliasing rulesの例で紹介した*(U *)&v
もType punningの一種である。
先ほども書いた通り、Strict aliasing rules違反をした場合のType punningは、一般的にはUBとなる。しかし例外として、一部のCコンパイラではunion
を使ってType punningすることが許されている。
#include <stdint.h> #include <stdio.h> int main(void) { const union { uint32_t u32s[2]; double d; } u = { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) .u32s = {UINT32_C(0x00000000), UINT32_C(0x40450000)}, }; printf("%.1lf\n", u.d); // -> 42.0 return 0; }
42.0
一見、こっちのほうが効率の良いアセンブリコードを生成しそうに見える。しかし、実際にはmemcpy
を使った場合と似たようなアセンブリコードを生成する(まったく同じコードを生成することすらある)。
さらに、先ほど書いたように、これは一部のCコンパイラで許可されているものであり、実際にはほかのunion
メンバをアクセスした場合の挙動は規定されていない。どのように処理されるかは処理系に依存しており、積極的に使うべきではないだろう。
また注意点として、メンバに直接アクセスする分には問題ないが、メンバにポインタを介してアクセスするのはStrict aliasing rules違反となる。これは、別の型のポインタを介してアクセスするのと同じとなるためである。
#include <stdint.h> #include <stdio.h> static inline void f(const double *p) { printf("%.1lf\n", *p); } int main(void) { const union { uint32_t u32s[2]; double d; } u = { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) .u32s = {UINT32_C(0x00000000), UINT32_C(0x40450000)}, }; f(&u.d); // Undefined behavior return 0; }
(3) __attribute__((__may_alias__))
を使用する(GCC拡張)
型を指定するときに、__attribute__((__may_alias__))
を使用することで、その型のポインタを介したアクセスはStrict aliasing rules違反を許容する。ただしこの機能はGCC拡張であり、GCC以外でもコンパイルできる保証はない。
#include <stdint.h> #include <stdio.h> typedef double __attribute__((__may_alias__)) UnstrictDouble; int main(void) { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) uint32_t u32s[2] = {UINT32_C(0x00000000), UINT32_C(0x40450000)}; printf("%.1lf\n", *(UnstrictDouble *)u32s); // -> 42.0 return 0; }
42.0
(4) コンパイラオプション-fno-strict-aliasing
を使用する
そのコードが古いもので、もうどうしようもない場合は、コンパイラオプションに-fno-strict-aliasing
を追加してコンパイルすると良い。ただしこれはコードの最適化を一部諦めることを示すので、どうしようもなくなったときの最終手段として使うべきである。
gcc -O2 -fno-strict-aliasing -Wall old_program.c -o old_program
(おまけ) std::bit_cast
を使う(C++20以降)
ここからはCではないので、おまけとして紹介しておくが、C++20以降では、std::bit_cast
を使ってメモリを別の型として再解釈する方法がある。memcpy
ではメモリサイズが同じ場合においても安全にメモリコピーできるかどうかは、プログラマに全責任を委ねられているが、std::bit_cast
ではメモリサイズが異なる場合コンパイルエラーとして弾くため、memcpy
よりも安全にメモリコピーが可能である。
#include <cstdint> #include <bit> #include <iostream> #include <iomanip> int main() { // `uint32_t[2]`型で`42.0`を表現(リトルエンディアンとする) std::uint32_t u32s[2] = { std::uint32_t(0x00000000), std::uint32_t(0x40450000) }; std::cout << std::fixed << std::setprecision(1); std::cout << std::bit_cast<double>(u32s) << std::endl; // -> 42.0 return 0; }
42.0