C++のクラス・RAII・0/3/5のルール:二重解放をASanで追う

C++講座3。Apple clang 21 で実行:コンストラクタとデストラクタ、ファイルとロックの RAII、浅いコピーの二重解放を ASan で確認、0/3/5 のルール、explicit と {} の罠。

シリーズ:C++講座 3/6(中級)。 関数を書けて std::vector と std::string を使える人(講座 2)向けです。読み終えると、コンストラクタとデストラクタで資源を管理するクラスを書き、ファイルとロックで RAII を説明し、浅いコピーによる二重解放を ASan のレポートから見抜き、0・3・5 のルールのどれを使うか判断できるようになります。

C++ が「文字列が便利な C」でなくなるのはこの講座からです。クラスはメモリ、ファイル、ロック、ソケットといった資源を所有します。中級の C++ コードで起きるクラッシュの多くは、2 つのオブジェクトが同じものを「自分のもの」だと思っているか、エラー経路で資源が解放されないかのどちらかです。どちらも書くのは簡単で、レビューでは見えにくい。以下はすべて Apple Silicon Mac(M1 Pro)、macOS 26、Apple clang 21.0.0 + libc++ で、特に断りがなければ clang++ -std=c++23 -Wall -Wextra -pedantic で実行しました。

本番サーバーが数日おきに「Too many open files」で落ちる、という障害対応をしたことがある人には、2 つ目の節の実験が見覚えのある光景になるはずです。C から来た人は C言語講座 3 と比べてください。同じ二重解放ですが、ここではバグ入りのコードをコンパイラが書いてくれます。

コンストラクタ・デストラクタと、それが走る時点

コンストラクタはオブジェクトが作られるときに走って準備をし、デストラクタ(~名前)は寿命が終わるときに走ります。ローカル変数ならスコープの閉じ括弧で、作られたのと逆の順に壊れます。

#include <iostream>
#include <string>

class Tracer {
public:
    explicit Tracer(std::string name) : name_(std::move(name)) {
        std::cout << "  ctor " << name_ << '\n';
    }
    ~Tracer() { std::cout << "  dtor " << name_ << '\n'; }
private:
    std::string name_;
};

Tracer global("global");

int main() {
    std::cout << "main start\n";
    Tracer a("a");
    {
        Tracer b("b");
        Tracer c("c");
        std::cout << "leaving inner block\n";
    }
    Tracer* h = new Tracer("heap");
    std::cout << "main end\n";
    (void)h;   // never deleted
}
  ctor global
main start
  ctor a
  ctor b
  ctor c
leaving inner block
  dtor c
  dtor b
  ctor heap
main end
  dtor a
  dtor global

: name_(std::move(name)) はメンバ初期化子リストで、コンストラクタ本体より前にメンバを作ります。global は main の前に作られ、後に壊れます。heap の dtor は表示されません。new で作ったオブジェクトは誰かが delete するまで生き続けます。macOS 付属のツールで見つけられます(-g 付きでビルド)。

$ leaks --atExit -- ./lifetime_g
Process 43333: 1 leak for 32 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in main>':
3   lifetime_g   0x100620740 main + 220  lifetime.cpp:24

sizeof(Tracer) は 24(std::string 1 つ)で、アロケータが 32 に切り上げています。この Mac では AddressSanitizer のリーク検出は使えず、ASAN_OPTIONS=detect_leaks=1 は detect_leaks is not supported on this platform と表示しました。

RAII:デストラクタを後始末に使う

RAII(Resource Acquisition Is Initialization)は、資源をコンストラクタで取得し、デストラクタで解放するという考え方です。デストラクタは return でも例外でも、スコープを出るときに必ず走るので、後始末を忘れようがありません。

それを示す実験です。2 つの関数がファイルを開いて 1 バイト読み、ヘッダが違えば例外を投げます。手動版は最後に fclose を呼び、RAII 版は File クラスに任せます。

#include <cerrno>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>

int first_char_manual(const char* path) {
    std::FILE* f = std::fopen(path, "r");
    if (!f) throw std::runtime_error(std::string("fopen: ") + std::strerror(errno));
    int c = std::fgetc(f);
    if (c != 'X') throw std::runtime_error("bad header");   // leaks f
    std::fclose(f);
    return c;
}

class File {
public:
    File(const char* path, const char* mode) : f_(std::fopen(path, mode)) {
        if (!f_) throw std::runtime_error(std::string("fopen: ") + std::strerror(errno));
    }
    ~File() { std::fclose(f_); }
    File(const File&) = delete;              // one owner only
    File& operator=(const File&) = delete;
    std::FILE* get() const { return f_; }
private:
    std::FILE* f_;
};

int first_char_raii(const char* path) {
    File f(path, "r");
    int c = std::fgetc(f.get());
    if (c != 'X') throw std::runtime_error("bad header");   // f closed here
    return c;
}

int main(int argc, char** argv) {
    bool raii = argc > 1 && std::string(argv[1]) == "raii";
    for (int i = 1; i <= 1000; ++i) {
        try {
            raii ? first_char_raii("data.txt") : first_char_manual("data.txt");
        } catch (const std::exception& e) {
            if (std::string(e.what()) != "bad header") {
                std::printf("call %d: %s\n", i, e.what());
                return 1;
            }
        }
    }
    std::printf("%s: 1000 calls, no error\n", raii ? "raii" : "manual");
}

サブシェルで開けるファイル数の上限を 256 にして実行しました。

$ (ulimit -n 256; ./raii_file manual; ./raii_file raii)
call 254: fopen: Too many open files
raii: 1000 calls, no error

手動版はエラーのたびにファイルハンドルを 1 つ漏らし、254 回目で死にました(256 から stdin・stdout・stderr を引いて 1 を足した数)。サーバーでこのエラー経路が 1 時間に 1 回しか通らないなら、クラッシュはデプロイの数日後にやってきます。RAII 版は 1,000 回すべて通りました。

= delete はコピーを禁止します。2 つの File が同じ FILE* を閉じれば、次の節のバグの「二重クローズ版」になるからです。コンパイラが止めてくれます。

filecopy.cpp:13:10: error: call to deleted constructor of 'File'
   13 |     File b = a;
filecopy.cpp:6:5: note: 'File' has been explicitly marked deleted here

ロックも資源:RAII はタダか

std::lock_guard はコンストラクタで mutex をロックし、デストラクタでアンロックします。ロックとアンロックの間で例外が飛んでも mutex が永遠にロックされたままになりません。手動の lock()/unlock() と比べました。競合なしで 1,000 万回、7 回の中央値です。

[[gnu::noinline]] void inc_manual() { m.lock(); ++counter; m.unlock(); }
[[gnu::noinline]] void inc_guard()  { std::lock_guard<std::mutex> lk(m); ++counter; }
ビルド 手動 lock_guard
-O2 86.5 ms 86.0 ms
-O0 88.2 ms 96.8 ms
棒グラフ。-O2 では手動が 86.5 ミリ秒、std::lock_guard が 86.0 で、両者は同じ機械語になる。-O0 では手動 88.2、lock_guard 96.8。インライン化されないとガードのコンストラクタとデストラクタが本物の関数呼び出しになるため。
競合のない std::mutex に lock・加算・unlock を 1,000 万回。手で lock()/unlock() する場合と std::lock_guard の比較。7 回の中央値、Apple Silicon Mac(M1 Pro)。

-O2 では 2 つの関数が同じ命令列になりました(objdump -d で比較)。差はノイズです。-O0 では何もインライン化されず、ガードのコンストラクタとデストラクタが本物の呼び出しになって、ここでは約 10% 遅くなりました。「抽象化は最適化すればタダ」という C++ の約束どおりです。

測り方についても書いておきます。最初の測定は間違っていました。どの版を走らせるかをループの中で strcmp して選んでいたため、std::scoped_lock が lock_guard より約 30% 遅く見えたのです。逆アセンブルすると関数は同一で、余分な時間は 3 番目の分岐に入るまでの strcmp 2 回分でした。上の数値は、選択をループの外(関数ポインタを 1 回だけ選ぶ)に出してから取ったものです。

二重解放で終わる浅いコピー

次は、生ポインタでヒープメモリを持ち、デストラクタだけを書いたクラスです。

#include <cstddef>
#include <cstring>
#include <iostream>

class Buffer {
public:
    explicit Buffer(std::size_t n) : size_(n), data_(new char[n]) {
        std::memset(data_, 0, n);
    }
    ~Buffer() { delete[] data_; }
    char* data() { return data_; }
    std::size_t size() const { return size_; }
private:
    std::size_t size_;
    char* data_;
};

int main() {
    Buffer a(16);
    std::strcpy(a.data(), "hello");
    Buffer b = a;                 // compiler-generated copy: copies the pointer
    b.data()[0] = 'J';
    std::cout << "a: " << a.data() << "  b: " << b.data() << '\n';
}   // b's destructor frees, then a's destructor frees the same block

-Wall -Wextra -pedantic でも警告なしでコンパイルが通ります。端末で実行すると:

a: Jello  b: Jello
Trace/BPT trap: 5          (exit code 133)

b を変えたら a も変わりました。両方が同じ領域を指しているからです。そして 2 回目の delete[] で、メッセージもなくトラップのシグナルで落ちました(出力をファイルにパイプすると 1 行目すら消えます。クラッシュでバッファの中身が捨てられるからです)。AddressSanitizer はすべてを説明してくれます。

==43345==ERROR: AddressSanitizer: attempting double-free on 0x6020000000f0 in thread T0:
    #1 0x000104bb9460 in Buffer::~Buffer() buffer_bad.cpp:10
    #3 0x000104bb8c7c in main buffer_bad.cpp:24
0x6020000000f0 is located 0 bytes inside of 16-byte region [0x6020000000f0,0x602000000100)
freed by thread T0 here:
    #1 0x000104bb9460 in Buffer::~Buffer() buffer_bad.cpp:10
    #3 0x000104bb8c60 in main buffer_bad.cpp:24
previously allocated by thread T0 here:
    #1 0x000104bb9304 in Buffer::Buffer(unsigned long) buffer_bad.cpp:7
SUMMARY: AddressSanitizer: double-free buffer_bad.cpp:10 in Buffer::~Buffer()

同じデストラクタ、同じ 24 行目(main の閉じ括弧)で解放が 2 回です。

図。Buffer a は new char[16] で確保した 16 バイトの領域を持つ。Buffer b = a はポインタだけをコピーするので b も同じ領域を指す。main の終わりで先に b のデストラクタが領域を解放し、次に a のデストラクタが同じ領域をもう一度解放して二重解放になる。直し方は、コピーコンストラクタで深いコピーをするか、メンバを std::vector にしてデストラクタ自体を書かないこと。
生ポインタを持つクラスを、コンパイラ任せでコピーすると落ちる理由。オブジェクト 2 つ、領域 1 つ、delete[] 2 回。ASan は「attempting double-free」と報告し、ASan なしでは終了コード 133 で落ちた。

実行する前に見つける方法もあります。次の 2 つが検出しました。

$ clang++ -std=c++23 -Wdeprecated -c buffer_bad.cpp
buffer_bad.cpp:10:5: warning: definition of implicit copy constructor for 'Buffer' is deprecated because it has a user-provided destructor [-Wdeprecated-copy-with-user-provided-dtor]

$ clang++ --analyze -std=c++23 buffer_bad.cpp
buffer_bad.cpp:10:17: warning: Attempt to free released memory [cplusplus.NewDelete]

-Wdeprecated は -Wall -Wextra に含まれません。追加を検討する価値があります。

3 のルールと 5 のルール:正しい Buffer

3 のルールとは、デストラクタ・コピーコンストラクタ・コピー代入のどれかを自分で書く必要があるクラスは、ほぼ確実に 3 つすべてが必要だ、というものです。C++11 でムーブ操作が加わり 5 のルールになりました。正しい Buffer を、代入演算子 1 つでコピーとムーブの両方を扱える copy-and-swap イディオムで書きます。

#include <cstddef>
#include <cstring>
#include <iostream>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : size_(n), data_(new char[n]()) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& o) : size_(o.size_), data_(new char[o.size_]) {   // deep copy
        std::memcpy(data_, o.data_, size_);
        std::cout << "  copy ctor\n";
    }
    Buffer(Buffer&& o) noexcept                                            // steal
        : size_(std::exchange(o.size_, 0)), data_(std::exchange(o.data_, nullptr)) {
        std::cout << "  move ctor\n";
    }
    Buffer& operator=(Buffer other) noexcept {   // other was built by copy or move ctor
        swap(*this, other);
        std::cout << "  assign (swap)\n";
        return *this;
    }                                            // other's destructor frees our old block
    friend void swap(Buffer& a, Buffer& b) noexcept {
        std::swap(a.size_, b.size_);
        std::swap(a.data_, b.data_);
    }
    char* data() { return data_; }
    std::size_t size() const { return size_; }
private:
    std::size_t size_;
    char* data_;
};

-fsanitize=address,undefined 付きでビルドしたテスト用 main の出力です。終了コードは 0 でした。

Buffer b = a;
  copy ctor
a: hello  b: Jello
a = a;
  copy ctor
  assign (swap)
a: hello
Buffer c = std::move(a);
  move ctor
a.size() after move: 0  c: hello
b = Buffer(8);
  assign (swap)

これで a と b は独立しました。細かい点が 2 つあります。a = a(clang は -Wself-assign-overloaded で警告)が正しく動くのは、copy-and-swap が先にコピーして最後に解放するからです。そして b = Buffer(8) ではムーブすら表示されません。C++17 以降、値渡しの引数に一時オブジェクトを渡すと、引数の場所に直接作られます。

自己代入をなぜ気にするのか。delete[] data_; してから o.data_ をコピーする素朴な operator= は、&o == this のとき自分のデータを壊します。i == j のときの v[i] = v[j] のようなコードで起きます。試すと、普通のビルドは a: の後に何も表示せず、ASan は表示の行の strlen の中で heap-buffer-overflow を報告しました。本当のバグより 2 行後です。サニタイザが報告するのはメモリが誤用された場所であって、ロジックが間違った場所ではありません。

0 のルール:実際に書くべき版

ポインタ 1 本のために特殊関数を 5 つ正しく書くのは大仕事です。0 のルールは、普通のクラスでは生の資源を持たない、というものです。自分で後始末できるメンバを使い、5 つのどれも書きません。

class Buffer {                       // rule of zero
public:
    explicit Buffer(std::size_t n) : bytes_(n) {}
    char* data() { return bytes_.data(); }
    std::size_t size() const { return bytes_.size(); }
private:
    std::vector<char> bytes_;        // copies, moves and frees itself
};

class Packet {                       // move-only, still rule of zero
    std::unique_ptr<char[]> payload_;
public:
    explicit Packet(std::size_t n) : payload_(std::make_unique<char[]>(n)) {}
};

Buffer b = a; は ASan 付きでもエラーなしに a: hello b: Jello と表示します。Packet はコピーできず、エラーが理由を説明します。

rule0e.cpp:35:12: error: call to implicitly-deleted copy constructor of 'Packet'
note: copy constructor of 'Packet' is implicitly deleted because field 'payload_' has a deleted copy constructor
判断図。クラスが new・fopen・ソケットのような生のハンドルで資源を直接持っているか。いいえなら 0 のルール:デストラクタもコピー・ムーブ関数も書かず、std::string・std::vector・std::unique_ptr などのメンバに任せる。はいなら、そのハンドルを小さな RAII クラスや削除子付き unique_ptr に包めるかを問い、包めるならそうして 0 のルールに戻る。包めないなら 5 つすべて(デストラクタ、コピーコンストラクタ、コピー代入、ムーブコンストラクタ、ムーブ代入)を書くか、コピーが意味をなさないならコピーの 2 つを delete する。
0・3・5 のルールのどれが今書いているクラスに当てはまるか。ほとんどのクラスは最初の答え(0 のルール)で終わるべき。

中途半端の罠:デストラクタだけ書くとムーブが消える

0 のルールのクラスに(ログ用などで)「デストラクタだけ」足すと、黙って性質が変わります。

struct Payload {
    Payload() = default;
    Payload(const Payload&) { std::cout << "  Payload copied\n"; }
    Payload(Payload&&) noexcept { std::cout << "  Payload moved\n"; }
};
struct Plain  { Payload p; };                                    // rule of zero
struct Logged { Payload p; ~Logged() { /* log something */ } };  // only a destructor added
Plain  b = std::move(a):
  Payload moved
Logged d = std::move(c):
  Payload copied

ユーザーが宣言したデストラクタは暗黙のムーブ操作の生成を抑止するので、std::move はコピーに戻ります。警告はありません。中に大きな vector があれば、黙った性能バグです(ムーブとコピーの差は講座 4 で測ります)。デストラクタを書かないか、5 つすべてを宣言し、既定で良いものは = default にします。

メンバのコストを、このプラットフォームの sizeof で測りました。

クラス sizeof
空の struct {} 1
virtual デストラクタ 1 つ 8(vtable へのポインタ)
生の size_t + char* 16
std::vector<char> メンバ 24
std::unique_ptr<char[]> メンバ 8、生ポインタと同じ

explicit と初期化子リストの罠

引数 1 つのコンストラクタは暗黙の型変換にもなります。explicit がないと、次のコードが通ります。

class Buffer {
public:
    Buffer(std::size_t n) : bytes_(n) {}       // NOT explicit
    std::size_t size() const { return bytes_.size(); }
private:
    std::vector<char> bytes_;
};
void send(const Buffer& b) { std::cout << "sending " << b.size() << " bytes\n"; }

send(1000000);     // "sending 1000000 bytes": a 1 MB buffer built from an int
Buffer b = 'A';    // b.size() = 65

explicit を付けると両方がエラーになります。no matching function for call to 'send'(注記は no known conversion from 'int' to 'const Buffer')と no viable conversion from 'char' to 'Buffer' です。変換そのものが目的(const char* からの std::string など)でない限り、引数 1 つのコンストラクタは explicit にします。

クラスに std::initializer_list コンストラクタがあると、波括弧と丸括弧で呼ばれるコンストラクタが変わります。

a(3, 5): size 3  b{3, 5}: size 2          // std::vector<int>
s1 = "xxx" size 3  s2 size 2 first byte 3  // std::string s1(3, 'x'), s2{3, 'x'}
Range r1(1, 10):
  Range(int,int)
Range r2{1, 10}:
  Range(initializer_list) size 2
sizeof x = 4  y.size() = 1                 // auto x{42}; auto y = {42};

std::vector<int> b{3, 5} は「5 が 3 つ」ではなく 2 要素。std::string s2{3, 'x'} は先頭バイトが制御文字 3 の文字列です。自作クラスに initializer_list コンストラクタを足すと、呼び出し側の波括弧はすべてそちらへ切り替わります。

同じ分野からもう 2 つ。

vexing.cpp:4:13: warning: empty parentheses interpreted as a function declaration [-Wvexing-parse]
    4 |     Widget w();          // meant: default-construct a Widget
vexing.cpp:5:12: error: base of member reference is a function; perhaps you meant to call it with no arguments?

reorder.cpp:5:35: warning: initializer order does not match the declaration order [-Wreorder-ctor]
reorder.cpp:5:79: warning: field 'width_' is uninitialized when used here [-Wuninitialized]

Widget w(); は関数の宣言になります(「最も厄介な構文解析」)。Widget w; か Widget w{}; と書きます。メンバは初期化子リストの順ではなく宣言順に初期化されます。Label は height_ を先に宣言し、まだ作られていない width_ から計算していました。普通のビルドは width 2 height -8、ASan 付きは width 2 height 1 を表示しました。実行のたびにゴミが変わるのは、未初期化の読み取りの特徴です。

よくある間違い → 実際に起きたこと → 直し方

間違い 実際に見たもの 直し方
クラス内で生の new、デストラクタだけ 終了コード 133、ASan attempting double-free 0 のルール(std::vector、unique_ptr)
素朴な operator= 自己代入でデータ消失、ASan は strlen を指す copy-and-swap
throw の前に手動 fclose 254 回目で Too many open files RAII ラッパー
デストラクタだけ追加 std::move が黙ってコピー 5 つとも書かないか、5 つとも書く
引数 1 つのコンストラクタ send(1000000) が通る explicit
std::vector<int> v{3, 5} 2 要素 「5 が 3 つ」なら v(3, 5)
初期化子リストの順番違い height -8 依存順にメンバを宣言、-Wreorder-ctor に従う

正直な限界・バージョンの注記

  • 実行したのは arm64 macOS 26 上の Apple clang 21.0.0 と libc++ だけです。GCC、MSVC、Linux、Valgrind は試していません。Linux なら二重解放で glibc が free(): double free detected のようなメッセージを出すはずですが(未実行)、macOS は何も表示せずにトラップしました。
  • ムーブコンストラクタ、= delete、= default、noexcept、std::initializer_list は C++11。std::exchange と std::make_unique は C++14。std::scoped_lock と一時オブジェクトのコピー省略保証は C++17。
  • -Wdeprecated-copy-with-user-provided-dtor:この場合の暗黙コピーは C++11 から非推奨です。警告名は clang のものです。
  • ロックの時間は競合なし・シングルスレッド・1 台のマシンでの値です。競合するロックは別の話で、講座 6 で測ります。

演習

1. 次のコードは警告なしでコンパイルできます。実行すると何が起き、0 のルールでどう直しますか。

class Holder {
public:
    explicit Holder(int v) : p_(new int(v)) {}
    ~Holder() { delete p_; }
    int value() const { return *p_; }
private:
    int* p_;
};
int main() {
    std::vector<Holder> v;
    v.push_back(Holder(1));
    std::cout << v[0].value() << '\n';
}

2. それぞれの vector の要素数は? std::vector<int> a(5); std::vector<int> b{5}; std::vector<int> c(5, 1); std::vector<int> d{5, 1};

解答

1. Holder(1) は一時オブジェクトです。push_back はそれをコピーし(生成されたコピーはポインタだけをコピー)、その後一時オブジェクトのデストラクタが int を delete します。v[0] はぶら下がりポインタを持つことになります。ASan は Holder::value() const ex.cpp:7 で heap-use-after-free、解放は 13 行目から呼ばれた Holder::~Holder() ex.cpp:6 と報告しました。最後には vector のデストラクタがもう一度 delete しようとします。直し方は std::unique_ptr<int> p_; を std::make_unique<int>(v) で初期化し、デストラクタを消すこと。Holder はムーブ専用になり、push_back(Holder(1)) はムーブされます。2 要素で試すと 1 2 と表示され、ASan も無反応でした。

2. a:0 が 5 個。b:1 要素(5)。c:1 が 5 個。d:2 要素(5 と 1)。

次の講座

講座 4 で生の new とはお別れです。unique_ptr・shared_ptr・weak_ptr、デストラクタの表示で目に見える循環参照リーク、そして std::move と noexcept が伸びていく vector に何をするかを扱います。

よくある質問

C++ のクラスをコピーすると二重解放になるのはなぜ?

new で得た生ポインタを持ち、デストラクタで解放するクラスでは、コンパイラが生成するコピーコンストラクタはポインタだけをコピーします。2 つのオブジェクトが 1 つの領域を持ち、両方が delete します。手元の Buffer クラスは終了コード 133(Trace/BPT trap)で落ち、AddressSanitizer は Buffer::~Buffer() で「attempting double-free」を報告しました。深いコピーを書く(3/5 のルール)か、メンバを std::vector にします(0 のルール)。

C++ の 0 のルールとは?

クラスにデストラクタもコピー・ムーブ操作も書かず、自分で後始末できるメンバ(std::string、std::vector、std::unique_ptr)に任せることです。こうすればコンパイラ生成の操作が正しく動きます。デストラクタだけ書くのは何も書かないより悪く、手元のテストでは std::move が黙ってコピーに変わりました。ユーザー宣言のデストラクタが暗黙のムーブを抑止するためです。

RAII や std::lock_guard は性能に影響する?

最適化を有効にすれば影響しません。-O2 で lock/unlock 1,000 万回は手動 86.5 ms、std::lock_guard 86.0 ms で、両関数は同じ機械語になりました。-O0 ではコンストラクタとデストラクタが本物の関数呼び出しになるため、ガードが約 10% 遅くなりました(96.8 ms 対 88.2 ms)。

  • #C++
  • #中級
  • #RAII
  • #5のルール
  • #AddressSanitizer
  • #コンストラクタ

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑