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
この出力から読み取ること:
unique_ptrは生ポインタと同じ 8 バイト。標準のデリータならサイズのコストはゼロです。shared_ptrとweak_ptrは 16 バイトで、オブジェクトへのポインタと、カウントを持つ制御ブロックへのポインタの2本です。release Aはmainの最後ではなく、block 1 の閉じ括弧で出ています。deleteは一度も書いていません。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 の記事で別の言語の同じリークを見られます。
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 を考えずに正しくするには、第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_VERSION220106)、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 を書いても実際にはコピーされ、警告も出ません。