C# で演算子を実装する (6)

前回の記事では、論理演算子のオーバーロードについて説明しました。
今回は補足として、その他の注意点や設計について書いていきます。

タプル型との相互変換

C# 7.0 以降の機能で、ユーザー定義型でも Deconstruct メソッドを追加することにより、タプル型と同様に分解を利用できます (後付けの拡張メソッドでも可)。次のコードは、タプル型とのキャスト演算子と Deconstruct メソッドにより、ユーザー定義の構造体をタプル型に近い形で扱うことを目指した実装例です。

namespace OperatorsLib.Structs
{
public struct Vector2
{
public double X { get; }
public double Y { get; }
public Vector2(double x, double y) => (X, Y) = (x, y);
public void Deconstruct(out double x, out double y) => (x, y) = (X, Y);
public static implicit operator Vector2((double x, double y) v) => new Vector2(v.x, v.y);
public static explicit operator (double, double)(Vector2 v) => (v.X, v.Y);
}
}
view raw Vector2.cs hosted with ❤ by GitHub
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OperatorsLib.Structs;
namespace UnitTest.Structs
{
[TestClass]
public class Vector2Test
{
[TestMethod]
public void Cast()
{
Vector2 v = (3, 4);
var (a, b) = v;
var t = ((double c, double d))v;
Assert.AreEqual(v, (a, b));
Assert.AreEqual(v, t);
Assert.AreEqual((v.X, v.Y), (t.c, t.d));
}
}
}
view raw Vector2Test.cs hosted with ❤ by GitHub

KeyValuePair

(1) のタプル型など の補足となりますが、タプル型 (ValueTuple) が登場する以前は、2つの値を値型で保持するために KeyValuePair<TKey,TValue> 構造体を使うことがありました。この KeyValuePair<TKey,TValue> には等値演算子のオーバーロードも Equals メソッドのオーバーライドもないため、いちおう Equals メソッドで Key および Value に対する等価比較はできるもののパフォーマンスは最適化されていません。

なお、最近のバージョンの基本クラスライブラリでは KeyValuePair<TKey,TValue> に Deconstruct メソッドが追加されているため、
例えば Dictionary<TKey,TValue> の要素を列挙するときに次のように分解を利用できます。

[TestMethod]
public void KeyValuePair_Deconstruct()
{
var d = Enumerable.Range(1, 100).ToDictionary(i => i, i => i / 2.0);
foreach (var (i, value) in d)
Console.WriteLine($"{i} {value}");
}
view raw Vector0Test.cs hosted with ❤ by GitHub

ValueType のような抽象クラスを作る

(1) の記事で、構造体は暗黙的に ValueType クラスを継承するため最初から Equals メソッドでフィールドごとの等価性評価ができると書きました。
これのクラス版で、パフォーマンスは気にしないけど簡単な実装で等値演算を備えたクラスを実装したいという場合、
ValueType と同様にリフレクションでフィールドごとの等価性評価をする抽象クラスを次のコードで作ることができます。
なお、ValueType クラスを直接継承することはできません。

using System;
using System.Reflection;
namespace OperatorsLib.Classes
{
public abstract class EquatableObject
{
// ValueType と同様に、すべてのフィールドで等価性を評価します。
public override bool Equals(object obj)
{
var type = GetType();
if (type != obj?.GetType()) return false;
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
if (!Equals(field.GetValue(this), field.GetValue(obj))) return false;
return true;
}
public override int GetHashCode()
{
HashCode hc = default;
foreach (var field in GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
hc.Add(field.GetValue(this));
return hc.ToHashCode();
}
}
}
view raw EquatableObject.cs hosted with ❤ by GitHub
namespace OperatorsLib.Classes
{
public class VectorEq : EquatableObject
{
public double X { get; }
public double Y { get; }
public VectorEq(double x, double y) => (X, Y) = (x, y);
}
}
view raw VectorEq.cs hosted with ❤ by GitHub

構造体の既定のコンストラクター

主に (3) の記事への補足です。
構造体では引数付きのコンストラクターを追加しても、引数のない暗黙的な既定のコンストラクターの存在が残ります。
構造体の既定のコンストラクターは、既定値 default(T) を得るために使われます (クラスにおいては null に相当)。
開発者が明示的に既定のコンストラクターを宣言することはできません。

構造体では、この暗黙的な既定のコンストラクターが呼び出されるときはすべてのフィールドが型の既定値で初期化されます。
したがって、明示的なコンストラクターと暗黙的なコンストラクターの間で齟齬が発生しないように注意しなければなりません。
例えば、明示的なコンストラクターの中で、引数で渡された値を単純にフィールドに設定することの他に何らかの処理がある場合や、
0 や null などの既定値を不正 (invalid) な値として扱っている場合、
値が必要になったら計算してキャッシュさせるために -1 や NaN で初期化する場合などが該当します。

次のコードは Norm とAngle を初期化時に計算して設定している2次元ベクトルの例です。
すべてのプロパティの値が 0 でも問題なく使えることが期待されます。

using System;
namespace OperatorsLib.Structs
{
public struct VectorInit
{
public double X { get; }
public double Y { get; }
public double Norm { get; }
public double Angle { get; }
// ctor() と ctor(0, 0) の結果が同等になる必要があります。
public VectorInit(double x, double y)
{
(X, Y) = (x, y);
Norm = Math.Sqrt(X * X + Y * Y);
Angle = Math.Atan2(Y, X);
}
}
}
view raw VectorInit.cs hosted with ❤ by GitHub

演算子の優先順位

演算子が実行される優先順位は C# 演算子と式 (C# リファレンス) に載っています。
優先順位の低い演算を先に実行するには、丸括弧 () で囲みます。この表はなかなかすべて覚えられるものではないため、
優先順位が高くてもあまり馴染みのない演算の組合せの場合には丸括弧で囲むことがあります。
Visual Studio などのエディターに省略を推奨されるのであれば丸括弧を省略してよいでしょう (下図で丸括弧が灰色になっています)。
とくに省略を推奨されない (どちらでもよい) 組合せもあります。

VS-Precedence

前回: C# で演算子を実装する (5)

作成したサンプル

バージョン情報

  • C# 8.0
  • .NET Standard 2.1
  • .NET Core 3.1

参照

C# で演算子を実装する (5)

前回の記事では、キャスト演算子、インデクサーなどについて説明しました。
今回は論理演算子を扱います。

ユーザー定義型で直接オーバーロードできる論理演算子には、!, &, ^, | さらに true および false 演算子があります。
ただし整数型においては、&, ^, | はビット演算を表します。
また、否定 ! は、整数のビット演算においては補数 ~ に相当します。

論理演算については、bool 型や bool? 型の状態で扱えば実務上は事足りることが多く、ユーザー定義型でこれらの演算子をオーバーロードして使う機会はあまりないと思います。
そこで、今回は && および || 演算子による短絡評価 (ショート サーキット) を検証してみました。

短絡評価 (ショート サーキット)

ユーザー定義型で && および || 演算子による短絡評価を利用するには、次の条件を満たす必要があります。

  • & および | について、2つの引数と戻り値の型はすべて、定義元の型
  • true および false 演算子が宣言されている

そしてこのとき、x && y は x が偽を表すならば y には何もせず x を返し、そうでなければ x & y を評価します。
形式的に書くと T.false(x) ? x : (x & y) のようになります。
同様に、x || y は x が真を表すならば y には何もせず x を返し、そうでなければ x | y を評価します。
形式的に書くと T.true(x) ? x : (x | y) のようになります。

bool 型における短絡評価も上記の法則に従っていると見なすことができ、短絡評価の概念を bool 型以外に拡張したことになります。
なお、bool? 型では && および || 演算子を利用できません。

さて、上記の仕組みから考えると、短絡評価を適用できる論理体系とは、x と y のうち1つ以上が偽を表すのであれば x & y も偽を表し、x と y のうち1つ以上が真を表すのであれば x | y も真を表すものである必要があります。
これは、真・偽のほかに第3の値の存在を考える3値論理で体系を構成する場合、クリーネの3値論理に相当します。
bool? 型における論理演算 (!, &, ^, |) もこのクリーネの3値論理に従います。
3値論理については記事の後半で補足します。

実装例

今回の実装例では、与えられた文字列が真を表すのか、偽を表すのか、それ以外かの3値を判定する構造体を作成し、各論理演算子をオーバーロードしています。
以下にソースコードを示します。

namespace OperatorsLib.Structs
{
public struct StringBool
{
public static StringBool True { get; } = bool.TrueString;
public static StringBool False { get; } = bool.FalseString;
public static StringBool Unknown { get; } = null;
// bool? として持つこともできますが、この例ではあえて論理演算を自作します。
public string Value { get; }
public bool IsTrue => bool.TryParse(Value, out var b) && b;
public bool IsFalse => bool.TryParse(Value, out var b) && !b;
public bool IsUnknown => !bool.TryParse(Value, out var _);
public StringBool(string value) => Value = value;
public override string ToString() => Value ?? "Unknown";
public static implicit operator StringBool(string v) => new StringBool(v);
public static explicit operator bool?(StringBool v) => v.IsUnknown ? default(bool?) : v.IsTrue;
public static bool operator true(StringBool v) => v.IsTrue;
public static bool operator false(StringBool v) => v.IsFalse;
public static StringBool operator !(StringBool v) => v.IsUnknown ? Unknown : v.IsTrue ? False : True;
public static StringBool operator &(StringBool v1, StringBool v2) => v1.IsFalse || v2.IsFalse ? False : v1.IsUnknown || v2.IsUnknown ? Unknown : True;
public static StringBool operator ^(StringBool v1, StringBool v2) => v1.IsUnknown || v2.IsUnknown ? Unknown : v1.IsTrue ^ v2.IsTrue ? True : False;
public static StringBool operator |(StringBool v1, StringBool v2) => v1.IsTrue || v2.IsTrue ? True : v1.IsUnknown || v2.IsUnknown ? Unknown : False;
}
}
view raw StringBool.cs hosted with ❤ by GitHub

コンストラクターの処理で bool? に変換することもできますが、この例ではあえて Value プロパティで入力の文字列を保持することとし、論理演算を自作しています。上記のように実装することで、下記のコードのように短絡評価を使えるようになります。

[TestMethod]
public void Tables()
{
StringBool t = "true";
StringBool n = "force";
StringBool f = "false";
var s = new[] { t, n, f };
// 真理値表を作成します。
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
Console.Write($"{s[i] && s[j],-8}");
}
Console.WriteLine();
}
Console.WriteLine();
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
Console.Write($"{s[i] || s[j],-8}");
}
Console.WriteLine();
}
}
view raw StringBoolTest.cs hosted with ❤ by GitHub

このコードにより、下図のような真理値表が得られます。
短絡になったケースは小文字始まりで表示されています。

StringBool-Tables

クリーネの3値論理

情報技術で多く使われているクリーネの3値論理における、真でも偽でもない3つ目の値 (C# では bool? 型の null) とは、「真でも偽でもない、もう一つの異なる固定値」ではなく「本来は真または偽の値を持つが、現在はわかっていない状態」と考えたほうが意味論的には理解しやすいと思います。
(命題論理の考え方については、以前に命題論理を実装する (C#) という記事で書きました。)

そこで、null の代わりに unknown という名前を使うことにします。
具体例として、unknown & false は、左側のオペランドが実際に true とわかっても false とわかっても式全体では false であることが確定しています。また、unknown & true は、左側のオペランドが true とわかるか false とわかるかで式全体の結果が変わるため現在は unknown です。すると、x & y が現時点で true と確定するのは x と y がともに true のときだけです。
残りの !, ^, | についても同様に考えて体系を構成できます。

bool? 型における論理演算もこれと同じになります。
演算結果の真理値表はなかなか丸暗記できないと思いますが、上記のように短絡評価と関連付けることで導けるようになるはずです。

また、== および != 演算子を等価性ではなく命題論理における同値性として扱いたい場合、これらの演算子の戻り値の型を bool ではなく定義元の型とし、x == y!(x ^ y) と同じ意味に、x != yx ^ y と同じ意味になるようにオーバーロードします。

もし null を unknown ではなく「もう一つの異なる固定値」と考えて「一方が null ならば x & y は null」のような体系を立てる場合であっても、各論理演算子をオーバーロードすれば実現できるでしょう。ただし、短絡評価はできません。

制御条件式

true 演算子が宣言されていることで、if ステートメントなどにおける制御条件式として if (obj) のように使えます。
また、true 演算子の代わりに bool 型への暗黙的型変換を宣言することででも、制御条件式として使えるようになります。
両方を宣言した場合は、この暗黙的型変換が優先されるようです。

public static implicit operator bool(StringBool v) => v.IsTrue;

次回はその他の注意点や設計についてです。

前回: C# で演算子を実装する (4)
次回: C# で演算子を実装する (6)

作成したサンプル

バージョン情報

  • C# 8.0
  • .NET Standard 2.1
  • .NET Core 3.1

参照

C# で演算子を実装する (4)

前回の記事では、算術演算子のオーバーロードについて説明しました。
今回は例として、.NET の基本クラスライブラリにある BitArray クラスBitVector32 構造体のような、整数をビットの配列として扱えるものを実装します。キャスト演算子、インデクサー、インクリメント演算子 (算術演算子の一部) をオーバーロードしており、その他にもいろいろな観点が詰め込まれています。

まずはソースコードを示します。

using System;
using System.Diagnostics;
namespace OperatorsLib.Structs
{
// デバッグ時の表示を ToString と異なるものにしたい場合、[DebuggerDisplay] を追加します。
[DebuggerDisplay(@"\{{Value.ToString(""X8"")}\}")]
public struct BitArray
{
public int Value { get; private set; }
public bool this[int index]
{
get => (Value & (1 << index)) != 0;
set
{
if (value) Value |= 1 << index;
else Value &= ~(1 << index);
}
}
public bool this[Index index]
{
get => this[index.GetOffset(32)];
set => this[index.GetOffset(32)] = value;
}
public BitArray(int value) => Value = value;
public override string ToString() => Value.ToString();
public static BitArray Parse(string s) => int.Parse(s);
public static implicit operator BitArray(int v) => new BitArray(v);
public static explicit operator int(BitArray v) => v.Value;
public static BitArray operator ++(BitArray v) => v.Value + 1;
public static BitArray operator --(BitArray v) => v.Value - 1;
}
}
view raw BitArray.cs hosted with ❤ by GitHub

以下、それぞれについて説明していきます。

キャスト演算子

型変換 (キャスト) には暗黙的 (implicit) な型変換と明示的 (explicit) な型変換があり、ユーザー定義型でキャスト演算子を実装するときにいずれかを選べます。
基本的な方針として、情報の消失がない場合は暗黙的な型変換を実装可能である、と考えます。
ただし、一方が他方のインスタンスをラップしている関係の場合には、ラップする操作を暗黙的な型変換として、ラップを解除する操作を明示的な型変換として定義することが多いです。今回の例では、BitArray が int をラップしています。

Parse メソッド

ToString メソッドの逆の操作として、インスタンスの文字列表現からインスタンスを復元するための Parse メソッドを用意することがあります。Parse メソッドを実装する場合は、少なくとも ToString メソッドで得られた文字列をそのまま Parse メソッドで解析でき、元と同等のインスタンスが得られることが望ましいでしょう。

[TestMethod]
public void Parse()
{
BitArray b1 = 65535; // implicit conversion
Assert.AreEqual("65535", b1.ToString());
var b2 = BitArray.Parse(b1.ToString());
Assert.AreEqual(65535, (int)b2); // explicit conversion
Assert.AreEqual(b1, b2);
}
view raw BitArrayTest.cs hosted with ❤ by GitHub

インデクサー

this[] という特殊な形式のプロパティを実装することで、配列のように [] でアクセスできるようになります。
多次元配列と同様に、複数の引数を受け付けることもできます。

この例では setter でビットを書き換えています。
構造体は、基本的には不変 (immutable) な値として扱うことが多いのですが、値を書き換えることもできます。
このインデクサーの setter を実装することで、キーと値を指定するオブジェクト初期化子を利用できるようになります。
他にも、Add メソッドを定義することで、List のように値を追加するコレクション初期化子を利用できます (後付けの拡張メソッドでも可)。

また、C# 8.0 で Index および Range の機能が導入されたため、インデクサーのオーバーロードで Index 型を引数として使えるようにしています。これで b[^i] の形式により、逆順のインデックスでもアクセスできるようになります。
Range 型は、例えば数列の部分和を求めるというケースで使えるでしょう。

[TestMethod]
public void Indexer()
{
BitArray b = 10;
Assert.AreEqual(false, b[0]);
Assert.AreEqual(true, b[1]);
Assert.AreEqual(false, b[2]);
Assert.AreEqual(true, b[3]);
Assert.AreEqual(false, b[4]);
Assert.AreEqual(false, b[5]);
Assert.AreEqual(false, b[^27]); // Index
Assert.AreEqual(10, (int)b);
b[5] = true;
Assert.AreEqual(true, b[5]);
Assert.AreEqual(true, b[^27]); // Index
Assert.AreEqual(42, (int)b);
}
[TestMethod]
public void Initializer()
{
var b = new BitArray
{
[3] = true,
[6] = true,
};
Assert.AreEqual(72, b.Value);
}
view raw BitArrayTest.cs hosted with ❤ by GitHub

インクリメント演算子

インクリメント演算子 ++ およびデクリメント演算子 -- は、引数の型も戻り値の型も定義元と同じでなければなりません (派生型はOK)。

bit 全探索

以上のように実装すると、いわゆる bit 全探索のアルゴリズムが次のようなコードでできます。

[TestMethod]
public void BitSearch()
{
var n = 8;
var n2 = 1 << n; // 256
// bit 全探索
for (BitArray b = 0; b.Value < n2; b++)
{
for (int i = 0; i < n; i++)
{
// b[i] の真偽による何らかの処理
Console.Write(b[i] ? 1 : 0);
}
Console.WriteLine();
}
}
view raw BitArrayTest.cs hosted with ❤ by GitHub

BitSearch

なお、.NET の基本クラスライブラリの BitArray クラスでは new BitArray(new[] { x }) のようにコンストラクターで元の整数を配列で渡し、BitVector32 構造体ではインデクサーで b[i] ではなく b[1 << i] のようにマスクを表す整数を指定する、という違いがあります。

デバッグ時の表示

Visual Studio において、デバッグ時の [ローカル] ウィンドウや [ウォッチ] ウィンドウで値を表示するとき、既定では ToString メソッドを呼び出した結果が利用されます。これを ToString と異なるものにしたい場合、型に [DebuggerDisplay] 属性を追加します。
コンストラクターに指定する文字列では、{} の中にコードを記述することができます。
上の実装例では、整数を 16 進数形式で表示させています。

VS-Debug-Attribute

次回は論理演算子についてです。

前回: C# で演算子を実装する (3)
次回: C# で演算子を実装する (5)

作成したサンプル

バージョン情報

  • C# 8.0
  • .NET Standard 2.1
  • .NET Core 3.1

参照

C# で演算子を実装する (3)

前回の記事では、等値演算子と比較演算子のオーバーロードについて説明しました。
今回は算術演算子を扱います。算術演算子には、単項演算子 (+, -, ++, --) と二項演算子 (+, -, *, /, %) があります。

実装例 (構造体)

例として、2次元ベクトルを表す構造体を実装してみます。
等値演算子に加え、単項演算子 (+, -) と二項演算子 (+, -, *, /) をオーバーロードします。

using System;
namespace OperatorsLib.Structs
{
// デバッグ時の表示を ToString と異なるものにしたい場合、[DebuggerDisplay] を追加します。
public struct Vector : IEquatable<Vector>
{
public static Vector Zero { get; } = new Vector();
public static Vector UnitX { get; } = new Vector(1, 0);
public static Vector UnitY { get; } = new Vector(0, 1);
public double X { get; }
public double Y { get; }
public double Norm => Math.Sqrt(X * X + Y * Y);
public double Angle => Math.Atan2(Y, X);
public Vector(double x, double y) => (X, Y) = (x, y);
public override string ToString() => $"({X}, {Y})";
#region Equality Operators
public bool Equals(Vector other) => X == other.X && Y == other.Y;
public static bool operator ==(Vector v1, Vector v2) => v1.Equals(v2);
public static bool operator !=(Vector v1, Vector v2) => !v1.Equals(v2);
public override bool Equals(object obj) => obj is Vector v && Equals(v);
public override int GetHashCode() => HashCode.Combine(X, Y);
#endregion
#region Unary Operators
public static Vector operator +(Vector v) => v;
public static Vector operator -(Vector v) => new Vector(-v.X, -v.Y);
#endregion
#region Binary Operators
public static Vector operator +(Vector v1, Vector v2) => new Vector(v1.X + v2.X, v1.Y + v2.Y);
public static Vector operator -(Vector v1, Vector v2) => new Vector(v1.X - v2.X, v1.Y - v2.Y);
public static Vector operator *(double c, Vector v) => new Vector(v.X * c, v.Y * c);
public static Vector operator *(Vector v, double c) => new Vector(v.X * c, v.Y * c);
public static Vector operator /(Vector v, double c) => new Vector(v.X / c, v.Y / c);
// ドット積 (dot product)、内積 (inner product)
// 実際には静的メソッドとして定義することが多いです。
public static double operator *(Vector v1, Vector v2) => v1.X * v2.X + v1.Y * v2.Y;
#endregion
public static double Area(Vector v1, Vector v2) => Math.Abs(v1.X * v2.Y - v2.X * v1.Y) / 2;
}
}
view raw Vector.cs hosted with ❤ by GitHub
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OperatorsLib.Structs;
namespace UnitTest.Structs
{
[TestClass]
public class VectorTest
{
[TestMethod]
public void Arithmetic()
{
var v1 = new Vector(2, 2);
var v2 = new Vector(3, 4);
var v3 = new Vector(18, 24);
Assert.AreEqual(new Vector(5, 6), v1 + v2);
Assert.AreEqual(-new Vector(1, 2), v1 - v2);
Assert.AreEqual(new Vector(6, 8), 2 * v2);
Assert.AreEqual(new Vector(6, 8), v3 / 3);
Assert.AreEqual(14, v1 * v2);
}
}
}
view raw VectorTest.cs hosted with ❤ by GitHub

これで、ベクトルの基本的な四則演算が定義され、数式に近い記述ができるようになりました。
またこの例では、二次的な計算結果である Norm, Angle プロパティと、静的メソッド Area を追加しています。
インスタンスに付随する概念や演算子で表現することが難しい概念に対しては、このようにプロパティや静的メソッドで実装します。

単項演算子または二項演算子をオーバーロードするには、引数のいずれかに定義元の型 (ここでは Vector) が含まれていなければなりません。戻り値については、void 以外の型を自由に設定できます。

この例では、ベクトルのドット積 (内積) を * 演算子で定義しています。
しかし、戻り値が Vector である他の * 演算子とは意味が変わってしまうため、演算子ではなく静的メソッド (名前は Dot など) で定義することが多いです。(しかもベクトルには内積も外積もある、という事情もあります。)

また、数値を表す型で ^ 演算子を累乗として利用することも、同様の理由で推奨されていません。
C# の組込み型では ^ 演算子が累乗の意味で使われていないため、累乗は静的メソッド (名前は Pow または Power) として定義することが多いです。

演算子の戻り値の型を自由に設定することは原理上は可能であり、個人の範囲で利用する分にはかまわないと思うのですが、
他の開発者に公開するライブラリでは利用を避けます。
演算子として利用できる記号の種類は決まっており、しかも組込み型で既に意味を与えられているものばかりです。
コンパイラは引数の型の組合せから呼び出すべきメソッドを解決している、と考えれば、演算子の「オーバーロード」と呼ぶことに納得できるでしょう。

クラスとして実装する場合

構造体 (値型) の場合との大きな違いとして、クラス (参照型) の場合は引数で渡されるオブジェクトが null 値である可能性を考慮しなければなりません。このため、数式のように演算子を多くオーバーロードする型を実装するときは構造体にしたほうが簡潔なのですが、例えば継承を利用するためにクラスとして実装したい、というケースもあります。
実装やテストを少し簡単にするために、null 値を new Vector() と同一視する方針も考えられます。

デバッグ時の表示

Visual Studio において、デバッグ時の [ローカル] ウィンドウや [ウォッチ] ウィンドウで値を表示するとき、既定では ToString メソッドを呼び出した結果が利用されます。したがって、ToString メソッドをオーバーライドしていない場合は型名が表示されますが、オーバーライドしておくことで、下図のようにそのオブジェクトを表現する文字列を表示させることができます。

VS-Debug-ToString

このデバッグ時の表示を、ToString メソッドの結果以外のものに設定する方法については次回で紹介します。

次回はキャスト演算子、インデクサーなどについてです。

前回: C# で演算子を実装する (2)
次回: C# で演算子を実装する (4)

作成したサンプル

バージョン情報

  • C# 8.0
  • .NET Standard 2.1
  • .NET Core 3.1

参照

C# で演算子を実装する (2)

ユーザー定義型で演算子を実装することを「演算子をオーバーロードする」と呼びます。
オーバーロードできる演算子の一覧は、演算子のオーバーロード (C# リファレンス) に記載されています。

今回は、実装の頻度が高く、かつ最もややこしい等値演算子 (==, !=) と比較演算子 (<, >, <=, >=) を扱います。

インターフェイスの実装について

等値演算子をオーバーロードするときは、IEquatable<T> インターフェイスを実装します。
また、比較演算子をオーバーロードするときは、IComparable<T> インターフェイスを実装します。

原理上はこれらのインターフェイスを実装しなくても演算子のオーバーロードはできるのですが、これらのジェネリックなインターフェイスを実装することで、各種のライブラリ (Array.IndexOf<T>, Array.Sort<T> など) からは型変換を伴わずに Equals(T) メソッドおよび CompareTo(T) メソッドが呼び出されます。構造体であれば値のボックス化を避けることができます。

これは、各種のライブラリが EqualityComparer<T>.Default および Comparer<T>.Default を通じて各インターフェイスにアクセスする仕組みによるものです。
逆に、自身が型引数 T に対して等値演算・比較演算を呼び出すライブラリを作る立場のときは、
EqualityComparer<T>.Default および Comparer<T>.Default を利用しましょう。

また、各種ライブラリからの等値演算・比較演算さえできればよいというケース (ソートに使うだけ、など) では、
演算子をオーバーロードせずにインターフェイスを実装するだけで済ませる、という選択肢もあります。

実装例 (構造体)

まず、構造体で等値演算子と比較演算子をオーバーロードする実装例を示します。
この構造体は、string 型と int 型のプロパティを持ちます。

using System;
namespace OperatorsLib.Structs
{
public struct Title : IEquatable<Title>, IComparable<Title>
{
public string Name { get; }
public int Number { get; }
public Title(string name, int number) => (Name, Number) = (name, number);
public override string ToString() => $"{Name} #{Number}";
#region Equality Operators
public bool Equals(Title other) => Name == other.Name && Number == other.Number;
public static bool operator ==(Title v1, Title v2) => v1.Equals(v2);
public static bool operator !=(Title v1, Title v2) => !v1.Equals(v2);
public override bool Equals(object obj) => obj is Title v && Equals(v);
public override int GetHashCode() => HashCode.Combine(Name, Number);
// HashCode.Combine を利用できない場合
//public override int GetHashCode() => (Name, Number).GetHashCode();
#endregion
#region Comparison Operators
public int CompareTo(Title other)
{
// 参照型の場合は null 値があるため、静的メソッドが実装されることも多いです。
var c1 = string.Compare(Name, other.Name);
if (c1 != 0) return c1;
return Number.CompareTo(other.Number);
}
public static bool operator <(Title v1, Title v2) => v1.CompareTo(v2) < 0;
public static bool operator >(Title v1, Title v2) => v1.CompareTo(v2) > 0;
public static bool operator <=(Title v1, Title v2) => v1.CompareTo(v2) <= 0;
public static bool operator >=(Title v1, Title v2) => v1.CompareTo(v2) >= 0;
#endregion
}
}
view raw Title.cs hosted with ❤ by GitHub

== および != 演算子をオーバーロードするときは、Equals(object) および GetHashCode メソッドもオーバーライドします。
これらをオーバーライドしないと、警告が出ます (エラーではない)。

ここでは HashCode.Combine メソッドを利用していますが、これは比較的新しく、.NET Standard 2.1 には含まれ、.NET Standard 2.0 には含まれていません。もし HashCode.Combine メソッドを利用できない環境であれば、ValueTuple や Tuple を構成してその GetHashCode メソッドを呼び出すとよいでしょう。

さて、実装しなければならないメソッドがいくつもあるのですが、本題である各プロパティに対する等価性評価をいずれかの一か所で実装して、他からそれを呼び出す形にすればよいです。
ここではインターフェイスを実装する Equals(T) メソッドの中に実際の処理を一元的に記述しています。

いずれの場所に実装しても、処理が本質的に同じであれば問題ないでしょう。
例えば、対称性や読みやすさを重視して、静的メソッドや == 演算子に実装するという考え方もあります。
ただし、Equals(object) メソッドで一元的に実装するのは、ボックス化が発生するため避けます。

比較演算についても同様に CompareTo(T) メソッドの中に実際の処理を記述し、各比較演算子からこれを呼び出しています。

実装例 (クラス)

クラスでも同様に、等値演算子と比較演算子をオーバーロードする実装例を示します。

using System;
namespace OperatorsLib.Classes
{
public class Title : IEquatable<Title>, IComparable<Title>
{
public string Name { get; }
public int Number { get; }
public Title(string name, int number) => (Name, Number) = (name, number);
public override string ToString() => $"{Name} #{Number}";
#region Equality Operators
// other != null では無限ループ。
public bool Equals(Title other) => !(other is null) && Name == other.Name && Number == other.Number;
// 参照型の場合は null 値があるため、静的メソッドが実装されることも多いです。
public static bool Equals(Title v1, Title v2) => v1?.Equals(v2) ?? (v2 is null);
public static bool operator ==(Title v1, Title v2) => Equals(v1, v2);
public static bool operator !=(Title v1, Title v2) => !Equals(v1, v2);
public override bool Equals(object obj) => Equals(obj as Title);
public override int GetHashCode() => HashCode.Combine(Name, Number);
#endregion
#region Comparison Operators
public int CompareTo(Title other)
{
if (other is null) return 1;
var c1 = string.Compare(Name, other.Name);
if (c1 != 0) return c1;
return Number.CompareTo(other.Number);
}
// 参照型の場合は null 値があるため、静的メソッドが実装されることも多いです。
public static int Compare(Title v1, Title v2) => v1?.CompareTo(v2) ?? (v2 is null ? 0 : -1);
public static bool operator <(Title v1, Title v2) => Compare(v1, v2) < 0;
public static bool operator >(Title v1, Title v2) => Compare(v1, v2) > 0;
public static bool operator <=(Title v1, Title v2) => Compare(v1, v2) <= 0;
public static bool operator >=(Title v1, Title v2) => Compare(v1, v2) >= 0;
#endregion
}
}
view raw Title.cs hosted with ❤ by GitHub

クラス (参照型) の場合は null 値を考慮するため、構造体 (値型) のコードよりも複雑になっています。

とくに等値演算子の実装をするときは、循環参照に注意しなければなりません。
例えば、このコードにある !(other is null)other != null としてしまうと無限ループです。
プログラミングに慣れているつもりでも陥りがちですが、このような実装ミスを予防するハックとしては、
定義する順に記述する (上に書いたメンバーから下に書いたメンバーを呼び出さない) などの方法があります。

クラスでは初めから等値演算子が参照の等価比較として定義されているため、Equals メソッドをオーバーライドするのであれば同時に等値演算子もオーバーロードします。
基本的な方針として、各プロパティの値が不変 (immutable) であり、参照ではなくその値であることに意味がある場合に限り、Equals メソッドをオーバーライドして等値演算子をオーバーロードします。逆に、値としての等価性が期待されておらず、等値演算が参照の等価比較を表すことが望ましい場合にはこれらの実装を追加しません。

Visual Studio による自動生成

Visual Studio には等値演算に関するコードを自動生成する機能があります。
型名のところで [Ctrl + .] を押し、[Equals および GetHashCode を生成する] を選択して利用できます。

VS-Equality-1

VS-Equality-2

すると、だいたい上記の実装例のようなコードが生成されます。構造体とクラスのパターンがあります。
実装が面倒な場合はこの機能を使うのもよいでしょう。

次回は算術演算子のオーバーロードについてです。

前回: C# で演算子を実装する (1)
次回: C# で演算子を実装する (3)

作成したサンプル

バージョン情報

  • C# 8.0
  • .NET Standard 2.1
  • .NET Core 3.1

参照