8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C++】スマートポインタの特徴を図解しました

Last updated at Posted at 2024-07-25

はじめに

C++ 11で、unique_ptr, shared_ptr, weak_ptrの3種類のポインタが導入されました。本記事では、これら3つのポインタの特徴や使い分けについて簡単にまとめました。

生ポインタの問題

これまでメジャーであった生ポインタですが、生ポインタのメモリ管理は、プログラマが手動で行う必要がありました。生ポインタの管理ルールとして以下のようなものがあげられます。

  • 不要になったポインタは必ず1回解放されなければならない
  • ポインタを2回以上解放してはならない
  • 解放後のポインタにはnullptrを代入しなければならない (ダングリングポインタ対策)

これらのルールを守らないと厄介な問題が発生します。例えば、ある関数内で生ポインタを生成し、ポインタの解放を忘れると、アドレスが確保され続けるという問題が発生します(メモリリーク)。また、解放済みのポインタを再度解放すると未定義の動作を引き起こす問題もあります。これらの問題をプログラマの注意力のみで防ぐのには限界があり、その解決策の一つとして、スマートポインタが導入されました。

スマートポインタの概念

スマートポインタは、「所有権」をもとに、リソースを自動解放してくれるポインタです。所有権とは、特定のリソース(通常は動的に割り当てられたメモリ)をどのオブジェクトが所有しているかを明確にする概念です。スマートポインタは所有権を管理し、対象のリソースを保有するオブジェクトがなくなれば、自動的にリソースを解放します。これにより、プログラマがメモリ管理をする負担が大幅に小さくなり、より安全に開発できるようになりました。

表1: 各ポインタの性質比較表

所有権の数 コピー ムーブ 解放処理 リソース解放のタイミング
生ポインタ - 〇※1 手動 ポインタ解放時
unique_ptr 1 X 自動 ポインタ解放時
shared_ptr 1..N 自動 全てのポインタ解放時※2
weak_ptr 0 自動 ※3

※1: 生ポインタをstd::moveすることはできますが、ポインタの解放は手動で実施しないといけません。
※2: 参照カウントが0になった時にリソースが解放されます。
※3: weak_ptr自体を解放しても、shared_ptrを解放しない限り、リソースは解放されません。

下記では、スマートポインタの性質の違いについて、サンプルコードで説明します。

生ポインタとunique_ptrの違い

生ポインタを関数の戻り値として返す

まず、生ポインタを使った例を紹介します。main関数から呼び出した関数内で文字列を生成し、そのポインタを呼び出し元のmain関数に返却する場合を考えます。その後、main関数内で、文字列の中身を表示します。
※ なお、生ポインタの解放はプログラマの責務になるため、筆者は生ポインタの利用は非推奨と考えています。

std::string* functionC() {
	std::string* ptr_string = new std::string("Hello, Raw Pointer!");
	return ptr_string;
}

void mainC() {
	std::string* ptr_string = functionC();
	std::cout << "ポインタの中身: " << *ptr_string << std::endl;
    //
    // ポインタの解放を忘れているため、メモリリークが発生する
    //
}

/* 実行結果
ポインタの中身: Hello, Raw Pointer!
*/

このプログラムの挙動をシーケンス図で表現します。オレンジの箱は、生ポインタの生存期間(スコープ)を表しています。関数内で生成された文字列のポインタをmain関数に返却していますが、main関数内でポインタの解放を忘れているため、文字列のデータはメモリ上に残り続けています。このようにプログラマの記述ミスにより、簡単にメモリリークが発生します。

d_raw_ptr_as_return_value.png

unique_ptrを関数の戻り値として返す

次に、先程と同様の処理を、スマートポインタ(unique_ptr)で記載します。先程の例では、ポインタの型がstd::string* ptr_string でしたが、std::unique_ptr<std::string>になりました。

std::unique_ptr<std::string> functionD() {
	std::unique_ptr<std::string> ptr_string = std::make_unique<std::string>("Hello, Unique Pointer!");
	return std::move(ptr_string);
}

void mainD() {
	std::unique_ptr<std::string> ptr_string = functionD();
	std::cout << "ポインタの中身: " << *ptr_string << std::endl;
	std::cout << "関数呼び出し後のポインタ参照数: " << bool(ptr_string) << std::endl;
    //
    // この関数を抜ける際に、ポインタは自動で解放される
    //
}

/* 実行結果
ポインタの中身: Hello, Unique Pointer!
関数呼び出し後のポインタ参照数: 1
*/

このプログラムの挙動をシーケンス図で表現します。ポインタの所有権を持っている区間をオレンジの箱で表しています。先程の例とは異なり、main関数のスコープを抜ける際にポインタが自動で解放され、メモリリークも発生しません。このようにスマートポインタを使用すると、プログラマがメモリ管理に気を遣う量が減り、開発作業に集中できます。
unique_ptrの特徴として、リソース(文字列)を所有するオブジェクトは必ず1つしかありません。そのため、functionからmain関数にポインタを返却する場合は、ポインタのコピーができません。そのため、 所有権の移動(ムーブ) を行います。サンプルコード上では、return std::move(ptr_string)のように記述している箇所です。シーケンス図でも、オレンジの箱が重なる箇所がなく、functionからmain関数に移動していることが分かります。

c_unique_ptr_as_return_value.png

unique_ptrとshared_ptrの違い

ここまで、生ポインタとunique_ptrを説明しました。次に、unique_ptrとshared_ptrの違いについて、サンプルコードで解説します。

unique_ptrを関数の引数として渡す

関数に文字列を渡す場合を考えます。引数には文字列のポインタであるunique_ptrを指定します。関数内では、受け取った文字列ポインタの中身を出力します。

void functionB(std::unique_ptr<std::string> ptr_string) {
	std::cout << "ポインタの中身: " << *ptr_string << std::endl;
	std::cout << "関数呼び出し中のポインタ参照数: " << bool(ptr_string) << std::endl;
}

void mainB() {
	std::unique_ptr<std::string> ptr_string = std::make_unique<std::string>("Hello, Unique Pointer!");
	std::cout << "関数呼び出し前のポインタ参照数: " << bool(ptr_string) << std::endl;
	functionB(std::move(ptr_string));
	std::cout << "関数呼び出し後のポインタ参照数: " << bool(ptr_string) << std::endl;
}

/* 実行結果
関数呼び出し前のポインタ参照数: 1
ポインタの中身: Hello, Unique Pointer!
関数呼び出し中のポインタ参照数: 1
関数呼び出し後のポインタ参照数: 0
*/

このプログラムの挙動をシーケンス図で表現します。ポインタの所有権を持っている区間をオレンジの箱で表しています。main関数からfunctionを呼び出すときに、引数として文字列のポインタを渡します。この時、ポインタの所有権はmain関数からfunction側に移動します。ポインタの所有権はfunctionが持っているため、functionを抜けるときに、ポインタも自動解放されます。main関数へ戻った後に、unique_ptr経由から文字列の値は参照できなくなります。
このように、unique_ptrを所有するオブジェクトは必ず1つだけという強いルールがあり、ポインタのコピーもできません。このルールは一見不便ですが、このルールのおかげでメモリ安全にプログラミングすることができます。

b_unique_ptr_as_arg.png

shared_ptrを関数の引数として渡す

先程の例と同様な処理ですが、引数にshared_ptrを使用した場合を考えます。関数内では、受け取った文字列ポインタの中身を出力します。

void functionA(std::shared_ptr<std::string> ptr_string) {
	std::cout << "ポインタの中身: " << *ptr_string << std::endl;
	std::cout << "関数内でのポインタ参照数: " << ptr_string.use_count() << std::endl;
}

void mainA() {
	std::shared_ptr<std::string> ptr_string = std::make_shared<std::string>("Hello, Shared Pointer!");
	std::cout << "関数呼び出し前のポインタ参照数: " << ptr_string.use_count() << std::endl;
	functionA(ptr_string);
	std::cout << "関数呼び出し後のポインタ参照数: " << ptr_string.use_count() << std::endl;
}

/* 実行結果
関数呼び出し前のポインタ参照数: 1
ポインタの中身: Hello, Shared Pointer!
関数呼び出し中のポインタ参照数: 2
関数呼び出し後のポインタ参照数: 1
*/

このプログラムの挙動をシーケンス図で表現します。ポインタの所有権を持っている区間をオレンジの箱で表しています。main関数からfunctionを呼び出すときに、引数として文字列のポインタを渡します。unique_ptrとは異なり、リソースの所有権はfunction側に コピーされます 。functionのスコープ内では、リソースの所有権の数は2つに増えます。functionを抜けるときに、ポインタも自動解放され、リソースの所有権の個数は1に減ります。main関数へ戻った後もリソースは残っているため、shared_ptr経由での文字列参照は可能です。最後にmain関数を抜ける際にポインタが自動解放され、所有権は0になります。このタイミングでリソースも解放されます。
このように、shared_ptrを使うと、複数のポインタが同じリソースを所有することが可能です。

a_shared_ptr_as_arg.png

shared_ptrとweak_ptrの挙動の違い

2つのshared_ptrを定義し、一方のshared_ptrAを解放したとき、shared_ptrBからリソースを参照できるか?

次の例では、2つのshared_ptrを使用します。文字列をshared_ptrA経由で生成し、その参照をshared_ptrBにコピーします。shared_ptrAを先に解放した場合、shared_ptrBは、リソースを参照できるでしょうか?

void mainE() {
	std::shared_ptr<std::string> ptr_stringA = std::make_shared<std::string>("Hello,Shared Pointer!");
	std::shared_ptr<std::string> ptr_stringB = ptr_stringA;
	std::cout << "ポインタの中身: " << *ptr_stringB << std::endl;	
	std::cout << "ポインタの参照数: " << ptr_stringB.use_count() << std::endl;
	ptr_stringA.reset();
	std::cout << "ポインタの中身: " << *ptr_stringB << std::endl;
	std::cout << "ポインタの参照数: " << ptr_stringB.use_count() << std::endl;
}

/* 実行結果
ポインタの中身: Hello,Shared Pointer!
ポインタの参照数: 2
ポインタの中身: Hello,Shared Pointer!
ポインタの参照数: 1
*/

答えはYesです。
このプログラムの挙動をシーケンス図で表現します。ポインタの所有権を持っている区間をオレンジの箱で表しています。文字列生成時に、リソースへの参照をshared_ptrAが持ちます。よって参照カウントは1です。次に、shared_ptrBに参照をコピーすることで参照カウントは2に増えます。その後、shared_ptrAを解放すると参照カウントは1に減りますが、shared_ptrBでの参照が有効のため、引き続きリソースを参照することが可能です。

e_shared_ptr_count.png

shared_ptrとweak_ptrを一つずつを定義し、一方のshared_ptrAを解放したとき、weak_ptrBからリソースを参照できるか?

次の例では、shared_ptrBをweak_ptrとして定義した場合を考えます。この場合、shared_ptrAを先に解放すると、shared_ptrBは、リソースを参照できるでしょうか?

void mainF() {
	std::shared_ptr<std::string> ptr_stringA = std::make_shared<std::string>("Hello,Shared Pointer!");
	std::weak_ptr<std::string> ptr_stringB = ptr_stringA;
	if (auto ptr = ptr_stringB.lock()) {
		std::cout << "ポインタの中身: " << *ptr << std::endl;
	}
	std::cout << "ポインタの参照数: " << ptr_stringA.use_count() << std::endl;
	ptr_stringA.reset();
	if (ptr_stringB.expired()) {
		std::cout << "ポインタは解放済みです" << std::endl;
	}
}

/* 実行結果
ポインタの中身: Hello,Shared Pointer!
ポインタの参照数: 1
ポインタは解放済みです
*/

答えはNoです。
このプログラムの挙動をシーケンス図で表現します。ポインタの所有権を持っている区間をオレンジの箱で表しています。文字列生成時に、shared_ptrAがリソースを参照します。よって参照カウントは1です。次に、shared_ptrBに参照をコピーします。しかし、weak_ptrBを生成しても、参照カウントは1のままです。その後、shared_ptrAを解放すると、参照カウントは0になり、この時点でリソースが破棄されます。その後、shared_ptrBから参照しようとしても、リソースは破棄されているため参照できません。このように、weak_ptrはリソースへの参照は持ちますが、所有権を持ちません。 そのため、weak_ptrが参照しているリソースは、別処理で解放されている可能性があることに気を付ける必要があります。

f_weak_ptr_count.png

まとめ

本記事では、C++11で導入されたunique_ptr、shared_ptr、weak_ptrの特徴と使い分けについて説明しました。従来の生ポインタは手動でメモリ管理が必要で、プログラムミスが原因でメモリリークや未定義動作が発生する可能性があります。一方、スマートポインタは所有権の概念を取り入れ、リソースを自動で解放することでプログラマのメモリ管理の負担を軽減します。
よって筆者は、スマートポインタの使用を強く推奨しています。筆者は、制約の最も強いunique_ptrの使用を第一に考えており、次いでshared_ptr, weak_ptrの使い分けを勧めます※4。生ポインタは、古いライブラリとのつなぎこみなど、どうしても生ポインタが必要な箇所にのみスポットで導入すると良いでしょう。

※4: shared_ptrの使いすぎは循環参照といった問題を生じるため注意が必要

スマートポインタは万能ではありませんが、スマートポインタの使用で、C++を擬似的にメモリ安全な言語としてみなすこともできます。ぜひスマートポインタを積極的に使っていきましょう。

8
8
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?