C++の演算子のオーバーロードについて

AI実装検定のご案内

C++の演算子オーバーロードとは、+-==[]() などの演算子を、自作したクラスや構造体に対して使用できるようにする機能です。

例えば、2次元ベクトルを表す Vector2 クラスがあるとします。

Vector2 a{1.0, 2.0};
Vector2 b{3.0, 4.0};

Vector2 c = a + b;

演算子オーバーロードを定義していない場合、次のような名前付き関数を使う必要があります。

Vector2 c = a.add(b);

どちらも同じような処理を表せますが、ベクトルや分数、行列などの数学的な型では、a + b のほうが意味を直感的に理解できます。

ただし、演算子オーバーロードは単にコードを短くするための機能ではありません。

既存の演算子が持つ意味に沿って、自作型を自然に扱えるようにすることが目的です。

目次

演算子オーバーロードの基本構文

演算子オーバーロードでは、operator キーワードを使用します。

戻り値の型 operator演算子(引数)
{
    // 処理
}

例えば、+ 演算子は次のように定義できます。

class Vector2
{
public:
    double x;
    double y;

    Vector2(double x, double y)
        : x(x), y(y)
    {
    }

    Vector2 operator+(const Vector2& other) const
    {
        return Vector2{x + other.x, y + other.y};
    }
};

使用例は次のとおりです。

#include <iostream>

class Vector2
{
public:
    double x;
    double y;

    Vector2(double x, double y)
        : x(x), y(y)
    {
    }

    Vector2 operator+(const Vector2& other) const
    {
        return Vector2{x + other.x, y + other.y};
    }
};

int main()
{
    Vector2 a{1.0, 2.0};
    Vector2 b{3.0, 4.0};

    Vector2 c = a + b;

    std::cout << c.x << ", " << c.y << '\n';
}

出力結果は次のようになります。

4, 6

式としては、次のメンバー関数呼び出しに近い意味を持ちます。

Vector2 c = a.operator+(b);

メンバー関数として定義する方法

演算子オーバーロードは、クラスのメンバー関数として定義できます。

class Number
{
private:
    int value_;

public:
    explicit Number(int value)
        : value_(value)
    {
    }

    Number operator+(const Number& other) const
    {
        return Number{value_ + other.value_};
    }
};

次の式では、左辺の a が暗黙的に this として扱われます。

Number c = a + b;

概念的には、次の呼び出しに相当します。

Number c = a.operator+(b);

そのため、二項演算子をメンバー関数として定義する場合、明示的な引数は1つです。

Number operator+(const Number& other) const;

左側のオペランドが *this、右側のオペランドが other になります。

非メンバー関数として定義する方法

演算子は、クラスの外側に通常の関数として定義することもできます。

class Number
{
private:
    int value_;

public:
    explicit Number(int value)
        : value_(value)
    {
    }

    int value() const
    {
        return value_;
    }
};

Number operator+(const Number& lhs, const Number& rhs)
{
    return Number{lhs.value() + rhs.value()};
}

非メンバー関数では、左辺と右辺の両方を引数として受け取ります。

Number c = a + b;

概念的には、次の関数呼び出しに相当します。

Number c = operator+(a, b);

メンバー関数と非メンバー関数の違い

両者の重要な違いは、左辺と右辺に対して暗黙の型変換を適用できるかどうかです。

例えば、ベクトルと数値を掛け算するメンバー関数を考えます。

class Vector2
{
public:
    double x;
    double y;

    Vector2(double x, double y)
        : x(x), y(y)
    {
    }

    Vector2 operator*(double scalar) const
    {
        return Vector2{x * scalar, y * scalar};
    }
};

この定義では、次の式を使用できます。

Vector2 v{2.0, 3.0};

Vector2 result = v * 2.0;

しかし、次の式には対応できません。

Vector2 result = 2.0 * v;

メンバー関数版では、左辺が必ず Vector2 でなければならないためです。

数値を左側にも置けるようにするには、非メンバー関数を追加します。

Vector2 operator*(double scalar, const Vector2& vector)
{
    return Vector2{
        scalar * vector.x,
        scalar * vector.y
    };
}

これにより、両方の書き方に対応できます。

Vector2 a = v * 2.0;
Vector2 b = 2.0 * v;

加算や乗算など、左右の対称性が重要な演算子では、非メンバー関数として定義する方法が適しています。

friend関数として定義する方法

非メンバー関数からクラスの非公開メンバーにアクセスしたい場合は、friend を使用できます。

class Number
{
private:
    int value_;

public:
    explicit Number(int value)
        : value_(value)
    {
    }

    friend Number operator+(const Number& lhs, const Number& rhs);
};

Number operator+(const Number& lhs, const Number& rhs)
{
    return Number{lhs.value_ + rhs.value_};
}

operator+ はメンバー関数ではありませんが、friend に指定されているため、value_ に直接アクセスできます。

クラス内でそのまま定義することも可能です。

class Number
{
private:
    int value_;

public:
    explicit Number(int value)
        : value_(value)
    {
    }

    friend Number operator+(const Number& lhs, const Number& rhs)
    {
        return Number{lhs.value_ + rhs.value_};
    }
};

値を表すクラスでは、この形式がよく使われます。

単項演算子のオーバーロード

単項演算子とは、1つのオペランドに対して使用する演算子です。

代表例は次のとおりです。

-a
+a
!a
~a

単項マイナス

class Vector2
{
public:
    double x;
    double y;

    Vector2 operator-() const
    {
        return Vector2{-x, -y};
    }
};

使用例は次のとおりです。

Vector2 a{3.0, -5.0};
Vector2 b = -a;

b の値は次のようになります。

(-3.0, 5.0)

メンバー関数として単項演算子を定義する場合、通常は明示的な引数を取りません。

Vector2 operator-() const;

二項演算子のオーバーロード

二項演算子は、左右2つのオペランドを持つ演算子です。

a + b
a - b
a * b
a == b
a < b

例えば、減算演算子は次のように定義できます。

Vector2 operator-(const Vector2& other) const
{
    return Vector2{x - other.x, y - other.y};
}

非メンバー関数として定義する場合は、次の形になります。

Vector2 operator-(const Vector2& lhs, const Vector2& rhs)
{
    return Vector2{
        lhs.x - rhs.x,
        lhs.y - rhs.y
    };
}

複合代入演算子

+=-=*= などの複合代入演算子は、左辺のオブジェクト自身を変更します。

class Vector2
{
public:
    double x;
    double y;

    Vector2& operator+=(const Vector2& other)
    {
        x += other.x;
        y += other.y;

        return *this;
    }
};

戻り値は通常、自分自身への参照にします。

Vector2&

これにより、次のような連続した演算にも対応できます。

a += b += c;

operator+= が参照を返すのは、組み込み型の複合代入演算子と似た動作にするためです。

加算演算子は複合代入演算子を利用して実装する

++= に同じ処理を別々に書くと、コードが重複します。

そのため、まず += を実装し、それを使って + を定義する方法が一般的です。

class Vector2
{
public:
    double x;
    double y;

    Vector2& operator+=(const Vector2& other)
    {
        x += other.x;
        y += other.y;
        return *this;
    }

    friend Vector2 operator+(Vector2 lhs, const Vector2& rhs)
    {
        lhs += rhs;
        return lhs;
    }
};

左辺の lhs は値渡しで受け取っています。

friend Vector2 operator+(Vector2 lhs, const Vector2& rhs)

lhs は元の左辺オブジェクトのコピーまたはムーブされた値なので、変更しても元のオブジェクトには影響しません。

lhs += rhs;
return lhs;

この設計には、次のような利点があります。

  • 加算処理を operator+= に集約できる
  • コードの重複を減らせる
  • ++= の動作を統一できる
  • 右辺値が渡された場合にムーブが利用される可能性がある

減算も同様に実装できます。

Vector2& operator-=(const Vector2& other)
{
    x -= other.x;
    y -= other.y;
    return *this;
}

friend Vector2 operator-(Vector2 lhs, const Vector2& rhs)
{
    lhs -= rhs;
    return lhs;
}

等価比較演算子

operator==の定義

class Point
{
public:
    int x;
    int y;

    bool operator==(const Point& other) const
    {
        return x == other.x && y == other.y;
    }
};

使用例は次のとおりです。

Point a{1, 2};
Point b{1, 2};

if (a == b)
{
    // 座標が等しい
}

C++17以前では、!= も使用したい場合、通常は自分で定義します。

bool operator!=(const Point& other) const
{
    return !(*this == other);
}

C++20以降のoperator!=

C++20以降では、operator== が定義されていれば、多くの場合、a != b も使用できます。

Point a{1, 2};
Point b{3, 4};

if (a != b)
{
}

ただし、operator!= という関数が単純に自動生成されるわけではありません。

a != b のオーバーロード解決時に、対応する operator== が書き換え候補として考慮されます。

概念的には、次のような処理です。

!(a == b)

C++17以前にも対応するコードでは、operator!= を明示的に定義する必要があります。

三方比較演算子

C++20では、三方比較演算子 <=> を使用できます。

一般に「宇宙船演算子」とも呼ばれます。

#include <compare>

class Version
{
public:
    int major;
    int minor;
    int patch;

    auto operator<=>(const Version&) const = default;
};

このようにデフォルト定義すると、各メンバーが宣言順に比較されます。

Version a{1, 2, 0};
Version b{1, 3, 0};

bool r1 = a < b;
bool r2 = a <= b;
bool r3 = a > b;
bool r4 = a >= b;
bool r5 = a == b;
bool r6 = a != b;

デフォルト定義された operator<=> があり、同じクラスに operator== が明示的に宣言されていなければ、対応する operator== も暗黙に宣言されます。

そのため、単純な値オブジェクトでは、通常は次の1行で比較演算をまとめて定義できます。

auto operator<=>(const Version&) const = default;

operator==を明示的に定義する場合

比較の基準を独自に決めたい場合は、operator== を明示的に定義します。

#include <compare>
#include <string>

class Record
{
public:
    int id;
    std::string displayName;

    bool operator==(const Record& other) const
    {
        return id == other.id;
    }

    auto operator<=>(const Record& other) const
    {
        return id <=> other.id;
    }
};

この例では、displayName が異なっていても、id が同じなら等しいと判定します。

前置インクリメントと後置インクリメント

++ には、前置形式と後置形式があります。

++value;
value++;

両者は異なる関数として定義します。

前置インクリメント

Counter& operator++()
{
    ++value_;
    return *this;
}

後置インクリメント

Counter operator++(int)
{
    Counter old = *this;
    ++(*this);
    return old;
}

完全な例は次のとおりです。

class Counter
{
private:
    int value_;

public:
    explicit Counter(int value = 0)
        : value_(value)
    {
    }

    Counter& operator++()
    {
        ++value_;
        return *this;
    }

    Counter operator++(int)
    {
        Counter old = *this;
        ++(*this);
        return old;
    }

    int value() const
    {
        return value_;
    }
};

後置版の int は、前置版と後置版を区別するためのダミー引数です。

Counter operator++(int);

前置版は増加後の値を返し、後置版は増加前の値を返します。

一般に、前置版は参照を返し、後置版は古い値のコピーを返します。

添字演算子

配列やコンテナのように要素へアクセスできるクラスを作る場合は、operator[] を定義します。

#include <cstddef>
#include <vector>

class IntArray
{
private:
    std::vector<int> data_;

public:
    explicit IntArray(std::size_t size)
        : data_(size)
    {
    }

    int& operator[](std::size_t index)
    {
        return data_[index];
    }

    const int& operator[](std::size_t index) const
    {
        return data_[index];
    }
};

使用例は次のとおりです。

IntArray array{5};

array[0] = 100;
int value = array[0];

const 版では、要素を書き換えられるように参照を返します。

int& operator[](std::size_t index);

const オブジェクトでも要素を読み取れるように、const 版も定義します。

const int& operator[](std::size_t index) const;

ただし、この例では範囲チェックを行っていません。

範囲外アクセスを検出したい場合は、別途 at() のような関数を用意する方法があります。

int& at(std::size_t index)
{
    return data_.at(index);
}

C++23の複数引数operator[]

C++23以降では、operator[] に複数の引数を渡せます。

class Matrix
{
public:
    int& operator[](std::size_t row, std::size_t column);
};

使用例は次のとおりです。

matrix[2, 3] = 10;

古いC++規格では、この形式は使用できません。

関数呼び出し演算子

operator() を定義すると、オブジェクトを関数のように呼び出せます。

class Multiplier
{
private:
    int factor_;

public:
    explicit Multiplier(int factor)
        : factor_(factor)
    {
    }

    int operator()(int value) const
    {
        return value * factor_;
    }
};

使用例は次のとおりです。

Multiplier triple{3};

int result = triple(10);

結果は 30 です。

このようなオブジェクトは、関数オブジェクトまたはファンクタと呼ばれます。

関数オブジェクトは、通常の関数と異なり、オブジェクト内部に状態を保持できます。

Multiplier doubleValue{2};
Multiplier tripleValue{3};

また、operator() は複数の引数を受け取ることもできます。

class Calculator
{
public:
    int operator()(int a, int b, int c) const
    {
        return a + b + c;
    }
};
Calculator calculator;
int result = calculator(1, 2, 3);

出力演算子

自作クラスを std::cout で出力したい場合は、operator<< を定義します。

#include <iostream>

class Point
{
private:
    int x_;
    int y_;

public:
    Point(int x, int y)
        : x_(x), y_(y)
    {
    }

    friend std::ostream& operator<<(std::ostream& os, const Point& point)
    {
        os << '(' << point.x_ << ", " << point.y_ << ')';
        return os;
    }
};

使用例は次のとおりです。

Point point{10, 20};

std::cout << point << '\n';

出力結果は次のようになります。

(10, 20)

戻り値として std::ostream& を返すことで、連続出力に対応できます。

std::cout << point << " end" << '\n';

operator<< は左辺が std::ostream になるため、通常は対象クラスのメンバー関数としては定義できません。

そのため、非メンバー関数として定義します。

入力演算子

入力演算子 operator>> も、通常は非メンバー関数として定義します。

#include <iostream>

class Point
{
private:
    int x_;
    int y_;

public:
    friend std::istream& operator>>(std::istream& is, Point& point)
    {
        is >> point.x_ >> point.y_;
        return is;
    }
};

使用例は次のとおりです。

Point point;

std::cin >> point;

入力先のオブジェクトを変更するため、引数は const Point& ではなく Point& にします。

入力失敗時の状態を考慮する

より安全に実装する場合は、一時変数に読み込んでからオブジェクトへ代入します。

friend std::istream& operator>>(std::istream& is, Point& point)
{
    int x;
    int y;

    if (is >> x >> y)
    {
        point.x_ = x;
        point.y_ = y;
    }

    return is;
}

この方法では、途中で入力に失敗しても、point が部分的に変更されることを防げます。

型変換演算子

クラスを別の型へ変換するための演算子も定義できます。

class BooleanValue
{
private:
    bool value_;

public:
    explicit BooleanValue(bool value)
        : value_(value)
    {
    }

    explicit operator bool() const
    {
        return value_;
    }
};

使用例は次のとおりです。

BooleanValue value{true};

if (value)
{
    // trueの場合
}

explicit を付けることで、意図しない暗黙変換を防げます。

BooleanValue value{true};

// 通常は不可
bool result = value;

明示的に変換する場合は、次のように記述できます。

bool result = static_cast<bool>(value);

ただし、ifwhile などの条件式では、explicit operator bool() でも使用できます。

変換演算子は、クラスのメンバー関数として定義する必要があります。

explicit operator bool() const;

代入演算子

代入演算子 operator= もオーバーロードできます。

class Resource
{
public:
    Resource& operator=(const Resource& other)
    {
        if (this != &other)
        {
            // リソースをコピーする処理
        }

        return *this;
    }
};

戻り値は通常、自分自身への参照にします。

Resource&

これにより、次の連続代入が可能になります。

a = b = c;

コピー代入演算子は、一般に次の形です。

ClassName& operator=(const ClassName& other);

ムーブ代入演算子は、一般に次の形です。

ClassName& operator=(ClassName&& other) noexcept;

ただし、自己代入チェックが常に必要とは限りません。

また、クラスのメンバーが std::stringstd::vectorstd::unique_ptr などの標準ライブラリ型で構成されている場合、コンパイラが生成する代入演算子で十分なことが多くあります。

class Person
{
private:
    std::string name_;
    std::vector<int> values_;
};

必要がなければ、代入演算子を自作しない設計が望まれます。

オーバーロードできる演算子

C++では、多くの演算子をオーバーロードできます。

代表的なものは次のとおりです。

+   -   *   /   %
^   &   |   ~   !
=   <   >   +=  -=
*=  /=  %=  ^=  &=
|=  <<  >>  >>= <<=
==  !=  <=  >=  <=>
&&  ||
++  --
,   ->* ->
()  []
new     delete
new[]   delete[]
co_await

ただし、演算子ごとに定義方法や制約が異なります。

特に newdeletenew[]delete[] は、通常の計算用演算子とは異なり、メモリの割り当てや解放を行う特別な関数です。

オーバーロードできない演算子や構文

次の演算子や言語構文はオーバーロードできません。

.
.*
::
?:
sizeof
typeid
alignof
noexcept

また、次のキャスト構文そのものをオーバーロードすることもできません。

static_cast
dynamic_cast
const_cast
reinterpret_cast

ただし、変換コンストラクタや変換演算子を定義することで、型変換に関与することは可能です。

explicit operator bool() const;

メンバー関数として定義する必要がある演算子

代表的な次の演算子は、クラスのメンバーとして定義する必要があります。

operator=
operator[]
operator()
operator->
operator 型名

例えば、添字演算子はクラスのメンバーとして定義します。

int& operator[](std::size_t index);

変換演算子もメンバー関数として定義します。

explicit operator bool() const;

C++23では、operator()operator[] を一定の条件で static メンバー関数として定義できます。

ただし、非メンバー関数として定義できるわけではなく、クラスのメンバーである必要があります。

演算子の優先順位は変更できない

演算子をオーバーロードしても、元の演算子の優先順位は変わりません。

a + b * c

自作型であっても、* が先に解釈されます。

a + (b * c)

次のような優先順位へ変更することはできません。

(a + b) * c

結合規則も変更できません。

演算子のオペランド数は基本的に変更できない

単項演算子を二項演算子にしたり、二項演算子を三項演算子にしたりすることはできません。

例えば、+ は単項形式または二項形式としてのみ定義できます。

+a
a + b

次のような3引数の operator+ は定義できません。

Result operator+(A a, B b, C c);

ただし、operator() は複数の引数を受け取れます。

int operator()(int a, int b, int c) const;

C++23以降では、operator[] も複数の引数を受け取れます。

また、配置形式の operator new は追加引数を取ることがあります。

void* operator new(std::size_t size, MemoryPool& pool);

したがって、すべての演算子が必ず1引数または2引数に限定されるわけではありません。

通常の演算子ではユーザー定義型が必要

組み込み型同士の演算そのものを変更することはできません。

例えば、整数同士の加算を独自の動作へ変更することはできません。

int operator+(int lhs, int rhs);

通常の演算子関数では、少なくとも一方のオペランドがクラス型、クラス型への参照、列挙型などに関係する必要があります。

Number operator+(const Number& lhs, const Number& rhs);

ただし、次の割り当て・解放関数には特別な規則があります。

operator new
operator delete
operator new[]
operator delete[]

例えば、グローバルな operator new は次のように定義できます。

void* operator new(std::size_t size);

この場合、引数はユーザー定義型ではありません。

論理演算子のオーバーロードには注意する

&&|| はオーバーロードできますが、組み込み演算子と同じ短絡評価にはなりません。

組み込みの && では、左辺が false の場合、右辺は評価されません。

condition && expensiveFunction()

しかし、オーバーロードされた operator&& は関数呼び出しとして扱われるため、組み込み演算子と同じ短絡評価を再現できません。

class Flag
{
public:
    bool value;
};

Flag operator&&(const Flag& lhs, const Flag& rhs)
{
    return Flag{lhs.value && rhs.value};
}

利用者は &&|| に短絡評価を期待するため、通常はこれらの演算子をオーバーロードしないほうが安全です。

カンマ演算子 , も、組み込み演算子と完全に同じ評価上の性質になるとは限りません。

constの付け方

オブジェクトを変更しないメンバー関数には、末尾に const を付けます。

Vector2 operator+(const Vector2& other) const
{
    return Vector2{x + other.x, y + other.y};
}

末尾の const は、左辺のオブジェクトを変更しないことを表します。

const Vector2 a{1.0, 2.0};
const Vector2 b{3.0, 4.0};

Vector2 c = a + b;

operator+ に末尾の const がなければ、const オブジェクトに対して呼び出せません。

一方、+= は左辺を変更するため、末尾に const は付けません。

Vector2& operator+=(const Vector2& other)
{
    x += other.x;
    y += other.y;
    return *this;
}

引数はconst参照で受け取る

コピーが不要な場合、引数は const 参照で受け取るのが一般的です。

Vector2 operator+(const Vector2& other) const;

値渡しではコピーが発生する可能性があります。

Vector2 operator+(Vector2 other) const;

ただし、次のように左辺を意図的に値渡しにして、+= を利用する設計もあります。

friend Vector2 operator+(Vector2 lhs, const Vector2& rhs)
{
    lhs += rhs;
    return lhs;
}

この場合、左辺のコピーまたはムーブされた値を変更して返すことが目的です。

値渡しが常に悪いわけではなく、演算子の設計に応じて使い分けます。

戻り値を値で返す場合と参照で返す場合

新しい結果を作る演算子

+- は新しい結果を作るため、通常は値で返します。

Vector2 operator+(const Vector2& other) const;

自分自身を変更する演算子

+= や前置 ++ は自分自身を変更するため、通常は参照で返します。

Vector2& operator+=(const Vector2& other);
Counter& operator++();

要素へアクセスする演算子

[] は、要素を書き換えられるように参照を返すことがあります。

int& operator[](std::size_t index);

戻り値の型は、組み込み型の演算子がどのように動作するかを参考にすると設計しやすくなります。

分数クラスの実装例

演算子オーバーロードをまとめた分数クラスの例です。

#include <compare>
#include <iostream>
#include <limits>
#include <numeric>
#include <stdexcept>

class Fraction
{
private:
    int numerator_;
    int denominator_;

    void normalize()
    {
        if (denominator_ == 0)
        {
            throw std::invalid_argument{
                "denominator must not be zero"
            };
        }

        if (denominator_ == std::numeric_limits<int>::min())
        {
            throw std::overflow_error{
                "denominator cannot be normalized"
            };
        }

        if (denominator_ < 0)
        {
            if (numerator_ == std::numeric_limits<int>::min())
            {
                throw std::overflow_error{
                    "numerator cannot be negated"
                };
            }

            numerator_ = -numerator_;
            denominator_ = -denominator_;
        }

        const int divisor = std::gcd(numerator_, denominator_);

        numerator_ /= divisor;
        denominator_ /= divisor;
    }

    static int checkedCast(long long value)
    {
        if (value < std::numeric_limits<int>::min()
            || value > std::numeric_limits<int>::max())
        {
            throw std::overflow_error{"fraction overflow"};
        }

        return static_cast<int>(value);
    }

public:
    Fraction(int numerator = 0, int denominator = 1)
        : numerator_(numerator),
          denominator_(denominator)
    {
        normalize();
    }

    Fraction& operator+=(const Fraction& other)
    {
        const long long numerator =
            static_cast<long long>(numerator_)
                * other.denominator_
            + static_cast<long long>(other.numerator_)
                * denominator_;

        const long long denominator =
            static_cast<long long>(denominator_)
                * other.denominator_;

        numerator_ = checkedCast(numerator);
        denominator_ = checkedCast(denominator);

        normalize();
        return *this;
    }

    Fraction& operator-=(const Fraction& other)
    {
        const long long numerator =
            static_cast<long long>(numerator_)
                * other.denominator_
            - static_cast<long long>(other.numerator_)
                * denominator_;

        const long long denominator =
            static_cast<long long>(denominator_)
                * other.denominator_;

        numerator_ = checkedCast(numerator);
        denominator_ = checkedCast(denominator);

        normalize();
        return *this;
    }

    Fraction& operator*=(const Fraction& other)
    {
        const long long numerator =
            static_cast<long long>(numerator_)
                * other.numerator_;

        const long long denominator =
            static_cast<long long>(denominator_)
                * other.denominator_;

        numerator_ = checkedCast(numerator);
        denominator_ = checkedCast(denominator);

        normalize();
        return *this;
    }

    Fraction& operator/=(const Fraction& other)
    {
        if (other.numerator_ == 0)
        {
            throw std::invalid_argument{"division by zero"};
        }

        const long long numerator =
            static_cast<long long>(numerator_)
                * other.denominator_;

        const long long denominator =
            static_cast<long long>(denominator_)
                * other.numerator_;

        numerator_ = checkedCast(numerator);
        denominator_ = checkedCast(denominator);

        normalize();
        return *this;
    }

    Fraction operator-() const
    {
        if (numerator_ == std::numeric_limits<int>::min())
        {
            throw std::overflow_error{
                "numerator cannot be negated"
            };
        }

        return Fraction{-numerator_, denominator_};
    }

    friend Fraction operator+(Fraction lhs, const Fraction& rhs)
    {
        lhs += rhs;
        return lhs;
    }

    friend Fraction operator-(Fraction lhs, const Fraction& rhs)
    {
        lhs -= rhs;
        return lhs;
    }

    friend Fraction operator*(Fraction lhs, const Fraction& rhs)
    {
        lhs *= rhs;
        return lhs;
    }

    friend Fraction operator/(Fraction lhs, const Fraction& rhs)
    {
        lhs /= rhs;
        return lhs;
    }

    friend bool operator==(const Fraction& lhs, const Fraction& rhs)
    {
        return lhs.numerator_ == rhs.numerator_
            && lhs.denominator_ == rhs.denominator_;
    }

    friend std::strong_ordering operator<=>(
        const Fraction& lhs,
        const Fraction& rhs)
    {
        const long long left =
            static_cast<long long>(lhs.numerator_)
                * rhs.denominator_;

        const long long right =
            static_cast<long long>(rhs.numerator_)
                * lhs.denominator_;

        return left <=> right;
    }

    friend std::ostream& operator<<(
        std::ostream& os,
        const Fraction& value)
    {
        os << value.numerator_ << '/' << value.denominator_;
        return os;
    }
};

使用例は次のとおりです。

int main()
{
    Fraction a{1, 2};
    Fraction b{1, 3};

    std::cout << "a = " << a << '\n';
    std::cout << "b = " << b << '\n';

    std::cout << "a + b = " << a + b << '\n';
    std::cout << "a - b = " << a - b << '\n';
    std::cout << "a * b = " << a * b << '\n';
    std::cout << "a / b = " << a / b << '\n';

    if (a > b)
    {
        std::cout << "a is greater than b\n";
    }
}

出力例は次のとおりです。

a = 1/2
b = 1/3
a + b = 5/6
a - b = 1/6
a * b = 1/6
a / b = 3/2
a is greater than b

分数クラスのオーバーフローについて

単純に int 同士を掛け算すると、計算途中で符号付き整数オーバーフローが発生する可能性があります。

符号付き整数オーバーフローはC++では未定義動作です。

そのため、上の例では一度 long long に変換して計算し、結果が int の範囲に収まるかを確認しています。

const long long numerator =
    static_cast<long long>(numerator_)
        * other.denominator_;

ただし、約分前の値が int の範囲を超え、約分後には範囲内へ収まるケースでも、上の実装では例外になります。

さらに堅牢な分数クラスを作る場合は、計算前に最大公約数を使って交差約分する方法や、任意精度整数を使用する方法があります。

よくある間違い

operator+で左辺を変更する

次の実装は、一般的な値型の + としては不適切です。

Vector2& operator+(const Vector2& other)
{
    x += other.x;
    y += other.y;
    return *this;
}

通常、次の式で a が変更されることは期待されません。

Vector2 c = a + b;

変更を伴う演算は += に担当させます。

Vector2 operator+(const Vector2& other) const;
Vector2& operator+=(const Vector2& other);

ただし、特殊なプロキシ型や式テンプレートでは、異なる戻り値設計が使われることもあります。

一般的な値型では、+ は元のオブジェクトを変更せず、新しい結果を返すのが自然です。

operator+=が値を返す

次のように値を返すことも文法上は可能です。

Vector2 operator+=(const Vector2& other);

しかし、不要なコピーが発生する可能性があり、組み込み型の動作とも一致しません。

通常は参照を返します。

Vector2& operator+=(const Vector2& other);

後置インクリメントが参照を返す

次の実装では、変更前の値を返せません。

Counter& operator++(int)
{
    ++value_;
    return *this;
}

正しくは、古い値を保存して値で返します。

Counter operator++(int)
{
    Counter old = *this;
    ++(*this);
    return old;
}

constを付け忘れる

次の比較演算子はオブジェクトを変更しないため、末尾に const を付けるべきです。

bool operator==(const Point& other)
{
    return x == other.x && y == other.y;
}

修正後は次のようになります。

bool operator==(const Point& other) const
{
    return x == other.x && y == other.y;
}

operator<<でストリームを返さない

次の実装では、連続出力ができません。

std::ostream& operator<<(std::ostream& os, const Point& point)
{
    os << point.x << ", " << point.y;
}

必ずストリームを返します。

std::ostream& operator<<(std::ostream& os, const Point& point)
{
    os << point.x << ", " << point.y;
    return os;
}

演算子に不自然な意味を持たせる

技術的には、+ で削除処理を実行したり、== で大小比較を行ったりすることも可能です。

しかし、そのような設計は避けるべきです。

Data operator+(const Data& other)
{
    clear();
    return *this;
}

利用者は + に対して、加算、連結、結合、合成などの意味を期待します。

演算子オーバーロードでは、既存の演算子が持つ直感的な意味を守ることが重要です。

演算子オーバーロードに適したクラス

演算子オーバーロードは、演算子の意味を自然に理解できる型に適しています。

代表例は次のとおりです。

  • 複素数
  • 分数
  • ベクトル
  • 行列
  • 座標
  • 日付や時刻
  • 金額
  • 単位付き数値
  • 文字列
  • イテレータ
  • スマートポインタ
  • 値オブジェクト

一方、処理の意味が演算子から推測しにくい場合は、名前付き関数のほうが適切です。

例えば、次の式は意味が分かりにくい可能性があります。

user + permission

この場合は、名前付き関数のほうが意図を明確にできます。

user.grantPermission(permission);

演算子オーバーロードの設計方針

演算子オーバーロードを設計するときは、次の点を意識する必要があります。

  • 組み込み型と同じ直感的な意味を持たせる
  • + は原則として元の値を変更しない
  • += は自身を変更して参照を返す
  • 読み取り専用のメンバー関数には const を付ける
  • 比較演算子には一貫性を持たせる
  • 対称的な演算は非メンバー関数も検討する
  • 変換演算子には原則として explicit を検討する
  • 単純な比較では = default を活用する
  • &&|| のオーバーロードは慎重に行う
  • 演算子の意味が不自然なら名前付き関数を使用する

特に重要なのは、++= の違いです。

a + b

この式では、通常、ab を変更せず、新しい値を返します。

a += b

この式では、a 自身を変更し、a への参照を返します。

演算子オーバーロードでは、文法的に定義できるかどうかだけでなく、利用者が自然に意味を理解できるかどうかが重要です。

以上、C++の演算子のオーバーロードについてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次