C++20:Spaceship Operator との比較の最適化

この投稿では、3 者間比較演算子のミニシリーズをいくつかの微妙な詳細で締めくくります。微妙な詳細には、コンパイラによって生成された ==演算子と !=演算子、および従来の比較演算子と 3 者間比較演算子の相互作用が含まれます。

前回の投稿「C++20:宇宙船オペレーターの詳細」を次のクラス MyInt で完成させました。この具体的なケースでは、明示的なコンストラクターと非明示的なコンストラクターの違いについて詳しく説明することを約束しました。経験則では、引数を 1 つ取るコンストラクターは明示的でなければなりません。

明示的なコンストラクター

これは基本的に、前回の投稿のユーザー定義型 MyInt です。

// threeWayComparisonWithInt2.cpp

#include <compare>
#include <iostream>

class MyInt {
 public:
 constexpr explicit MyInt(int val): value{val} { } // (1)
 
 auto operator<=>(const MyInt& rhs) const = default; // (2)
 
 constexpr auto operator<=>(const int& rhs) const { // (3)
 return value <=> rhs;
 }
 
 private: 
 int value;
};


int main() {
 
 std::cout << std::boolalpha << std::endl;
 
 constexpr MyInt myInt2011(2011);
 constexpr MyInt myInt2014(2014);
 
 std::cout << "myInt2011 < myInt2014: " << (myInt2011 < myInt2014) << std::endl; // (4)

 std::cout << "myInt2011 < 2014: " << (myInt2011 < 2014) << std::endl; // (5)
 
 std::cout << "myInt2011 < 2014.5: " << (myInt2011 < 2014.5) << std::endl; // (6)
 
 std::cout << "myInt2011 < true: " << (myInt2011 < true) << std::endl; // (7)
 
 std::cout << std::endl;
 
}

(1) のような引数を 1 つ取るコンストラクターは、int から MyInt のインスタンスを生成できるため、変換コンストラクターと呼ばれることがよくあります。

MyInt には、明示的なコンストラクター (1)、コンパイラー生成の 3 方向比較演算子 (2)、および int(3) のユーザー定義比較演算子があります。 (4) MyInt にはコンパイラが生成した比較演算子を使用し、(5、6、および 7) int にはユーザー定義の比較演算子を使用します。 int への暗黙のナローイング (6) と整数昇格 (7) のおかげで、MyInt のインスタンスは double 値および bool 値と比較できます。

MyInt をさらに int 風にすると、明示的なコンストラクター (1) の利点が明らかになります。次の例では、MyInt は基本的な算術演算をサポートしています。

// threeWayComparisonWithInt4.cpp

#include <compare>
#include <iostream>

class MyInt {
 public:
 constexpr explicit MyInt(int val): value{val} { } // (3)
 
 auto operator<=>(const MyInt& rhs) const = default; 
 
 constexpr auto operator<=>(const int& rhs) const {
 return value <=> rhs;
 }
 
 constexpr friend MyInt operator+(const MyInt& a, const MyInt& b){
 return MyInt(a.value + b.value);
 }
 
 constexpr friend MyInt operator-(const MyInt& a,const MyInt& b){
 return MyInt(a.value - b.value);
 }
 
 constexpr friend MyInt operator*(const MyInt& a, const MyInt& b){
 return MyInt(a.value * b.value);
 }
 
 constexpr friend MyInt operator/(const MyInt& a, const MyInt& b){
 return MyInt(a.value / b.value);
 }
 
 friend std::ostream& operator<< (std::ostream &out, const MyInt& myInt){
 out << myInt.value;
 return out;
 }
 
 private: 
 int value;
};


int main() {
 
 std::cout << std::boolalpha << std::endl;
 
 constexpr MyInt myInt2011(2011);
 constexpr MyInt myInt2014(2014);
 
 std::cout << "myInt2011 < myInt2014: " << (myInt2011 < myInt2014) << std::endl;

 std::cout << "myInt2011 < 2014: " << (myInt2011 < 2014) << std::endl;
 
 std::cout << "myInt2011 < 2014.5: " << (myInt2011 < 2014.5) << std::endl;
 
 std::cout << "myInt2011 < true: " << (myInt2011 < true) << std::endl;
 
 constexpr MyInt res1 = (myInt2014 - myInt2011) * myInt2011; // (1)
 std::cout << "res1: " << res1 << std::endl;
 
 constexpr MyInt res2 = (myInt2014 - myInt2011) * 2011; // (2)
 std::cout << "res2: " << res2 << std::endl;
 
 constexpr MyInt res3 = (false + myInt2011 + 0.5) / true; // (3)
 std::cout << "res3: " << res3 << std::endl;
 
 
 std::cout << std::endl;
 
}

MyInt は、型 MyInt (1) のオブジェクトを使用した基本演算をサポートしますが、int (2)、double、または bool (3) などの組み込み型を使用した基本演算はサポートしません。コンパイラのエラー メッセージは明確なメッセージを示します:

コンパイラは、(2) int から const MyInt への変換がないこと、および (3) bool から const MyInt への変換形式がないことを認識しています。 int、double、または bool を const MyInt にする実行可能な方法は、非明示的なコンストラクターです。したがって、コンストラクターから明示的なキーワードを削除すると (1)、暗黙的な変換が開始され、プログラムがコンパイルされ、驚くべき結果が生成されます。

コンパイラで生成された ==および !=演算子は、パフォーマンス上の理由から特別です。

最適化された ==および !=演算子

最初の投稿「C++20:三方比較演算子」で、コンパイラ生成の比較演算子は辞書式比較を適用すると書きました。辞書式比較とは、すべての基本クラスが左から右に比較され、クラスのすべての非静的メンバーが宣言順に比較されることを意味します。

Andrew Koenig は、Facebook グループ C++ Enthusiast の私の投稿「C++20:宇宙船オペレーターへの詳細」にコメントを書きました。ここで引用したいと思います:

アンドリューのコメントに追加するものは何もありませんが、1つの観察があります。標準化委員会はこのパフォーマンスの問題を認識しており、論文 P1185R2 で修正しました。したがって、コンパイラが生成した ==および !=演算子は、文字列またはベクトルの場合、最初に長さを比較し、必要に応じて内容を比較します。

ユーザー定義および自動生成の比較演算子

6 つの比較演算子の 1 つを定義し、さらに spaceship 演算子を使用してそれらすべてを自動生成できる場合、1 つの問題があります。どちらが優先度が高いでしょうか?たとえば、私の新しい実装 MyInt には、ユーザー定義の縮小演算子と ID 演算子があり、コンパイラによって生成された 6 つの比較演算子もあります。

何が起こるか見てみましょう:

// threeWayComparisonWithInt5.cpp

#include <compare>
#include <iostream>

class MyInt {
 public:
 constexpr explicit MyInt(int val): value{val} { }
 bool operator == (const MyInt& rhs) const { 
 std::cout << "== " << std::endl;
 return value == rhs.value;
 }
 bool operator < (const MyInt& rhs) const { 
 std::cout << "< " << std::endl;
 return value < rhs.value;
 }
 
 auto operator<=>(const MyInt& rhs) const = default;
 
 private:
 int value;
};

int main() {
 
 MyInt myInt2011(2011);
 MyInt myInt2014(2014);
 
 myInt2011 == myInt2014;
 myInt2011 != myInt2014;
 myInt2011 < myInt2014;
 myInt2011 <= myInt2014;
 myInt2011 > myInt2014;
 myInt2011 >= myInt2014;
 
}

ユーザー定義の ==および <演算子の動作を確認するために、対応するメッセージを std::cout に書き込みます。 std::cout はランタイム操作であるため、両方の演算子を constexpr にすることはできません。

この場合、コンパイラはユーザー定義の ==および <演算子を使用します。さらに、コンパイラは ==演算子から !=演算子を合成します。コンパイラは、!=演算子から ==演算子を合成しません。

C++ は Python と同様に動作するため、この動作は私にとって驚くべきことではありません。 Python 3 では、コンパイラは必要に応じて ==から !=を生成しますが、その逆は生成しません。 Python 2 では、いわゆるリッチ比較 (ユーザー定義の 6 つの比較演算子) が、Python の 3 者間比較演算子 __cmp__ よりも優先されます。 Python 3 では 3 方向比較演算子が削除されているため、Python 2 と言わざるを得ません。

次は?

指定された初期化は、集約初期化の特殊なケースであり、名前を使用してクラスのメンバーを直接初期化できるようにします。設計されたイニシャライザは、私の次の C++20 トピックです。