C# オブジェクトを値で比較する方法

こんにちは!トミセンです。

オブジェクトを比較するにはSystem.Object.ReferenceEquals()System.Object.Equals()が使われますが、

「C#でオブジェクトを値で比較するのはどうやるの?」
「C#って、簡単に値で比較する方法ないの?」

オブジェクトを比較する方法は様々あって、どれを選べばいいのか迷ってしまいがちです。
また、簡単に出来そうなソースを探してきても、おかしな結果になって手間取ってしまうこともありますよね。

今回はそんなお悩みを解決します。
ずばり!「C# オブジェクトを値で比較する方法」についてご紹介します。

それでは一緒に学んでいきましょう!

この記事の内容
  • オブジェクトを比較するときの問題点
  • オブジェクトを値で比較する方法
目次

オブジェクトを比較するときの問題点

一般的なオブジェクトの比較方法を確認しておきます。

Object.ReferenceEquals()

  • 参照先の比較なので、異なったインスタンスは常にFalse と判定される。
    別々にnewしたものやDeepCopyした場合には使えない。

Object.Equals()

  • 参照型は参照で比較し、値型は値で比較するわけでもない。

となっていて、Object.Equals()の挙動はいまいちよくわからないし、Object.ReferenceEquals()だと、参照先の比較になってしまいます。

newして別々に生成したオブジェクトやDeepCopyしたオブジェクトを値の一致で判定したい場合は、うまくいきません。

ではどうしたらいいのか? 次で説明していきます。

オブジェクトを値で比較する方法

それでは、さっそくオブジェクトを値で比較する方法を紹介します。

結論から言うと、自分でロジックを作るしかありません。

さっそく作ってみました。次のソースを見てください。

using System;
using System.Linq;
using System.Reflection;

public class Test
{
    public void Main()
    {
        var src = new Member { Name = "佐藤", Amount = 1m, UpdateAt = DateTime.Parse("2020/5/5"), UpdateBy = "USER" };
        var dest = new Member { Name = "佐藤", Amount = 1.0000m, UpdateAt = DateTime.Parse("2020/11/11"), UpdateBy = "EMP" };

        Console.WriteLine($@"srcMember   CreateAt:{src.UpdateAt}");
        Console.WriteLine($@"destMember  CreateAt:{dest.UpdateAt}");
		
        // オブジェクトを値で比較
        var result = ObjectCompareHelper.Compare(src, dest, nameof(Member.UpdateBy));

        Console.WriteLine($@"result:{result}");
    }
}


/// <summary>
/// データクラス
/// </summary>
public class Member
{
    /// <summary>名前</summary>
    public string Name { get; set; }

    /// <summary>金額</summary>
    public decimal Amount { get; set; }
    
    /// <summary>更新日時</summary>
    public DateTime UpdateAt { get; set; }

    /// <summary>更新者</summary>
    public string UpdateBy { get; set; }
}


/// <summary>
/// オブジェクト内のプロパティの値が同じか比較する
/// </summary>
public class ObjectCompareHelper
{
    /// <summary>
    /// オブジェクト内のプロパティの値が同じか比較する
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="object1"></param>
    /// <param name="object2"></param>
    /// <param name="exclusion">比較除外プロパティ名</param>
    /// <returns></returns>
    public static bool Compare<T>(T object1, T object2, params string[] exclusion)
    {
        Type type = typeof(T);

        if (object.Equals(object1, default(T)) || object.Equals(object2, default(T)))
        {
            return false;
        }

        foreach (PropertyInfo property in type.GetProperties().Where(x => !exclusion.Contains(x.Name)))
        {
            switch (property.Name)
            {
                case "ExtensionData":
                case "UpdateAt":
                    continue;
            }

            // テスト用ログ
            Console.WriteLine($@"property.Name:{property.Name}");

            var value1 = NormalizedValue(type, property, object1);
            var value2 = NormalizedValue(type, property, object2);
            if (value1 != value2)
            {
                // テスト用ログ
                Console.WriteLine($@"【差分】  class:{type.Name}  Property:{property.Name}  値1:{value1}  値2:{value2}");
                return false;
            }
        }
        return true;
    }

    /// <summary>
    /// 整形した値を取得
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="type"></param>
    /// <param name="property"></param>
    /// <param name="obj"></param>
    /// <returns></returns>
    private static string NormalizedValue<T>(Type type, PropertyInfo property, T obj)
    {
        var value = type.GetProperty(property.Name).GetValue(obj, null);
        if (value == null)
        {
            return string.Empty;
        }

        var propType = type.GetProperty(property.Name);
        if (propType.PropertyType == typeof(decimal))
        {
            // decimalのTrailing Zero(.0000)除去
            return ((decimal)value).Normalize().ToString();
        }
        return value.ToString().Trim();
    }
}


public static class DecimalExtensions
{
    /// <summary>
    /// decimalのTrailing Zero(.0000)除去
    ///  1.0000 => 1、1.444 => 1.444
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public static decimal Normalize(this decimal value)
    {
        //valueはTrailing Zero付の数値
        return value / 1.000000000m;
    }
}

ソースコードだけでは分かりにくい部分があるので、いくつかのポイントをコードを見ながら解説していきます!

最初は、テスト用データ用のクラスを見ていきます。

/// <summary>
/// データクラス
/// </summary>
public class Member
{
    /// <summary>名前</summary>
    public string Name { get; set; }

    /// <summary>金額</summary>
    public decimal Amount { get; set; }
    
    /// <summary>更新日時</summary>
    public DateTime UpdateAt { get; set; }

    /// <summary>更新者</summary>
    public string UpdateBy { get; set; }
}

このクラスは、Entityクラスのイメージです。

名前や金額などの項目と、更新日時や更新者などのテーブルにもあるような共通の項目があるEntityです。

通常比較するのは名前や金額などの部分だけで、更新日時や更新者などは比較対象にならないですよね。

では、次にテスト用のデータを作っていきます。

var src = new Member { Name = "佐藤", Amount = 1m, UpdateAt = DateTime.Parse("2020/5/5"), UpdateBy = "USER" };
var dest = new Member { Name = "佐藤", Amount = 1.0000m, UpdateAt = DateTime.Parse("2020/11/11"), UpdateBy = "EMP" };

Console.WriteLine($@"srcMember   CreateAt:{src.UpdateAt}");
Console.WriteLine($@"destMember  CreateAt:{dest.UpdateAt}");
		
// 【実行結果】
// srcMember   CreateAt:2020/05/05 0:00:00
// destMember  CreateAt:2020/11/11 0:00:00

それぞれの項目を見ていきます。Nameは、"佐藤"で一緒です。Amountは、「1」と「1.0000」にしています。

同じ値の意味ですが、decimal型はToString()すると「1」が"1"になる場合と"1.0000"となる場合があるので、その検証するために変えています。

共通の項目の部分です。UpdateAtUpdateByも、値を変えています。比較除外対象のテストをしたいのでそのために差をつけています。

念のためログ出力してみるとUpdateAtが違っていることが確認できています。

実際の比較メソッドを見ていきましょう。次のソースを見てください。

    /// <summary>
    /// オブジェクト内のプロパティの値が同じか比較する
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="object1"></param>
    /// <param name="object2"></param>
    /// <param name="exclusion">比較除外プロパティ名</param>
    /// <returns></returns>
    public static bool Compare<T>(T object1, T object2, params string[] exclusion)
    {

このメソッドは、同じ型のオブジェクトを比較する前提です。

引数のT object1, T object2に比較したいオブジェクトを渡します。List<T>型には対応していません。

※List<T>型を比較したい場合は、ご自身で拡張されるか、トミセンが気が向くのを待ってください。

また、params string[] exclusionの部分は比較対象から除外したい項目を指定します。画面の項目やデータ項目ではないもの引数で渡したクラスに存在する場合は指定してください。

可変長引数になっているので、除外したい項目がない場合は、T object1, T object2の2つを渡して、params string[] exclusionは無視して大丈夫です。

クラスの項目を実際に比較している箇所に移っていきます。

foreach (PropertyInfo property in type.GetProperties().Where(x => !exclusion.Contains(x.Name)))
{
}

foreachでクラスのプロパティを回して比較していきます。.Where(x => !exclusion.Contains(x.Name)))の部分で、先ほどparams string[] exclusionで指定した値を除外された状態でループが回ります。

次からはループ内でプロパティを比較する処理です。

switch (property.Name)
{
    case "ExtensionData":
    case "UpdateAt":
        continue;
}

ここでも比較対象から除外する判定をしています。

params string[] exclusionがクラス特有の項目の除外だとすると、ここではこの処理をするときに共通で除外したい項目の設定になります。

共通で除外したい項目は、case "UpdateAt":のようケース文を追加しておくことで、continue;して値の比較処理をスキップします。

次はテスト用にログを出力している箇所です。

// テスト用ログ
Console.WriteLine($@"property.Name:{property.Name}");

// 【実行結果】
// property.Name:Name
// property.Name:Amount

除外されないで比較処理までされているのは、NameAmountの2つだけでした。

それ以外の項目は.Where(x => !exclusion.Contains(x.Name)))case "UpdateAt":でしっかり除外できていることが確認できました。

それでは、プロパティの値の比較している箇所です。

var value1 = NormalizedValue(type, property, object1);
var value2 = NormalizedValue(type, property, object2);
if (value1 != value2)
{
    // テスト用ログ
    Console.WriteLine($@"【差分】  class:{type.Name}  Property:{property.Name}  値1:{value1}  値2:{value2}");
    return false;
}

プロパティの値を取得してくる箇所です。

このvar value1 = NormalizedValue(type, property, object1);でプロパティの値を取得しています。

取得した値をif (value1 != value2)で文字列で値を比較して、一致しない場合は、falseを返します。

使っているメソッドのNormalizedValueについては、次で説明します。

/// <summary>
/// 整形した値を取得
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="type"></param>
/// <param name="property"></param>
/// <param name="obj"></param>
/// <returns></returns>
private static string NormalizedValue<T>(Type type, PropertyInfo property, T obj)
{
    var value = type.GetProperty(property.Name).GetValue(obj, null);
    if (value == null)
    {
        return string.Empty;
    }

    var propType = type.GetProperty(property.Name);
    if (propType.PropertyType == typeof(decimal))
    {
        // decimalのTrailing Zero(.0000)除去
        return ((decimal)value).Normalize().ToString();
    }
    return value.ToString().Trim();
}

取得した値がnullの場合は、string.Emptyを返します。

decimal型の場合だけ、((decimal)value).Normalize().ToString();で、Trailing Zeroと呼ばれる、文字列に".0000"と付いてしまう現象の考慮を入れています。

この.Normalize()の部分は拡張メソッドです。内容は次のソースを見てください。

public static class DecimalExtensions
{
    /// <summary>
    /// decimalのTrailing Zero(.0000)除去
    ///  1.0000 => 1、1.444 => 1.444
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public static decimal Normalize(this decimal value)
    {
        //valueはTrailing Zero付の数値
        return value / 1.000000000m;
    }
}

Trailing Zeroを除去する拡張メソッドです。

具体的には、1.0000.0000は除外しますが、1.444のような場合の.444は除外されませんので安心してください。

以上が実際の比較メソッドの中身になります。

最後に、メソッドの使い方を見ていきます。

// オブジェクトを値で比較
var result = ObjectCompareHelper.Compare(src, dest, nameof(Member.UpdateBy));

Console.WriteLine($@"result:{result}");

// 【実行結果】
// result:True

引数として比較したいオブジェクトのsrcdestを渡しています。

除外項目としてnameof(Member.UpdateBy)を渡しています。"UpdateBy"と書いているのと同じですが、nameof()で書くことで、項目がクラスから削除されたときにコンパイルエラーになります。

ちょっとしたことですが、バグの可能性を減らすテクニックのひとつです。

そもそもUpdateByの項目もEntityの共通的な項目で、case "UpdateAt":と同じように比較メソッドのcase文で処理すべき内容ですが、今回は説明のために別のやり方で除外してみました。

比較結果を振り返ってみます。

  • Name:一致
  • Amount:一致 ※.0000が除外されるので
  • UpdateAt:一致 ※比較除外されるので
  • UpdateBy:一致 ※比較除外されるので

となるので、結果はTrueが返ります。

念のため、結果がFalseになるようなテストデータも試してみます。

データ作成箇所だけ変更してみます。

public class Test2
{
    public void Main()
    {
        var src = new Member { Name = "佐藤", Amount = 2m, UpdateAt = DateTime.Parse("2020/5/5"), UpdateBy = "USER" };
        var dest = new Member { Name = "佐藤", Amount = 1.0000m, UpdateAt = DateTime.Parse("2020/11/11"), UpdateBy = "EMP" };

        Console.WriteLine($@"srcMember   CreateAt:{src.UpdateAt}");
        Console.WriteLine($@"destMember  CreateAt:{dest.UpdateAt}");

        // オブジェクトを値で比較
        var result = ObjectCompareHelper.Compare(src, dest, nameof(Member.UpdateBy));

        Console.WriteLine($@"result:{result}");
    }
}

// 【実行結果】
// srcMember   CreateAt:2020/05/05 0:00:00
// destMember  CreateAt:2020/11/11 0:00:00
// property.Name:Name
// property.Name:Amount
// 【差分】  class:Member  Property:Amount  値1:2  値2:1
// result:False

変更箇所ですが、Amountを「2」にしています。

Amountの21.0000が比較されるので、結果はFalseが返ります。

これで、「C#でオブジェクトを値で比較する」ことができました!

まとめ

画面に遷移してきたときにDeepCopyをして、閉じるときに変更チェックするなど、オブジェクトを値で比較したい場面は結構ありますよね。

でも、標準クラスやメソッドでは対応できないので、この記事が参考になると嬉しいです。

DeepCopyする方法が知りたい方は、こちらの記事「C# DeepCopyする方法」をどうぞ。

それでは、また。

あわせて読みたい
C# DeepCopyする方法 こんにちは!トミセンです。 オブジェクトを複製する場合に必要なDeepCopyですが、 「C#でDeepCopyどうやるの?」 「C#って、Javaと書き方が違うの?」 分かってしまえ...

こちらの記事も読まれています!


参考サイト

Best way to compare two complex objects|stackoverflow.com

よかったらシェアしてね!
目次
閉じる