なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

C言語のStrict aliasing rulesについて極力わかりやすく書きたい

C, C++の標準規格に存在する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は、ある型TUにおいて、T型の値vに対して、1つでも満たしていると*(U *)&vのようにアクセスすることが許される、以下の規則である。

  1. T = Uである
  2. UTに修飾した型(const, signed, unsigned)である
  3. Tstruct { U; ... }, union { U; ... }もしくはそれに修飾した型である
  4. Ucharもしくはそれに修飾した型である

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++プログラムを高パフォーマンスなものにする手助けをしているということだけ覚えておけば良いだろう。

mallocmmap、共有メモリの扱いについて

Strict aliasing rulesを見て、mallocmmap、共有メモリを生成したときも、常に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;
}

何よりC++では常にUBとなるため、C++との互換性がなくなってしまうのはかなり痛い。

(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

終わりに

memcpy以外のType punningによる方法は、処理系依存であったり、UBであったりするため、個人的にはお勧めできない。

守る気がない、もしくはそれが古いコードであるなら、-fno-strict-aliasingをつけてコンパイルすると良いが、これは最終的にはボトルネックとなってくることは覚悟しておくべきである。

故に、Cで最も効率良く、安全にメモリを別の型として使うプログラムを書くためには、どの処理系でも問題なく使えるmemcpyを使った方法が最善である。