
C#においてクラスは参照型なのでEqualsや==では参照の等価性(参照が同一か)が評価されます。クラスを値の等価性(値が同一か)で評価したい場合は別の実装が必要になります。また、構造体は値型で値の等価性を評価しますがこの際 boxing が発生したりなどでパフォーマンスが落ちます。そのため構造体の場合もやはり別の実装をした方が良いこと多いです。
このようなとき必要となる実装をたまによく忘れるのでここに書いておくことにします。
レコードを使う
クラスに値の等価性が必要な場合はclassではなくrecord(またはrecord class)を使います。構造体の場合はstructではなくrecord structを使います。
public record Point(int x, int y);(完)
レコード便利ですね。ただ、レコードが導入されたのは C# 9 から(record structは C# 10 から)なので、昔ながらのプロジェクトでは使えないことが多そうです。
Visual Studio で自動実装する
レコード型使えない民はどうしたらよいのでしょうか。昔ながらの方法で実装が必要ですが、Visual Studio で自動実装すると楽です。ここでは Visual Studio 2022 を使用した場合を示します。例えば以下のクラスについて考えます。
public class Point
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
};このクラスで[クイック アクションとリファクタリング] > [Equals および GetHashCode を生成する]を選択します。すると以下のようなダイアログが表示されます。
まず、等価性の定義に用いるメンバーへチェックを入れます。今回の場合 X および Y いずれの値も一致しているときを"等しい"としたいためどちらにもチェックを入れています。次に、「IEquatable<T>を実装する」は文字通りの意味ですがこれは基本的にチェックを入れたら良いと思います。最後に、「演算子を生成する」も文字通りの意味ですがこれもたいていの場合(とくに immutable なクラスや構造体の場合)はチェックを入れて良いと思っています。チェックを入れなかった場合Equalsと==で結果が変わることになります。
以上を行うと以下のように自動実装されます。
public class Point : IEquatable<Point>
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
return Equals(obj as Point);
}
public bool Equals(Point other)
{
return other is not null &&
X == other.X &&
Y == other.Y;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
public static bool operator ==(Point left, Point right)
{
return EqualityComparer<Point>.Default.Equals(left, right);
}
public static bool operator !=(Point left, Point right)
{
return !(left == right);
}
}楽ちんですね。
(おまけ)自分で書く
自動実装できたものの何が実装されているのかよくわからないので自分でも書いてみます。例によって以下のクラスについて考えます。
public class Point
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
};IEquatable<T>の実装
まずは型固有のEqualsを定義するためIEquatable<T>を実装いてみます。
public class Point : IEquatable<Point>
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public bool Equals(Point other)
{
return other is not null &&
X == other.X &&
Y == other.Y;
}
};ここで実装したIEquatable<T>.EqualsはList<T>.Containsなどで等価性を評価する場合でも用いられるようになります。
Object.Equals のオーバーライド
型固有のEqualsは定義できましたが現状Object.Equalsは考慮されていません。たとえば、new Point(1, 2).Equals((object)new Point(1, 2))は現状では False となります。そこでObject.Equalsをオーバーライドします。
public class Point : IEquatable<Point>
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
return Equals(obj as Point);
}
public bool Equals(Point other)
{
return other is not null &&
X == other.X &&
Y == other.Y;
}
};これでIEquatable<T>.EqualsとObject.Equalsの動作が一致します。ところで、Object.EqualsがあるのでわざわざIEquatable<T>.Equalsは不要なのではと思えてきますが、型がつくし boxing もなくなるので基本的にIEquatable<T>.Equalsを実装したほう良さそうです。
Object.GetHashCode のオーバーライド
次にやらなければならないのがObject.GetHashCodeのオーバーライドです。これは等しい 2 つのオブジェクトは等しいハッシュコードを返す必要があるためです。現状ではDictionary<Point,TValue>なんかは意図した動作にならないです。
public class Point : IEquatable<Point>
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
return Equals(obj as Point);
}
public bool Equals(Point other)
{
return other is not null &&
X == other.X &&
Y == other.Y;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
};演算子のオーバーロード
最後に必要に応じて演算子==, !=をオーバーロードします。現状ではEqualsと==で動作が異なります。
public class Point : IEquatable<Point>
{
public int X {get;}
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
return Equals(obj as Point);
}
public bool Equals(Point other)
{
return other is not null &&
X == other.X &&
Y == other.Y;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
public static bool operator ==(Point left, Point right)
{
return EqualityComparer<Point>.Default.Equals(left, right);
}
public static bool operator !=(Point left, Point right)
{
return !(left == right);
}
}ちなみにここで出てくるEqualityComparer<T>.Default.Equalsは、 T がIEquatable<T>を実装していればIEquatable<T>.Equalsを使用し、それ以外はObject.Equalsを使用します。どこかで聞いたことがあるような仕組みですね。
これで Visual Studio で自動実装した実装になりました。長かった。
(余談)null チェックと演算子のオーバーロード
C#には null チェックを行う方法がいくつかあります。よく使われるのはhoge == nullのように演算子を利用する方法でしょうか。この方法には実は罠があって、それは今回のように演算子をオーバーロードしたときです。
演算子をオーバーロードしているときにhoge == nullと書いてしまうとオーバーロード先の処理が呼ばれてしまいます。オーバーロード先の処理が重かったりすると無駄に時間がかかることになります。さらにまずいのは以下のようにオーバーロードしてしまうことです。
...
public static bool operator ==(Point left, Point right)
{
return !(left == null) && left.Equals(right);
}
...個人的には思わず書いてしまいそうになるのですがこれはStackOverflowExceptionになります。ようするに無限ループになります。演算子の中で演算子を呼んでいるのでまあよく考えたら当然ですね。
個人的には単に null チェックをしたいだけなのであれば演算子は使わないほうが無難な気もしています。こういとき昔ながらの方法はReferenceEquals(hoge, null)になるんですがなんかちょっといけてない気がします。最近の C#はhoge is nullと書けて嬉しいです。