Groovy and Multiversal Equality

Author: Paul King
Published: 2024-04-24 03:00PM


Introduction

In Scala 3, an opt-in feature called multiversal equality was introduced. Earlier versions of Scala supported universal equality, where any two objects can be compared for equality. Universal equality makes a lot of sense when you understand that Scala’s (== and !=) equality operators, like Groovy’s, is based on Java’s equals method and that method takes any Object as its argument.

Java folks might be more familiar with those operators when used with objects as being used for reference equality. Groovy, like Scala and Kotlin, reserves those operators for structural equality (since that is what we are interested in most of the time) and has identity operators (=== and !==) for referential equality (pointing to the same instance).

The Scala documentation has an online book which gives further details on the benefits of having multiversal equality as an option. Let’s look at a concrete example inspired by one of their code snippets. Consider the following code:

var blue = getBlue() // returns Color.BLUE
var pink = Color.PINK
assert blue != pink

Now, suppose the getBlue method is refactored to use a different color library, and now returns RGBColor.BLUE. In our case, the assertion will still fail, as before, but we aren’t really testing what we thought. In general, the behavior of our code might change in subtle or catastrophic ways, and we may not find out until runtime. Multiversal equality takes a stricter stance on the types which can be checked for equality and would pick up the issue in our above example at compilation time. With multiversal equality enabled, you might see an error like this:

[Static type checking] - Invalid equality check: com.threed.jpct.RGBColor != java.awt.Color
 @ line 3, column 8.
       assert blue != pink
              ^

Let’s look at the Book case study from the online Scala documentation.

Book Case Study

The case study involves an online bookstore which sells physical printed books, and audiobooks. We’ll start without considering multiversal equality, and then look at how that could be added later in Groovy.

As a first attempt, we might define a Book trait containing the common properties:

trait Book {
    String title
    String author
    int year
}

A domain class for printed books:

@Immutable(allProperties = true)
class PrintedBook implements Book {
    int pages
}

The @Immutable annotation is a meta-annotation which conceptually expands into the @EqualsAndHashCode annotation (and others). @EqualsAndHashCode is an AST transform which instructs the compiler to inject an equals method into our code.

In a similar way, we’ll create a domain class for audiobooks:

@Immutable(allProperties = true)
class AudioBook implements Book {
    int lengthInMinutes
}

At this stage, we can create and compare audio and printed books, but they will always be non-equal:

var pBook = new PrintedBook(328, "1984", "George Orwell", 1949)
var aBook = new AudioBook(682, "1984", "George Orwell", 2006)
assert pBook != aBook
assert aBook != pBook

The generated equals method in our code will always return false when comparing objects from other classes. It turns out that writing a correct equality method can be surprisingly difficult. As that article alludes to, a common best practice when wanting to compare objects within a class hierarchy is to write a canEqual method. We also capture within our trait’s equals method, our definition of what equals should mean for different subclasses. In our case, if the title and author are the same, they are deemed equal.

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
    }
}

When comparing different subclasses of Book, we’d like to use the equals logic from the trait. When comparing two printed books or two audiobooks, we might want normal structural equality to apply. This turns out to be not too hard to do.

If the @EqualsAndHashCode transform finds an explicit equals method, it generates instead a private _equals method containing the normal structural equality logic which you are free to use. Let’s do that for the PrintedBook class:

@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
        }
    }
}

With these changes in place, we can change our first assertion from above to now show equality of the audiobook to the printed book:

assert pBook == aBook
assert aBook != pBook

The second assertion remains unchanged since we haven’t at this stage changed the equals method in AudioBook. Modifying AudioBook in this way, and making the relationship symmetrical would be the next logical step, but we’ll leave the example as is for now to match the Scala example.

Groovy doesn’t yet currently support multiversal equality as a standard feature, but let’s look at how we could add it. We’ll first consider an ad-hoc approach.

Groovy supports type checking extensions. It has a DSL for writing snippets that augment static type checking. Checks on binary operators are not common and don’t currently have a very compact DSL syntax, but it isn’t hard to do by making use of the afterVisitMethod hook and using a special CheckingVisitor helper class. In this case, we’ll write our extension in a file called strictEqualsButRelaxedForPrintedBook.groovy. It looks like this:

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
            }
        }
    })
}

Don’t worry if you don’t understand this code at first glance. Users familiar with writing their own AST transforms will recognise parts of it. To fully understand it, you need to understand the type checking extension DSL. The good news is that, you don’t need to understand how it works, just what it does.

This code turns on strict equality. If the types on the left and right hand sides of the == or != operators are different, compilation will fail. The only exception is when a PrintedBook is compared to an AudioBook, since we hard-coded that in our ad-hoc extension.

Using it is fairly simple. Simply declare the extension on any method or class:

@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
}

This compiles and executes successfully. Attempting to use other types gives compilation errors:

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

As coded in our extension, even math primitives comparisons are strict. The Scala compiler has numerous predefined CanEqual instances to allow comparison between various types including between primitives, and between primitives and their wrapper classes.

If we compare this solution so far with the Scala example, the Scala example uses a more general approach. Let’s make our example slightly more general, although still not production ready.

First we’ll create a marker interface:

interface CanEqual { }

A production version of this feature would probably also add generics information to this definition, but we’ll discuss that later.

Let’s change our trait into an abstract class and even though our year property is common, let’s move it down into the audio and printed book classes. Now we can use the standard generated equals method. By default, the method also knows about the canEqual pattern and also generates that method and makes use of it in the generated equals logic.

@EqualsAndHashCode
@TupleConstructor
abstract class Book {
    final String title
    final String author
}

Now let’s create our PrintedBook class extending from our abstract class and implementing our marker interface:

@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)
    }
}

We do the same for 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)
    }
}

Now we alter our type checking extension to be aware of the CanEqual marker interface. Strict equality is turned on in all cases except where both types implement our marker interface:

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
            }
        }
    })
}

We use it in a similar way as before, but now comparisons are symmetric:

@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
}

Now, compilation will fail when comparing any types which don’t implement the marker interface. This works nicely but still isn’t perfect. If we had two hierarchies and our classes in both hierarchies implemented our marker interface, comparing objects across the two hierarchies would compile but always return false.

The obvious way around this would be to add generics. We could for instance add generics to CanEqual and then PrintedBook might implement CanEqual<Book> or we could follow Scala’s lead and supply two generic parameters.

Conclusion

At this stage, Groovy isn’t planning to have multiversal equality as a standard feature but if you think you would find it useful, do let us know!