Ruby中所有形式的平等性以及如何实现它们

135 阅读18分钟

在Ruby中实现平等性

Ruby是为数不多的能够正确处理平等问题的编程语言之一。我经常玩其他语言,但总是会回到Ruby。这主要是因为Ruby对平等的实现非常好。

尽管如此,Ruby中的平等性并不直接了当。有#==,#eql?,#equal?,#===,等等。即使你熟悉如何使用它们,实现它们也是一个完全不同的故事。

让我们来看看Ruby中所有形式的平等性以及如何实现它们。

为什么正确实现平等性很重要

我们一直在检查对象是否相等。有时我们明确地这样做,有时隐含地这样做。这里有一些例子:

  • 这两个Employees ,是否在同一个Team ?或者,在代码中。denis.team == someone.team.
  • 给定的DiscountCode 对这个特定的Product 有效吗?或者,在代码中。product.discount_codes.include?(given_discount_code).
  • 谁是这组雇员的(不同的)经理?或者,在代码中。employees.map(&:manager).uniq.

一个好的平等的实现是可预测的;它与我们对平等的理解相一致。

另一方面,一个不正确的平等的实现,与我们通常认为的真实情况相冲突。下面是一个例子,说明这种不正确的实现会发生什么。

gebgeb_also 对象肯定应该是相等的。事实上,代码中说它们不相等,这必然会导致下一步的错误。幸运的是,我们可以自己实现平等,避免这类错误。

对于平等的实现,没有一个放之四海而皆准的解决方案。然而,有两种对象,我们确实有实现平等的一般模式:实体和价值对象。这两个术语来自领域驱动设计(DDD),但即使你没有使用DDD,它们也是相关的。让我们仔细看看。

实体

实体是具有明确身份属性的对象。通常情况下,实体存储在某个数据库中,并有一个唯一的id 属性,对应于唯一的id 表列。下面的Employee 示例类就是这样一个实体。

当两个实体的ID相同时,它们是相等的。所有其他的属性都被忽略了。毕竟,一个雇员的名字可能会改变,但这并不改变他们的身份。想象一下,你结婚了,改了名字,却再也拿不到工资了,因为人力资源部门已经不知道你是谁了!

ActiveRecord,作为Ruby on Rails的一部分的ORM,把实体称为 "模型",但它们是同一个概念。这些模型对象自动有一个ID。事实上,ActiveRecord模型已经正确实现了开箱即用的平等性

值对象

值对象是没有明确身份的对象。相反,它们的值作为一个整体构成了身份。考虑一下这个Point 类。

两个Points ,如果它们的xy值相等,就会相等。xy的值构成了点的身份。

在Ruby中,基本的值对象类型是数字(包括整数和浮点数)、字符、布尔运算和nil 。对于这些基本类型,平等性是开箱即用的。

值对象的数组本身也是值对象。对于值对象的数组来说,平等性是开箱即用的--例如,[17, true] == [17, true] 。这似乎是显而易见的,但在所有的编程语言中并不是这样。

其他价值对象的例子有时间戳、日期范围、时间间隔、颜色、三维坐标和货币对象。这些对象是由其他价值对象构建的;例如,一个货币对象由一个固定的十进制数字和一个货币代码字符串组成。

基本等价(双等价)

Ruby有==!= 操作符来检查两个对象是否相等。

Ruby的内置类型都有一个合理的实现== 。一些框架和库提供了自定义类型,它们也会有一个合理的实现== 。下面是一个关于ActiveRecord的例子。

对于自定义类,== 操作符在两个对象是同一实例时返回真。Ruby通过检查内部对象的ID是否相等来做到这一点。这些内部对象的ID可以通过#__id__ 。实际上,gizmo == thinggizmo.__id__ == thing.__id__ 是一样的。

然而,这种行为往往不是一个好的默认值。为了说明这一点,考虑一下前面的Point 类。

== 操作符只有在对它本身进行调用时才会返回真。

这种默认行为在自定义类中往往是不可取的。毕竟,两点是相等的,如果(也只有当)它们的xy值相等。这种行为对于值对象(如Point )和实体(如前面提到的Employee 类)来说是不可取的。

对于价值对象和实体的理想行为如下:

  • 对于价值对象(a),我们希望检查所有属性是否相等。
  • 对于实体(b),我们想检查显式ID属性是否相等。
  • 默认情况下(c),Ruby检查内部对象的ID是否相等。

Point 的实例是价值对象。考虑到上述情况,一个好的== 对于Point 的实现将如下所示。

这个实现检查两个对象的所有属性和class 。通过检查类,检查一个Point 实例和一个不同类的东西是否相等,返回false ,而不是引发一个异常。

Point 对象的平等性检查现在可以按计划进行。

!= 操作符也工作了。

一个正确的平等的实现有三个属性:反射性、对称性和反证性:

  • 反射性(a):一个对象与它自己相等。a == a
  • 对称性(b):如果a == b ,则b == a
  • 跨越性(c):如果a == bb == c ,那么a == c

这些属性体现了对平等含义的共同理解。Ruby不会为你检查这些属性,所以你必须保持警惕,确保你在自己实现平等时不会破坏这些属性。

IEEE 754和反身性的违反

某物与自身相等似乎很自然,但有一个例外。IEEE 754将NaN(非数字)定义为由未定义的浮点运算产生的数值,比如用0除以0,根据定义,NaN不等于自身。你可以自己看一下。

这意味着Ruby中的== ,不是普遍的反身性。幸运的是,反身性的例外极为罕见;这是我所知道的唯一例外。

值对象的基本平等性

Point 类是一个价值对象的例子。一个价值对象的身份,也就是平等,是基于它的所有属性。这正是前面的例子所做的。

实体的基本平等性

实体是具有明确身份属性的对象,通常是@id 。与价值对象不同,当且仅当它们的明确身份相等时,实体才与另一个实体相等。

实体是唯一可识别的对象。通常情况下,任何带有id 列的数据库记录都对应于一个实体。考虑下面这个Employee 实体类。

其他形式的ID也是可能的。例如,书籍有一个ISBN,而录音有一个ISRC。但是如果你的图书馆有同一本书的多个副本,那么ISBN就不能再唯一地识别你的书。

对于实体来说,== 操作符的实现要比价值对象的实现更加复杂。

这段代码做了以下工作:

  • super 调用平等的默认实现:Object#== 。在Object#== 方法返回true ,当且仅当两个对象是同一个实例。因此,这个super 调用确保了反射性属性始终成立。
  • Point ,实现Employee#== ,检查class 。这样,一个Employee 实例可以与其他类的对象进行平等检查,这将总是返回false
  • 如果@idnil ,那么该实体被认为不等于任何其他实体。这对于新创建的、还没有被持久化的实体是很有用的。
  • 最后,这个实现检查ID是否与其他实体的ID相同。如果是的话,这两个实体是相等的。

现在,检查实体上的平等性可以如期进行。

特修斯的博客文章

在实体对象上实现平等并不总是简单的。一个对象可能有一个id 属性,它与对象的概念身份不完全一致。

以一个BlogPost 类为例,它有id,title, 和body 属性。想象一下,创建一个BlogPost ,然后在写到一半的时候,把所有东西都划掉,用一个新的标题和新的主体重新开始。那个BlogPostid 仍然是一样的,但它仍然是同一篇博文吗?

如果我关注一个Twitter账户,后来被黑客攻击,变成了一个加密货币的垃圾邮件,它还是同一个Twitter账户吗?

这些问题都没有一个合适的答案。这并不奇怪,因为这本质上是特修斯之船的思想实验。幸运的是,在计算机的世界里,普遍接受的答案似乎是肯定的:如果两个实体有相同的id ,那么实体也是平等的。

带有类型强制的基本平等性

通常情况下,一个对象不等于一个不同类的对象。然而,这并不总是这样的。考虑一下整数和浮点数。

这里,float_twoFloat 的一个实例,integer_twoInteger 的一个实例。它们是相等的:float_two == integer_twotrue ,尽管它们的类不同。当涉及到平等问题时,IntegerFloat 的实例是可以互换的。

作为第二个例子,考虑这个Path 类。

这个Path 类提供了一个用于创建路径的API。

Path 类是一个值对象,实现#== 可以像对待其他值对象那样进行。

然而,Path 类很特别,因为它代表了一个可以被认为是字符串的值。当检查与任何不是Path 的东西相等时,== 操作符将返回false

true 如果path == "/usr/bin/ruby" ,而不是false ,可能会有好处。为了实现这一点,需要以不同的方式实现== 操作符。

== 的这个实现将两个对象都强制为Strings ,然后检查它们是否相等。检查一个Path 的平等性现在可以了。

这个类实现了#to_str ,而不是#to_s 。这些方法都返回字符串,但是根据惯例,to_str 方法只在可以与字符串互换的类型上实现。

Path 类就是这样一种类型。通过实现Path#to_str ,实现说明这个类的行为就像一个String 。例如,现在可以把一个Path (而不是String )传给IO.open ,而且可以工作,因为IO.open 接受任何响应#to_str

String#== 呼叫器也使用 方法。正因为如此, 操作符是反身的。to_str ==

严格的平等性

Ruby提供了#equal? 来检查两个对象是否是同一个实例。

在这里,我们最终有两个具有相同内容的String 实例。因为它们是不同的实例,#equal? 返回false ,因为它们的内容是相同的,#== 返回true

请不要在你自己的类中实现#equal? 。它不应该被重写。这一切都会以泪水收场。

在这篇文章的前面,我提到#== 有一个反射性的属性:一个对象总是等于它自己。下面是#equal? 的一个相关属性。

属性。给定对象ab 。如果a.equal?(b) ,则a == b

Ruby不会为你的代码自动验证这个属性。这取决于你在实现平等方法时确保这个属性成立。
例如,回顾本文前面的Employee#== 的实现。

第一行对super 的调用使得这个#== reflexive 的实现。这个super 调用了#== 的默认实现,它委托给#equal? 。因此,我可以使用#equal? 而不是super

我更喜欢使用super ,尽管这可能是一个品味问题。

哈希等价

在Ruby中,任何对象都可以作为Hash 中的一个键。字符串、符号和数字通常被用作Hash 的键,但是你自己的类的实例也可以作为Hash 的键--只要你实现了#eql?#hash

#eql?方法

#eql? 方法的行为类似于#==

然而,#eql? ,不像#== ,不执行类型强制。

如果#== 不执行类型强制,#eql?#== 的实现将是相同的。然而,我们不是复制粘贴,而是把实现放在#eql? ,并让#== 委托给#eql?

我特意决定把实现放在#eql? ,让#== 委托给它,而不是反过来。如果我们让#eql? 委托给#== ,就会有更大的风险,有人会更新#== ,并在这个过程中无意中破坏了#eql? 的属性(如下所述)。

对于Path 值对象,其#== 方法确实执行类型强制,#eql? 的实现将与#== 的实现不同。

在这里,#== 不委托给#eql? ,也不反过来。

#eql? 的正确实现有以下两个属性:

  • 属性:给定对象ab 。如果a.eql?(b) ,则a == b
  • 属性:给定对象ab 。如果a.equal?(b) ,那么a.eql?(b)

这两个属性在Ruby的文档中没有明确地叫出来。然而,据我所知,#eql?#== 的所有实现都尊重这个属性。

Ruby不会自动验证这些属性在你的代码中是否成立。这取决于你是否能确保这些属性不被违反。

#hash方法

对于一个对象来说,它不仅需要实现#eql? ,而且还需要实现#hash ,才能作为Hash 中的一个键使用。这个#hash 方法将返回一个整数,即哈希代码,它尊重以下属性。

属性:给定对象ab 。如果a.eql?(b) ,则a.hash == b.hash

通常,#hash 的实现会创建一个构成身份的所有属性的数组,并返回该数组的哈希值。例如,这里是Point#hash

对于Path#hash 的实现将看起来类似。

对于Employee 类,它是一个实体而不是一个值对象,#hash 的实现将使用该类和@id

如果两个对象不相等,哈希代码最好也是不同的。然而,这并不是强制性的。两个不相等的对象有相同的哈希代码也是可以的。Ruby将使用#eql? 来区分具有相同哈希代码的对象。

避免用XOR来计算哈希代码

一个流行但有问题的实现#hash 的方法是使用XOR(^ 操作符)。这样的实现将计算每个单独属性的哈希代码,并将这些哈希代码与XOR结合起来。比如说。

有了这样的实现,出现哈希码碰撞的机会,也就是多个对象有相同的哈希码,比委托给Array#hash 。哈希码碰撞将降低性能,并可能造成拒绝服务的安全风险。

一个更好的方法,尽管仍有缺陷,是在组合哈希码之前将其组成部分乘以唯一的素数。

由于新的乘法,这样的实现有额外的性能开销。它还需要付出脑力劳动,以确保实现是并保持正确。

实现#hash 的一个更好的方法是我之前提出的,即使用Array#hash

使用Array#hash 的实现方式很简单,性能相当好,而且产生的哈希码碰撞的几率最低。这是实现#hash的最佳方法。

把它放在一起

有了#eql?#hashPoint,Path, 和Employee 对象就可以作为哈希键使用了。

在这里,我们使用一个Hash 实例来跟踪一个Points 的集合。我们也可以使用一个Set 来做这个,它在引擎盖下使用一个Hash ,但提供了一个更好的API。

Sets 中使用的对象需要同时实现#eql?#hash ,就像用作哈希键的对象一样。

执行类型强制的对象,如Path ,也可以作为哈希键使用,因此也可以在集合中使用。

我们现在有了一个适用于所有类型对象的平等的实现。

可变性,平等性的克星

到目前为止,关于值对象的例子都假定这些值对象是不可变的。这是有原因的,因为可变的值对象更难处理。

为了说明这一点,考虑一个用作哈希键的Point 实例。

当改变这个点的属性时,问题就出现了。

因为哈希码是基于属性的,而一个属性发生了变化,哈希码就不再相同了。因此,collection 似乎不再包含该点。啊哦!

除了使价值对象不可变之外,没有好的方法来解决这个问题。

这并不是实体的问题。这是因为实体的#eql?#hash 方法只基于它的显式身份--而不是它的属性。

到目前为止,我们已经涵盖了#==#eql?#hash 。这三种方法对于正确实现平等已经足够了。然而,我们可以进一步提高Ruby开发者的甜蜜体验,实现#===

案例等价(三重等价)

#=== 操作符,也被称为案例平等操作符,其实根本就不是一个平等操作符。相反,最好把它看成是一个成员测试运算符。考虑一下下面的情况。

这里,Range#=== ,检查一个范围是否包括某个元素。使用case 表达式来实现同样的目的也很常见。

这也是案例平等的名字的由来。Triple-equals被称为case equality,因为case 表达式使用它。

你永远不需要使用case 。可以使用if=== 重写一个case 表达式。一般来说,case 表达式往往看起来更干净。比较一下。

上面的例子都使用了Range#=== ,来检查范围是否包括某个数字。另一个常用的实现是Class#=== ,它检查一个对象是否是一个类的实例。

我相当喜欢#grep 方法,它使用#=== ,从数组中选择匹配的元素。它可以比使用#select 更短、更方便。

正则表达式也实现了#=== 。你可以用它来检查一个字符串是否与一个正则表达式匹配。

把正则表达式看作是由它产生的所有字符串的(无限的)集合是有帮助的。由/[a-z]/ 产生的所有字符串的集合包括示例字符串"+491573abcde" 。同样,你可以把Class 看成是它所有实例的(无限)集合,而Range 则是该范围内所有元素的集合。这种思考方式阐明了#=== 实际上是一个成员测试操作符。

一个可以实现#=== 的类的例子是PathPattern

一个例子是PathPattern.new("/bin/*") ,它可以匹配直接在/bin 目录下的任何东西,比如/bin/ruby ,但不能匹配/var/log

PathPattern#=== 的实现使用 Ruby 内置的File.fnmatch 来检查模式字符串是否匹配。下面是一个使用中的例子。

值得注意的是,File.fnmatch 在其参数上调用#to_str 。这样一来,#=== 也会自动对其他类似字符串的对象起作用,比如Path 实例。

PathPattern 类实现了#=== ,因此PathPattern 实例也能与case/when 一起工作。

有序比较

对于某些对象,不仅要检查两个对象是否相同,还要检查它们的排序情况。它们大吗?更小?考虑一下这个Score ,它是我在比利时根特大学的评分系统的模型。

(我是一个糟糕的学生。我不确定这是否真的是打分的方式--但作为一个例子,它就可以了)。

在任何情况下,我们都会从拥有这样一个Score 类中受益。我们可以在那里编码相关的逻辑,比如确定分数和检查分数是否合格。例如,从一个列表中得到最低和最高的分数可能是有用的。

然而,按照现在的情况,表达式scores.minscores.max 将导致一个错误:比较Score with Score failed (ArgumentError) 。我们还没有告诉Ruby如何比较两个Score 对象。我们可以通过实现Score#&<=> 来做到这一点。

#<=> 的实现会返回四个可能的值:

  • 当两个对象相等时,它返回0
  • self 小于other ,它返回-1
  • self 大于other 时,它返回1
  • 当这两个对象不能被比较时,它返回nil

#<=>#== 操作符是相连的:

  • 属性:给定对象ab 。如果(a <=> b) == 0 ,则a == b
  • 属性:给定对象ab 。如果(a <=> b) != 0 ,则a != b

和以前一样,在实现#==#<=> 的时候,你要确保这些属性成立。Ruby不会为你检查这个。

为了简单起见,我在上面的Score例子中忽略了实现Score#== 。不过,如果能有这样的实现,那肯定是好事。

Score#<=> 的情况下,如果其他的不是一个分数,我们就放弃,否则,我们就对这两个值调用#<=> 。我们可以检查这是否有效:表达式Score.new(6) <=> Score.new(12) 的值为-1 ,这是正确的,因为 6 分比 12 分低。(你知道比利时的高中系统曾经有一个评分系统,1 分是最高分,10 分是最低分?想象一下这种混乱吧!)

有了Score#<=>scores.max ,现在返回最高分。其他的方法,如#min#minmax ,和#sort ,也可以工作。

然而,我们还不能使用像< 这样的运算符。例如,表达式scores[0] < scores[1] ,将引发一个未定义的方法错误:undefined method `<' for #<Score:0x00112233 @value=6> 。我们可以通过包括Comparable mixin来解决这个问题。

通过包含ComparableScore 类自动获得了<<=>>= 操作符,这些操作符都在内部调用<=> 。表达式scores[0] < scores[1] 现在评估为一个布尔值,正如预期的那样。

Comparable mixin还提供了其他有用的方法,如#between?#clamp

总结

我们讨论了以下主题:

  • #== 运算符,用于基本的平等,并可选择类型强制。
  • #equal?,它检查两个对象是否是同一个实例
  • #eql? 和 ,用于测试一个对象是否是哈希中的一个键。#hash
  • #===符号,不完全是一个平等运算符,而是一个 "是同类 "或 "是成员 "的运算符
  • #<=> 用于有序比较,还有 模块,它提供了诸如 和 等运算符。Comparable < >=

你现在知道你需要知道的关于在Ruby中实现平等的所有信息。欲了解更多信息,请查阅以下资源。

Ruby文档是了解更多关于平等的好地方:

我还发现以下资源很有用: