C++ スマートポインタとムーブセマンティクスを実測で理解する

C++講座4。Apple clang 21 で実行:unique_ptr・shared_ptr・weak_ptr、デストラクタの出力で見る循環リーク、std::move の実体、noexcept の有無で vector のコピー回数と時間を実測。

シリーズ:C++講座 4/6(中級)。 クラス・コンストラクタ・RAII まで分かる人(第3回)で、レビューのたびに std::unique_ptr、std::move、&& を指摘されて戸惑っている人向けです。読み終えると、場面に合ったスマートポインタを選び、shared_ptr の循環をデストラクタの出力と macOS の leaks で見つけ、ムーブ後のオブジェクトに何が残るか説明でき、noexcept を1つ付け忘れただけで std::vector が3倍遅くなる理由が分かります。

Java や C# から C++ の現場に来た人が最初にはまるのが、この「所有権」と「ムーブ」です。GC がないので誰かが delete しなければならないのですが、モダン C++ では delete を書かないのが正解で、その代わりに型で所有者を表します。間違えても落ちないのが厄介で、解放されない循環、ムーブのつもりのコピー、持ち主より長生きした生ポインタは、テストを素通りします。以下はすべて Apple Silicon Mac(M1 Pro)、macOS 26、Apple clang 21.0.0 + libc++ で、clang++ -std=c++23 -Wall -Wextra -pedantic、時間計測は -O2 で実行しました。GCC・MSVC・Linux・Valgrind はここでは動かしていません。

3種類のスマートポインタを1本のプログラムで

スマートポインタは、ヒープ上のオブジェクトを所有し、自分のデストラクタで削除する小さなオブジェクトです(第3回の RAII そのものです)。標準ライブラリには3つあります。

  • std::unique_ptr<T>:持ち主はちょうど1人。コピー不可、ムーブのみ。
  • std::shared_ptr<T>:参照カウントで共有。最後の持ち主が削除する。
  • std::weak_ptr<T>:shared_ptr の対象を所有せずに見張る。lock() で、まだ生きていれば shared_ptr が得られる。
#include <cstdio>
#include <memory>
#include <string>

struct Res {
    std::string name;
    explicit Res(std::string n) : name(std::move(n)) { std::printf("  acquire %s\n", name.c_str()); }
    ~Res() { std::printf("  release %s\n", name.c_str()); }
};

std::unique_ptr<Res> make(const char *n) { return std::make_unique<Res>(n); }

int main() {
    std::printf("sizeof raw=%zu unique_ptr=%zu shared_ptr=%zu weak_ptr=%zu\n",
                sizeof(Res *), sizeof(std::unique_ptr<Res>),
                sizeof(std::shared_ptr<Res>), sizeof(std::weak_ptr<Res>));
    {
        std::printf("block 1\n");
        auto a = make("A");
        auto b = std::move(a);              // ownership moves; a is now null
        std::printf("  a=%s b=%s\n", a ? "set" : "null", b ? b->name.c_str() : "null");
    }
    std::printf("block 2\n");
    std::shared_ptr<Res> s1 = std::make_shared<Res>("S");
    {
        auto s2 = s1;
        std::printf("  use_count=%ld\n", s1.use_count());
        std::weak_ptr<Res> w = s1;
        std::printf("  weak use_count=%ld expired=%d\n", w.use_count(), w.expired());
    }
    std::printf("  use_count=%ld\n", s1.use_count());
    std::weak_ptr<Res> w = s1;
    s1.reset();
    std::printf("  after reset: expired=%d lock()=%s\n", w.expired(), w.lock() ? "obj" : "nullptr");
    std::printf("end of main\n");
}
sizeof raw=8 unique_ptr=8 shared_ptr=16 weak_ptr=16
block 1
  acquire A
  a=null b=A
  release A
block 2
  acquire S
  use_count=2
  weak use_count=2 expired=0
  use_count=1
  release S
  after reset: expired=1 lock()=nullptr
end of main

この出力から読み取ること:

  1. unique_ptr は生ポインタと同じ 8 バイト。標準のデリータならサイズのコストはゼロです。shared_ptr と weak_ptr は 16 バイトで、オブジェクトへのポインタと、カウントを持つ制御ブロックへのポインタの2本です。
  2. release A は main の最後ではなく、block 1 の閉じ括弧で出ています。delete は一度も書いていません。
  3. weak_ptr は use_count を増やしません(s1・s2・w があっても 2)。最後の持ち主が reset した後、lock() はぶら下がりポインタではなく nullptr を返します。

unique_ptr をコピーしようとするとコンパイルエラーになり、これはありがたいエラーです。

copyerr.cpp:4:10: error: call to implicitly-deleted copy constructor of 'unique_ptr<int>'
    4 |     auto b = a;
      |          ^   ~
.../c++/v1/__memory/unique_ptr.h:209:55: note: copy constructor is implicitly deleted because 'unique_ptr<int>' has a user-declared move constructor

意図をはっきり書けば直ります。所有権を渡すなら auto b = std::move(a);、借りるだけなら auto& b = a; です。

make_shared の確保は2回ではなく1回です。 グローバルな operator new を回数を数える版に差し替えて確かめました。

make_unique:            1 allocation(s)
shared_ptr<T>(new T):   2 allocation(s)
make_shared:            1 allocation(s)

shared_ptr<T>(new T) はオブジェクトと制御ブロックを別々に確保し、make_shared は1ブロックにまとめます。make_unique / make_shared を使えばコードに裸の new も残りません。(make_unique は C++14 から。-std=c++11 では no member named 'make_unique' in namespace 'std' になりました。)

shared_ptr の循環:数えれば見えるリーク

参照カウントには1つ死角があります。A が B を持ち、B が A を持つと、どちらのカウントも 0 になりません。

#include <cstdio>
#include <memory>
#include <string>

static int alive = 0;

struct Person {
    std::string name;
    std::shared_ptr<Person> partner;     // strong link: the bug
    // std::weak_ptr<Person> partner;    // weak link: the fix
    explicit Person(std::string n) : name(std::move(n)) { ++alive; std::printf("ctor %s\n", name.c_str()); }
    ~Person() { --alive; std::printf("dtor %s\n", name.c_str()); }
};

int main() {
    {
        auto a = std::make_shared<Person>("alice");
        auto b = std::make_shared<Person>("bob");
        a->partner = b;
        b->partner = a;
        std::printf("use_count a=%ld b=%ld\n", a.use_count(), b.use_count());
    }
    std::printf("after scope: alive=%d\n", alive);
}
ctor alice
ctor bob
use_count a=2 b=2
after scope: alive=2

コンストラクタ2回、デストラクタ0回。スコープを抜けると a と b が消えてカウントは 2 から 1 に下がるだけで、2つのオブジェクトは互いを生かし続けます。落ちもせず ASan も黙っているので、レビューをすり抜けます。上のようにデストラクタの出力を数えるのが、一番安く書けるテストです。

macOS 付属の leaks は、このパターンをそのまま名指しします(抜粋)。

$ leaks --atExit -- ./cycle
Process 42649: 2 leaks for 160 total leaked bytes.
    2 (160 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c24050> [80]
       1 (80 bytes) ROOT CYCLE: <std::__shared_ptr_emplace<Person> 0x726c240a0> [80]
          CYCLE BACK TO <std::__shared_ptr_emplace<Person> 0x726c24050> [80]

1つ 80 バイトなのは、制御ブロックと Person が1回の確保に入っているから(先ほどの make_shared の構造)です。コメントの2行を入れ替えて partner を weak_ptr にすると、こうなります。

ctor alice
ctor bob
use_count a=1 b=1
dtor bob
dtor alice
after scope: alive=0
Process 42675: 0 leaks for 0 total leaked bytes.

原則は「所有関係は木にする」です。親→子は shared_ptr(か unique_ptr)、子→親・監視者→対象・キャッシュ→要素は weak_ptr か普通の参照にします。iOS 開発の経験があれば、Swift の循環参照と weak var と同じ話です。Swift ARC の記事で別の言語の同じリークを見られます。

判断図。まず「このコードはオブジェクトを所有する必要があるか」。いいえなら参照か生ポインタの引数。はいなら「持ち主はちょうど1人か」。はいなら unique_ptr、いいえなら shared_ptr。shared_ptr からは、生かし続けてはいけない逆リンクや監視者に weak_ptr を使い、shared_ptr で双方向に結ぶと循環リークになる。
ヒープ上のオブジェクトの持ち主の選び方。所有関係は木にし、逆向きのリンクは weak_ptr にする。

std::move は何も動かさない

std::move(x) はただのキャストです。x を右辺値(T&&)に変え、オーバーロード解決でムーブコンストラクタやムーブ代入が選ばれるようにするだけです。実際に「動かす」のはそのコンストラクタで、たいてい中のポインタを奪って元を空にします。

std::string s = "a string long enough to live on the heap";
std::string t = std::move(s);
std::printf("s=\"%s\" size=%zu | t size=%zu\n", s.c_str(), s.size(), t.size());

std::vector<int> v(5, 1);
std::vector<int> w = std::move(v);
v.push_back(3);                          // valid: reuse after move is allowed

const std::string c = "const string that cannot be moved from at all";
std::string d = std::move(c);            // silently copies
s="" size=0 | t size=40
small="" size=0
v.size=0 w.size=5
after push_back v.size=1
c still="const..." size=45
  • ムーブ後のオブジェクトは「有効だが未規定」の状態です。libc++ では長い文字列も 5 文字の短い文字列も vector も空でしたが、規格が約束するのは「有効」だけです。代入するか破棄するだけにし、中身を読まないこと。
  • const オブジェクトへの std::move は -Wall -Wextra -pedantic でも警告なしでコンパイルされ、コピーになります。const std::string&& はムーブコンストラクタに束縛できず、コピーコンストラクタが選ばれるためです。
  • ムーブ後の使用は clang++ --analyze が見つけました:Method called on moved-from object 's' of type 'std::basic_string' [cplusplus.Move]。3つのオブジェクトに3件です。

逆に、コンパイラの方が上手な場所に std::move を足す間違いもあります。

std::vector<int> build() {
    std::vector<int> v(1000, 7);
    return std::move(v);                 // looks faster, is not
}
movedfrom.cpp:8:12: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
movedfrom.cpp:8:12: note: remove std::move call here

return v; なら呼び出し側の変数に直接結果を作れます(コピー省略)。return v; と書きましょう。

コピーとムーブの差を測る

差はどれくらいか。32 文字のランダムな英字(std::mt19937、シード 42)の文字列 100 万個の std::vector<std::string> を作り、コピーとムーブをしました。-O2、7 回の中央値です。

auto t0 = clk::now();
std::vector<std::string> copy = src;               // copy: 1,000,001 heap allocations
auto t1 = clk::now();
std::vector<std::string> moved = std::move(copy);  // move: 3 pointers change hands
auto t2 = clk::now();
copy: median 16.97 ms
move: median 0 ns

「0 ns」は本当の値ではありません。この Mac の steady_clock は約 42 ns 刻み(now() を2回続けて呼ぶと 42 と 0 が出ました)なので、ムーブが時計の分解能より短かっただけです。そこで同じ vector をコンパイラバリア付きで 1,000 万回往復させて割りました。

1.56 ns per move assignment, a.size=1000000

コピー約 17 ms、ムーブ約 1.6 ns。およそ 1,000 万倍の差で、ムーブの時間は要素数に関係しません。コピーでは文字列ごとにヒープ確保が起きます。32 文字は文字列内部の小さなバッファに入らないからです(その境目は第6回で測ります)。

noexcept と vector の伸長:頼んでいないコピー

std::vector は容量が足りなくなると、大きなブロックを確保して古い要素を移し替えます。移すときにムーブを使うのは、ムーブコンストラクタが noexcept のときだけで、そうでなければコピーします。理由は強い例外保証です。ムーブが途中で例外を投げると、元の要素はもう中身を抜かれていて戻せません。コピーなら失敗しても元のブロックは無傷です。

違いが noexcept だけの型で数えました。

static long copies = 0, moves = 0;

template <bool NoExcept>
struct Item {
    std::string s;
    explicit Item(int i) : s(40, char('a' + i % 26)) {}
    Item(const Item &o) : s(o.s) { ++copies; }
    Item(Item &&o) noexcept(NoExcept) : s(std::move(o.s)) { ++moves; }
    Item &operator=(const Item &) = default;
    Item &operator=(Item &&) = default;
};
// for (int i = 0; i < n; ++i) v.push_back(Item<NoExcept>(i));
move without noexcept  n=1000     copies=1023      moves=1000      (nothrow_move=0)
move with noexcept     n=1000     copies=0         moves=2023      (nothrow_move=1)
move without noexcept  n=1000000  copies=1048575   moves=1000000   (nothrow_move=0)
move with noexcept     n=1000000  copies=0         moves=2048575   (nothrow_move=1)
median of 7: without noexcept 43.32 ms, with noexcept 14.57 ms

(最初の4行は時間の列を省いています。)1 行目のムーブ 1,000 回は push_back(Item(i)) 自体が一時オブジェクトをムーブした分で、どちらでも起きます。コピー 1,023 回が移し替えです。libc++ は容量を 1, 2, 4, …, 1024 と倍々にするので、1 + 2 + … + 512 = 1,023 個を移しました。noexcept がないと、その1つ1つが確保付きの文字列コピーになります。100 万要素ではコピー 1,048,575 回、時間は約3倍です。

棒グラフ。noexcept でないムーブコンストラクタでは、vector の伸長で 1,048,575 回のコピーが起き 43.32 ミリ秒。noexcept 付きではコピー 0 回で 14.57 ミリ秒。
40 文字の string を持つ要素を 100 万回 push_back。7 回の中央値、-O2、Apple clang 21 / libc++、Apple Silicon Mac(M1 Pro)。

noexcept を考えずに正しくするには、第3回の rule of zero に従うことです。特殊メンバ関数を1つも書かない struct Plain { std::string s; }; はコンパイラがムーブコンストラクタを作り、std::is_nothrow_move_constructible_v<Plain> は 1 でした。問題は特殊メンバを1つだけ手書きしたときに起きます。練習問題2が一番気づきにくい例です。

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

所有権のバグ3つを1ファイルに入れ、そのままと -fsanitize=address,undefined -g 付きで実行しました。

struct Widget : std::enable_shared_from_this<Widget> {
    int id = 7;
    std::shared_ptr<Widget> self_wrong() { return std::shared_ptr<Widget>(this); }
    std::shared_ptr<Widget> self_right() { return shared_from_this(); }
};
// in main(), selected by argv[1]:
if (mode == 1) {                          // two owners of one raw pointer
    int *raw = new int(42);
    std::unique_ptr<int> a(raw);
    std::unique_ptr<int> b(raw);
} else if (mode == 2) {                   // raw pointer outlives its owner
    auto up = std::make_unique<Widget>();
    Widget *view = up.get();
    up.reset();
    std::printf("id=%d\n", view->id);
} else if (mode == 3) {                   // shared_ptr(this)
    auto sp = std::make_shared<Widget>();
    auto again = sp->self_wrong();
}
間違い そのままのビルド ASan 付き 直し方
1つの new から unique_ptr を2つ Trace/BPT trap: 5、終了コード 133 attempting double-free、19 行目 make_unique で作り、生ポインタを2回包まない
reset() 後に get() の値を使う id=0 と表示、終了コード 0 heap-use-after-free、23 行目 持ち主を生かしておくか、shared_ptr + weak_ptr
メソッド内で shared_ptr<T>(this) Trace/BPT trap: 5、終了コード 133 bad-free(attempting free on address which was not malloc()-ed) enable_shared_from_this を継承し shared_from_this()
shared_ptr で双方向 エラーなし、デストラクタが呼ばれない 無反応 逆リンクは weak_ptr、leaks --atExit で確認
return std::move(local); 動くがコピー省略を妨げる — return local;(-Wpessimizing-move)
デストラクタだけ宣言、ムーブなし 伸長のたびにコピー — rule of zero、または5つとも = default

一番危ないのは mode 2 で、そのままのビルドはもっともらしい id=0 を出して正常終了しました。mode 3 は「コールバックに自分自身を登録する」実務でよくある形で、同じオブジェクトを2つの制御ブロックが所有してしまいます。shared_from_this() にすると両方 use_count=2、つまり制御ブロックは1つでした。

どの持ち方を選ぶか

状況 使うもの 理由
持ち主がはっきり1人(メンバ、ファクトリの戻り値) std::unique_ptr<T> 8 バイト、カウントなし、ムーブが安い
持ち主が複数で寿命の終わりが読めない(共有キャッシュ、非同期コールバック) std::shared_ptr<T> 制御ブロックでカウント。16 バイト、カウント更新はアトミック
逆リンク、監視者、生かしてはいけないキャッシュ std::weak_ptr<T> 所有しない。使うときは lock()
関数がオブジェクトを使うだけ T& か T* の引数 シグネチャに所有権を持ち込まない
オブジェクトの配列 std::vector<T> すでに要素を所有している

まず unique_ptr から始めましょう。後から shared_ptr に変えられます(std::shared_ptr<T> s = std::move(u);)が、逆はできません。また、読むだけの関数にスマートポインタを値で渡さないこと。void draw(const Shape&) の方が意図が明確で、呼ぶたびにアトミックなカウンタを触ることもありません。

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

  • 実行環境は Apple clang 21.0.0(libc++、_LIBCPP_VERSION 220106)、arm64 の macOS 26 だけです。GCC/libstdc++、MSVC、Linux、Valgrind は動かしていません。libstdc++ ではムーブ後の文字列や伸長率が違う可能性があります。
  • バージョン:unique_ptr・shared_ptr・weak_ptr・ムーブセマンティクス・noexcept は C++11、make_unique は C++14(-std=c++11 で拒否されるのを確認)。ここのコードは -std=c++17・-std=c++20 でもコンパイルできました。
  • 伸長率 2 倍や確保サイズ(make_shared<Person> 1つで 80 バイト)は libc++ の実装の話で、規格ではありません。
  • 時間は1台での中央値です。コピーは実行ごとに 16.5〜17.0 ms、noexcept の比較は別々の2回で 43.3 ms 対 14.6 ms でした。
  • Apple Silicon の macOS では AddressSanitizer のリーク検出が動きません(C言語講座 第3回参照)。そのため leaks を使いました。

練習問題

1. 各 Node が children と親へのリンク parent を持つ木を作ってください。根を手放したら全部解放されるようにポインタの型を選び、デストラクタの出力と leaks --atExit で確かめてください。

2. 次の型は std::vector を遅くします。ムーブについては何も書いていないのに、なぜでしょう。

struct Logged {
    std::string text;
    ~Logged() {}            // added "just to set a breakpoint"
};

解答

1. 親→子は所有、子→親は見張るだけにします。

struct Node {
    std::string name;
    std::weak_ptr<Node> parent;                    // child -> parent: weak
    std::vector<std::shared_ptr<Node>> children;   // parent -> child: owning
    explicit Node(std::string n) : name(std::move(n)) {}
    ~Node() { std::printf("dtor %s\n", name.c_str()); }
};

根・子 a・孫 a1 を作り、孫だけ外側の leaf で持っておくと、a1's parent is a の後、スコープを抜けた時点で dtor root、dtor a、続いて tree gone? parent expired=1、最後に dtor a1 が出ました。leaks は 0 leaks for 0 total leaked bytes. です。lock() は一時的な shared_ptr を返すので、if (auto p = ...) の中で使います。

2. デストラクタを自分で宣言すると、暗黙のムーブコンストラクタが作られなくなります。コピーはできるので std::is_nothrow_move_constructible_v<Logged> は 0(例外を投げ得る string のコピーに頼るため)でした。容量 1,024 から 2,048 に伸びる vector に後半の 1,000 要素を入れる間の確保回数を数えると、Logged=3025、デストラクタを消した Clean=1001 です。差の 2,024 回は、移し替えのコピー 1,024 回と、渡した一時オブジェクトのコピー 1,000 回です。空のデストラクタを消す(rule of zero)か、Logged(Logged&&) noexcept = default; などを5つ揃えて書きます。

次回

第5回はテンプレートです。114 行のテンプレートエラーの読み方、C++20 のコンセプトでそれが 22 行になる様子、ラムダと STL アルゴリズム、遅延評価の ranges、そして std::sort・std::stable_sort・qsort の実測です。

よくある質問

vector が伸びるとき、要素がムーブではなくコピーされるのはなぜ?

ムーブコンストラクタが noexcept でないからです。vector は強い例外保証を守るため、再配置でムーブが例外を投げ得るならコピーを選びます。手元の計測では 100 万要素の push_back で、noexcept なしはコピー 1,048,575 回・43.3 ms、noexcept 付きはコピー 0 回・14.6 ms でした。デストラクタを自分で宣言しただけでも暗黙のムーブコンストラクタが消え、同じことが起きます。

shared_ptr でもメモリリークは起きる?

起きます。2つのオブジェクトが互いを shared_ptr で持つと参照カウントが 0 になりません。手元の例ではデストラクタが一度も呼ばれず、スコープを抜けても alive=2 のまま、macOS の leaks は「2 leaks for 160 total leaked bytes」と ROOT CYCLE を報告しました。片方向を weak_ptr にすると両方のデストラクタが走り、leaks も 0 になりました。

std::move した後のオブジェクトには何が残る?

「有効だが未規定の状態」です。libc++ では長い文字列も短い文字列も vector も空になり、その後 vector に push_back するのも問題ありませんでした。ただし中身には依存せず、新しい値を代入するか破棄するだけにします。const オブジェクトに std::move を書いても実際にはコピーされ、警告も出ません。

  • #C++
  • #中級
  • #スマートポインタ
  • #ムーブセマンティクス
  • #noexcept
  • #AddressSanitizer

t.laboratory

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

運営者情報 · アプリ

↑