C#においてクラスは参照型なのでEquals
や==
では参照の等価性(参照が同一か)が評価されます。クラスを値の等価性(値が同一か)で評価したい場合は別の実装が必要になります。また、構造体は値型で値の等価性を評価しますがこの際 boxing が発生したりなどでパフォーマンスが落ちます。そのため構造体の場合もやはり別の実装をした方が良いこと多いです。
このようなとき必要となる実装をたまによく忘れるのでここに書いておくことにします。
レコードを使う
クラスに値の等価性が必要な場合はclass
ではなくrecord
(またはrecord class
)を使います。構造体 の場合はstruct
ではなくrecord struct
を使います。
public record Point(int x, int y);
(完)
レコード便利ですね。ただ、レコードが導入されたのは C# 9 から(record struct
は C# 10 かから)で、 C# 9 は.NET 5 以降が必要です。そのため、たとえば.NET Framework を使っているプロジェクトでは基本的にレコードは使えません。悲しい。
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
とスマートに書けます。
やっぱりレコードもあってパターンマッチングもある最新の C#を使うべき!