C++では自作クラスに対して演算子の意味を定義でき、直感的で読みやすいコードが書けます。
本記事は初心者向けに、演算子オーバーロードの基礎と安全な実装手順を、分数(有理数)を表すRationalクラスを題材に具体的なサンプルと実行結果つきで丁寧に解説します。
「複合代入を先に作り、単純演算子はそれを再利用する」という王道パターンを中心に進めます。
演算子オーバーロードの基本(意味とルール)
演算子オーバーロードとは
演算子オーバーロードとは、クラスに対して+
や==
などの演算子の振る舞いを定義する機能です。
これにより、例えばRational a + b
のように、独自型同士でも自然な式で計算できます。
可読性と安全性を両立するため、数学的に自然な意味だけを与えることが最重要です。
できる演算子/できない演算子
C++では多くの演算子がオーバーロード可能ですが、一部は言語仕様上できません。
代表的なものを簡潔にまとめます。
種別 | 例 | 可否 | 備考 |
---|---|---|---|
算術 | +, -, *, /, % | 可能 | 二項/単項どちらも定義可(単項-は符号反転) |
複合代入 | +=, -=, *=, /=, %= | 可能 | 自分を書き換える用途で重要 |
比較 | ==, !=, <, <=, >, >= | 可能 | 整合性を保つ必要あり |
論理/ビット | &&, ||, !, &, |, ^, ~, <<, >> | 可能 | 短絡評価は維持されない点に注意 |
添字/呼出/参照 | [], (), ->, * | 可能 | 設計難度が上がるため初心者は慎重に |
入出力 | <<, >> | 可能 | 通常は非メンバ(フレンド)で実装 |
new/delete | new, delete | 可能 | メモリ管理の特殊用途 |
オーバーロード不可 | ., .*, ::, ?:, sizeof, typeid, alignof, co_await, cast群 | 不可 | 言語仕様の都合 |
特に.
や::
、?:
は絶対にオーバーロードできません。
設計時に混同しないよう注意します。
メンバ関数での書き方の型
演算子はメンバ関数としても非メンバ(フリー関数)としても定義できます。
初心者が最初に覚えるべき典型的な形は次のとおりです。
// 単項演算子(例: 符号反転)
T T::operator-() const;
// 複合代入演算子(自分を書き換える)
T& T::operator+=(const T& rhs);
T& T::operator-=(const T& rhs);
// 二項算術演算子(新しい値を返す)は「非メンバ + 参照引数」を推奨
// コピー値(lhs)に複合代入を適用して再利用するイディオム
T operator+(T lhs, const T& rhs) {
lhs += rhs;
return lhs;
}
// 比較演算子(読み取りだけ)
bool operator==(const T& a, const T& b);
bool operator<(const T& a, const T& b);
メンバにするか非メンバにするかは、対称性(左辺・右辺どちらの変換も効かせたいか)や内部実装へのアクセス(friendで許可するか)で決めます。
まずは「複合代入はメンバ」「算術は非メンバで複合代入を再利用」という型を覚えると設計が安定します。
戻り値と引数の考え方(初心者向け)
戻り値と引数は、次の原則で考えると分かりやすいです。
- 複合代入(+=など): 自分を書き換えるので
T&
を返し、引数はconst T&
にします。 - 算術(+,-,*,/): 新しい値を作るので
T
を値で返し、実装は「値渡しの左辺 + 参照の右辺」でコピー省略に期待します。 - 比較(==, !=, <): 真偽なので
bool
を返し、引数はconst T&
にします。 - 可能ならconstの約束(読み取りだけの関数は
const
メンバ)を守ると安全です。
計算途中で例外が発生してもオブジェクトが壊れないよう、強い例外安全性を意識しましょう。
特に複合代入の実装は一時値で計算してから最後に代入するのが安全です。
C++演算子オーバーロードの実装手順(初心者向け)
ここでは分数(有理数)を表すRationalクラスを例に、複合代入を先に作り、それを再利用して他の演算子を実装する流れを示します。
分母0のチェックと既約分数化も行います。
手順1: クラスと対象演算子を決める
今回は加減乗除と比較(==, !=, <)を対象にします。
分数は常に約分して保持し、分母は正に統一します。
// Rational.h / サンプル用に1ファイルにまとめてもOK
#include <iostream>
#include <numeric> // std::gcd (C++17)
#include <stdexcept> // std::invalid_argument
class Rational {
long long num_; // 分子
long long den_; // 分母(常に正)
// 内部の正規化: 符号の整理と約分
void normalize() {
if (den_ == 0) {
throw std::invalid_argument("denominator must not be 0");
}
if (den_ < 0) {
num_ = -num_;
den_ = -den_;
}
long long g = std::gcd(num_, den_);
if (g != 0) {
num_ /= g;
den_ /= g;
} else {
// num_=0のときはg=0になることがあるので分母を1にする
den_ = 1;
}
}
public:
// コンストラクタ(デフォルトは0/1)
Rational(long long n = 0, long long d = 1) : num_(n), den_(d) { normalize(); }
// ゲッター(読み取り専用)
long long num() const { return num_; }
long long den() const { return den_; }
// ここに各種演算子を足していく
};
クラス不変条件(分母は正、常に約分済み)を明確化し、全演算子で守ることが品質の鍵です。
手順2: 複合代入(+=,-=,*=,/=)を先に作る
まずは自分自身を書き換える複合代入を定義します。
暗黙のうちに*this
が左辺であることを忘れないようにします。
class Rational {
// ... (前掲の定義)
public:
// a += b; => a = a + b
Rational& operator+=(const Rational& rhs) {
// a/b + c/d = (ad + bc) / bd
long long n = num_ * rhs.den_ + rhs.num_ * den_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d;
normalize();
return *this; // 連続適用を可能にする(a+=b+=c;)
}
// a -= b; => a = a - b
Rational& operator-=(const Rational& rhs) {
long long n = num_ * rhs.den_ - rhs.num_ * den_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d;
normalize();
return *this;
}
// a *= b;
Rational& operator*=(const Rational& rhs) {
long long n = num_ * rhs.num_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d;
normalize();
return *this;
}
// a /= b; -> bが0のときは例外
Rational& operator/=(const Rational& rhs) {
if (rhs.num_ == 0) {
throw std::invalid_argument("division by zero");
}
long long n = num_ * rhs.den_;
long long d = den_ * rhs.num_;
num_ = n; den_ = d;
normalize();
return *this;
}
};
複合代入は演算子オーバーロード設計の要であり、これが正しければ他の多くの演算子を安全に再利用で構築できます。
手順3: 算術演算子(+,-,*,/)を再利用で実装
非メンバ関数として定義し、左辺を値渡しして複合代入を再利用します。
これによりロジックの重複を避け、保守性が高まります。
// 非メンバ(友達)として定義し、内部にアクセスできるようにする
inline Rational operator+(Rational lhs, const Rational& rhs) {
// lhsはコピー(値)なので破壊的変更OK
lhs += rhs;
return lhs;
}
inline Rational operator-(Rational lhs, const Rational& rhs) {
lhs -= rhs;
return lhs;
}
inline Rational operator*(Rational lhs, const Rational& rhs) {
lhs *= rhs;
return lhs;
}
inline Rational operator/(Rational lhs, const Rational& rhs) {
lhs /= rhs;
return lhs;
}
演算子の本体は常に1カ所(複合代入)に集約されるため、バグが減り、性能と例外安全性の検討も一度で済みます。
手順4: 比較演算子(==,!=,<)を実装
等値は約分後の内部表現をそのまま比較できます。
不等号は浮動小数にせず交差乗算で整数のまま比較します。
// 等値
inline bool operator==(const Rational& a, const Rational& b) {
return a.num() == b.num() && a.den() == b.den();
}
// 非等値は==の否定で定義
inline bool operator!=(const Rational& a, const Rational& b) {
return !(a == b);
}
// 小なり(ソート用)
// a/b < c/d <=> a*d < c*b (分母は常に正なので向きが変わらない)
inline bool operator<(const Rational& a, const Rational& b) {
return a.num() * b.den() < b.num() * a.den();
}
入出力を簡単にするため、ストリーム演算子も用意しておきます。
#include <ostream>
inline std::ostream& operator<<(std::ostream& os, const Rational& r) {
return os << r.num() << '/' << r.den();
}
手順5: 簡単なサンプルで動作確認
ここまでの部品を1つのプログラムにまとめて実行します。
#include <iostream>
#include <numeric>
#include <stdexcept>
// ---- Rational クラス定義(前述を1ファイルに統合) ----
class Rational {
long long num_;
long long den_;
void normalize() {
if (den_ == 0) throw std::invalid_argument("denominator must not be 0");
if (den_ < 0) { num_ = -num_; den_ = -den_; }
long long g = std::gcd(num_, den_);
if (g != 0) { num_ /= g; den_ /= g; } else { den_ = 1; }
}
public:
Rational(long long n = 0, long long d = 1) : num_(n), den_(d) { normalize(); }
long long num() const { return num_; }
long long den() const { return den_; }
Rational& operator+=(const Rational& rhs) {
long long n = num_ * rhs.den_ + rhs.num_ * den_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d; normalize(); return *this;
}
Rational& operator-=(const Rational& rhs) {
long long n = num_ * rhs.den_ - rhs.num_ * den_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d; normalize(); return *this;
}
Rational& operator*=(const Rational& rhs) {
long long n = num_ * rhs.num_;
long long d = den_ * rhs.den_;
num_ = n; den_ = d; normalize(); return *this;
}
Rational& operator/=(const Rational& rhs) {
if (rhs.num_ == 0) throw std::invalid_argument("division by zero");
long long n = num_ * rhs.den_;
long long d = den_ * rhs.num_;
num_ = n; den_ = d; normalize(); return *this;
}
};
// 非メンバ演算子
Rational operator+(Rational lhs, const Rational& rhs) { lhs += rhs; return lhs; }
Rational operator-(Rational lhs, const Rational& rhs) { lhs -= rhs; return lhs; }
Rational operator*(Rational lhs, const Rational& rhs) { lhs *= rhs; return lhs; }
Rational operator/(Rational lhs, const Rational& rhs) { lhs /= rhs; return lhs; }
bool operator==(const Rational& a, const Rational& b) { return a.num()==b.num() && a.den()==b.den(); }
bool operator!=(const Rational& a, const Rational& b) { return !(a==b); }
bool operator<(const Rational& a, const Rational& b) { return a.num()*b.den() < b.num()*a.den(); }
std::ostream& operator<<(std::ostream& os, const Rational& r) { return os << r.num() << '/' << r.den(); }
// ---- 動作確認 ----
int main() {
Rational a(1, 2); // 1/2
Rational b(3, 4); // 3/4
Rational s = a + b; // 加算
Rational d = a - b; // 減算
Rational m = a * b; // 乗算
Rational q = a / b; // 除算
a += b; // 複合代入(加算)
b *= Rational(2,3); // 複合代入(乗算)
std::cout << "s=" << s << '\n';
std::cout << "d=" << d << '\n';
std::cout << "m=" << m << '\n';
std::cout << "q=" << q << '\n';
std::cout << "a=" << a << '\n';
std::cout << "b=" << b << '\n';
std::cout << std::boolalpha;
std::cout << "(1/2 == 2/4): " << (Rational(1,2) == Rational(2,4)) << '\n';
std::cout << "(1/2 < 2/3): " << (Rational(1,2) < Rational(2,3)) << '\n';
}
s=5/4
d=-1/4
m=3/8
q=2/3
a=5/4
b=1/2
(1/2 == 2/4): true
(1/2 < 2/3): true
約分と分母の正規化が効いているため、1/2 と 2/4 が等しいと判定できています。
よく使う演算子の使い方と実装ポイント
+ と += の違い(新しい値/自分を更新)
+
は新しいオブジェクトを返すため、元の2つの値は変化しません。
一方+=
は左辺自身を書き換えます。
式の意図が値の生成か状態の更新かで選び分けます。
複合代入に処理を集約し、+はそれを再利用するのが保守的です。
– と -= の基本
-
と-=
も同様に、-=
を信頼できる実装にし、-
はコピーした左辺に対して-=
を呼ぶだけにすると一貫性が保てます。
単項マイナス(符号反転)を追加する場合はT T::operator-() const
で値を返す形にします。
* と / の基本
乗算と除算は桁あふれ(オーバーフロー)や0除算に注意が必要です。
Rationalでは/=
で分母が0になるケースを例外で防止しました。
整数ベースで実装すれば丸め誤差がなく、比較とも相性が良いです。
== と != の基本
==
は型の同値性を定義します。
Rationalでは内部を約分することで1/2 == 2/4
がtrueになります。
!=
は==
の否定として実装すると重複がなく安全です。
< の基本(ソート用)
<
は順序を定義し、標準アルゴリズム(例: std::sort
)で利用されます。
Rationalでは交差乗算で安全に実装しました。
全順序の整合性(反射律・反対称・推移性)が崩れないよう注意します。
初心者向けの注意点とベストプラクティス
意味が自然な演算子だけを定義
演算子は読み手の直感に一致する必要があります。
数学的に不自然な意味づけは避けることで、後から読む人が迷いません。
対になる演算子はセットで用意
==
と!=
、<
と>
(必要なら<=
と>=
)のように、対になる関係は整合的に揃えます。
実装は1つを基準に他を導出し、重複と矛盾を排除します。
不要な暗黙変換は避ける
片側だけにコンストラクタによる暗黙変換があると、意図しないマッチングが起きます。
単一引数コンストラクタにはexplicit
を付ける、あるいは非メンバ演算子で対称性を確保するなどして、安全側に倒します。
分かりやすさを最優先にする
短いコードは必ずしも良いコードではありません。
ロジックの一元化(複合代入に集約)と、例外メッセージ・コメントの充実により、将来の自分やチームメンバーが安心して保守できます。
パフォーマンス最適化は計測してからが原則です。
まとめ
演算子オーバーロードは、独自型を標準型のように自然に扱える強力な機能です。
この記事では複合代入を起点に、算術や比較を再利用で構築する王道パターンをRationalクラスで実演しました。
実装では、約束(不変条件)の徹底、例外安全性、対になる演算子の整合性、そして読みやすさを重視することが重要です。
まずはこの記事のサンプルを手元で動かし、自分の問題領域に合った自然な意味の演算子を丁寧に設計・実装してみてください。