研究で使うプログラムの開発中に見事に未定義動作を踏み抜いた話(C++)


前置き

C++で研究に使う遺伝的アルゴリズムを実装しているときに踏み抜いてしまいました.
問題のプログラムはGithubに挙げていますので興味のある方はぜひ覗いてみてください.

さて,問題のプログラムの目的と概要について説明します.
 遺伝的アルゴリズムでは,解を遺伝子で表現し交叉,突然変異,選択などの操作によりより良い解を見つけるということを行います.私の実装した方法では遺伝子をバイト列で表し,それらを別の構造体へ再解釈することにより解の表現を行っているのです.バイト列で表された遺伝子へのデータの挿入と読み取りを簡単にするという目的で新しいコンテナクラスを作りました.
 察しの良い方はこの時点でお気付きかもしれませんが,この実装には主に二つの未定義動作が紛れ込む可能性があります.一つ目はstrict aliasing rulesに違反したことによる未定義動作でこちらはすぐ気付いたのですが,もう一つのアラインメントに関する未定義動作はサニタイザーを適用して初めて気付きました.では,説明していきます.

strict aliasing rules違反による未定義動作

まずstrict aliasing rulesとは何でしょうか.
strict aliasing rulesとは簡単に言えば,「ある型のオブジェクトの占有しているメモリを別の型として再解釈してアクセスすることは基本的にできない」というルールです.この”基本的に”というのが重要で例外的に型を再解釈できるパターンがあります.以下がそれらのパターンです.

  • 同一の型
  • 同一の型のCV修飾
  • 同一の型のsigned, unsigned違い
  • 同一の型のCV修飾のsigned, unsigned違い
  • 上の四つのいずれかをメンバ変数に持つstruct, class, union(子や孫さらに下のメンバも含む)
  • 文字型とstd::byte(C++17から)
  • 基底クラス,派生クラスとそれらのCV修飾(C++)

厳密には少し違いますが大体こんな感じです.
例えば,これらは大丈夫です.

double d1 = 1.5;
const double* pd2 = &d1;

class base {};
class derive : public base {};
base&& b = derive{};

int i = 10;
unsigned char* bytes = reinterpret_cast<unsigned char*>(&i);

 
一方,これらは未定義動作を引き起こします.

int d1 = 10;
float* pf2 = reinterpret_cast<float*>(&d1);

class base {};
class not_derive {};
not_derive d{};
base* b = reinterpret_cast<not_derive*>(&d);

ここで一つ注意点がありまして,”文字型とstd::byte”についてです.どうやらstd::uint8_t, std::int8_tはこれらには含まれていないようで,これらの型の配列から別の型に再解釈してアクセスすることは未定義動作を引き起こすようです.

アラインメントに関する未定義動作

strict aliasingに関しては気を付けていれば防げるものだと思いますが,アラインメントに関してはかなり苦心しました.

さて、アラインメントとは何でしょう.
Wikipediaではこのようになっています.

データ構造アライメント(データこうぞうアライメント、英語: data structure alignment)は、コンピュータのメモリ(主記憶装置)内のデータにアクセス(読み書き)する際に、メモリ上の位置の調整を行うことである。

メモリアドレス a は、a が n バイトの倍数(n は2の累乗)であるときに、「n バイトアライメント」と呼ばれる。この場合、バイトはメモリアクセスの最小単位である。つまり、各メモリアドレスは異なるバイトを指定する。二進数で表現した場合、n バイトアライメントされたアドレスの下位の桁は、最小で log2(n) 桁がゼロになる。

つまり,nバイトアラインメントのオブジェクトのアドレスはnの倍数でなければならないということです.例えば変数xのアラインメントが4であれば,xのアドレスは4でなければなりません.

では以下のようなコードを考えてみましょう.

unsigned char bytes[4] = { 10, 20, 30, 40 };
std::uint32_t* x = reinterpret_cast<std::uint32_t*>(bytes);

std::printf("%lu\n", *x);

追記:
(8.8)はある型の領域がstd::byteで読み替えられる、ということを保証するものであり逆を保証するものではないようです。
つまり、このコードはアラインメントにかかわらず明確に違法であると言えます。しかしそれでは話が進まないので、ここでの文脈においてはfno-strict-aliasingオプションをオンにしたと仮定してください。(ただし、strict aliasingを仮定した上での最適化は有用であり―――というよりもむしろこのような仮定がない場合に得られるコードがあまり良くない―――このオプションをオンにすることは推奨しません)

このようなコードを含んだプログラムは最適化を切ればうまく動くかもしれません.しかし最適化を有効化すればどのような動作をするか全く保証されていません.
なぜなら,xのアラインメントが4であり xのアドレスが4の倍数でなければならないにも関わらず,アラインメントが1であるunsigned char型のbytesのアドレスが4の倍数である保証はどこにもないからです.

これを解決するために以下のようにしました.

unsigned char bytes[alignof(std::uint32_t) * 2];
std::size_t align = alignof(std::uint32_t);
std::uintptr_t count = bytes % align ? (align - bytes % align) : 0;
bytes[count]     = 10;
bytes[count + 1] = 20;
bytes[count + 2] = 30;
bytes[count + 3] = 40;
std::uint32_t* x = reinterpre_cast<std::uint32_t*>(bytes + count);

std::printf("%lu\n", *x);

とりあえず,このようにすることでサニタイザの吐くruntime errorは消えました.
しかし,あまり納得はいっていません.メモリが必ずアラインメントの倍数になっていることをコンパイル時に保証できていないため,最適化でどのようになるかわかりません.

ここら辺のことが詳しい方,コメント欄等で最良の方法をお教えいただけるとありがたいです.

追記:
上の追記でも書きましたがアラインメント違反が起こるようなコードはそもそもstrict aliasing rulesに引っかかる可能性が高いということです。もともとの目的からすれば格好つきませんがバイト配列を再解釈して(ましてや全く関係のない型を再解釈して)好き勝手にいじくり回すのはやめましょう、あるいはmemcpyやstd::bit_castを使いましょう、というのが結論です。

ここから下追記

どうすれば良かったのか、あるいはどうするべきか

例えばこんなクラスを考えて見ましょう

class buffer {
public:
    buffer() { /* initialize */ }
    const std::int32_t& access() const {
        return *reinterpret_cast<const std::int32_t*>(buff); //undefined behavior!
    }

    std::int32_t& access() {
        return *reinterpret_cast<std::int32_t*>(buff); //undefined behavior!
    }
private:
    unsigned char buff[4];
};

//buffer b;
//b.access() = 4;
//b.access();   -> 4

このようなコードは前述の議論の通り未定義動作を引き起こします。
この例では2つの解決策が考えられます。

一つめの方法が関数の形を変える方法です。

class buffer {
public:
    buffer() { /* initialize */ }
    std::int32_t read() const {
        std::int32_t ret;
        std::memcpy(&ret, buff, 4); //OK
        return ret;
    }
    void write(std::int32_t x) {
        std::memcpy(buff, &x, 4);   //OK
    }
private:
    unsigned char buff[4];
};

//buffer b;
//b.write(4);
//b.read();  -> 4

この場合バッファの読み書きは安全に行なえます。
しかし、operator[]をオーバーロードしてバイト配列を別の型の配列に再解釈する、といったことをする場合には未定義動作は一旦置いて、後者によるアクセスより前者によるアクセスのほうがユーザー側がわかりやすいはずです。前者のようなアクセス方法を実現するためにはProxyパターンによる実装が考えられます。

class buffer {
public:
    class proxy {
    public:
        proxy& operator=(std::int32_t x) {
            std::memcpy(buff, &x, 4);
            return *this;
        }
        std::int32_t operator std::int32_t() const {
            std::int32_t ret;
            std::memcpy(&ret, buff, 4);
            return ret;
        }
    private:
        unsigned char buff[4];
    };
public:
    buffer() { /* initialize */ }
    proxy& access() {
        return proxy_;
    }
    const proxy& access() const {
        return proxy_;
    }
private:
    proxy proxy_;
};

//buffer b;
//b.access() = 4;
//std::int32_t x = b.access();    -> 4

このようにすることでユーザー(あるいは未来の自分)に対してわかりやすいインターフェースを提供できます。
ただし注意点が2つほどあります。
一つはこのクラスが実現可能な最小限のクラスであるため、インスタンスのコピー・ムーブなどが考慮されていないことです。もう一つは任意の型に拡張するためテンプレートを使った場合です。この場合、再解釈先の型はtrivially copyableでなければなりません。