C++のルール・オブ・スリー、ファイブ、ゼロ

Phil Nash photo

Phil Nash

Developer Advocate

TL;DR 概要

  • C++のルール・オブ・スリー、ファイブ、ゼロは、リソースを安全に管理し、二重解放エラーのようなバグを避けるために、特別なメンバー関数(コピー/ムーブコンストラクタ、代入演算子、デストラクタ)を定義するタイミングを規定しています。
  • ルール・オブ・ゼロは推奨されるデフォルトです。特別なメンバー関数を明示的に定義する必要がないようにクラスを設計し、リソース管理をスマートポインタのような専門の型に委ねます。
  • ルール・オブ・ファイブは、C++11のムーブセマンティクスで導入されたムーブコンストラクタとムーブ代入演算子を含むように、元のルール・オブ・スリーを拡張したものです。
  • SonarのルールS3624とS4963は、静的解析でこれらのガイドラインを強制し、ルール・オブ・ファイブまたはルール・オブ・ゼロに違反するクラスを検出します。

C++におけるルール・オブ・スリー、ファイブ、ゼロとは何か?

この投稿では、ルール・オブ・スリー、ファイブ、ゼロを紹介し、どのルールをいつ使用すべきかを説明します。続編の投稿では、異なるケースに対するルール・オブ・ファイブの実装についてさらに深く掘り下げます。

現在、C++はRAII(リソース取得は初期化)の原則で長らく有名です。

この用語は、コピーおよびムーブコンストラクタ、デストラクタ、代入演算子という5つの特別なメンバー関数を通じて、メモリなどのリソースを管理する能力に関連しています。

しばしば、RAIIが言及されるときは、スコープの終わりでデストラクタが決定的に呼び出されることを指します。

すでに不格好な名前を考えると、少し皮肉です。

しかし、RAIIの他のスーパーパワーも同様に重要です。

多くの言語が「値型」と「参照型」を区別するだけであるのに対し(例:C#は構造体で値型を、クラスで参照型を定義します)、C++はこの特別なメンバー関数のセットを通じて、アイデンティティとリソースを扱うためのはるかに豊かなキャンバスを提供します。

しかし、C++11以前でも、この柔軟性は複雑さの代償を伴いました。

いくつかの相互作用は微妙で、間違えやすいです。

そこで1991年にMarshall Clineが「ルール・オブ・スリー」を提唱しました。これはほとんどのケースをカバーする簡単な経験則です。

C++11がムーブセマンティクスを導入したとき、これは「ルール・オブ・ファイブ」にアップグレードされました。

その後、R. Martinho Fernandesが「ルール・オブ・ゼロ」を提唱しました。これはデフォルトとしてルール・オブ・ファイブを凌駕することを示唆しています。

しかし、これらのルールは何なのでしょうか?そして、私たちはそれらに従う必要があるのでしょうか?

C++におけるルール・オブ・スリーがルール・オブ・ファイブになる

ルール・オブ・スリーは、コピーコンストラクタ、コピー代入演算子、デストラクタのいずれかを定義する必要がある場合、通常は「すべての3つ」を定義する必要があることを示唆しています。

「すべての3つ」を引用符で囲んでいるのは、C++11以降ではそのアドバイスが時代遅れだからです。

現在、ムーブセマンティクスにより、2つの追加の特別なメンバー関数があります:ムーブコンストラクタとムーブ代入演算子です。

したがって、ルール・オブ・ファイブは、5つのうちのいずれかを定義する必要がある場合、すべての5つを定義または削除(または少なくとも考慮)する必要があることを示唆する拡張です。

(この声明はルール・オブ・スリーほど強くはありません。なぜなら、ムーブ操作を定義しない場合、それらは生成されず、呼び出しはコピー操作にフォールバックするからです。これは間違いではありませんが、最適化の機会を逃すかもしれません。)

厳密にC++11以前のコンパイルを行っていない限り、ルール・オブ・ファイブに従うべきです。

いずれにせよ、これは理にかなっています。

カスタムの特別なメンバー関数(デフォルトコンストラクタ以外)を定義する必要がある場合、それは通常、何らかのリソースを管理しているためです。

その場合、ライフタイムの各段階で何が起こるかを考慮する必要があります。

特別なメンバー関数のデフォルト実装が抑制または削除される理由はいくつかありますが、それについては第2の記事で詳しく見ていきます。

ここに例があります。P1950からのindirect_valueに緩やかに触発されたものです:

template<typename T>
class IndirectValue {
   T* ptr;
public:

   // Init & destroy
   explicit IndirectValue(T* ptr ) : ptr(ptr) {}
   ~IndirectValue() noexcept { if(ptr) delete ptr; }

   // Copy (along with the destructor, gives us the Rule of Three)
   IndirectValue(IndirectValue const& other) : ptr(other.ptr ? new T(*other.ptr) : nullptr) {}

   IndirectValue& operator=(IndirectValue const& other) {
       IndirectValue temp(other);
       std::swap(ptr, temp.ptr);
       return *this;
   }

   // Move (Adding these gives us the Rule of Five)
   IndirectValue(IndirectValue&& other) noexcept : ptr(other.ptr) {
       other.ptr = nullptr;
   }
   IndirectValue& operator=(IndirectValue&& other) noexcept {
       IndirectValue temp(std::move(other));
       std::swap(ptr, temp.ptr);
       return *this;
   }

   // Other methods
};

代入演算子を実装するためにコピー・アンド・スワップ(およびムーブ・アンド・スワップ)イディオムを使用して、リークを防ぎ、自己代入を自動的に処理することに注意してください(この例では両方の関数を示したかったので、2つの演算子を1つにまとめて引数を値で受け取ることもできます)。


さて、両方のルールは「もしあなたが…のいずれかを定義する必要があるなら」と始まります。時には否定的な空間が興味深いです。

これらのルールの暗黙の側面は、特別なメンバー関数のいずれも定義する必要がない有用なケースがあり、期待通りに動作するということです。

これが最も重要なケースであるかもしれませんが、その理由を理解するためには、少し視点を変える必要があります。ルール・オブ・ゼロの登場です。

ルール・オブ・ゼロ

特別なメンバー関数がユーザー定義されていない場合、(メンバ変数に依存して)コンパイラはそれらすべてのデフォルト実装を提供します。ルール・オブ・ゼロは、特別なメンバー関数を定義する必要がないケースを優先すべきであるという単純なものです。これは2つのケースに分かれます:

  1. クラスが純粋な値型を定義し、その状態が純粋な値型(例:プリミティブ)で構成されている。
  2. クラスの状態の一部として維持されるリソースは、リソース管理に特化したクラス(例:スマートポインタ、ファイルオブジェクトなど)によって管理される。

2番目のケースはもう少し説明が必要です。

別の表現として、任意のクラスは、最大で1つのリソースを直接管理すべきです。

したがって、メモリを管理する必要がある場合は、そのメモリを管理するために特化したクラスを使用または作成すべきです。それがスマートポインタであれ、配列ベースのコンテナであれ、他のものであれ。

これらのリソース管理型はルール・オブ・ファイブに従います。

しかし、そのようなクラスは非常に稀であるべきです。標準ライブラリは、そのコンテナ、スマートポインタ、ストリームオブジェクトでほとんどの一般的なケースをカバーしています。

リソース管理型を使用するクラスは、ルール・オブ・ゼロに従うことで「ただ動作する」べきです。

この厳密な区別を維持することで、コードはよりシンプルでクリーンになり、焦点が絞られ、正しく書くのが容易になります。

「コードが少ないほどバグも少ない」ので、書くコードが少なくて済む(特にリソース管理コード)は通常良いことです。

したがって、再び、ルール・オブ・ゼロは理にかなっています。そして、実際に、SonarのアナライザーはS493 - 「ルール・オブ・ゼロ」に従うべきでこれをガイドします。

C++でどのルールをいつ使うべきか?

ある意味で、ルール・オブ・ゼロはルール・オブ・ファイブを包含しているので、ただそれに従うべきです。しかし、別の考え方としては、デフォルトでルール・オブ・ゼロに従うことです。

特化したリソース所有クラスを書く必要があるとき(これは稀であるべきです)にルール・オブ・ファイブにフォールバックします。

これもまた、S3624 - 「ルール・オブ・ゼロ」が適用されない場合、「ルール・オブ・ファイブ」に従うべきで捉えられています。

ルール・オブ・スリーは、厳密にC++11以前で作業している場合にのみ関係します。

しかし、これで本当にすべてのケースをカバーできるのでしょうか?

C++でルール・オブ・スリー、ファイブ、ゼロが十分でないとき

ポリモーフィックな基底クラスは、上記のルールが適用される一般的なケースですが、少し重いように感じます。

なぜでしょうか?

そのようなクラスは(デフォルトの)仮想デストラクタを持つべきだからです(S1235 - ポリモーフィックな基底クラスのデストラクタは、パブリック仮想またはプロテクテッド非仮想であるべき)。

それは他の特別なメンバー関数を持つべきではないという意味ではありません。実際、ポリモーフィックな基底クラスは純粋な抽象基底クラスであるべきで、機能を持たないのが良い慣習です。

ポリモーフィックな階層でパブリックなコピーおよびムーブ操作を提供すると、スライシングに対して脆弱になります。これは、静的型と動的型の違いがコピーで失われることを意味します。

コピー可能性(またはムーブ可能性)が必要な場合、それらは仮想メソッドを介して行うべきです。

この場合、仮想clone()メソッドが一般的です。

これらの仮想メソッドの実装は、コピーおよびムーブ操作を使用することができます。その場合、それらはプロテクテッドメンバーとして実装またはデフォルト化することができます。これにより、外部からの誤用を防ぎます。

それ以外の場合、ほとんどの場合、それらは削除されるべきです。


   virtual ~MyBaseClass() = default;
   MyBaseClass(MyBaseClass const &) = delete;
   MyBaseClass(MyBaseClass &&) = delete;
   MyBaseClass operator=(MyBaseClass const &) = delete;
   MyBaseClass operator=(MyBaseClass &&) = delete;

すべての特別なメンバー関数を実装または削除することは、特に多くのポリモーフィックな基底クラスを持つコードベースで作業している場合、少し面倒になることがあります(ただし、これは今日ではかなり稀であり、少なくとも新しいコードではそうです)。

これを回避する方法の1つは、実際にはC++11以前の唯一の方法ですが、これらの5つの定義を持つ基底クラスからプライベートに継承することです(または、C++11以前では、「削除された」関数をプライベートにして未実装にすることです)。

これは依然として有効なオプションであり、議論の余地はありますが、ルール・オブ・ゼロに戻ります。

しかし、ムーブ代入演算子を削除するだけで十分であることが判明しました。

特別なメンバー関数間の相互作用がどのように指定されているかのため、これにより同じ効果が得られます(実際には、次の記事で見るように、少し良いかもしれません)。

virtual ~MyBaseClass() = default;
   MyBaseClass operator=(MyBaseClass &&) = delete;

それが奇妙または疑わしいと感じる場合、または異なるケースに対するルール・オブ・ファイブの実装についてもっと掘り下げたい場合は、このシリーズの第2部を読み進めてください。ここでは、これらすべてについてさらに深く掘り下げ、これらの相互作用がどのように指定されているかについても説明します。