zzzkan.me

C#でクラスあるいは構造体に値の等価性を定義する

  • 7min
コードの図で付箋を示す笑みを浮かべて男の写真
Credit: Hitesh Choudhary

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 を生成する]を選択します。すると以下のようなダイアログが表示されます。

Visual Studio 2022スクリーンショット

まず、等価性の定義に用いるメンバーへチェックを入れます。今回の場合 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>.EqualsList<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>.EqualsObject.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#を使うべき!

参考

zzzkan
zzzkan

アルフォートは水色派です。

© 2023 zzzkan, Built with Gatsby