うどんてっくメモ

技術的なメモをまったりと

【C#】IEquatableの実装をSource Generatorで生やす

Generator.Equals

C#には等価性評価を行うIEquatableがありますが、大抵の実装はメンバのパラメータでの等価性評価を統合したようなものになり、いわばテンプレートのような実装になりがちです。 そこで、実装を自動生成したい!となった時に便利なのがGenerator.Equalsです。Source Generatorを活用して、IEquatableの実装をよしなに生やしてくれます。

github.com

今回は簡単なこちらの使い方を紹介します。

使い方

導入自体はNuget経由で導入可能です。Visual StudioなどのIDEを使用している場合はサクッと入れられます。

Unityのプロジェクトなどの場合はパッケージを直接入れちゃうパターンもあるかと思います。

www.nuget.org

使い方はシンプルで、Attributeで制御するだけです。 対象のclassやstructについてpartialにし、Attributeを次のようにつけます。

// partialにする
[Equatable]
public partial class Sample
{
    public int Number { get; set; }
    public string Id { get; set; }
    public float Value { get; set; }
}

public class SampleTest
{
    public void Test()
    {
        var hoge = new Sample
        {
            Number = 0,
            Id = "hoge",
            Value = 0
        };
        var fuga = new Sample
        {
            Number = 0,
            Id = "fuga",
            Value = 0
        };
        // a == false
        var a = hoge.Equals(fuga);
        Console.WriteLine(a);
        
        // b == true
        hoge.Id = "fuga";
        var b = hoge.Equals(fuga);
        Console.WriteLine(b);
    }
}

非常にシンプルにIEquatableの実装が実現できます。 このEqualsの実装として生えている実装を見に行ってみます。

partial class Sample : global::System.IEquatable<Sample>
{
    /// <summary>
    /// Indicates whether the object on the left is equal to the object on the right.
    /// </summary>
    /// <param name="left">The left object</param>
    /// <param name="right">The right object</param>
    /// <returns>true if the objects are equal; otherwise, false.</returns>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public static bool operator ==(
        global::Plugins.SourceGenerator.Sample? left,
        global::Plugins.SourceGenerator.Sample? right) =>
        global::Generator.Equals.DefaultEqualityComparer<global::Plugins.SourceGenerator.Sample?>.Default
            .Equals(left, right);
    
    /// <summary>
    /// Indicates whether the object on the left is not equal to the object on the right.
    /// </summary>
    /// <param name="left">The left object</param>
    /// <param name="right">The right object</param>
    /// <returns>true if the objects are not equal; otherwise, false.</returns>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public static bool operator !=(global::Plugins.SourceGenerator.Sample? left, global::Plugins.SourceGenerator.Sample? right) =>
        !(left == right);
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public override bool Equals(object? obj) =>
        Equals(obj as global::Plugins.SourceGenerator.Sample);
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public bool Equals(global::Plugins.SourceGenerator.Sample? other)
    {
        return
            !ReferenceEquals(other, null) && this.GetType() == other.GetType()
            && global::Generator.Equals.DefaultEqualityComparer<global::System.Int32>.Default.Equals(this.Number!, other.Number!)
            && global::Generator.Equals.DefaultEqualityComparer<global::System.String>.Default.Equals(this.Id!, other.Id!)
            && global::Generator.Equals.DefaultEqualityComparer<global::System.Single>.Default.Equals(this.Value!, other.Value!)
            ;
    }
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public override int GetHashCode()
    {
        var hashCode = new global::System.HashCode();
        
        hashCode.Add(this.GetType());
        hashCode.Add(
            this.Number!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.Int32>.Default);
        hashCode.Add(
            this.Id!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.String>.Default);
        hashCode.Add(
            this.Value!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.Single>.Default);
        
        return hashCode.ToHashCode();
    }
}

EqualsやGetHashCodeがメンバに沿って生成されています。Generator.Equalsでは等価性の評価に組み込むメンバを調整することも可能です。 たとえば無視したいメンバにはIgnoreEqualityをつけることで実現できます。

[Equatable]
public partial class Sample
{
    // 評価に入らない
    [IgnoreEquality]
    public int Number { get; set; }
    public string Id { get; set; }
    public float Value { get; set; }
}

コレクション系統のclassでは内部配列の中身で等価性の評価が求められることもありますが、そう言った需要にも応えられるようになっています。

[Equatable]
public partial class Sample
{
    // SequenceEqualで評価
    [OrderedEquality]
    public int[] Numbers { get; set; }
    // 順序を問わない等価性評価、要素数でチェックする
    [UnorderedEquality]
    public string[] Labels { get; set; }
    // ISet.SetEqualsを利用した等価性評価
    [SetEquality]
    public HashSet<string> LabelSet { get; set; }
}

自前実装のEqualityComparerもAttributeの引数で設定できるようになっています。

[CustomEquality(typeof(CustomEqualityComparer))] 
public string Name1 { get; set; }

内部実装

内部的にはIEqualityComparerをそれぞれの設定に沿って実装し、それを使うようなソースコードを生成しています。 たとえば何もAttributeをつけないメンバではDefaultEqualityComparerによるロジックが適用されています。

public class DefaultEqualityComparer<T> : IEqualityComparer<T>
{
    private static readonly IEqualityComparer<T> _underlying;

    public static DefaultEqualityComparer<T> Default { get; } = new DefaultEqualityComparer<T>();

    static DefaultEqualityComparer()
    {
        if (typeof (T).IsSealed)
        DefaultEqualityComparer<T>._underlying = (IEqualityComparer<T>) EqualityComparer<T>.Default;
        else
        DefaultEqualityComparer<T>._underlying = (IEqualityComparer<T>) new DefaultEqualityComparer<T>.ObjectEqualityComparer();
    }

    public bool Equals(T x, T y) => DefaultEqualityComparer<T>._underlying.Equals(x, y);

    public int GetHashCode(T obj) => DefaultEqualityComparer<T>._underlying.GetHashCode(obj);

    private class ObjectEqualityComparer : IEqualityComparer<T>
    {
        public bool Equals(T x, T y) => object.Equals((object) x, (object) y);

        public int GetHashCode(T obj) => (object) obj == null ? 0 : obj.GetHashCode();
    }
}

対処のメンバを列挙し、適切な実装をひとつずつ実行します

// すべてのメンバに対するEqualsの生成処理の抜粋
public static void BuildMembersEquality(
    ITypeSymbol symbol,
    AttributesMetadata attributesMetadata,
    IndentedTextWriter writer,
    bool explicitMode,
    Predicate<ISymbol>? filter = null)
{
    foreach (ISymbol propertiesAndField in symbol.GetPropertiesAndFields())
    {
        if (filter == null || filter(propertiesAndField))
        {
            switch (propertiesAndField)
            {
                case IPropertySymbol memberSymbol1:
                    EqualityGeneratorBase.BuildEquality(attributesMetadata, writer, (ISymbol) memberSymbol1, memberSymbol1.Type, explicitMode);
                    break;
                case IFieldSymbol memberSymbol2:
                    EqualityGeneratorBase.BuildEquality(attributesMetadata, writer, (ISymbol) memberSymbol2, memberSymbol2.Type, explicitMode);
                    break;
                default:
                    DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(29, 1);
                    interpolatedStringHandler.AppendLiteral("Member of type ");
                    interpolatedStringHandler.AppendFormatted<Type>(propertiesAndField.GetType());
                    interpolatedStringHandler.AppendLiteral(" not supported");
                    throw new NotSupportedException(interpolatedStringHandler.ToStringAndClear());
            }
        }
    }
}

// 一つ一つの生成処理部分の抜粋
private static void BuildEquality(
    AttributesMetadata attributesMetadata,
    IndentedTextWriter writer,
    ISymbol memberSymbol,
    ITypeSymbol typeSymbol,
    bool explicitMode)
{
    // ..中略 各種Attributeでの実装
    
    // DefaultEqualityComparerに対する実装部分 
    else
    {
        if (!memberSymbol.HasAttribute(attributesMetadata.DefaultEquality) && (explicitMode || !(memberSymbol is IPropertySymbol)))
            return;
        IndentedTextWriter indentedTextWriter = writer;
        DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(85, 3);
        interpolatedStringHandler.AppendLiteral("&& global::Generator.Equals.DefaultEqualityComparer<");
        interpolatedStringHandler.AppendFormatted(nullableFqf);
        interpolatedStringHandler.AppendLiteral(">.Default.Equals(this.");
        interpolatedStringHandler.AppendFormatted(fqf);
        interpolatedStringHandler.AppendLiteral("!, other.");
        interpolatedStringHandler.AppendFormatted(fqf);
        interpolatedStringHandler.AppendLiteral("!)");
        string stringAndClear = interpolatedStringHandler.ToStringAndClear();
        indentedTextWriter.WriteLine(stringAndClear);
    }
}

自前でSource Generatorの実装を行う際に、IEquatableのようにテンプレートのような実装になるinterfaceの生成などで参考になりそうです。