使用C#序列化比较两个对象

13
为什么将两个对象序列化并比较字符串不是一个好的实践,就像以下示例一样?
public class Obj
{
    public int Prop1 { get; set; }
    public string Prop2 { get; set; }
}

public class Comparator<T> : IEqualityComparer<T>
{
    public bool Equals(T x, T y)
    {
        return JsonConvert.SerializeObject(x) == JsonConvert.SerializeObject(y);
    }

    public int GetHashCode(T obj)
    {
        return JsonConvert.SerializeObject(obj).GetHashCode();
    }
}

Obj o1 = new Obj { Prop1 = 1, Prop2 = "1" };
Obj o2 = new Obj { Prop1 = 1, Prop2 = "2" };

bool result = new Comparator<Obj>().Equals(o1, o2);

我已经进行了测试,它可以工作。它是通用的,因此可以代表各种对象,但我想知道这种比较对象的方法有哪些缺点?在这个问题中提出了这种方法,并获得了一些赞同,但我无法理解为什么这不被认为是最好的方法,如果有人只想比较两个对象的属性值? 编辑:我严格指的是Json序列化,而不是XML。
我之所以问这个问题,是因为我想为一个单元测试项目创建一个简单通用的Comparator,所以比较的性能并不是很重要,因为我知道这可能是最大的缺点之一。此外,在Newtonsoft.Json的情况下,可以使用TypeNameHandling属性设置为All来处理类型问题。

4
由于您已经在使用Json.NET,它提供了一个API方法JToken.DeepEquals() 用于比较两个序列化对象。 - dbc
2
你需要用一个新的哈希函数替换掉return obj.GetHashCode(),默认的哈希函数使用了和Equals相同的逻辑,但是由于你改变了Equals的行为,所以哈希值现在是不正确的。因为很多进程在检查哈希值是否相等后才会调用Equals,所以现在的情况可能会导致非常奇怪的结果。 - MikeT
10个回答

16

最主要的问题是效率低下。

举个例子,想象一下这个等值函数:

public bool Equals(T x, T y)
{
    return x.Prop1 == y.Prop1
        && x.Prop2 == y.Prop2
        && x.Prop3 == y.Prop3
        && x.Prop4 == y.Prop4
        && x.Prop5 == y.Prop5
        && x.Prop6 == y.Prop6;
}

如果prop1不同,则其他5个比较无需检查。如果您使用JSON进行此操作,则每次都必须将整个对象转换为JSON字符串,然后进行比较,这还要加上序列化本身就是一项昂贵的任务。
接下来的问题是,序列化是为通信而设计的,例如从内存到文件,跨网络等。如果您利用序列化进行比较,则可能会降低其正常使用的能力,即您不能忽略不需要传输的字段,因为忽略它们可能会破坏您的比较器。
接下来,JSON具体而言是无类型的,这意味着以任何方式不相等的值可能会被误认为相等,反之,如果它们序列化为相同的值,则相等的值可能不会比较相等,这再次是不安全和不稳定的。
这种技术唯一的好处是程序员实现起来很容易。

我只知道Newtonsoft有这个功能,但其他序列化库肯定也有类似的功能:TypeNameHandling可以解决类型问题,因此对于同一项目中的类可以正常工作。 - meJustAndrew
1
如果您不使用JSON,而是选择XML,则类型问题就会消失,但字符串大小会大大增加,更不用说XML有几种定义相同对象的属性的方式,这些属性以不同的顺序排列可能会导致其他问题。如果您不关心使用序列化进行传输和效率不重要,那么没有任何理由不使用简单但次优的方法。 - MikeT
3
写英语而不是美式英语不算打字错误 ;) - MikeT
好的,但考虑像这样使用Newtonsoft,唯一的缺点是性能? - meJustAndrew
1
你可以在二檔行駛時將車速提高至60英里每小時,因為這比使用換檔來得容易,只要你不介意效率和性能的問題,那是你的選擇。但你不會得到任何人的推薦。 - MikeT
这是相对正确的,但如果您在二档以60英里/小时的速度驾驶汽车,引擎会超负荷使用,最终会损坏,而且油耗会更快。这就是我要寻找的论点类型,如果性能是唯一的考虑因素,那么像这样比较对象真的很好,但我表示怀疑。 - meJustAndrew

9
您可能会一直为问题添加赏金,直到有人告诉您这样做很好。所以你明白了,不要犹豫,利用NewtonSoft.Json库使代码简单化。如果您的代码被审查或其他人接管代码维护,您只需要一些好的论据来捍卫决定。
他们可能提出的一些反对意见及其反驳:
“这是非常低效的代码!”
的确如此,特别是如果您在字典或哈希集中使用对象,则GetHashCode()可能使您的代码变得非常缓慢。
最好的反驳是指出,在单元测试中效率并不重要。最典型的单元测试花费的时间比实际执行的时间还要长,无论它花费1毫秒还是1秒都没有关系。而且这是一个你很可能很早就会发现的问题。
“你正在对一个你没有编写的库进行单元测试!”
那确实是一个合理的担忧,实际上你正在测试NewtonSoft.Json生成对象一致字符串表示的能力。这方面确实有原因值得警惕,特别是浮点数值(float和double)从来都不是问题。还有some evidence表明库的作者不确定如何正确处理它。
最好的反驳是该库被广泛使用和维护良好,作者多年来发布了许多更新。当您确保完全相同的程序与完全相同的运行时环境生成两个字符串时(即不存储它),并且您确保单元测试使用禁用优化构建时,可以推断出浮点一致性问题。

你没有对需要测试的代码进行单元测试!

是的,只有在类本身未提供比较对象的方式时,才会编写此代码。换句话说,它不重写Equals/GetHashCode,并且不公开比较器。因此,在单元测试中测试相等性会执行一个未实际支持的功能的功能。这是单元测试永远不应该做的事情,当测试失败时,您无法编写错误报告。
反驳的观点是需要测试相等性以测试类的另一个特征,如构造函数或属性设置器。在代码中添加简单的注释就足以记录这一点。

阅读这个答案已经三年了,我觉得有必要再次点赞。与此同时,支持这种方法的库也像deep equal一样增长了,我仍然相信在许多情况下这是正确的做法。 - meJustAndrew

5
通过将对象序列化为JSON,您基本上将所有对象更改为另一种数据类型,因此适用于您的JSON库的所有内容都会对您的结果产生影响。
因此,如果一个对象中有像[ScriptIgnore]这样的标记,您的代码将简单地忽略它,因为它已从数据中省略。
此外,不同对象的字符串结果可能相同,例如以下示例。
static void Main(string[] args)
{
    Xb x1 = new X1()
    {
        y1 = 1,
        y2 = 2
    };
    Xb x2 = new X2()
    {
        y1 = 1,
        y2= 2
    };
   bool result = new Comparator<Xb>().Equals(x1, x2);
}
}

class Xb
{
    public int y1 { get; set; }
}

class X1 : Xb
{
    public short y2 { get; set; }
}
class X2 : Xb
{
    public long y2 { get; set; }
}

正如您所看到的,x1的类型与x2不同,即使y2的数据类型也对这两者不同,但json结果仍然相同。

除此之外,由于x1和x2都是Xb类型,我可以毫无问题地调用您的比较器。


我只知道Newtonsoft有这个功能,但其他序列化库肯定也有类似的功能:TypeNameHandling可以解决类型问题,因此对于同一项目中的类可以正常工作。 - meJustAndrew

4

我想在开头更正 GetHashCode 方法。

public class Comparator<T> : IEqualityComparer<T>
{
    public bool Equals(T x, T y)
    {
        return JsonConvert.SerializeObject(x) == JsonConvert.SerializeObject(y);
    }
    public int GetHashCode(T obj)
    {
        return JsonConvert.SerializeObject(obj).GetHashCode();
    }
}

好的,接下来我们讨论这种方法的问题。


首先,它不能处理具有循环链接的类型。

如果您拥有像A -> B -> A这样简单的属性链接,则该方法将失败。

不幸的是,在列表或映射中相互链接非常常见。

更糟糕的是,几乎没有一个有效的通用循环检测机制。


其次,与序列化相比,它是低效的。

JSON 需要反射和大量的类型判断才能成功编译其结果。

因此,在任何算法中,您的比较器都会成为严重的瓶颈。

通常,即使在成千上万的记录情况下,JSON 也被认为速度较慢。


第三,JSON 必须遍历每个属性。

如果您的对象链接到任何大对象,它将变成一场灾难。

如果您的对象链接到一个大文件怎么办?


因此,C# 简单地将实现留给用户。

在创建比较器之前,用户必须深入了解自己的类。

比较需要良好的循环检测、早期终止和效率考虑。

通用解决方案根本不存在。


1
你已经得到了你的分数,至于循环链接,存在ReferenceLoopHandling,因此可以使用选项Ignore,以便不再将对象序列化为自身,所以在这种情况下它会起作用。其次,你提到序列化很慢,这是正确的。我之所以问这个问题,是因为我想在单元测试项目中制作一个自定义比较器,所以它是否会延迟一秒并不影响我。这也节省了我几天编程时间来制作自定义比较器。我应该在问题中添加这个内容,只是为了澄清。 - meJustAndrew

1
这些是一些缺点:
a)对象树越深,性能越差。
b)new Obj { Prop1 = 1 } 等于 new Obj { Prop1 = "1" } 等于 new Obj { Prop1 = 1.0 } c)new Obj { Prop1 = 1.0, Prop2 = 2.0 } 不等于 new Obj { Prop2 = 2.0, Prop1 = 1.0 }

我知道这只适用于Newtonsoft,但肯定还有其他序列化库:TypeNameHandling可以消除无类型问题,因此对于同一项目中的类将正常工作。在这种情况下,只有性能是有效的,但还是谢谢! - meJustAndrew

1

对于单元测试,您不需要编写自己的比较器。 :)

只需使用现代框架。例如尝试 FluentAssertions库

o1.ShouldBeEquivalentTo(o2);

1

序列化是为了存储对象或将其发送到当前执行上下文之外的管道(网络)而进行的。不用于在执行上下文中执行操作。

一些序列化值可能被视为不相等,但实际上它们是相等的:例如,十进制数"1.0"和整数"1"。

当然你可以,就像你可以用铁锹吃饭,但你不会这样做,因为你可能会弄坏牙齿!


1

首先,我注意到你说“将它们序列化然后比较字符串”。一般来说,普通的字符串比较不能用于比较XML或JSON字符串,你需要更加复杂一些。作为与字符串比较相反的例子,请考虑以下XML字符串:

<abc></abc>
<abc/>

他们显然不是字符串相等的,但它们确实“意味着”相同的事情。虽然这个例子似乎是人为的,但事实证明,在许多情况下,字符串比较是无效的。例如,空格和缩进在字符串比较中很重要,但在XML中可能不重要。
对于JSON来说,情况也并不好。你可以做类似的反例。
{ abc : "def" }
{
   abc : "def"
}

显然,这些意思相同,但它们不是字符串相等。

实际上,如果您进行字符串比较,您正在信任序列化程序始终以完全相同的方式序列化特定对象(没有任何添加的空格等),这最终变得非常脆弱,特别是考虑到大多数库不提供此类保证。如果您在某个时候更新序列化库,并且它们在序列化方面存在微妙的差异,则会出现问题;在这种情况下,如果您尝试比较使用先前版本库序列化的保存对象与使用当前版本序列化的对象,则不起作用。

另外,关于您的代码本身,"=="运算符不是比较对象的正确方法。通常,"=="测试引用相等性,而不是对象相等性。

关于哈希算法,还有一个快速的离题:它们作为相等性测试手段的可靠性取决于它们的碰撞抗性。换句话说,给定两个不同的、非相等的对象,它们哈希到相同值的概率是多少?反过来,如果两个对象哈希到相同的值,它们实际上相等的可能性有多大?很多人认为他们的哈希算法是100%的碰撞抗性(即只有在它们相等时,两个对象才会哈希到相同的值),但这并不一定是真的。(一个特别著名的例子是MD5加密哈希函数,它的相对较差的碰撞抗性已经使它无法再被使用)。对于一个正确实现的哈希函数,在大多数情况下,哈希到相同值的两个对象实际上相等的概率已经足够高,可以作为相等性测试的手段,但这并不是保证。

考虑使用Newtonsoft.Json作为库,因此XML问题在这种情况下不适用。另外,由于我已经在我的问题中编写了比较器,所以只会使用一个库来比较两个对象,因此库过时的情况不是问题。在这种情况下,你的所有答案都不适用于这个问题。 - meJustAndrew
我不同意。正如我在帖子中提到的那样,这个问题适用于XML和JSON。你不能只做简单的字符串相等比较,并期望它按照你的期望工作。为了正确地比较XML或JSON字符串,你必须考虑它们的含义,而不仅仅是字符串相等性。你的算法在一般情况下可能会起作用的唯一方法是,如果你能保证序列化程序始终完全一致地格式化,并且文档没有提供这样的保证。 - EJoshuaS - Stand with Ukraine

1
使用序列化并比较字符串表示来进行对象比较,在以下情况下不是有效的:
当需要比较的类型中存在 DateTime 类型的属性时。
public class Obj
{
    public DateTime Date { get; set; }
}

Obj o1 = new Obj { Date = DateTime.Now };
Obj o2 = new Obj { Date = DateTime.Now };

bool result = new Comparator<Obj>().Equals(o1, o2);

即使是在时间上非常接近创建的对象,除非它们不共享完全相同的属性,否则它将返回false

对于具有双精度或十进制值的对象,需要与 Epsilon 进行比较,以验证它们是否最终非常接近

public class Obj
{
    public double Double { get; set; }
}

Obj o1 = new Obj { Double = 22222222222222.22222222222 };
Obj o2 = new Obj { Double = 22222222222222.22222222221 };

bool result = new Comparator<Obj>().Equals(o1, o2);

这也会返回false,即使双精度浮点数非常接近,在涉及计算的程序中,由于多次除法和乘法操作后精度丢失,这将成为一个真正的问题,并且序列化不提供处理这些情况的灵活性。
考虑到上述情况,如果一个人不想比较某个属性,就会面临引入序列化属性到实际类的问题,即使这并不是必要的,它也会导致代码污染或在实际使用该类型进行序列化时出现问题。
注意:这些是这种方法的一些实际问题,但我希望能找到其他问题。

0

您可以使用 System.Reflections 命名空间来获取实例的所有属性,就像 this answer 中所述的那样。通过使用 Reflection,您不仅可以比较public 属性或字段(就像使用 Json Serialization 一样),还可以比较一些privateprotected等属性,以提高计算速度。当然,显而易见的是,如果两个对象不同,则您不必比较实例的所有属性或字段(除非只有对象的最后一个属性或字段不同的情况除外)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接