16.1 strict aliasing rule

C/C++言語には、strict aliasing rule と言われるルールが存在します。これは、as-if ルールに基づくより詳細な最適化に関するルールです(as-if ルールについては14.1.4 最適化とはにて取り上げています。)。

そのルールは、ミュータブルなオブジェクトに対して変更を加えた時、どのような場合にその変更が別の変数に影響を与えないと、安全に仮定できるかを定めた規則です。具体的に述べると、どのような場合に二つのオブジェクトが同じメモリ位置を参照するかもしれないと仮定すべきかを定めたルールです。

まず、aliasingとはなんでしょうか。以下は二つの変数がaliasingであると言える例です。

int i=0;
int* ptr=&i;

「7.1.2 ポインターの基本」でも述べた通り、ポインタptrに対して何か変更操作を行なったその後に変数iから値を読み取ると、そのptrに対する操作に依存して当然iから読み取る値も変動するはずです。何故ならiptrの示すメモリ位置は同一だからです。このような同一のメモリ位置を2つ以上のlvalueが示す事をaliasingと言います。 以下に更に例を示します。

int a;
void f(int& b,int& c);
f(a,a);

fの両方の引数にaを指定しています。この時、f内ではbcは、同一のメモリ位置を示すためaliasingとなります。 ここで、コンパイラ製作者の気持ちになって見てください。例えば以下のコードは、どのように最適化できると考えられるでしょうか。

int a;
int f(double* ptr)
{
    a=10;
    *ptr=20.0;
    return a;
}

aには10を代入しています。その後、全く関係のないように思われるptr20.0を代入し、その後aを返しました。つまりaは初めから10である、つまりイミュータブルなオブジェクトであると仮定する事は安全であるように思えます。よって以下のように最適化できます。(実際にはC++コードに対して最適化を行うのではなくアセンブリコードの生成に対して最適化を行います。以下は便宜上のイメージです。)

int a;
int f(double* ptr)
{
    a=10;
    *ptr=20.0;
    return 10;
}

変数aに対して読み込みを行なってからその値を返すよりも、コンパイル時に10という値であると決めつけてしまった方が良いパフォーマンスとなります。これでめでたく高速な実行ファイルが生成されました、めでたしめでたし...ともなりませんね。

例えばもし以下のように、実はptraと同じメモリ位置を指していて、それに対して変更操作を行なったら、当然値は変動してしまいます。(以下は極端な例です)

int a;
int f(double* ptr)
{
    a=10;
    ptr=reinterpret_cast<double*>(&a);
    *ptr=20.0;
    return 10; // ...とは出来ない。
}

やはりそう考えると、aのデータを再度動的に読み込むしかありません(上のコードで言えばreturn文の部分)。それが例え、aliasingな変数や変更操作が無かったとしても...

そこで、aliasを行えるある条件を定めてしまい、それに従わない全てのケースではコンパイラ製作者がlvalue間のaliasingが無いと仮定する事を許容する事にしたのです。この決まりに従う事で、プログラマーは適切な最適化の恩恵を授かる事ができるのです。

  • オブジェクトは、下記に示されたいずれかの型をもつlvalue式によってのみアクセスされ、格納された値を持たなけれればならない
  • オブジェクトの有効な型と互換性のある型
  • 修飾された(const,volatailなど)オブジェクトの有効な型と互換性のある型
  • signedまたunsignedのオブジェクトの有効な型に対応する型
  • オブジェクトの有効な型の修飾バージョンに対応するsignedまたはunsignedな型
  • そのメンバとして前述のいずれかの型を含むアグリゲート(class,struct)またはunion型(サブアグリゲートや包含するunionのメンバも再帰的に含まれる)
  • 文字型
  • 派生された動的型(dynamic type)の修飾された/されない(const,volatailなど)基底クラス

この条件に合致しない場合、lvalue間のaliasingが無いと仮定して良いので、コンパイラ製作者はその仮定に基づいたオプティマイザを自由に実装できます。条件に合致した場合、aliasingの可能性をコンパイラ製作者は考慮しなければなりません。

前述したコード

int a;
int f(double* ptr)
{
    a=10;
    *ptr=20.0;
    return a;
}

で考えると、strict aliasing(標準規格)の下では、非互換(incompatible)な型であるdoubleintはaliasになりえないため、コンパイラ製作者には関数fを最適化する自由度が与えられます。もしff(reinterpret_cast<double*>(&a))のように呼び出したら、上記の条件に合致してしまうため最適化の余地は与えられません。

よって総括すると、全てのプログラマーは極力strict aliasing ruleに完璧に従うべきです。従わなかった場合、コンパイラ製作者はそれに従って(SOHUD)最適化を実装する可能性があるため、プログラマーの期待通りに動かない可能性を孕むかもしれません。例えば、以下はstrict aliasing ruleに背いたコードです。

// prog.cc
#include<cstdio>int main()
{
 int x=0;
 short* ptr=reinterpret_cast<short*>(&x); // strict aliasing ruleに反したコード
 *ptr=42;
 std::printf("%d\n",x);
}

これを、例えばGCC 4.3.6を使い、以下のコマンドでコンパイルします。

g++ prog.cc -O2 -march=native -std=c++98 -Wstrict-aliasing

すると以下のような警告が出力されます。

prog.cc: In function 'int main()': prog.cc:5: warning: likely type-punning may break strict-aliasing rules: object '*ptr' of main type 'short int' is referenced at or around prog.cc:6 and may be aliased to object 'x' of main type 'int' which is referenced at or around prog.cc:4.

実行してみると...

0

ptrを通じてxの場所に対して42を代入しているように思えますが、0のままです。これは、プログラマの意図した動きではありませんね。しかし、もちろんコンパイラのバグではありません。全ての責任は、strict aliasing ruleに背いたプログラマーに帰結するのです。これは、strict aliasing ruleに基づき、short*intのaliasとなる事は無いという規則が適用された結果ですから当然の報いと言えます。例えばここでstrict-aliasingを無効にすると動くかもしれません。GCCでは-fno-strict-aliasingオプションを使う事で無効にする事ができます。

$ g++ prog.cc -O2 -march=native -std=c++98 -fno-strict-aliasing

実行すると

42

上手くいきました。しかし、標準規格に従わないコードである事は確かですし、それによってコンパイラによる最適化を抑止してしまいますので、上記のようなコードを記述するべきではありません。また、極力コンパイルオプションを指定する際は規則を緩めるのではなく、厳しくするべきです。それが、解明できないレガシーな壊れたコード記述してしまわないための1つの助けとなるのです。

volatile

さて、14.1.6 volatile の意味にてvolatileについて触れましたが再度ここで strict aliasing rule と交えて触れておきましょう。例えば、以下のコードがあるとします。

// 何らかのデータ i があるとする
int x = i;
x = i; // 同じiをxに代入

ここまで述べてきたstrict aliasing ruleに基づくと、このコードは以下のように記述されたものとする事、つまり最適化を行う事ができますね。

int x = i; // #1

当たり前ですね。xiで初期化した後に、なぜまたiを代入する必要があるのでしょうか。このような冗長なコードは最適化された方が良いに決まっています。しかし、例えば、#1の前に他のスレッドが動いており、iの値が変動していたらどうなってしまうでしょうか。

int x = i;
// ... 他のスレッドでiが変更された
// 最適化によりx=iが除去される。xは更新されず...

当然、変動されたiのデータはxに適用できるはずがなく、他のスレッドで実行された結果をxに反映させる事はできません。

そこで、volatileキーワードの登場です。volatileによって、任意のデータがstrict aliasing ruleに則った最適化対象の操作を行なっていたとしても、そのコードを省いてはならない事を強制する事ができます。

int i=0;
volatile int x = i;
// ... 他のスレッドでiが変更された
x=i; // 除去されない。

volatileキーワードは他言語では別のセマンティックを持つ事などから排他制御をするためのキーワードと勘違いされる事が多いですが、C++ではそのような意味ではなく、単にコンパイラによる最適化を抑制するキーワードです。排他制御は、std::mutexや、std::atomicなどの標準ライブラリーを用いる事で実現できます。一つ注意して欲しいのが、第14章でも述べられている通り、実際のプロセッサー上での実行順序関係の最適化に対してはvolatileキーワードのみでは抑制する効力を持たないという事です。

当然ですが、std::atomicvolatile修飾する事が可能です。このようにした場合、排他制御をしつつ、コンパイラによるデータへの見かけ上の冗長なアクセスの排除(最適化)を抑制する事ができます。

std::atomic<int> i(0);
volatile std::atomic<int> x(i.load());
x.store(i.load()); // 冗長なアクセスに見えるが、除去されない。

尚、初期化の文とストアの文は、それぞれアトミック操作ではあるものの、単一のアトミック操作ではない事に留意する必要があります。それぞれload()という別の関数を呼び出していますから、それぞれが排他制御を行なっていると言えます。

最後に、volatileを型推論するとどのように推論されるのか、考えて見ましょう。

#include<boost/type_index.hpp>
#include<iostream>

int main()
{
    using namespace boost::typeindex;

    volatile int x=0;
    auto deducter_1=x;

    std::cout<< type_id<decltype(deducter_1)>().pretty_name() <<std::endl;
}

実行結果は以下となります。

int

constの時と同じく、このような修飾子は、型推論時、その推論型から排除されます。

x = i; // 除去されない。 `` [14.1.6 volatile の意味](/Chap14/multithread_intro.md)でも述べた通り、volatileキーワードは他言語では別のセマンティックを持つ事などから排他制御をするためのキーワードと勘違いされる事が多いですが、C++ではそのような意味ではなく、単に最適化を抑制するキーワードです。排他制御は、std::mutexや、std::atomic`などの標準ライブラリーを用いる事で実現できます。