Groovy™ 与多向相等性
发布时间:2024-04-24 03:00PM
简介
在 Scala 3 中,引入了一项可选功能,称为多向相等性。早期版本的 Scala 支持普适相等性,即任何两个对象都可以进行相等性比较。当你理解 Scala(`==` 和 `!=`)相等运算符(与 Groovy 类似)是基于 Java 的 `equals` 方法,并且该方法接受任何 `Object` 作为其参数时,普适相等性就非常有意义了。
Scala 文档中有一本在线书籍,其中进一步详细说明了将多向相等性作为选项的好处。让我们看一个受其代码片段启发的具体示例。考虑以下代码:
var blue = getBlue() // returns Color.BLUE
var pink = Color.PINK
assert blue != pink
现在,假设 `getBlue` 方法被重构为使用不同的颜色库,现在返回 `RGBColor.BLUE`。在我们的例子中,断言仍然会失败,和以前一样,但我们实际上并没有测试我们所想的。一般来说,我们代码的行为可能会以微妙或灾难性的方式改变,而且我们可能直到运行时才会发现。多向相等性对可以检查相等性的类型采取了更严格的立场,并会在编译时发现我们上述示例中的问题。启用多向相等性后,您可能会看到如下错误:
[Static type checking] - Invalid equality check: com.threed.jpct.RGBColor != java.awt.Color @ line 3, column 8. assert blue != pink ^
让我们看看在线 Scala 文档中的《图书》案例研究。
图书案例研究
该案例研究涉及一家销售实体印刷书籍和有声读物的在线书店。我们将首先不考虑多向相等性,然后看看如何在 Groovy 中添加它。
作为第一次尝试,我们可能会定义一个包含通用属性的 `Book` trait
trait Book {
String title
String author
int year
}
印刷书籍的领域类
@Immutable(allProperties = true)
class PrintedBook implements Book {
int pages
}
`@Immutable` 注解是一个元注解,在概念上扩展为 `@EqualsAndHashCode` 注解(以及其他注解)。`@EqualsAndHashCode` 是一种 AST 转换,它指示编译器将 `equals` 方法注入到我们的代码中。
以类似的方式,我们将为有声读物创建一个领域类
@Immutable(allProperties = true)
class AudioBook implements Book {
int lengthInMinutes
}
在此阶段,我们可以创建和比较有声读物和印刷书籍,但它们将始终不相等
var pBook = new PrintedBook(328, "1984", "George Orwell", 1949)
var aBook = new AudioBook(682, "1984", "George Orwell", 2006)
assert pBook != aBook
assert aBook != pBook
当我们比较来自其他类的对象时,我们代码中生成的 `equals` 方法将始终返回 false。事实证明,编写正确的相等方法可能出奇地困难。正如那篇文章所暗示的,当想要比较类层次结构中的对象时,一种常见的最佳实践是编写一个 `canEqual` 方法。我们还在 trait 的 `equals` 方法中,捕获了我们对不同子类之间相等性的定义。在我们的例子中,如果 `title` 和 `author` 相同,则它们被视为相等。
trait Book {
String title
String author
int year
boolean canEqual(Object other) {
other in Book
}
boolean equals(Object other) {
if (other in Book) {
return other.canEqual(this)
&& other.title == title
&& other.author == author
}
false
}
}
当比较 `Book` 的不同子类时,我们希望使用 trait 中的 `equals` 逻辑。当比较两本印刷书籍或两本有声读物时,我们可能希望应用正常的结构相等性。这 оказалось不是太难做到。
如果 `@EqualsAndHashCode` 转换找到一个显式的 `equals` 方法,它会生成一个包含正常结构相等性逻辑的私有 `_equals` 方法,您可以自由使用。让我们为 `PrintedBook` 类这样做
@Immutable(allProperties = true)
class PrintedBook implements Book {
int pages
boolean equals(other) {
switch (other) {
case PrintedBook -> this._equals(other)
case AudioBook -> Book.super.equals(other)
default -> false
}
}
}
有了这些更改,我们可以将上面的第一个断言更改为现在显示有声读物与印刷书籍的相等性
assert pBook == aBook
assert aBook != pBook
第二个断言保持不变,因为我们在此阶段尚未更改 `AudioBook` 中的 `equals` 方法。以这种方式修改 `AudioBook`,并使关系对称将是下一个逻辑步骤,但我们暂时将示例保持原样以匹配 Scala 示例。
Groovy 目前还不支持多向相等性作为标准功能,但让我们看看如何添加它。我们首先考虑一种临时方法。
Groovy 支持类型检查扩展。它有一个用于编写增强静态类型检查的代码片段的 DSL。二进制运算符的检查不常见,目前也没有非常紧凑的 DSL 语法,但通过使用 `afterVisitMethod` 钩子和特殊的 `CheckingVisitor` 辅助类,这并不难做到。在这种情况下,我们将在一个名为 `strictEqualsButRelaxedForPrintedBook.groovy` 的文件中编写我们的扩展。它看起来像这样
afterVisitMethod { method ->
method.code.visit(new CheckingVisitor() {
@Override
void visitBinaryExpression(BinaryExpression be) {
if (be.operation.type !in [Types.COMPARE_EQUAL, Types.COMPARE_NOT_EQUAL]) {
return
}
lhsType = getType(be.leftExpression)
rhsType = getType(be.rightExpression)
if (lhsType != rhsType &&
lhsType != classNodeFor(PrintedBook) &&
rhsType != classNodeFor(AudioBook)) {
addStaticTypeError("Invalid equality check: $lhsType.name != $rhsType.name", be)
handled = true
}
}
})
}
如果乍一看你不理解这段代码,请不要担心。熟悉编写自己的 AST 转换的用户会认出其中的一部分。要完全理解它,你需要理解类型检查扩展 DSL。好消息是,你不需要理解它是如何工作的,只需要理解它做什么。
此代码启用严格相等性。如果 `==` 或 `!=` 运算符的左侧和右侧类型不同,则编译将失败。唯一的例外是当 `PrintedBook` 与 `AudioBook` 进行比较时,因为我们在临时扩展中硬编码了这一点。
使用它相当简单。只需在任何方法或类上声明扩展即可
@TypeChecked(extensions = 'strictEqualsButRelaxedForPrintedBook.groovy')
def method() {
var pBook = new PrintedBook(328, "1984", "George Orwell", 1949)
var aBook = new AudioBook(682, "1984", "George Orwell", 2006)
assert pBook == aBook
}
这编译并成功执行。尝试使用其他类型会产生编译错误
assert aBook != pBook // [Static type checking] - Invalid equality check: AudioBook != PrintedBook
assert 3 != 'foo' // [Static type checking] - Invalid equality check: int != java.lang.String
assert 3 == 3f // [Static type checking] - Invalid equality check: int != float
正如我们的扩展中所编写的,即使是数学原语比较也是严格的。Scala 编译器有许多预定义的 `CanEqual` 实例,允许在各种类型之间进行比较,包括原语之间以及原语与其包装类之间。
如果我们将目前的解决方案与 Scala 示例进行比较,Scala 示例采用了一种更通用的方法。让我们让我们的示例稍微更通用一些,尽管仍然不是生产就绪的。
首先我们创建一个标记接口
interface CanEqual { }
此功能的生产版本可能还会向此定义添加泛型信息,但我们稍后会讨论。
让我们将我们的特性改为抽象类,即使我们的 `year` 属性是通用的,我们也将其移到音频和印刷书籍类中。现在我们可以使用标准生成的 `equals` 方法。默认情况下,该方法也知道 `canEqual` 模式,并且还生成该方法并在生成的 `equals` 逻辑中使用它。
@EqualsAndHashCode
@TupleConstructor
abstract class Book {
final String title
final String author
}
现在让我们创建 `PrintedBook` 类,它继承自我们的抽象类并实现我们的标记接口
@EqualsAndHashCode(callSuper = true, useCanEqual = false)
@TupleConstructor(callSuper = true, includeSuperProperties = true)
class PrintedBook extends Book implements CanEqual {
final int pages
final int year
boolean equals(other) {
other in PrintedBook ? _equals(other) : super.equals(other)
}
}
我们对 `AudioBook` 也做同样的事情
@EqualsAndHashCode(callSuper = true, useCanEqual = false)
@TupleConstructor(callSuper = true, includeSuperProperties = true)
class AudioBook extends Book implements CanEqual {
final int lengthInMinutes
final int year
boolean equals(other) {
other in AudioBook ? _equals(other) : super.equals(other)
}
}
现在我们修改我们的类型检查扩展,使其能够识别 `CanEqual` 标记接口。严格相等性在所有情况下都开启,除了两种类型都实现了我们的标记接口的情况
afterVisitMethod { method ->
method.code.visit(new CheckingVisitor() {
@Override
void visitBinaryExpression(BinaryExpression be) {
if (be.operation.type !in [Types.COMPARE_EQUAL, Types.COMPARE_NOT_EQUAL]) {
return
}
var lhsType = getType(be.leftExpression)
var rhsType = getType(be.rightExpression)
if ([lhsType, rhsType].every { type ->
implementsInterfaceOrIsSubclassOf(type, classNodeFor(CanEqual))
}) {
return
}
if (lhsType != rhsType) {
addStaticTypeError("Invalid equality check: $lhsType.name != $rhsType.name", be)
handled = true
}
}
})
}
我们像以前一样使用它,但现在比较是对称的
@TypeChecked(extensions = 'canEquals.groovy')
def method() {
var pBook = new PrintedBook("1984", "George Orwell", 328, 1949)
var aBook = new AudioBook("1984", "George Orwell", 682, 2006)
assert pBook == aBook
assert aBook == pBook
var reprint = new PrintedBook("1984", "George Orwell", 328, 1961)
assert pBook != reprint
assert aBook == reprint
}
现在,当比较任何未实现标记接口的类型时,编译将失败。这工作得很好,但仍然不完美。如果我们有两个层次结构,并且我们两个层次结构中的类都实现了我们的标记接口,那么跨两个层次结构比较对象将编译,但始终返回 false。
解决此问题的明显方法是添加泛型。例如,我们可以向 `CanEqual` 添加泛型,然后 `PrintedBook` 可以实现 `CanEqual
更多信息
结论
目前,Groovy 不打算将多向相等性作为标准功能,但如果您认为它有用,请告诉我们!