Groovy 和多元相等

作者: Paul King
发布日期: 2024-04-24 03:00PM


简介

在 Scala 3 中,引入了一种名为 多元相等 的可选功能。早期的 Scala 版本支持通用相等,其中任何两个对象都可以比较相等性。当您了解到 Scala 的 (==!=) 相等运算符(如 Groovy 的运算符)基于 Java 的 equals 方法,并且该方法接受任何 Object 作为参数时,通用相等性就很有意义了。

Java 人员可能更熟悉在对象上使用这些运算符时用于引用相等性。Groovy 与 Scala 和 Kotlin 一样,将这些运算符保留用于结构相等性(因为这是我们大多数时间感兴趣的),并且使用标识运算符 (===!==) 用于引用相等性(指向同一个实例)。

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 文档Book 案例研究。

书籍案例研究

案例研究涉及一家在线书店,出售实体印刷书籍和有声读物。我们将从不考虑多元相等性开始,然后看看如何在 Groovy 中稍后添加它。

作为第一次尝试,我们可以定义一个包含公共属性的 Book 特性

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 方法。我们还在我们特性 equals 方法中捕获了我们对不同子类应该意味着什么的定义。在我们的案例中,如果 titleauthor 相同,则它们被认为相等。

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 的不同子类时,我们希望使用特性的 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 的文件中。它看起来像这样

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。好消息是,您不需要了解它是如何工作的,只需要了解它做了什么。

此代码打开严格相等性。如果 ==!= 运算符左侧和右侧的类型不同,编译将失败。唯一例外是当 PrintedBookAudioBook 比较时,因为我们在我们的临时扩展中硬编码了它。

使用它相当简单。只需在任何方法或类上声明扩展即可

@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 标记接口。除了两种类型都实现了我们的标记接口的情况外,在所有情况下都打开严格相等性

canEquals.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
            }
            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<Book>,或者我们可以遵循 Scala 的领先并提供 两个泛型参数

结论

在此阶段,Groovy 不打算将多元相等性作为标准功能,但如果您认为您会发现它有用,请 告诉我们