18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C++】初心者のためのクラス設計基礎③ ~演算子オーバーロード~

Last updated at Posted at 2021-03-22

はじめに

 前回の続きです。超初心者向けです。今回は演算子オーバーロードについて話すつもりです。今回はかなり短い記事になるはずでしたが、そうはなりませんでした。

前回までのあらすじ

 前回まで実際に配列クラスを設計しながら、カプセル化やconstの伝播について考えてきました。作った配列クラスは今のところ下のコードのようになっているはずです。

class Array {
private:
  int length;       // 要素数
  int* array_ptr;   // 配列のポインタ
public:
  void allocate(int len) {         // メモリ確保
    length = len:
    array_ptr = new int[length];
  }
  void clear() {                   // メモリ解放
    delete[] array_ptr;
    length = 0;
  }
  int size() const { return length; }    // 要素数の取得
  int nth_elem(int n) const {      // n番目の要素へのアクセス (constバージョン)
    assert(n >= 0 && n < length);  // 配列外参照を避けるためのassert
    return array_ptr[n];
  }
  int& nth_elem(int n) {           // n番目の要素へのアクセス (非constバージョン)
    assert(n >= 0 && n < length);  // 配列外参照を避けるためのassert
    return array_ptr[n];
  }
};

 メンバ関数にconstをつけることで、暗黙に渡されるthisポインタの型をArray*からconst Array*に変更できるのでしたね。
 また、前回は右辺値と左辺値、左辺値参照とconst左辺値参照の違いについても触れました。右辺値はすぐに消えてしまうオブジェクトで基本アドレスが取れません。それに対して、左辺値はすぐには消えないオブジェクトで、オブジェクトの存在するメモリのアドレスを取ることができるのでした。さらに、左辺値参照は左辺値のみを参照し、const左辺値参照は右辺値と左辺値の両方を参照することができるのでした。

クラス設計と演算子オーバーロード

 今までと同じように、配列のクラスを設計しながら、演算子オーバーロードを解説していきます。C++の便利な機能の一つに演算子オーバーロードがあります。これは独自に定義したクラスに対して、四則演算や剰余算あるいは比較や要素アクセスなどの操作をより直感的に書きたい時に使います。

動機

 いままでArrayクラスの要素アクセスはnth_elemというメンバ関数でやっていましたが、これはあまり直感的ではないですね。できれば、[]を使ってアクセスしたいわけです。

Array arr;
arr.allocate(42);

// 要素アクセス
arr.nth_elem(0) = 42;  // これはあまり綺麗ではない
// arr[0] = 42;        // できればこう書きたい

 まあ別に[]を使わなくてもプログラムは期待通りに動くのだから問題ない、という意見もあるとは思いますが、個人的には[]が使えるのならば使うべきに思えます。

演算子オーバーロード

配列アクセス演算子

 さて、では早速[]を使ったアクセスを実装してみましょう。この[]は独自の関数名のようなものがついていて、operator[]と名付けられています。同様に+ならoperator+<ならoperator<->ならoperator->などなどというように名付けられています。
 そして、関数名nth_elemoperator[]に書き換えるだけで、[]を使ったアクセスができます。すごく簡単ですね。

class Array {
  /* 略 */
  int operator[](int n) const {  // operator[]の演算子オーバーロード
    assert(n >= 0 && n < length);
    return array_ptr[n];
  }
  int& operator[](int n) {       // operator[]の演算子オーバーロード
    assert(n >= 0 && n < length);
    return array_ptr[n];
  }
};

int main() {
  Array arr;
  arr.allocate(42);
  arr[0] = 42;             // []での配列アクセスが可能に
  arr.operator[](0) = 42;  // これも上と全く同じ意味
  return 0;
}

 あっけなかったですね。一応もう少し細かい話をすると、operator[]は1引数を取るメンバ関数として定義できます。2引数以上や0引数はC++20までは不可です。ただ、多次元配列のためにoperator[]に複数引数を許可しようという動きはありますし、もしかしたらC++23では2引数以上のoperator[]が可能になるかもしれません。

代入演算子

 まあこれだけでこの記事はおしまいでもいいのですが、せっかくなのでもう一つ演算子をオーバーロードしましょう。何をオーバーロードするかというと、operator=です。a = bのように代入に使うあれです。実は何も書かなくとも代入することはできますが、書くことで挙動をカスタマイズできます。何も書かなかった時の挙動は、大雑把に言えば、非静的メンバ変数全てが=で代入されるのと同等になります。「非静的」という意味を知らない人は、いままでに出てきた普通のメンバ変数と同じと考えてくれて結構です。

// operator=を自分で書かなかった場合
Array arr1, arr2;
arr1 = arr2;  // OK: arr1.length = arr2.length, arr1.array_ptr = arr2.array_ptr と等価

 今、arr1 = arr2を行うとarray_ptr自体がコピーされてarray_ptrの指す先まではコピーしてくれていませんね。ということは、arr1.array_ptrarr2.array_ptrの指す先は同じになってしまいます。これはつまり、arr1の中身を変えるとarr2の中身まで変わってしまうということです。

Array arr1, arr2;

// arr2の初期化
arr2.allocate(1);
arr2[0] = 42;

// 代入
arr1 = arr2;  // arr1[0] == 42 となる

arr2[0] = 0;  // arr1[0] == 0 になる (中身がarr2と同じだから)

 これは少々困りますね。普通はarr1arr2は全く別物であってほしいところです。そこで、operator=を中身もコピーするように独自定義しようということになります。operator=は代入の右辺を引数に取るメンバ関数です。返り値はそのクラス自身の左辺値参照にします。引数の型は右辺値も左辺値もconstな変数も非constな変数も取れるようにconst参照const Array&にしましょう。そのままの型Arrayではダメなのかと思うかもしれませんが、前回少し述べた通り、余計なコピーが生じて実行速度が遅くなるかもしれないのでダメです。C++において遅いのはバグです。遅くてもプログラムが動けばいいと思うのであれば、別の言語を使うことを強くお勧めします。

class Array {
  /* 略 */
  Array& operator=(const Array& arr) {   // *this に arr を代入する
    allocate(arr.length);                // 
    for (int i = 0; i < length; i++) { 
      array_ptr[i] = arr.array_ptr[i];   // 配列の中身をコピー
    }
    return *this;   // 自分自身の参照を返す
  }
};

int main() {
  Array arr1, arr2;

  arr2.allocate(1);
  arr2[0] = 42; 
  
  arr1 = arr2;    // operator=(const Array&)を呼ぶ。arr1.operator=(arr2)と同じ意味
  arr2[0] = 0;    // arr1[0] == 42 のまま
  return 0;
}

 実をいうとoperator=の実装は正しいとは言い難い (メモリリークを起こしかねない) ですが、これについては次回以降解説します。自分自身の参照を返す理由は、intやdoubleなどの組み込み型がそもそもそうなっていて、それらと揃えるためです。別のものを返すこともできますが、あまり好ましいとは言えません。

int i = 0, j = 1, k = 2;
i = (j = k);   // OK: j = k が動き、jの参照が返され、iに代入される。(i == 2, j == 2, k == 2になる)

i = 0, j = 1, k = 2;
(i = j) = k;   // OK: i = j が動き、iの参照が返され、kが代入される。(i == 2, j == 1, k == 2になる)

i = j = k;     // OK: i = (j = k) と同義

 少し話が逸れましたが、元に戻します。晴れて中身までコピーされるようになったわけです。しかし、右辺のArrayの要素数を$ N $とすると、デフォルトのoperator=と比べて計算量オーダーは $ O(1) $ から $ O(N) $ に増加しているので、遅くなっているわけですが、果たして本当にいつも $ O(N) $ の時間をかける必要があるのでしょうか? 中身をコピーせず、デフォルトのoperator=のようにポインタだけコピーすればいいのであれば、$ O(1) $ で済みますし、その方が嬉しいですよね? いや、C++において遅いのはバグと言った手前、「その方が嬉しい」のではなく「そうしなければならない」のですが。
 答えを言ってしまうと、ポインタだけコピーすればいいケースは代入の右辺が右辺値の時です。右辺値というのは、その場で消えてしまう値のことでしたね。どうせその場で消えるので、ポインタの指す先を一時的に共有しても (もしくは奪っても) 問題ないわけです。

Array init_array() {   // Arrayの初期化関数
  Array arr;
  arr.allocate(42);
  for (int i = 0; i < arr.size(); i++) {
    arr[i] = i;        // 適当に初期化
  }
  return arr;
}

int main() {
  init_array();  // 戻り値は右辺値。たちまち消える。
  Array arr;
  arr = init_array();  // この時、戻り値の配列とarrの配列を共有しても、arrは残り、戻り値は消えるので問題ない
  return 0;
}

 つまり、代入の右辺が右辺値の時だけポインタをコピーするようにoperator=を実装したいですね。そこで新しく出てくるのが右辺値参照です。これは参照の右辺値バージョンと思ってくれていいです。

int main() {
  int i = 42;

  // 今までの参照 (左辺値参照)
  int& rref1 = i;          // OK: 左辺値を取れる
  // int& rref2 = 42;      // error: 右辺値は取れない
  
  // 今までの参照 (const左辺値参照)
  const int& crref1 = i;   // OK: 左辺値を取れる
  const int& crref2 = 42;  // OK: 右辺値も取れる。注意: crref2は左辺値。生存期間はmain関数の終わりまで延びる

  // 新しい参照 (右辺値参照)
  // int&& lref1 = i;      // error: 左辺値は取れない
  int&& lref2 = 42;        // OK: 右辺値を取れる。注意: lref2は左辺値。生存期間はmain関数の終わりまで延びる
  return 0;
}

 気を付けなければいけないのは、const左辺値参照も右辺値参照も、たとえ参照先が右辺値でも変数は左辺値になるということです。一瞬謎に思えますが、参照を取ると右辺値は生存期間が延び (つまりすぐに消えない) 、そしてそれはすなわち左辺値になるということに他ならないのです。ちなみにconst右辺値参照もありますが、ほぼ使いません。なぜなら、通常右辺値はconstではないからです。
 さて、右辺値参照を使うと、引数が右辺値の時だけ適用される関数オーバーロードを作ることができます。

class Array {
  /* 略 */
  Array& operator=(const Array& arr) { /* 略 */ }
  Array& operator=(Array&& arr) {
    array_ptr = arr.array_ptr;     // ポインタ自体をコピー (中身はコピーしない)
    length = arr.length;
    return *this;
  }
};

int main() {
  Array arr1, arr2;
  arr2 = init_array();  // operator=(Array&&) が呼ばれる。戻り値は即座に消え、arr2のみに情報が残る
  arr1 = arr2;          // operator=(const Array&) が呼ばれる
  return 0;
}

 operator=(const Array&)は右辺値も左辺値も両方使えますが、引数に右辺値が与えられたときはoperator=(const Array&)よりもoperator=(Array&&)が優先されます。あと、当然ですがoperator=(const Array&)の引数型のconstを外すことはできません。constを外すとconstな変数を引数に取れなくなります。operator=(Array&&)operator=(const Array&)と同様にメモリリークを起こしうるのですが、やはり次回以降解説します。
 ちなみに、それ以降もう使わない左辺値を代入するときもoperator=(Array&&)の方を呼び出したくなりますが、右辺値参照にキャストすることでoperator=(const Array&)の代わりにoperator=(Array&&)を呼び出すこともできます。static_cast<Array&&>を使ってもいいですが、より簡潔に書ける右辺値へのキャスト関数として、標準ライブラリにstd::moveが用意されています。std::moveは基本どんな型に対しても使えます。

#include <utility>

int main() {
  Array arr1, arr2, arr3;
  arr3 = init_array();
  // ... arr3を使う
  arr2 = static_cast<Array&&>(arr3);  // arr3はこれ以降使わないので、operator=(Array&&)の方を呼ぶ
  // ... arr2を使う
  arr1 = std::move(arr2);             // arr2はこれ以降使わない。static_cast<Array&&>と同じ。
  // ... arr1を使う
  return 0;
}

 上の例だとarr3を使い続ければ良さそうに見えるのであまり有難みや意義を感じませんが、もっと複雑な例ではstd::moveの便利さに気付けます。operator=(const Array&)は大抵クラスの中身を全てコピーするので、コピー代入と呼ばれますが、それに対してoperator=(Array&&)はこのstd::move関数の名前を取って、ムーブ代入と呼ばれたりします。

残った問題 (解決は次回以降)

 問題点は山ほどあるのですが、とりあえず最も大きいと思われる問題を一つ上げましょう。

int main() {
  Array arr1;
  Array arr2 = arr1;
  return 0;
}

 こんな簡単なコードのコンパイルが通らなくなります。理由と解決方法は次回にしましょう。

演算子オーバーロード再考

 さて今回のテーマである演算子オーバーロードですが、他の演算子の一般的な実装例や注意点等も見てみましょう。調子乗ってオーバーロード可能な演算子を全部紹介したら死ぬほど長くなったので、欲しいところだけ掻い摘んで読んでいただけたらと思います。

符号

 符号の演算子operator+operator-+1-1などに使うあれです。正直operator+はあまり使うことはありませんが、一応紹介しておきます。通常は引数無しのメンバ関数として定義しますが、1引数のグローバルな関数としても定義できます。好みの問題ではありますが個人的には単項演算子は全てメンバ関数であるべきと考えています。仮に線形代数の2次元ベクトルのクラスを考えてみましょう。

class Vec2 {
  double x, y;
public:
  /* 配列確保や解放など必要なメンバ関数は省略 (Arrayとほぼ同じと思ってよい) */
 
  Vec2 operator+() const { return *this; }   // *thisのコピーを返す
  Vec2 operator-() const {
    Vec2 vec;
    vec.x = -x; 
    vec.y = -y;
    return vec;
  }
};
// Vec2 operator+(const Vec2&) { /* ... */ }  // 一応これでもOK
// Vec2 operator-(const Vec2&) { /* ... */ }  // 一応これでもOK

int main() {
  Vec2 vec;
  +vec;  // OK: Vec2::opeartor+() が呼ばれる
  -vec;  // OK: Vec2::operator-() が呼ばれる
  return 0;
}

 こんな具合に書けます。Vec2はコピーコストが比較的安い (高々double2つのコピー) なのでoperator+でコピーを返してもいいですが、コピーコストが高い (そしてムーブのコストは安い) クラスではどうでしょう。できれば不要なコピーは避けたいですね。*thisが左辺値か右辺値かでオーバーロードする必要がありそうです。そこで導入するのが参照修飾子です。例としてVec2を$ N $次元ベクトルに拡張してみましょう。

class VecN {    // コピーは遅いがムーブは速いクラス
  int N;        // 次元数
  double* ptr;  // ベクトルの要素のポインタ
public:
  /* 配列確保、解放等の必要なメンバ関数は省略 (Arrayとほぼ同じと思っていい) */

  // 関数の宣言の後に参照修飾子 & や && をつけることができる
  const VecN& operator+() const & {  // 暗黙の引数のイメージ: const VecN& (*this)
    return *this;                    // const左辺値参照を返すことでコピーコストを削減 
  }
  VecN operator+() && {              // 暗黙の引数のイメージ: VecN&& (*this)
    Vec vec;
    vec = std::move(*this);          // ムーブ代入でコピーコストを削減
    return vec;
  }

  VecN operator-() const & {   // 暗黙の引数のイメージ: const VecN& (*this)
    VecN vec;
    vec.N = N;
    vec.ptr = new double[N];   // 配列確保
    for (int i = 0; i < N; i++) { vec.ptr[i] = -ptr[i]; }  // 全要素符号反転
    return vec;
  }
  VecN operator-() && {        // 暗黙の引数のイメージ: VecN&& (*this)
    VecN vec;
    vec = std::move(*this);    // ムーブする (新しく配列確保しなくていいので高速)
    for (int i = 0; i < vec.N; i++) { vec.ptr[i] = -vec.ptr[i]; } 
    return vec;
  }
};

VecN init_vecn() {
  VecN vec;
  // 適当にvecを初期化
  return vec;
}

int main() {
  VecN vec;
  +vec;          // VecN::operator+() const & が呼ばれる。
  +init_vecn();  // VecN::operator+() && が呼ばれる。*thisが右辺値なので右辺値参照のオーバーロードが優先される
  -vec;          // VecN::operator-() const & が呼ばれる。
  -init_vecn();  // VecN::operator-() && が呼ばれる。
  return 0;
}

 前回の記事のconstの伝播でやった内容と似ていますね。*thisをconst左辺値参照として取るか右辺値参照として取るかでオーバーロードできるわけです。こうして晴れてより高速な符号演算子を書くことができました。

四則演算と剰余算

 次は1 + 21 - 2という文脈で使われる方のoperator+及びoperator-の話です。また、掛算opeartor*や割算operator/、さらに剰余算operator%を紹介します。これらは1引数のメンバ関数としても、2引数のグローバルな関数としても定義可能です。通常は2引数のフレンド関数としてクラス内に定義します。
 フレンドとは相手のprivateを覗くことができる人のことです。少し犯罪っぽいですね。なので何でもかんでも多用しすぎるのは危険です。余力があればフレンドの使いどころについても、いつかこのシリーズの記事で触れたいですね。

class C {
  int x;  // privateな変数 (通常クラス外からアクセスできない)
  friend int func(const C&) const;  // friend宣言
};
int func(const C& c) const { return c.x; }  // グローバルな関数だがc.xにアクセスできる

 フレンド関数はメンバ関数ではないので注意しましょう。また、フレンドはpublic:タグの下に書いてもprivate:タグの下に書いても同じ意味です。ちなみに関数だけでなくクラスもフレンドにできます。このフレンド関数の機能を使って四則演算と剰余算の演算子を定義します。

class MyInt {
  int i;
  friend MyInt operator+(const MyInt& mi1, const MyInt& mi2) { 
    MyInt mi;
    mi.i = mi1.i + mi2.i;
    return mi;
  }
  friend MyInt operator-(const MyInt& mi1, const MyInt& mi2) { /* 略 */ }
  friend MyInt operator*(const MyInt& mi1, const MyInt& mi2) { /* 略 */ }
  friend MyInt operator/(const MyInt& mi1, const MyInt& mi2) { /* 略 */ }
  friend MyInt operator%(const MyInt& mi1, const MyInt& mi2) { /* 略 */ }
};

int main() {
  MyInt mi1, mi2;
  mi1 + mi2;
  mi1 - mi2;
  mi1 * mi2;
  mi1 / mi2;
  mi1 % mi2;
  return 0;
}

 メンバ関数ではなくグローバル関数として定義するメリットは組み込み型との算術演算も可能にするためです。

class MyInt1 {  // operator+をグローバルな関数として定義
  int i;
  friend MyInt1 operator+(const MyInt1&, int) { /* 略 */ }  // OK: MyInt1 と int の足算
  friend MyInt1 operator+(int, const MyInt1&) { /* 略 */ }  // OK: int と MyInt1 の足算
};

class MyInt2 {  // operator+をメンバ関数として定義
  int i;
  MyInt2 operator+(int) const { /* 略 */ }  // OK: MyInt2 と int の足算
  // int と MyInt2 の足算はメンバ関数として書けない
};

int main() {
  MyInt1 mi1;
  mi1 + 42;    // OK: operator+(const MyInt1&, int) が呼ばれる
  42 + mi1;    // OK: operator+(int, const MyInt2&) が呼ばれる
  
  MyInt2 mi2;
  mi2 + 42;    // OK: MyInt2::operator+(int) const が呼ばれる
  // 42 + mi2; // メンバ関数ではできない
  return 0;
}

 また、friend関数をクラス外で定義するのではなくクラス内で定義するのをHidden Friendと呼びます。結構最近流行りだした実装手法な気がします。いや、僕が時代遅れだっただけかもしれませんが。Hidden Friendのメリットは、暗黙の変換によって意図しないオーバーロードが選択されるのを避けられることや、クラステンプレートにおいて宣言が簡潔になることなどが挙げられ、逆にデメリットは関数ポインタが取れないことですが、詳しくは省きます。もしかするとこのシリーズの記事でいつか触れるかもしれません。
 上では引数のMyIntを参照で渡していますが、MyIntクラスはコピーコストが安い (高々int一つ分のコピー) ので、値渡しでもいいでしょう。ただし、コピーコストが重いクラスは必ず参照で渡すべきです。場合によってはoperator=と同様にconst左辺値参照だけでなく右辺値参照のオーバーロードも用意するべきでしょう。"符号"の章と同様に線形代数の$ N $次元ベクトルなどを例にとってみましょう。

class VecN {
  /* 略 */
public:
  friend VecN operator+(const VecN& vec1, const VecN& vec2) {
    assert(vec1.N == vec2.N);     // 次元が等しいことを確認
    VecN vec;
    vec.N = vec1.N;
    vec.ptr = new double[vec.N];  // 配列確保
    for (int i = 0; i < vec.N; i++) {
      vec.ptr[i] = vec1.ptr[i] + vec2.ptr[i];  // 全要素足し合わせる
    }
    return vec;
  }

  friend VecN operator+(const VecN& vec1, VecN&& vec2) {  // 片方が右辺値参照
    assert(vec1.N == vec2.N);   // 次元が等しいことを確認
    VecN vec;
    vec = std::move(vec2);      // ムーブ代入する (配列確保を省略できるので高速)
                                // 注意: vec2自体は左辺値なので改めてstd::moveする必要あり
    for (int i = 0; i < vec.N; i++) { vec.ptr[i] += vec1.ptr[i]; }
    return vec;
  }

  friend VecN operator+(VecN&& vec1, const VecN& vec2) { 
    return vec2 + std::move(vec1);   // operator+(const VecN&, VecN&&) を呼ぶ
  }

  friend VecN operator+(VecN&& vec1, VecN&& vec2) {
    return vec1 + std::move(vec2);   // operator+(const VecN&, VecN&&) を呼ぶ
  }
};

 オーバーロードが大量に増えて面倒ですが、これの方がより高速に動くコードでしょう。

インクリメントとデクリメント

 ++iなどで使われるopertor++及びoperator--の話です。インクリメントもデクリメントもそれぞれ前置と後置の2種類ありますね。組み込み型におけるインクリメントを見てみましょう。

int i = 0;
int fi = ++i;  // 前置インクリメント。fi == 1, i == 1になる
i = 0;
int bi = i++;  // 後置インクリメント。bi == 0, i == 1になる 

++i = 0;       // OK: 代入可能 (戻り値は左辺値)
// i++ = 0;    // error: 代入できない (戻り値は右辺値)

 前置インクリメントの戻り値は足した後の値が戻されており、なおかつ型は左辺値参照ですね。それに対して、後置インクリメントは足す前の値が返っていて、戻り値の型は値型です。デクリメントも同様です。これに倣ってそれぞれ実装しましょう。前置と後置を判別するために、後置の方にはダミーの引数としてintを渡しますが、基本的には使わない引数です。またグローバルな関数としても定義可能ですが、メンバ関数として定義する方が僕は好みです。

class MyInt {
  int i;
public:
  MyInt& operator++() {    // 前置
    ++i;
    return *this;          // インクリメント後の値を参照で返す
  }
  MyInt operator++(int) {  // 後置 (ダミーのint)
    MyInt temp = *this;    // インクリメント前の値をコピー
    i++;
    return temp;           // インクリメント前の値を返す
  }
  MyInt& operator--() { /* 略 */ }
  MyInt operator--(int) { /* 略 */ }
}

 ちなみにインクリメントもデクリメントも算術演算子なので、単位元 (1に相当するもの) を足し引きする"算術"としての用途で使いそうですが、どちらかというと"算術"目的というよりもポインタのように参照するメモリの位置を1つ進めたり1つ戻したり、というような**"移動"としての使い方の方が多い気がします。"算術"目的でこの演算子をオーバーロードすることは少ない**でしょう。例えば行列演算などでも単位行列を足し引きためにoperator++operator--を使うことは少ないです。もちろん使ってはいけないわけではありませんが。

比較演算

 大小比較a < bや等値比較a == bなどの演算です。これも四則演算や剰余算と同様にHidden Friendとして定義し、返り値はboolもしくはboolへ変換可能な型にするのが普通だと思います。C++17までは、任意の比較をするためには6つの比較演算子operator<operator>operator<=operator>=operator==operator!=を全て書かねばなりませんでした。しかし、通常の比較演算子は

  1. static_cast<bool>(a < b)static_cast<bool>(b > a) の結果が同じ
  • static_cast<bool>(a <= b)static_cast<bool>(b >= a) の結果が同じ
  • static_cast<bool>(a != b)!(a == b) の結果が同じ
  • static_cast<bool>(a <= b)!(b < a) の結果が同じ
  • (a <= b) && (a >= b)static_cast<bool>(a == b) の結果が同じ (反対称律)
  • static_cast<bool>(a < a) は必ず false (反射律)
  • (a < b) && (b < c)true ならば static_cast<bool>(a < c) は必ず true (推移律)

という7つの条件をすべて満たしているべきです。このうち上の5つの条件を使えば、6つの比較演算子のうちどれか1つでも実装されていれば他の演算子はそれを用いて定義することができます。例えばoperator<を実装した場合を考えましょう。

class C {
  int x, y, z;
  friend bool operator<(const C& a, const C& b) {    // x, y, zの順に比較。条件6, 7を満たす 
    if (a.x != b.x) { return a.x < b.x; }
    if (a.y != b.y) { return a.y < b.y; }
    return a.z < b.z;
  }
  friend bool operator>(const C& a, const C& b) { return b < a; }             // 条件1より
  friend bool operator<=(const C& a, const C& b) { return !(b < a); }         // 条件4より
  friend bool operator>=(const C& a, const C& b) { return b <= a; }           // 条件2より
  friend bool operator==(const C& a, const C& b) { return a <= b && b >= a; } // 条件5より
  friend bool operator!=(const C& a, const C& b) { return !(a == b); }        // 条件3より
};

 しかし、1つ比較演算子を定義すれば他の6つの比較演算子が定義できるのであれば、わざわざ6つも演算子を書きたくはありませんね。そこで、C++20から一貫比較という概念が生まれます。これは新しい比較演算子 (宇宙船演算子operator<=>) によって一部の関数定義を省略できる機能です。さらにメンバ変数を宣言順に辞書順比較していくだけならdefault指定をすることで、より簡潔に書くことができます。operator<=>の返り値は0と比較可能な値で、a <=> b < 0ならa < ba <=> b > 0ならa > ba <=> b <= 0ならa <= ba <=> b >= 0ならa >= ba <=> b == 0ならa == ba <=> b != 0ならa != bというように対応します。

#include <compare>

class C {
  int x, y, z;

  // defaultにすると自動的にメンバ変数を宣言順に (x, y, zの順に) 比較していく
  friend auto operator<=>(const C&, const C&) = default;
  /*
  // つまり、これは以下と同じような挙動をする
  friend auto operator<=>(const C& a, const C& b) {
    if (a.x <=> b.x != 0) { return a.x <=> b.x; }
    if (a.y <=> b.y != 0) { return a.y <=> b.y; }
    return a.z <=> b.z;
  } 
  */
}

int main() {
  // 宇宙船演算子は 0 と比較可能な値を返す
  1 <=> 2 < 0;   // 1 < 2 なので true になる
  1 <=> 2 > 0;   // 1 > 2 ではないので false になる
  1 <=> 2 <= 0;  // 1 <= 2 なので true
  1 <=> 2 >= 0;  // false
  1 <=> 2 == 0;  // false
  1 <=> 2 != 0;  // true

  C c1, c2;
  c1 < c2;    // OK: c1 <=> c2 < 0 と同じ
  c1 > c2;    // OK: c1 <=> c2 > 0 と同じ
  c1 <= c2;   // OK: c1 <=> c2 <= 0
  c1 >= c2;   // OK: c1 <=> c2 >= 0
  c1 == c2;   // OK: c1 <=> c2 == 0
  c1 != c2;   // OK: c1 <=> c2 != 0
  return 0;
}

 default指定しないと少し挙動が変わります。operator<=>を独自に定義するときは、operator==も定義しないといけませんoperator<=>では返り値はautoにしましたが、operator==の返り値はboolにしましょう。

class C {
  int x, y, z;
  friend auto operator<=>(const C& a, const C& b) {  // yのみを比較
    return a.y <=> b.y;
  }
  friend bool operator==(const C& a, const C& b) {   // operator==も必要
    return a.y == b.y;
  }
};

int main() {
  C c1, c2;
  c1 < c2;   // OK: c1 <=> c2 < 0
  c1 > c2;   // OK: c1 <=> c2 > 0
  c1 <= c2;  // OK: c1 <=> c2 <= 0
  c1 >= c2;  // OK: c1 <=> c2 >= 0
  c1 == c2;  // OK: c1 == c2
  c1 != c2;  // OK: !(c1 == c2)
  return 0;
}

 operator==を書かないとc1 == c2c1 != c2はコンパイルエラーになります。なぜこんな仕様になっているかというと、例えば今まで例に挙げてきたArrayクラスの要素を辞書順に比較する際にoperator==operator!=に関しては別の実装が推奨される場合があるからです。

#include <compare>
#include <algorithm>

class Array {
  /* 略 */

  // 要素をはじめから比較していく。どちらかの要素がなくなったらサイズを比較する (サイズが小さいほうが小さい)
  friend auto operator<=>(const Array& arr1, const Array& arr2) {
    for (int i = 0; i < (std::min)(arr1.size(), arr2.size()); i++) {
      if (arr1[i] != arr2[i]) { return arr1[i] <=> arr2[i]; } 
    }
    return arr1.size() <=> arr2.size();
  }

  friend bool operator==(const Array& arr1, const Array& arr2) {
    if (arr1.size() != arr2.size()) { return false; }   // 先にサイズを比較してしまう
    for (int i = 0; i < arr1.size(); i++) {
      if (arr1[i] != arr2[i]) { return false; }
    }
    return true;
  }
};

 この場合では一般的にoperator==は最初にサイズを比較した方が計算が早く済む可能性があり好ましいです。それに対して、opeartor<=>は先にサイズを比較しても無駄です。ちなみにstd::minに括弧をつけているのはWindows系のマクロminとの衝突を回避するためです。

アクセスや参照

 *ptrptr->x等ポインタの参照等で用いるoperator*operator->operator->*operator[]の話です。似たようなアクセス系演算子ではoperator.や``operator.もありますが、これらはオーバーロード不可です。operator->`というのはあまり親しみがない方も多いと思いますが、あるクラスのポインタからメンバへのポインタを参照するときに使います。メンバへのポインタというのはメンバの直接のアドレスではなく、メンバのクラスの中でのアドレスのようなものを指します。

struct S {
  int x;      // (大抵の場合は) クラスの頭から数えて0byte目から4byte目までに格納される
  int y;      // (大抵の場合は) クラスの頭から数えて4byte目から8byte目までに格納される
  int z;      // (大抵の場合は) クラスの頭から数えて8byte目から12byte目に格納される

  int f();
  int g();
};

int main() {
  S s;
  S* ptr = &s;              // 通常のポインタ。sがどこにあるか。
  int S::* memptr = &S::y;  // メンバのポインタ。S::yがクラスの中でどこにあるか。
                            // すなわち、頭から数えて4byte目という情報が格納される。

  ptr->*memptr = 0;         // これでptr->yへアクセスできる
  memptr = &S::z;           // 中身をS::zに切り替える。頭から数えて8byte目という情報が格納される。
  ptr->*memptr = 0;         // ptr->zにアクセス
  s.*memptr = 0;            // operator.*でポインタからではなくオブジェクトからアクセスできる。s.zにアクセス

  int (S::* funcptr)() = &C::f;  // メンバ関数のポインタも取れる
  ptr->*funcptr();          // ptr->f()を呼ぶ
  funcptr = &S::g;
  ptr->*funcptr();          // ptr->g()を呼ぶ  

  return 0;
}

 全部読み取りと書き込みの2つの使い方で使いうると思うので、const版と非const版のメンバ関数を用意するのが普通だと思います。もちろん場合によってはconst版のみで十分な場合もあるでしょう。operator*operator->*はグローバル関数としても定義可能ですが、個人的にはメンバ関数として定義する方が好みです。operator->は返り値としてポインタを返します。

struct S { /* 略 (上の例と同じ) */ };

class MyPointer {
  S* ptr;
public:
  void create(int n = 1) { ptr = new S[n]; }
  void destroy() { delete[] ptr; }

  S& operator*() { return *ptr; }
  const S& operator*() const { return *ptr; }
  S* operator->() { return ptr; }
  const S* operator->() const { return ptr; }
  int& operator->*(int S::* mem) { return ptr->*mem; }
  int operator->*(int S::* mem) const { return ptr->*mem; }
  S& operator[](int i) { return ptr[i]; }
  const S& operator[](int i) const { return ptr[i]; } 
};

int main() {
  MyPointer myptr;
  myptr.create();

  *myptr;           // OK
  myptr->x;         // OK: myptr.operator->()->x と評価される
  myptr->*(&S::x);  // OK: myptr.operator->*(&S::x) と評価される
  myptr[0];         // OK

  myptr.destroy();
  return 0;
}

 ただ、このままだとごく稀に問題が起きることがあります。端的に言えば、operator*operator[]で参照を取った直後に参照先が解放される場合の問題です。が、それは次回デストラクタの話をしてからにします。

アドレス

 &aなどアドレスを取るときに使うoperator&です。これはメンバ関数としてもグローバルな関数としても定義可能ですが、やはりメンバ関数として定義した方が綺麗でしょう。また、左辺値はアドレスを取れますが右辺値はアドレスが取れないのでしたね。なので、*thisが右辺値の時はアドレスが取れないように明示的にdeleteして関数を使えなくしてやりましょう

class MyInt {
  int x;
  const char* info = "MyInt";
public:
  int* operator&() & { return &x; }              // *thisが左辺値の時
  const int* operator&() const & { return &x; }  // *thisがconstな左辺値の時
  int* operator&() && = delete;                  // *thisが右辺値の時はdelete
  // deleteしないと*thisが右辺値の時 operator&() const & が呼ばれる (const左辺値参照は右辺値もとれるから)
};

int main() {
  MyInt i;
  int* ptr = &i;  // OK
  // int* ptr = &std::move(i);  // error: deleteされている関数は使えない
                                // 右辺値は本来アドレスが取れないものなのでこれでいい
  return 0;
}

 なお、operator&をオーバーロードしてもオブジェクト本来のアドレスが取れるように、std::addressofという関数が用意されています。

#include <memory>

struct S { S* operator&() const { return nullptr; } };
int main() {
  S s;
  &s;                 // nullptrが返る
  std::addressof(s);  // sのアドレスが返る
  return 0;
}

コンマ演算

 コンマ演算子operator,とは、複数の式を順番に実行する演算子です。特にオーバーロードしなければ返り値は最後の値です。

int i = 0, j, k;   // これはコンマ演算子とは別物
j = ++i, k = ++i;  // j == 1, k == 2 となる
                   // j = ++i が k = ++i より必ず先に実行されるので k == 1, j == 2 にはならない 
int l = (i, j, k); // l == k

 この演算子はC++14まではオーバーロードしない方が良いとされてきました。その理由としては評価順序が入れ替わってしまう可能性があるからです。C++では基本的にf(a, b)のように関数を呼び出すとき、aが先に評価されるかbが先に評価されるかは決まっていません。例えば以下のようなコードを書いたとき、どう動作するかはわかりませんでした。

int func(int, int x) { return x; } 
int main() {
  int i = 0;
  int j = func(++i, ++i);   // NG: どちらの ++i が先か決まっていないので j == 1 にも j == 2 にもなり得る
  return 0;
}

 そのためoperator,をオーバーロードすると通常のoperator,と違って動作順序が決まらないということになってしまう問題がありました。ですが、C++17以降ではオーバーロードされた演算子を呼び出すときのみf(a, b)の式のabの評価順序が通常の演算子と同じ順序で評価されることが規定されました。それにより通常のoperator,とオーバーロードされたoperator,の評価順序が同じになりました。なお、C++17以降でも演算子オーバーロードでない通常の関数の引数の評価順序は決まっていないので、上のコードは依然として動作が未規定であることに注意してください。
 下のコードは期待通り動きます。動くはずなのです。

#include <cmath>
struct CommaSeparatableInt {
  int x;
  friend CommaSeparatableInt operator,(const CommaSeparatableInt& i, const CommaSeparatableInt& j) {
    int digits = std::floor(std::log10(j.x)) + 1;   // j.xの桁数 
    CommaSeparatableInt t;
    t.x = i.x * std::pow(10, digits) + j.x;  // 桁数分繰り上げてから足す
    return t;
  }
};
CommaSeparatableInt csint(int x) {
  CommaSeparatableInt csi;
  csi.x = x;
  return csi;
}
int main() {
  auto x = (csint(12), csint(345), csint(678));  // OK: x.x == 12,345,678
  int i = 0;
  auto y = (csint(++i), csint(++i));  // C++14ではNG: y.x == 12 にも y.x == 21 にもなり得る
                                      // C++17はOK: y.x == 12
  return 0;
}

 いざ-std=c++17でコンパイルするとClang11.0.1はy.x12になって、GCC10.2はy.x21になります。これはGCCのバグです。おとなしくバグが治るのを待ちましょう。

論理演算

 論理演算にはoperator!operator&&operator||の3つがありますが、オーバーロードしてよいのはoperator!だけで、他2つはオーバーロードするのは好ましくありません。まずはoperator!から見ていきましょう。

class MyPointer {
  int* ptr = nullptr;
public:
  bool operator!() const { return ptr == nullptr; }
};

int main() {
  MyPointer myptr;
  !myptr;     // OK
  return 0;
}

 これは特に難しいことはありませんね。大抵はconstメンバ関数だけ用意すれば事足りるはずです。一応グローバルな関数としても定義可能です。返り値はboolboolにキャスト可能な型にしましょう。それ以外の使い方は滅多にありません。稀な例としてstd::valarray<bool>operator!の返り値としてstd::valarray<bool>かそれに準ずる何かを返すらしいです。なお、operator!を定義するより、この後"キャスト"の章で述べるoperator boolを定義した方が良い場合が多いです。
 さて問題のoperator&&operator||ですが、オーバーロードされていない場合これらは短絡評価という機能があります。それは何かというと、左辺の式を評価した時点で返す値が決まるなら右辺の式は評価しないというものです。

bool tr() { return true; }
bool fl() { return false; }

tr() && fl();  // tr() がtrueを返したので、fl() を呼んで、operator&& はその値を返す
fl() && tr();  // fl() がfalseを返したので、operator&& は即座にfalseを返す (tr()は呼ばない)
tr() || fl();  // tr() がtrueを返したので、operator|| は即座にtrueを返す (fl()は呼ばない)
fl() || tr();  // fl() がfalseを返したので、tr() を呼んで、operator|| はその値を返す

 この機能は高速化に役立つだけでなく、無意識のうちに配列の参照等でのバグを防いでくれてたりします。いや、意識的に使うべきではありますが。

const int size = 42;
double arr[size];
int arr_step(unsigned i) {       // i番目の要素に対するステップ関数
  if (i < size && a[i] >= 0.) {  // i < size が false なら a[i] >= 0. は評価されない
                                 // つまり a[i] が配列外参照にならない
    return 1; 
  }
  return 0;
}

 しかし、operator&&operator||をオーバーロードすると短絡評価の機能が失われ、バグの原因になります。それらがオーバーロードされているのか否かをいちいち確認すればいい話ではありますが、それは少々面倒であり、混乱も招きますし、また後からオーバーロードを追加するなどした場合、既存のコードがバグる可能性もありますよね。ゆえにoperator&&operator||はオーバーロードするのは好ましくありません。

const int size = 42;
double arr[size];

struct IsAccessable {
  bool flag;
  friend bool operator&&(const IsAccessable& ia, bool b) { return ia.flag && b; }  // 悪い例
};

IsAccessable is_accessable(unsigned i) { 
  IsAccessable ia;
  ia.flag = i < size;  // sizeを超えないかチェック
  return ia;
}

int arr_step(unsigned i) {
  if (is_accessable(i) && arr[i] >= 0.) {  // 短絡評価されないので i >= size なら配列外参照
    return 1;
  }
  return 0;
}

 どうしてもoperator&&operator||が欲しいなら、代わりと言っては何ですが、次で紹介するビット演算子operator&operator|があるので、それを使うといいでしょう。これらはそもそもオーバーロードしていなくとも短絡評価されないので余計な混乱は招かないはずです。

ビット演算

 あまり馴染みがない方も多いと思いますが、整数に対してビット単位の演算をする演算子があります。operator&operator|operator^operator~operator<<operator>>の6つです。最後2つはstd::cout << "Hello World\n"など標準入出力で見たことがある人も多いのではないでしょうか。実はあれはオーバーロードされたもので、本来はビットシフトのための演算子です。とりあえずこの6つの演算子について本来のビット演算としての使い方から見ていきましょうか。

0b1011;  // 0b始まりは2進数表記を表す。10進数における11を表す
0b0011 & 0b0101;  // 各ビット同士の論理積を取る。返り値は 0b0001 になる
0b0011 | 0b0101;  // 各ビット同士の論理和を取る。返り値は 0b0111 になる
0b0011 ^ 0b0101;  // 各ビット同士の排他的論理和を取る。返り値は 0b0110 になる
~0b0011;          // 各ビットの否定を取る。返り値は 0b1100 になる
0b000011 << 3;    // 左に3ビット分ずらす。返り値は 0b011000 になる
0b011000 >> 2;    // 右に2ビット分ずらす。返り値は 0b000110 になる

 おおむねこのような感じになります。C++20で符号付き整数型が2の補数表現で表されると規定されたのでより安全にビット演算を使えるようになりました。ビットシフトは負のビット数や整数型のビット長より長いビット数分シフトしたり、符号付き整数でオーバーフローさせたりすると未定義動作です。符号なし整数ならビットシフトでオーバーフローしてもはみ出た分が無視されるだけです。さらにビットシフトはもう少し注意が必要で、符号なし整数ならずらして空いた部分は必ず0で埋められ、また全ビットに対してシフトが働きますが、符号付き整数においては最上位ビットによって挙動が変わります。

// int型が32bitの時 
int i = 0b00000000'11111111'00000000'11111111;   // 間の'は無視される
int j = 0b10000000'11111111'00000000'11111111;

// 右シフトは最上位ビットと同じ値で埋められる。はみ出た分は無視される
i >> 8;     // 返り値は 0b00000000'00000000'11111111'00000000
j >> 8;     // 返り値は 0b11111111'10000000'11111111'00000000

// 左シフトは必ず0で埋められる。最上位ビットは変わらない。
i << 2;     // 返り値は 0b00000011'11111100'00000011'11111100
j << 2;     // 返り値は 0b10000011'11111100'00000011'11111100
// i << 8;  // 最上位ビットに1が到達するとオーバーフロー (未定義動作になる)

 なぜこんなに面倒な仕様になっているかというと、$ n $ビットの右シフト及び左シフトは、それぞれ$ 2^n $での割算、$ 2^n $での掛算になるように設計されているからです。実際に確かめてみると、符号なし整数も符号付き整数も確かに$ 2^n $の割算や掛算になっていることが確認できます。
 さて、話を戻して、一般的なオーバーロードのやり方を紹介しますが、operator~以外は"四則演算と剰余算"の章でやったのとほぼ同じで、Hidden Friendとして定義し、必要に応じて右辺値参照バージョンも追加しましょう。operator~もいつも通り、メンバ関数として定義してあげれば十分でしょうが、やはり必要に応じて右辺値と左辺値で場合分けしましょう。

class MyInt {
  int x;
public:
  MyInt operator~() const {  // 必要に応じて operator~() const & と opeator~() && に分ける
    MyInt mi;
    mi.x = ~x; 
    return mi;
  }
  
  friend MyInt operator&(const MyInt& mi1, const MyInt mi2) {
    MyInt mi;
    mi.x = mi1.x & mi2.x;
    return mi;
  }
  // friend MyInt operator&(const MyInt&, MyInt&&) { /* ... */ }   // 必要に応じて書く
  // friend MyInt operator&(MyInt&&, const MyInt&) { /* ... */ }   // 必要に応じて書く
  // friend MyInt operator&(MyInt&&, MyInt&&) { /* ... */ }        // 必要に応じて書く

  friend MyInt operator|(const MyInt&, const MyInt&) { /* 略 */ }
  friend MyInt operator^(const MyInt&, const MyInt&) { /* 略 */ }
  friend MyInt operator>>(const MyInt&, const MyInt&) { /* 略 */ }
  friend MyInt operator<<(const MyInt&, const MyInt&) { /* 略 */ }
};

int main() {
  MyInt mi1, mi2;
  ~mi1;
  mi1 & mi2;
  mi1 | mi2;
  mi1 ^ mi2;
  mi1 << mi2;
  mi1 >> mi2;
  return 0;
}

 シフト演算子にはもう一つ広く使われている使い方があります。馴染みのある人が多いと思いますが、標準入出力ストリーム等の書き込み、読み取りです。

#include <iostream>
int main() {
  std::cout << "Hello World" << std::endl;  // 標準出力に書き込む
  int i, j;
  std::cin >> i >> j;   // 標準入力から整数を読み取る
  return 0;
}

 std::cout << "Hello World"の返り値はstd::cout自身なので、そのまま繋げて... << std::endlを書くことができます。また、入力ストリームの方も同様です。これは独自のクラスを何らかの形式で標準出力に書き込んだり、標準入力から読み取ったりするように拡張することができます。必ず戻り値は左辺をそのまま返しましょう。出力ストリームの左辺と戻り値は一般的にstd::ostream&std::basic_ostream<Char, Traits>&ですが、テンプレートについてまだやっていないので、以下のコード例ではstd::ostream&を使うことにしましょう。入力ストリームの左辺と戻り値は一般的にstd::istream&std::basic_istream<Char, Traits>&です。

#include <iostream>
class C {
  int x;
  friend std::ostream& operator<<(std::ostream& out, const C& c) {
    return out << c.x;   // out << c.x は out を返すからOK
  }
  friend std::istream& operator>>(std::istream& in, const C& c) {
    return in >> c.x;    // in >> c.x は in を返すからOK
  }
};

int main() {
  C c, d;
  std::cout << c << d;  // OK
  std::cin >> c >> d;   // OK
  return 0;
}

代入、複合代入

 普通の代入operator=は上でやった通りです。基本的にoperator=は何も書かなければ自動で作られます。この辺の話は次回もっと詳しく紹介したいと思います。自動で作られるoperator=で十分ならばコピー代入やムーブ代入は改めて書かない方が良いです。ただ期待した挙動でない場合などは書きましょう。書く場合は通常コピー代入とムーブ代入を両方書きます。あと、自分以外の型からの代入をする場合はoperator=を改めて書く必要が出てくるかもしれません。自分以外の型からの代入は書いてもoperator=は自動生成されるので、安心して書きましょう。

class MyInt {
  int i;
public:
  MyInt& operator=(int x) {  // int からの代入
    i = x;
    return *this;
  }
};

int main() {
  MyInt mi1, mi2;
  mi1 = 0;   // OK: operator=(int) が呼ばれる。
  mi1 = mi2; // OK: 自動生成されたoperator=を使う。
  return 0;
}

 複合代入は演算と代入を同時に行うものです。operator+=operator-=operator*=operator/=operator%=operator&=operator|=operator^=operator<<=operator>>=の10個です。a += bを行うと結果はa = a + bと同じになります。他の演算子についても同様です。この性質はオーバーロードしても、保つべきように実装すべきでしょうoperator+が可換でない時、ごく稀にa += ba = b + aと同じになるケースを見かけたことがありますが、基本的には好ましくないです。abの型が違うならかろうじて許されるかも?
 安心なのはoperator+=operator+のうちどちらか片方をもう片方に依存させることです。大抵は速度性能の問題でoperator+opeartor+=に依存します。片方をもう片方に依存させないと、変更するときに両方変更しなければならなくなり、面倒です。しかし残念ながら、速度性能や空間計算量等の問題で互いに依存させづらいケースもあります。臨機応変に対応しましょう。
 複合代入は全てHidden Friendとして定義するのが僕の好みですが、一般的にはoperator=と揃えて、メンバ関数として定義できるならメンバ関数にしてしまう場合が多いです。あと、operator=と同様に戻り値は必ず代入先の参照を返しましょう

class MyInt {
  int i;
public:
  MyInt& operator+=(const MyInt& mi) {  // 足算の複合代入: メンバ関数として定義
    i += mi.i;
    return *this;
  }
  friend int& operator+=(int& x, const MyInt& mi) {   // 足算の複合代入: メンバ関数にできないパターン
    return x += mi.i; 
  }

  friend MyInt operator+(const MyInt& mi1, const MyInt& mi2) {  // 足算: 複合代入に依存させる
    MyInt mi = mi1;   // コピー
    mi += mi2;        // operator+= に依存させる
    return mi;
  }

  /* 他の複合代入演算子は省略 */
};

int main() {
  int x;
  MyInt mi1, mi2;
  mi1 += mi2;       // OK: MyInt::operator+=(const MyInt&) が呼ばれる
  x += mi1;         // OK: opeartor+=(int&, const MyInt&) が呼ばれる
  mi1 = mi1 + mi2;  // OK: 必ずmi1 += mi2と同じ結果になる
  return 0;
}

関数呼び出し

 f()のように関数を呼び出すときに用いるoperator()も実はオーバーロードできます。operator()をオーバーロードしたクラスのオブジェクトを関数オブジェクトなどと呼んだりします。これはメンバ関数としてしか定義できませんが、引数の数は自由です。

struct S {
  // operator()は引数も戻り値も自由度が高い。また、引数が同じでなければ何個でも書ける。
  int operator()() const { return 42; }                    // 引数なしでintを返す
  int operator()(int a, int b) const { return a + b; }     // 2引数でintを返す
  long operator()(long a, long b) const { return a - b; }  // 2引数でlongを返す
  void operator()(int, long, double) const {}              // 3引数でvoidを返す
};

int main() {
  S s;
  int i = s();         // OK: operator()() const
  int j = s(i, i);     // OK: operator()(int, int) const
  long k = s(1L, 2L);  // OK: operator()(long, long) const
  s(1, 2L, 3.);        // OK: operator()(int, long, double) const
  return 0;
}

キャスト

 自分のクラスを型Tへ変換したい時にoperator Tというものを呼びます。これは戻り値は必ず型Tになるので、通常の関数宣言と違って戻り値を書きません。またこれは引数無しのメンバ関数としてのみ定義できて、グローバル関数では定義不可です。よく使われるのがboolへの変換です。

class MyPointer {
  int* ptr = nullptr;
public:
  // 戻り値は書かない
  operator bool() const { return ptr != nullptr; }  // bool への変換
  operator int*() { return ptr; }                   // int* への変換
  operator const int*() const { return ptr; }       // const int* への変換
};

int main() {
  MyPointer myptr;
  bool b = myptr;                  // OK: operator bool() const 
  static_cast<bool>(myptr);        // OK: operator bool() const 
  int* p = myptr;                  // OK: operator int*() 
  static_cast<int*>(myptr);        // OK: operator int*()
  const int* cp = myptr;           // OK: operator int*() 注意: operator const int*() const ではない 
  static_cast<const int*>(myptr);  // OK: operator int*() 注意: operator const int*() const ではない
  const MyPointer cptr;
  const int* cp2 = cptr;           // OK: operator const int*() const
  static_cast<const int*>(cptr);   // OK: operator const int*() const
  return 0;
}

 注意点としてはconst int* cp = myptrstatic_cast<const int*>(myptr)ではoperator const int*() constではなく、operator int*()が呼ばれることです。これはなぜかというと、const int*へキャスト可能な型のうち、MyPointerからキャスト可能な型はoperator int*()operator const int*() constの二つがあるわけですが、myptrはconstではないのでオーバーロード解決において、非constメンバ関数が優先されます。
 なお、一般的にoperator!operator boolは正反対の結果になることが好ましいですが、operator boolを定義するとoperator!をオーバーロードしなくても、キャストしたのちに通常のoperator!を使えるため、operator!はオーバーロードせずに、operator boolのみをオーバーロードすることが多いです。operator!をオーバーロードするだけだとstatic_cast<bool>が有効になりません。

struct S { operator bool() const { return true; } };   // operator bool のみ
struct T { bool operator!() const { return false; } }; // operator! のみ
int main() {
  S s;
  static_cast<bool>(s);     // OK
  !s;                       // OK: 自動的にboolへ変換されたのちに、通常のoperator!が作用する

  T t;
  // static_cast<bool>(s);  // error: キャストできない
  !t;                       // OK: オーバーロードされたoperator!が動く
  return 0;
};

 なお、暗黙の型変換を避けるためにexplicit指定子をつけることが多いですが、この話はまた別の機会にしようかと思います。

リテラル

 42uのように数の後にuをつけるとunsigned int型になったり、4.2fのようにfをつけるとfloat型になったりしますよね。あれを自分で拡張することができます。ただし拡張できるのは接尾辞だけで接頭辞 (0xとか0bとか) はできません。大きく分けて整数リテラル、浮動小数点数リテラル、文字リテラル、文字列リテラルの4つがあります。まずは、整数リテラルから見ていきましょう。通常整数リテラルはunsigned long longを引数に取るグローバル関数として定義されます。

struct MyInt { int i; };
namespace myint_literals {
  MyInt operator""_mi(unsigned long long ull) { 
    MyInt myint;
    myint.i = static_cast<int>(ull);
    return myint;
  }
}

int main() {
  using namespace myint_literals;
  MyInt mi1 = 42_mi;    // OK: mi1.i == 42
  MyInt mi2 = 0x2A_mi;  // OK: mi2.i == 0x2A (== 42) 
  MyInt mi3 = -1_mi;    // OK: 一度 -1 が unsigned long long にキャストされ、そこからintへ再キャスト
                        //     結局 mi3.i == -1 に戻る
  return 0;
}

 名前空間で囲っている理由は、他と名前がぶつからないようにするためです。独自定義リテラルは取れる引数が決まっているので、他のリテラルと引数がかぶることが多いです。そのため、同じ名前のリテラルを作ってしまうと重複定義でエラーになってしまいます。そこで、名前空間で囲うことで、他のリテラルと名前がかぶることを避けています。リテラルを使用するときはusing namespaceしないといけません。
 浮動小数点数リテラルも整数リテラルと同様に定義します。引数型はlong doubleです。

struct MyFloat { float f; };
namespace myfloat_literals {
  MyFloat operator""_mf(long double ld) {
    MyFloat myfloat;
    myfloat.f = static_cast<float>(ld);
    return myfloat;
  }
}

int main() {
  using namespace myfloat_literals;
  MyFloat mf1 = 4.2_mf;      // OK: mf1.f == 4.2
  MyFloat mf2 = 1.34e-2_mf;  // OK: mf2.f == 1.34e-2 (== 0.0134)
  MyFloat mf3 = 0x1.Cp2_mf;  // OK: mf3.f == 0x1.Cp2 (== 1.75 * 2^2 == 7) 
  return 0;
}

 上の例だと、整数リテラルも浮動小数リテラルもそれぞれunsigned long longlong doubleで表現しきれない値を与えることはできません。しかし、そのような値を与えたいこともあるでしょう。そこで、const char*を引数に取るバージョンも用意されています。引数として渡される文字列の末端には必ず\0が入ります

#include <cassert>
#include <string>
#include <algorithm>

struct uint128_t { uint64_t i = 0, j = 0; };
namespace uint128_t_literals {
  uint128_t operator""_ui128(const char* val) {
    std::string str = val;
    assert(str.substr(0, 2) == "0x" || str.substr(0, 2) == "0X");   // とりあえず符号無し16進数のみ許可
    str.erase(0, 2);                                                // 接頭辞 (0x) の除去
    str.erase(std::remove(std::begin(str), std::end(str), '\''),    // 区切り文字 (') を後ろに追いやって
              std::end(str));                                       // 除去する
    uint128_t ui128;
    int len = std::size(str);
    if (len > 16) {
      ui128.i = std::stoull(str.substr(0, (std::max)(0, len - 16)), // 前半16文字分を
                            nullptr, 16);                           // 16進数で変換
    }
    ui128.j = std::stoull(str.substr((std::max)(0, len - 16)),      // 後半16文字分を
                           nullptr, 16);                            // 16進数で変換
    return ui128;
  }
}

int main() {
  using namespace uint128_t_literals;
  uint128_t ui128 = 0xFFFFFFFF'FFFFFFFF'FFFFFFFF'FFFFFFFF_ui128;  
                            // OK: 引数に"0xFFFFFFFF'FFFFFFFF'FFFFFFFF'FFFFFFFF"がそのまま渡される
                            //     unsigned long longをはみ出す値だが平気
  return 0;
}

 上の例では整数リテラルの例だけですが、浮動小数点数リテラルでも同様にconst char*を取ることができます。ただ、これはいろいろな文字列パターンがあるので結構実装が大変です。符号 (+-) がつく場合や、16進数 (0x, 0X) や10進数、8進数 (0)、2進数 (0b, 0B) のパターンの文字列があり得ますし、16進数では英字の小文字と大文字 (0xffと0xFF) の両方があります。浮動小数点ではさらに進数変換に加えて指数を使う形式 (1.2e-3や1.2p4など) もあります。また、整数でも浮動小数点でも通常は無視される区切り文字'も無視されません。
 ちなみに今回は実引数にconst char*を取りましたが、テンプレート引数として取るバージョンもあります。このシリーズの記事ではまだテンプレートの話を取り上げていないので、とりあえず今回は紹介しないでおきます。
 次は文字リテラルと文字列リテラルです。文字リテラルの引数はcharchar8_tchar16_tchar32_twchar_tのどれかです。char8_tはC++20で追加された型なので、それより前のバージョンでは使えません。

enum class DNAType { A, G, C, T, Nonclassified };
namespace DNA_literals {
  DNAType operator""_dna(char dna) {
    if (dna >= 'a' && dna <= 'z') { dna -= 'a' - 'A'; }  // 小文字から大文字へ変換  
    switch (dna) {
    case 'A': return DNAType::A;
    case 'G': return DNAType::G;
    case 'C': return DNAType::C;
    case 'T': return DNAType::T;
    default: return DNAType::Nonclassified;
    }
  }
}

int main() {
  using namespace DNA_literals;
  DNAType dna = 'A'_dna;
  return 0;
}

 また、文字列リテラルは2引数とります。第一引数はconst char*const char8_t*const char16_t*const char32_t*const wchar_t*のどれかで、第二引数はstd::size_tで文字列の長さ (末端の\0を除いた長さ) が渡されます。第二引数が渡されるのは途中で\0が来ても正しい文字列長を得るためです。

#include <string_view>

enum class BloodType { A, B, O, AB, Nonclassified };
namespace blood_literals {
  BloodType operator""_bld(const char* cc, std::size_t n) {
    std::string_view sv(cc, n);
    if (sv == "A") { return BloodType::A; }
    if (sv == "B") { return BloodType::B; }
    if (sv == "O") { return BloodType::O; }
    if (sv == "AB") { return BlootType::AB; }
    return BloodType::Nonclassified;
  }
}

int main() {
  using namespace blood_literals;
  BloodType bt = "AB"_bld;        // operator""_bld("AB", 2)
  return 0;
}

 リテラル関連を一通り解説しましたが、注意点として独自のリテラルを定義する場合、接尾辞名はアンダースコア+小文字で始めましょう。これは、アンダースコア無しの接尾辞リテラルはC++標準が予約しており、今後のアップデートによっては使えなくなる可能性があるからです。またアンダースコア+大文字やダブルアンダースコアは標準ライブラリで使われる名前とぶつかる可能性があります (これは接尾辞リテラルに限らず、関数名やクラス名等でも同様)。

namespace literals {
  int operator""uz(unsigned long long) { return 42; }
}
int main() {
  using namespace literals;
  int i = 20uz;   // 今のところOKだが今後のC++のアップデートで使えなくなる可能性あり
                  // 実際C++23では uz という std::size_t のための接尾辞が導入される予定
  return 0;
}

メモリ確保と解放

 operator newoperator delete及びその配列版operator new[]operator delete[]もオーバーロード可能です。あまりオーバーロードすることはありませんが、メモリリークしていないか確認するときや、ガーベジコレクションのようなものを作りたい時にオーバーロードすることはあるでしょう。
 ちなみに、ややこしいですがnew式というものとoperator newというものは全然別物で、new式はoperator newを呼び出した後に型のコンストラクタを呼びメモリを初期化します。それに対し、operator newはメモリ確保だけしてメモリの初期化はしません。細かい点は気にせず大雑把に言ってしまえばnew式 == operator new + コンストラクタといったところでしょうか。また、delete式 == operator delete + デストラクタです。まあ、コンストラクタやデストラクタの話は次回する予定なのであれですが。
 まずはグローバルなoperator newoperator deleteから解説します。これはオーバーロードするというより置き換えていると言った方が正しい気がしますが、オーバーロードと言われることが多いです。鉄則として、operator newを書いたら必ずoperator deleteも書きましょう。そして配列版も忘れずに書きましょう

#include <cstdlib>
#include <new>
#include <iostream>

// new/delete
[[nodiscard]] void* operator new(std::size_t size) {   // [[nodiscard]]属性はC++17以降
  void* p = std::malloc(size);
  if (p == nullptr) { throw std::bad_alloc{}; }
  std::cout << size << "bytes of memory allocated to " << p << "\n";
  return p;
}
void operator delete(void* p) noexcept {
  std::cout << "memory deallocated from " << p << "\n";
  std::free(p);
}
void operator delete(void* p, std::size_t size) noexcept {   // C++14以降はこのバージョンも書く
  std::cout << size << "bytes of memory deallocated from " << p << "\n";
  std::free(p);
}

// new[]/delete[]
[[nodiscard]] void* operator new[](std::size_t size) {  // [[nodiscard]]はC++17以降
  void* p = std::malloc(size);
  if (p == nullptr) { throw std::bad_alloc{}; }
  std::cout << size << "bytes of memory allocated to " << p << "\n";
  return p;
}
void operator delete[](void* p) noexcept {
  std::cout << "memory deallocated from " << p << "\n";
  std::free(p);
}
void operator delete[](void* p, std::size_t size) noexcept {  // C++14以降はこのバージョンも書く
  std::cout << size << "bytes of memory deallocated from " << p << "\n";
  std::free(p);
}

int main() {
  int* p1 = new int;      // new式の中で ::operator new(std::size_t) が呼ばれる
  delete p1;              // delete式の中で ::operator delete(void*, std::size_t) が呼ばれる

  int* p2 = new int[42];  // new[]式の中で ::operator new[](std::size_t) が呼ばれる
  delete[] p2;            // delete[]式の中で ::operator delete[](void*) または
                          // ::operator delete[](void*, std::size_t) が呼ばれる
  return 0;
}

 new式の中で、operator newが呼ばれるわけですが、引数には必要なメモリのバイト数ぴったりが与えられます。しかし、new[]式でも同様にoperator new[]が呼ばれますが、必要量より少し多めのバイト数を引数に与える場合もあるので注意が必要です。また、operator deleteはC++14以降なら1引数と2引数のバージョンを両方書くべきです。delete式の中で第一引数には解放するメモリのポインタが与えられ、通常は解放するメモリのサイズが第二引数に与えられますが、与えられないケースもあります。不完全型だったり、組み込み型の配列だったりする場合等は与えられないことも多いようです。
 また、C++17以降では上記にさらにstd::align_val_tの引数を追加したアラインメント指定付operator newoperator delete及びその配列版もあります。
 さらに例外を出さないoperator newとして、const std::nothrow_t&を引数に追加したものもあります。operator deleteは通常noexceptですが、operator newと対応させるために、const std::nothrow_t&を引数に追加したoperator deleteも存在します。
 他にも、他のoperator newoperator deleteと引数が同じにならなければ、独自に自由に引数を追加してオーバーロードすることができます。いわゆる配置newや配置deleteと言われるものです。
 [[nodiscard]]属性はC++17以降の機能で、戻り値がそのまま捨てられた場合に警告を出します。戻り値がそのまま捨てられるということはdeleteしなかったということなので、メモリリークに気付くことができます。
 なお、new式は最適化によりdelete式と相殺されたり、他のnew式とくっつくことがあります。特にこの最適化は通常とは異なり、見た目の挙動が変わってもいいとされています。そのため上に挙げたコード例では、operator newoperator deleteの中で出力を行っているため、見た目上は標準出力に何かしらが出力されるはずですが、最適化によって何も出力されなくなる場合があります。ちなみにoperator newoperator deleteは相殺されません。上のコードでoperator delete(operator new(1))と書けば必ず出力されます。不思議ですね。そしてClang11.0.1では正しく出力されますが、残念なことにGCC10.2は相殺されてしまうバグがあるようです。
 さて次はクラスの静的メンバ関数として定義するoperator newoperator deleteです。これもほとんど先程と同様です。なお、staticをつけなくても勝手にstaticになります。

#include <new>

struct S {
  [[nodiscard]] static void* operator new(std::size_t) { /* 略 */ }
  static void operator delete(void*) noexcept { /* 略 */ }
  static void operator delete(void*, std::size_t) noexcept { /* 略 */ }    // C++14以降
  [[nodiscard]] static void* operator new[](std::size_t) { /* 略 */ }
  static void operator delete[](void*) noexcept { /* 略 */ }
  static void operator delete[](void*, std::size_t) noexcept { /* 略 */ }  // C++14以降
};

int main() {
  int* p1 = new int;    // ::operator new
  delete p1;            // ::operator delete

  S* p2 = new S;        // S::operator new
  delete p2;            // S::operator delete
  
  S* p3 = ::new S;      // ::operator new
  ::delete p3;          // ::operator delete
  return 0;
}

 これも同様に必ず対応するoperator newoperator deleteoperator new[]operator delete[]をそれぞれ書きましょう。静的メンバ関数版もグローバル関数版同様、std::aligned_val_tconst std::nothrow_t&、或いは独自の引数を追加できます。

コルーチン

 C++20でコルーチンが導入され、新しくオーバーロードできる演算子としてoperator co_awaitというのが誕生しました。コルーチンというのは中断と再開が可能な関数のようなものです。コルーチンを実現するうえで重要な役割を果たすものが3つあります。コルーチンの見た目上の戻り値を格納し呼び出し元に渡す仲介役を担うPromiseオブジェクトと、呼び出し元等からコルーチンの再開を指示するためのCoroutine Handle、そして計算完了まで待機が必要な場合にコルーチンを中断させるAwaiterオブジェクトです。
 Promiseオブジェクトはユーザーが独自に定義可能なもので、幾つかのメンバ関数を必要とします。

  • get_return_object(): コルーチンの真の戻り値を初期化するのに使う。必須。
  • initial_suspend(): コルーチンを開始する前に待機が必要かどうかを指定するAwaiterオブジェクトを返す。必須。
  • final_suspend(): コルーチン終了後に待機が必要かどうかを指定するAwaiterオブジェクトを返す。必ずnoexceptでなければならない。必須。
  • unhandled_exception(): コルーチンが例外を投げ、処理されなかった時の挙動を定義する。必須。
  • return_value(retvalue): co_returnで与えられるコルーチンの見た目上の最終的な戻り値retvalueを処理する。これかreturn_void()かどちらかは必須。
  • return_void(): コルーチンが見た目上の戻り値がvoidの場合はreturn_value(retvalue)の代わりにこちらを使う。
  • yield_value(yldvalue): co_yieldで与えられるコルーチンの見た目上の途中の戻り値yldvalueを処理し、Awaiterオブジェクトを返す。co_yieldを使うときは必須。
  • await_transform(pre_awaitable): co_awaitの引数のpre_awaitableawaitableに変換してからco_awaitに渡す。任意。この関数がなければ変換されずにそのままco_awaitに渡される。
  • get_return_object_on_allocation_failure(): コルーチンのためのメモリ確保に失敗した際のコルーチンの真の戻り値を返す。任意。この関数が無ければ通常はメモリ確保失敗時に例外を出す。

 Coroutine Handleは標準ライブラリのstd::coroutine_handle<promise_type>においてサポートされています。重要なメンバ関数として以下が挙げられます。

  • from_promise(promise): PromiseオブジェクトpromiseからCoroutine Handleを生成する。
  • resume(): コルーチンを再開する。
  • operator()(): resume()と同じ。
  • promise(): Promiseオブジェクトを返す。
  • destroy(): 中断しているコルーチンを破棄する。コルーチンのために確保したすべてのメモリが解放される。
  • done(): final_suspend()で中断されているかどうか。そこで中断されていればtrue

 Awaiterオブジェクトはco_await式に渡されるオブジェクトであり、コルーチンが中断し待機するかどうかや中断時の直前の挙動を指示します。ユーザーが独自定義可能で、幾つかのメンバ関数を必要とします。

  • await_ready(): 中断せずそのまま続行可能ならtrue、中断が必要かもしれない場合はfalseを返す。
  • await_suspend(handle): Coroutine Handleのhandleを引数に取る。中断が必要かもしれない場合の挙動を定義する。返り値がvoidなら無条件に中断し、呼び出し元に戻る。返り値がboolならtrueを返した時だけ中断し、falseなら中断せずにコルーチンを再開する。返り値が別のコルーチンのCoroutine Handleならそのコルーチンを再開させる (そのコルーチンの呼び出し元はこのコルーチンになる)。
  • await_resume(): コルーチンを再開時の挙動。この関数が返す値がco_await式の返り値。

 Awaiterオブジェクトの代表例として、標準ライブラリに常に中断を指示するstd::suspend_alwaysと決して中断を指示しないstd::suspend_neverがあります。これらはawait_suspend(handle)await_resume()では何もしません。
 で、一体operator co_awaitのオーバーロードがどこに出てくるのかというと、Promiseオブジェクトのawait_transformで変換されたawaitable (await_transformがなければそのままの値) をAwaiterに変換するときに使います。つまり、await_transformoperator co_awaitで最大二回の変換が起こりえるということですね。ちなみにoperator co_awaitの戻り値は変換後の型ですが、co_await式の戻り値はawait_resume()の戻り値であることに注意してください。さて、コード例を少し見ましょう。

#include <iostream>
#include <coroutine>
#include <thread>

struct Task {   // コルーチンの真の返り値
  struct promise_type {  // Promiseオブジェクト
    Task get_return_object() {
      Task t;
      t.ch = std::coroutine_handle<promise_type>::from_promise(*this);
      return t;
    }
    auto initial_suspend() { return std::suspend_never{}; }            // コルーチン開始前は中断しない
    auto final_suspend() noexcept { return std::suspend_never{}; }     // コルーチン終了時も中断しない
    void return_void() {}                                              // コルーチンの値を返す
    void unhandled_exception() {}
  };
  void operator()() const { ch(); }  // コルーチンの再開を指示
private:
  std::coroutine_handle<promise_type> ch;   // Coroutine Handleを保持しておく
};

struct PrintAwaiter {  // Awaiterオブジェクト
  int i;
  bool await_ready() { return false; }                   // 常に中断されうる
  void await_suspend(std::coroutine_handle<> handle) {}  // 中断直前に何もしない
  std::thread await_resume() {
    return std::thread([i = this->i]() { std::cout << "resuming: " << i << "\n"; }); 
  }
};

auto print_in_other_thread(int i) {
  struct Awaitable {
    int i;
    auto operator co_await() const { return PrintAwaiter{.i = i}; }  // co_awaitのオーバーロード
  };
  return Awaitable{.i = i};
}

// コルーチン
Task task() {  // PromiseオブジェクトにはTask::promise_typeが使われる
  std::thread th1 = co_await print_in_other_thread(1);
  std::thread th2 = co_await print_in_other_thread(2);
  th1.join();
  th2.join();
}

int main() {
  auto t = task();  // コルーチン開始時initial_suspend()が返すAwaiterは中断を指示しない。
                    // つまりすぐにprint_in_other_thread(1)までたどり着く。
                    // その後co_await式によりawait_ready()が呼ばれ、falseが返るので、
                    // await_suspend(coroutine_handle)が呼ばれて、
                    // コルーチンが中断し、このmain関数上に制御が戻る。
  t();              // コルーチンが再開と同時にawait_resume()が呼ばれ、
                    // print_in_other_thread(2)までたどり着き、同様に中断されてここに制御が戻る。
  t();              // コルーチンが再開して最後まで実行が完了し、return_void()が動く。
                    // final_suspend()が返すAwaiterは中断を指示せず、コルーチンは自動的に破棄される。
  return 0;
} 

 果たしてこのコードに意味があるのかは甚だ疑問ですが、動くことには動きます。ちなみにこのコードでは出力順は担保されないので、resuming: 1が先に出力されるか、resuming: 2が先に出力されるか、はたまたresuming: resuming: 12のように出力が混ざるかはやってみてのお楽しみです。

終わりに

 あり得ないくらい長かったですね。ちょっと最後の方は初心者には難しすぎる話でしたが、まあそういう日もあります。次回の話題はコンストラクタとデストラクタです。よく今までコンストラクタとデストラクタをやらずに乗り切れたのか不思議ですが、というか今日の最後の方の話はコンストラクタ無しで乗り切れているとは言い難いですが、まあ次回の記事を読んでから戻ってきてもらえればいいでしょう。

次回: 【C++】初心者のためのクラス設計基礎④ ~コンストラクタとデストラクタ~

おしまい

18
9
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
18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?