C#のoperator overloadとは?演算子オーバーロードの書き方・使い方・注意点をサンプルコードで解説

はじめに

C#には、+-==<などの演算子に独自の意味を持たせる「operator overload(演算子オーバーロード)」という機能があります。

たとえば、数値同士であれば次のように自然に足し算できます。

C#
int a = 10;
int b = 20;

int result = a + b;

一方、自分で作成したMoney型やVector型、Complex型などでも、次のように書けたら直感的です。

C#
Money total = price + tax;
Vector moved = position + velocity;

このように、ユーザー定義型に対して演算子の動作を定義する仕組みが、C#のoperator overloadです。MicrosoftのC#言語リファレンスでも、ユーザー定義型では定義済みのC#演算子をオーバーロードでき、一方または両方のオペランドがその型である場合にカスタム実装を提供できると説明されています。Microsoft Learn+1

ただし、operator overloadは便利な反面、使い方を誤るとコードの意味が分かりにくくなります。この記事では、C#のoperator overloadの基本構文、サンプルコード、オーバーロードできる演算子、設計上の注意点、よくあるエラーまで順番に解説します。

1. C#のoperator overload(演算子オーバーロード)とは?

1-1. operator overloadの意味とできること

operator overloadとは、クラスや構造体などのユーザー定義型に対して、演算子の動作を独自に定義する機能です。

たとえば、Point型を作った場合、通常は次のようなメソッドで座標を加算します。

C#
Point p3 = p1.Add(p2);

operator overloadを使うと、次のように書けます。

C#
Point p3 = p1 + p2;

このとき、+演算子に「2つのPointのX座標とY座標をそれぞれ加算する」という意味を与えています。

C#
public readonly struct Point
{
public int X { get; }
public int Y { get; }

public Point(int x, int y)
{
X = x;
Y = y;
}

public static Point operator +(Point left, Point right)
{
return new Point(left.X + right.X, left.Y + right.Y);
}
}

使う側は次のように書けます。

C#
var p1 = new Point(10, 20);
var p2 = new Point(3, 5);

var result = p1 + p2;

Console.WriteLine(result.X); // 13
Console.WriteLine(result.Y); // 25

operator overloadを使うことで、独自型でも組み込み型のように自然な演算を表現できます。

1-2. メソッドのオーバーロードとの違い

メソッドのオーバーロードとは、同じメソッド名で引数の型や数が異なるメソッドを複数定義することです。

C#
public void Print(string value) { }
public void Print(int value) { }
public void Print(string value, int count) { }

一方、operator overloadは、+==などの演算子に対して独自の処理を定義します。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

違いを整理すると、メソッドのオーバーロードは「同じ名前のメソッドを複数定義する機能」、operator overloadは「演算子の動作をユーザー定義型向けに定義する機能」です。

メソッドのオーバーロードは名前で意味を説明できますが、operator overloadは記号だけで意味を伝える必要があります。そのため、演算子の意味が直感的であることが非常に重要です。

1-3. どんな場面で使う機能なのか

operator overloadがよく使われるのは、演算子で表現したほうが自然な型です。

代表例は次のような型です。

C#
// 金額
Money total = price + tax;

// ベクトル
Vector direction = v1 + v2;

// 複素数
Complex result = c1 * c2;

// 距離
Distance totalDistance = d1 + d2;

たとえば、Money型で金額同士を加算する場合、price.Add(tax)よりもprice + taxのほうが直感的に読めることがあります。

C#
var price = new Money(1000);
var tax = new Money(100);

var total = price + tax;

このように、数学的な値、数量、座標、単位付きの値、ドメイン上の値オブジェクトなどでは、operator overloadが有効に働きます。

1-4. C#で演算子オーバーロードを使うメリット・デメリット

operator overloadの主なメリットは、コードを直感的に書けることです。

C#
var total = price + tax;

このコードは、「価格に税を足して合計を求める」という意味がすぐに伝わります。

一方で、デメリットもあります。たとえば、+演算子に「データベースへ保存する」「ログを出力する」「外部APIを呼び出す」といった意味を持たせると、読み手は混乱します。

C#
// 悪い例:+なのに保存処理が走るような設計は分かりにくい
user + repository;

演算子は短く書けるぶん、処理内容が隠れやすい特徴があります。そのため、operator overloadは「誰が見ても自然な意味になる場合」に限定して使うべきです。

2. C#でoperator overloadを書く基本構文

2-1. operatorキーワードの基本

C#で演算子をオーバーロードするには、operatorキーワードを使います。基本形は次のとおりです。

C#
public static 戻り値の型 operator 演算子(引数1, 引数2)
{
// 処理
}

たとえば、Money型で+演算子を定義する場合は次のように書きます。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}
}

これで、Money型同士を+で加算できるようになります。

C#
var a = new Money(1000);
var b = new Money(500);

var total = a + b;

Console.WriteLine(total.Amount); // 1500

2-2. public staticで定義する理由

従来のC#の演算子オーバーロードは、基本的にpublic staticで定義します。演算子はインスタンスメソッドのように片方のオブジェクトに対して呼び出すのではなく、式全体に対して解決されるため、静的メンバーとして宣言します。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

publicにするのは、演算子を型の外部から利用できるようにするためです。privateな演算子を定義しても、通常の利用側コードから呼び出せません。

ただし、C# 14以降では複合代入演算子やインスタンス版の++--など、staticを付けない演算子オーバーロードも導入されています。従来型の+==などの基本的な演算子オーバーロードはpublic static、C# 14以降の複合代入演算子はpublicかつ非staticという違いがあります。Microsoft Learn+1

2-3. 戻り値・引数・演算子記号の書き方

演算子オーバーロードでは、戻り値の型、引数、演算子記号を明確に定義します。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

この例では、戻り値の型はMoney、演算子は+、引数はMoney leftMoney rightです。

比較演算子の場合は、戻り値をboolにするのが一般的です。

C#
public static bool operator <(Money left, Money right)
{
return left.Amount < right.Amount;
}

public static bool operator >(Money left, Money right)
{
return left.Amount > right.Amount;
}

型変換演算子では、implicitまたはexplicitを使います。

C#
public static explicit operator decimal(Money money)
{
return money.Amount;
}

2-4. クラス・構造体で定義する場合の違い

operator overloadはクラスにも構造体にも定義できます。

構造体は値を表す型として使われることが多いため、MoneyPointVectorのような値オブジェクトと相性が良いです。

C#
public readonly struct Distance
{
public double Meters { get; }

public Distance(double meters)
{
Meters = meters;
}

public static Distance operator +(Distance left, Distance right)
{
return new Distance(left.Meters + right.Meters);
}
}

クラスでも定義できます。

C#
public class Counter
{
public int Value { get; }

public Counter(int value)
{
Value = value;
}

public static Counter operator +(Counter left, Counter right)
{
return new Counter(left.Value + right.Value);
}
}

ただし、クラスは参照型なので、nullの扱いに注意が必要です。

C#
public static Counter operator +(Counter left, Counter right)
{
if (left is null) throw new ArgumentNullException(nameof(left));
if (right is null) throw new ArgumentNullException(nameof(right));

return new Counter(left.Value + right.Value);
}

構造体は通常nullにならないため、nullチェックの必要性はクラスより低くなります。ただし、Nullable<T>として扱う場合は別途注意が必要です。

2-5. コンパイルエラーになりやすい書き方

operator overloadでは、いくつかの書き方がコンパイルエラーになります。

たとえば、staticを付け忘れるとエラーになります。

C#
// C# 13以前の通常の二項演算子では不可
public Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

正しくは次のようにします。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

また、引数のどちらにも自分の型が含まれていない演算子は定義できません。

C#
// Money型の中で定義しても不可
public static int operator +(int left, int right)
{
return left + right;
}

これは、組み込み型同士の演算子をユーザー定義型側で勝手に変更できないようにするためです。

さらに、==を定義したのに!=を定義しない、<を定義したのに>を定義しない、といった書き方もできません。C#では等価演算子や比較演算子の一部はペアで定義する必要があります。Microsoft Learn+1

3. C#のoperator overloadをサンプルコードで理解する

3-1. +演算子をオーバーロードする基本例

まずは、+演算子をオーバーロードする基本例です。金額を表すMoney型を作ります。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

public override string ToString()
{
return $"{Amount:N0}円";
}
}

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

C#
var price = new Money(1200);
var tax = new Money(120);

var total = price + tax;

Console.WriteLine(total); // 1,320円

price + taxと書くだけで、Money型同士の加算ができます。金額のように「足し算」という意味が自然に成立する型では、operator overloadによって読みやすいコードになります。

3-2. -演算子をオーバーロードする例

次に、-演算子を定義します。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

public static Money operator -(Money left, Money right)
{
return new Money(left.Amount - right.Amount);
}

public override string ToString()
{
return $"{Amount:N0}円";
}
}

使用例です。

C#
var balance = new Money(5000);
var payment = new Money(1200);

var remaining = balance - payment;

Console.WriteLine(remaining); // 3,800円

Money型において、-は「金額を差し引く」という意味として自然です。このように、演算子の意味が明確な場合にoperator overloadは有効です。

3-3. ==演算子と!=演算子をオーバーロードする例

==!=は、必ずペアで定義します。片方だけを定義するとコンパイルエラーになります。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static bool operator ==(Money left, Money right)
{
return left.Amount == right.Amount;
}

public static bool operator !=(Money left, Money right)
{
return !(left == right);
}

public override bool Equals(object? obj)
{
return obj is Money other && this == other;
}

public override int GetHashCode()
{
return Amount.GetHashCode();
}
}

使用例です。

C#
var a = new Money(1000);
var b = new Money(1000);
var c = new Money(500);

Console.WriteLine(a == b); // True
Console.WriteLine(a != c); // True

==を定義するときは、EqualsGetHashCodeもあわせて整合性を取ることが重要です。特に、辞書や集合などで利用する型では、等価性のルールが不整合になるとバグの原因になります。

3-4. <演算子と>演算子をオーバーロードする例

<>もペアで定義します。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static bool operator <(Money left, Money right)
{
return left.Amount < right.Amount;
}

public static bool operator >(Money left, Money right)
{
return left.Amount > right.Amount;
}
}

使用例です。

C#
var small = new Money(500);
var large = new Money(1000);

Console.WriteLine(small < large); // True
Console.WriteLine(large > small); // True

<=>=も使いたい場合は、これらも別途ペアで定義します。

C#
public static bool operator <=(Money left, Money right)
{
return left.Amount <= right.Amount;
}

public static bool operator >=(Money left, Money right)
{
return left.Amount >= right.Amount;
}

比較演算子を定義する場合は、IComparable<T>の実装も検討するとよいでしょう。ソートや範囲比較などで使いやすくなります。

3-5. implicit・explicitによる型変換演算子の例

C#では、implicitexplicitを使って型変換演算子を定義できます。公式ドキュメントでは、ユーザー定義型は標準変換が存在しない型同士について、暗黙的または明示的な変換を定義できると説明されています。暗黙的変換は代入やメソッド呼び出しなどで特別な構文なしに実行されるため、失敗しない変換に使うべきです。Microsoft Learn+1

まず、decimalからMoneyへの暗黙的変換を定義します。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static implicit operator Money(decimal amount)
{
return new Money(amount);
}

public static explicit operator decimal(Money money)
{
return money.Amount;
}
}

使用例です。

C#
Money money = 1000m; // implicitにより自動変換

decimal amount = (decimal)money; // explicitなのでキャストが必要

Console.WriteLine(amount); // 1000

implicitは便利ですが、意図しない変換が起きるとコードが分かりにくくなります。そのため、情報が失われる可能性がある変換、例外が発生する可能性がある変換、意味が曖昧な変換にはexplicitを使うのが安全です。

4. C#でオーバーロードできる演算子・できない演算子

4-1. オーバーロード可能な単項演算子

C#では、次のような単項演算子をオーバーロードできます。

C#
+x
-x
!x
~x
++x
--x
true
false

たとえば、単項-を定義すると、値の符号を反転できます。

C#
public readonly struct Temperature
{
public double Celsius { get; }

public Temperature(double celsius)
{
Celsius = celsius;
}

public static Temperature operator -(Temperature value)
{
return new Temperature(-value.Celsius);
}
}

使用例です。

C#
var t = new Temperature(25);
var minus = -t;

Console.WriteLine(minus.Celsius); // -25

truefalse演算子は特殊で、条件式に独自型を使いたい場合などに利用されます。ただし、通常のアプリケーションコードで頻繁に使うものではありません。

4-2. オーバーロード可能な二項演算子

代表的な二項演算子には次のようなものがあります。

C#
+
-
*
/
%
&
|
^
<<
>>
>>>
==
!=
<
>
<=
>=

たとえば、ベクトル型では+-が自然に使えます。

C#
public readonly struct Vector2
{
public double X { get; }
public double Y { get; }

public Vector2(double x, double y)
{
X = x;
Y = y;
}

public static Vector2 operator +(Vector2 left, Vector2 right)
{
return new Vector2(left.X + right.X, left.Y + right.Y);
}

public static Vector2 operator -(Vector2 left, Vector2 right)
{
return new Vector2(left.X - right.X, left.Y - right.Y);
}
}

使用例です。

C#
var a = new Vector2(10, 20);
var b = new Vector2(3, 5);

var sum = a + b;
var diff = a - b;

Console.WriteLine($"{sum.X}, {sum.Y}"); // 13, 25
Console.WriteLine($"{diff.X}, {diff.Y}"); // 7, 15

4-3. 比較演算子・等価演算子のルール

比較演算子や等価演算子には、ペアで定義しなければならないルールがあります。

==を定義するなら!=も定義します。

C#
public static bool operator ==(Money left, Money right)
{
return left.Amount == right.Amount;
}

public static bool operator !=(Money left, Money right)
{
return !(left == right);
}

<を定義するなら>も定義します。

C#
public static bool operator <(Money left, Money right)
{
return left.Amount < right.Amount;
}

public static bool operator >(Money left, Money right)
{
return left.Amount > right.Amount;
}

<=を定義するなら>=も定義します。

C#
public static bool operator <=(Money left, Money right)
{
return left.Amount <= right.Amount;
}

public static bool operator >=(Money left, Money right)
{
return left.Amount >= right.Amount;
}

C#の言語リファレンスでも、==!=<><=>=はペアでオーバーロードする必要があるとされています。Microsoft Learn+1

4-4. true・false演算子の扱い

true演算子とfalse演算子は、独自型を条件判定に使うための特殊な演算子です。

C#
public readonly struct Result
{
public bool IsSuccess { get; }

public Result(bool isSuccess)
{
IsSuccess = isSuccess;
}

public static bool operator true(Result result)
{
return result.IsSuccess;
}

public static bool operator false(Result result)
{
return !result.IsSuccess;
}
}

使用例です。

C#
var result = new Result(true);

if (result)
{
Console.WriteLine("成功");
}

ただし、このような設計は読み手にとって分かりにくい場合もあります。多くの場合は、次のようにプロパティで明示したほうが分かりやすいです。

C#
if (result.IsSuccess)
{
Console.WriteLine("成功");
}

truefalse演算子は強力ですが、通常は慎重に使うべきです。

4-5. オーバーロードできない演算子一覧

C#にはオーバーロードできない演算子もあります。代表例は次のとおりです。

C#
=
&&
||
[]
?.
?[]
.
?.
?:
??
??=
..
->
=>
as
await
checked
unchecked
default
delegate
is
nameof
new
sizeof
stackalloc
switch
typeof
with

たとえば、代入演算子=はオーバーロードできません。インデクサーの[]そのものも演算子オーバーロードではなく、インデクサーとして定義します。

C#
public string this[int index]
{
get { return items[index]; }
}

キャスト式(T)x自体を直接オーバーロードするのではなく、implicitまたはexplicitの型変換演算子を定義します。

C#
public static explicit operator decimal(Money money)
{
return money.Amount;
}

C#の公式リファレンスでも、代入演算子、メンバーアクセス、条件演算子、null合体演算子、newisnameofなどはオーバーロード不可の演算子として整理されています。Microsoft Learn+1

4-6. +=や-=は直接オーバーロードできるのか

C# 13以前では、+=-=のような複合代入演算子は直接オーバーロードできませんでした。代わりに、対応する二項演算子を定義すると、複合代入も利用できました。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

この場合、次のように書けます。

C#
var total = new Money(1000);
total += new Money(500);

C# 13以前の考え方では、total += valueは概念的に次のような形で扱われます。

C#
total = total + value;

ただし、C# 14以降では、ユーザー定義型が+=-=などの複合代入演算子を明示的にオーバーロードできるようになりました。これは、既存の値から新しいインスタンスを作るのではなく、必要に応じてインプレースに更新して効率化する目的で導入されています。複合代入演算子はpublic、非static、戻り値void、引数1つという形で定義します。Microsoft Learn+2Microsoft Learn+2

C#
public class Counter
{
public int Value { get; private set; }

public Counter(int value)
{
Value = value;
}

// C# 14以降
public void operator +=(int value)
{
Value += value;
}
}

そのため、現在は「C# 13以前では直接オーバーロードできないが、C# 14以降では一部の複合代入演算子を明示的にオーバーロードできる」と理解すると正確です。

5. operator overloadを使うときの実践的な設計ポイント

5-1. 直感的に意味が伝わる演算子だけを定義する

operator overloadで最も重要なのは、演算子の意味が直感的であることです。

良い例です。

C#
Money total = price + tax;
Vector2 moved = position + velocity;
Distance totalDistance = d1 + d2;

これらは、+の意味が自然に理解できます。

悪い例です。

C#
// +でユーザー登録を行うような設計は分かりにくい
user + database;

演算子は短く書けるため便利ですが、意味が曖昧だとコードの可読性を大きく下げます。+は加算、-は減算、==は等価比較、<は大小比較というように、一般的な意味から外れすぎないようにしましょう。

5-2. ==を定義するときはEqualsとGetHashCodeも考慮する

==をオーバーロードする場合は、EqualsGetHashCodeもあわせて実装するのが基本です。

C#
public readonly struct ProductCode
{
public string Value { get; }

public ProductCode(string value)
{
Value = value;
}

public static bool operator ==(ProductCode left, ProductCode right)
{
return left.Value == right.Value;
}

public static bool operator !=(ProductCode left, ProductCode right)
{
return !(left == right);
}

public override bool Equals(object? obj)
{
return obj is ProductCode other && this == other;
}

public override int GetHashCode()
{
return Value.GetHashCode();
}
}

==では等しいと判断されるのに、Equalsでは等しくない、あるいはGetHashCodeが一致しないと、Dictionary<TKey, TValue>HashSet<T>で期待通りに動かない可能性があります。

等価性は型の根本的な設計に関わるため、operator overloadだけでなく、型全体の振る舞いとして整合性を持たせる必要があります。

5-3. !=・<・>など対になる演算子をセットで定義する

C#では、いくつかの演算子をペアで定義する必要があります。

C#
==  !=
< >
<= >=
true false

たとえば、==だけを定義するのではなく、必ず!=も定義します。

C#
public static bool operator ==(Money left, Money right)
{
return left.Amount == right.Amount;
}

public static bool operator !=(Money left, Money right)
{
return !(left == right);
}

同様に、<を定義するなら>も定義します。

C#
public static bool operator <(Money left, Money right)
{
return left.Amount < right.Amount;
}

public static bool operator >(Money left, Money right)
{
return left.Amount > right.Amount;
}

さらに、利用者が<=>=も期待する型であれば、それらも定義すると自然です。

5-4. null比較で注意すべきポイント

クラスで==!=を定義する場合、null比較に注意が必要です。

悪い例です。

C#
public static bool operator ==(User left, User right)
{
return left.Id == right.Id;
}

このコードは、leftまたはrightnullの場合にNullReferenceExceptionが発生します。

安全に書くなら、次のようにします。

C#
public class User
{
public int Id { get; }

public User(int id)
{
Id = id;
}

public static bool operator ==(User? left, User? right)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;

return left.Id == right.Id;
}

public static bool operator !=(User? left, User? right)
{
return !(left == right);
}

public override bool Equals(object? obj)
{
return obj is User other && this == other;
}

public override int GetHashCode()
{
return Id.GetHashCode();
}
}

ReferenceEquals(left, right)を先に確認することで、両方が同じ参照、または両方がnullの場合を処理できます。

5-5. 可読性を下げないための命名・設計

operator overloadは記号で処理を表すため、メソッド名のように処理内容を説明できません。そのため、型名やプロパティ名、ドメイン設計が重要になります。

たとえば、次のコードは読みやすいです。

C#
Money total = price + tax;

Moneypricetaxという名前から、+の意味が自然に分かります。

一方、次のようなコードは意味が分かりにくくなります。

C#
var result = a * b;

abが何を表しているのか、*が何を意味しているのか分からなければ、operator overloadの利点は薄れます。

演算子を定義する場合は、次の点を意識しましょう。

C#
// 良い設計の方向性
Money + Money Money を返す
Distance + Distance Distance を返す
Vector + Vector Vector を返す
Money < Money bool を返す

型と演算結果の関係が自然であるほど、operator overloadは読みやすくなります。

5-6. 演算子と同等の通常メソッドを用意すべきケース

演算子だけでは意味が伝わりにくい場合、通常メソッドも用意するとよいです。

C#
public readonly struct Money
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static Money operator +(Money left, Money right)
{
return left.Add(right);
}

public Money Add(Money other)
{
return new Money(Amount + other.Amount);
}
}

このようにしておくと、利用者は好みに応じて次のどちらも使えます。

C#
var total1 = price + tax;
var total2 = price.Add(tax);

特に、演算の意味が少しでも複雑な場合は、通常メソッドを用意したほうが親切です。たとえば、割引、税計算、単位変換、丸め処理などが絡む場合は、+-だけでは意図が伝わりにくくなります。

6. operator overloadのよくあるエラーと注意点

6-1. 引数のどちらかに自分の型が必要

演算子オーバーロードでは、少なくとも1つの引数に、その演算子を定義している型が含まれている必要があります。

たとえば、Money型の中で次のような演算子は定義できません。

C#
public readonly struct Money
{
// 不可
public static int operator +(int left, int right)
{
return left + right;
}
}

int + intの動作をMoney型の中で変更できてしまうと、言語全体の整合性が崩れてしまいます。

正しくは、次のように少なくとも一方にMoneyを含めます。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

public static Money operator +(Money left, decimal right)
{
return new Money(left.Amount + right);
}

6-2. staticを付け忘れた場合のエラー

C# 13以前の通常の演算子オーバーロードでは、staticを付け忘れるとエラーになります。

C#
// 不可
public Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

正しくは次のようにします。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

ただし、C# 14以降の複合代入演算子は非staticで定義します。

C#
// C# 14以降
public void operator +=(int value)
{
Value += value;
}

つまり、どの種類の演算子を定義しているかによって、staticが必要かどうかが変わります。通常の二項演算子や比較演算子はpublic static、C# 14以降の複合代入演算子はpublicかつ非staticと区別して覚えましょう。

6-3. 戻り値の型を誤った場合の問題

演算子の戻り値は、演算の意味に合った型にする必要があります。

たとえば、Money + Moneyであれば、通常はMoneyを返します。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

比較演算子ならboolを返します。

C#
public static bool operator <(Money left, Money right)
{
return left.Amount < right.Amount;
}

戻り値の型が不自然だと、利用側のコードが分かりにくくなります。

C#
// 悪い例:Money + Money が string を返すのは直感的ではない
public static string operator +(Money left, Money right)
{
return $"{left.Amount + right.Amount}円";
}

このような処理は、演算子ではなくToStringや専用メソッドに分けるべきです。

C#
public override string ToString()
{
return $"{Amount:N0}円";
}

6-4. ==だけ定義して!=を定義しない場合の問題

==だけを定義して!=を定義しないと、C#ではコンパイルエラーになります。

C#
public static bool operator ==(Money left, Money right)
{
return left.Amount == right.Amount;
}

// != がないため不可

正しくは、次のように両方定義します。

C#
public static bool operator ==(Money left, Money right)
{
return left.Amount == right.Amount;
}

public static bool operator !=(Money left, Money right)
{
return !(left == right);
}

このように、対になる演算子は必ず整合性を持たせます。!=の中で!(left == right)を使うと、比較ロジックを一箇所に集約でき、実装のズレを防ぎやすくなります。

6-5. 演算子の意味を変えすぎると保守性が下がる

operator overloadの大きな落とし穴は、演算子に本来とは異なる意味を持たせすぎることです。

悪い例です。

C#
// +なのにメール送信するような設計
user + message;

このコードを見た人は、まさかメール送信が行われるとは思いません。

次のように通常メソッドで書いたほうが明確です。

C#
user.SendMessage(message);

operator overloadは、処理を短く書くための機能ではありません。あくまで、演算子で表現することが自然な操作を、自然な記法で書くための機能です。

6-6. パフォーマンスやメモリ割り当てへの影響

operator overloadの中で毎回新しいインスタンスを作る場合、使い方によってはメモリ割り当てが増えることがあります。

C#
public static BigValue operator +(BigValue left, BigValue right)
{
return new BigValue(left.Items.Concat(right.Items).ToArray());
}

このような処理を大量に実行すると、パフォーマンスに影響する可能性があります。

特に、大きな配列、大きな構造体、大量のデータを持つ型では注意が必要です。C# 14以降の複合代入演算子は、必要に応じてインプレース更新を行い、新しいインスタンス生成やコピーを減らす目的で利用できます。Microsoft Learn+1

C#
public class Buffer
{
private readonly List<int> values = new();

public void operator +=(int value)
{
values.Add(value);
}
}

ただし、インプレース更新はミュータブルな設計になるため、値オブジェクトの不変性を重視する場合は慎重に判断しましょう。

7. operator overloadの使いどころ・使わないほうがよいケース

7-1. Vector・Money・Complexなど値オブジェクトで使う例

operator overloadは、値そのものを表す型と相性が良いです。

たとえば、ベクトル型では+-が自然です。

C#
public readonly struct Vector2
{
public double X { get; }
public double Y { get; }

public Vector2(double x, double y)
{
X = x;
Y = y;
}

public static Vector2 operator +(Vector2 left, Vector2 right)
{
return new Vector2(left.X + right.X, left.Y + right.Y);
}

public static Vector2 operator -(Vector2 left, Vector2 right)
{
return new Vector2(left.X - right.X, left.Y - right.Y);
}
}

金額を表すMoney型でも自然です。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

複素数、行列、座標、距離、重量、時間量なども、演算子で表現しやすい代表例です。

7-2. ドメインモデルで自然な演算を表現する例

業務システムでも、ドメインモデルに自然な演算が存在する場合はoperator overloadを活用できます。

たとえば、在庫数を表すQuantity型です。

C#
public readonly struct Quantity
{
public int Value { get; }

public Quantity(int value)
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
Value = value;
}

public static Quantity operator +(Quantity left, Quantity right)
{
return new Quantity(left.Value + right.Value);
}

public static Quantity operator -(Quantity left, Quantity right)
{
return new Quantity(left.Value - right.Value);
}
}

使用例です。

C#
var current = new Quantity(10);
var added = new Quantity(5);

var total = current + added;

ただし、Quantityの減算で負数を許可しない場合、例外が発生する可能性があります。その場合は、通常メソッドで意図を明示する設計も検討できます。

C#
public Quantity Decrease(Quantity quantity)
{
return new Quantity(Value - quantity.Value);
}

ドメインルールが複雑な場合は、演算子だけで表現しようとしないことが大切です。

7-3. 単なる省略記法として使うべきでない理由

operator overloadは、コードを短くするためだけに使うべきではありません。

たとえば、次のような処理は演算子にする必要がありません。

C#
// 分かりにくい
order << item;

通常メソッドのほうが意味が明確です。

C#
order.AddItem(item);

演算子は名前を持たないため、読み手は記号の一般的な意味から処理内容を推測します。その推測を裏切る設計は、保守性を下げます。

短く書けることよりも、意味が明確であることを優先しましょう。

7-4. チーム開発で混乱を招きやすいケース

チーム開発では、operator overloadの使いすぎが混乱を招くことがあります。

たとえば、ある開発者は+を「加算」と理解しているのに、別の開発者が「結合」「登録」「適用」などの意味で使っていると、コードレビューや保守で問題になります。

C#
// 何をしているのか分かりにくい
workflow + step;

次のように書いたほうが明確です。

C#
workflow.AddStep(step);

operator overloadを導入する場合は、チーム内で次のような基準を共有しておくと安全です。

C#
数学的・数量的に自然な演算に限定する
副作用の大きい処理には使わない
意味が曖昧な場合は通常メソッドを使う
==を定義する場合はEquals/GetHashCodeも整える

7-5. 通常メソッドのほうが読みやすいケース

通常メソッドのほうが読みやすいケースは多くあります。

たとえば、割引を適用する処理です。

C#
// 演算子だと意味が曖昧
var discounted = price - campaign;

この場合は、次のようなメソッドのほうが明確です。

C#
var discounted = price.ApplyDiscount(campaign);

また、税計算も単純な加算ではない場合があります。

C#
var total = price.CalculateTaxIncluded(taxRate);

業務ルールや条件分岐が絡む処理は、演算子ではなくメソッド名で意味を表現したほうが保守しやすくなります。

8. C#のoperator overloadに関するよくある質問

8-1. C#で演算子オーバーロードは非推奨なのか

C#のoperator overloadは非推奨ではありません。現在のC#言語リファレンスでも、ユーザー定義型で演算子をオーバーロードする方法や、オーバーロード可能な演算子が説明されています。Microsoft Learn+1

ただし、どんな場面でも積極的に使うべき機能ではありません。演算子で表現することが自然な型に限定して使うのが基本です。

良い使い方です。

C#
Money total = price + tax;
Vector2 moved = position + velocity;

避けたい使い方です。

C#
user + role;
service * request;

非推奨ではありませんが、乱用すると可読性が下がるため、設計判断が重要です。

8-2. 拡張メソッドでoperator overloadはできるのか

従来のC#では、拡張メソッドを使って既存の型に演算子オーバーロードを後付けすることはできません。演算子オーバーロードは、基本的に対象のユーザー定義型の中で定義します。

C#
public static class MoneyExtensions
{
// 拡張メソッドとしてoperatorは定義できない
}

そのため、外部ライブラリの型に対して、拡張メソッドのような感覚で+==を追加することはできません。

なお、C# 14では拡張メンバーや拡張演算子に関する仕様検討・導入が進んでいますが、使用できる構文や対象バージョンはプロジェクトのC#言語バージョンに依存します。一般的な既存コードでは、「演算子は型側で定義する」と理解しておくのが安全です。Microsoft Learn+1

8-3. インターフェースで演算子を定義できるのか

C# 11以降では、インターフェースにstatic abstractメンバーを定義できるようになり、演算子をインターフェースで要求できるようになりました。公式の仕様説明でも、インターフェースが実装型に対して提供すべき演算子を定義できるとされています。Microsoft Learn+1

例です。

C#
public interface IAddable<TSelf>
where TSelf : IAddable<TSelf>
{
static abstract TSelf operator +(TSelf left, TSelf right);
}

実装側です。

C#
public readonly struct Money : IAddable<Money>
{
public decimal Amount { get; }

public Money(decimal amount)
{
Amount = amount;
}

public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}
}

この機能は、ジェネリック数学などで重要です。ただし、通常の初心者向けコードでは少し高度な機能なので、まずはクラスや構造体に直接operator overloadを定義する基本を理解するとよいでしょう。

8-4. 継承したクラスで演算子オーバーロードは使えるのか

演算子オーバーロードは継承関係でも利用できますが、通常の仮想メソッドのようにポリモーフィックに動くものではありません。

たとえば、基底クラスに演算子を定義できます。

C#
public class Amount
{
public decimal Value { get; }

public Amount(decimal value)
{
Value = value;
}

public static Amount operator +(Amount left, Amount right)
{
return new Amount(left.Value + right.Value);
}
}

派生クラスを作ることもできます。

C#
public class SpecialAmount : Amount
{
public SpecialAmount(decimal value) : base(value)
{
}
}

ただし、演算子は静的メンバーとして解決されるため、インスタンスメソッドのvirtualoverrideのような感覚で設計すると混乱しやすくなります。継承階層が深い型では、operator overloadよりも通常メソッドやインターフェース設計を検討したほうが分かりやすい場合があります。

8-5. JavaやC++の演算子オーバーロードとの違い

Javaには、C#のような一般的な演算子オーバーロード機能はありません。たとえば、独自のMoney型に対してprice + taxのような演算を定義することはできず、通常はprice.add(tax)のようなメソッドを使います。

C++には強力な演算子オーバーロードがあります。C++では自由度が高い一方で、設計によっては非常に分かりにくいコードも書けます。

C#のoperator overloadは、C++ほど何でも自由にできるわけではありません。オーバーロードできる演算子とできない演算子が定められており、==!=のようにペア定義が必要なルールもあります。代入演算子=やメンバーアクセス.などはオーバーロードできません。Microsoft Learn+1

つまり、C#のoperator overloadは、Javaより柔軟で、C++より制約がある機能と考えると分かりやすいです。

8-6. operator overloadを学んだ後に理解すべきC#機能

operator overloadを理解したら、次のC#機能もあわせて学ぶと理解が深まります。

まず、EqualsGetHashCodeです。==!=を定義するなら、等価性の設計が欠かせません。

C#
public override bool Equals(object? obj)
{
return obj is Money other && Amount == other.Amount;
}

public override int GetHashCode()
{
return Amount.GetHashCode();
}

次に、IEquatable<T>です。型安全な等価比較を実装できます。

C#
public readonly struct Money : IEquatable<Money>
{
public decimal Amount { get; }

public bool Equals(Money other)
{
return Amount == other.Amount;
}
}

比較が必要な型では、IComparable<T>も重要です。

C#
public int CompareTo(Money other)
{
return Amount.CompareTo(other.Amount);
}

さらに、C# 11以降のstatic abstractインターフェースメンバーを学ぶと、ジェネリックな数値計算や演算可能な型の抽象化を理解しやすくなります。

C#
public interface IAddable<TSelf>
where TSelf : IAddable<TSelf>
{
static abstract TSelf operator +(TSelf left, TSelf right);
}

operator overloadは単独の機能として覚えるだけでなく、等価性、比較、型変換、ジェネリック、値オブジェクト設計とあわせて理解すると実践で使いやすくなります。

まとめ

C#のoperator overloadは、ユーザー定義型に対して+-==<などの演算子の動作を定義できる機能です。

基本的な書き方は次の形です。

C#
public static Money operator +(Money left, Money right)
{
return new Money(left.Amount + right.Amount);
}

この機能を使うと、MoneyVectorComplexDistanceなどの値を表す型で、組み込み型のように自然な演算を表現できます。

C#
Money total = price + tax;
Vector2 moved = position + velocity;

一方で、operator overloadは使い方を誤ると可読性を下げます。演算子は記号だけで意味を伝えるため、一般的な意味から外れた処理を割り当てるべきではありません。

特に重要なポイントは次のとおりです。

C#
演算子の意味が直感的な場合だけ使う
==を定義するなら!=も定義する
<を定義するなら>も定義する
==を定義するならEqualsとGetHashCodeも考慮する
クラスではnull比較に注意する
複雑な処理は通常メソッドで表現する

また、C# 14以降では+=-=などの複合代入演算子を明示的にオーバーロードできるようになっており、パフォーマンスやメモリ割り当ての観点で新しい選択肢も増えています。Microsoft Learn+1

operator overloadは、単なる省略記法ではなく、型の意味を自然に表現するための設計機能です。演算子の意味が明確で、読み手にとって自然な場合に限定して使えば、C#のコードをより分かりやすく、ドメインに近い形で表現できます。