blob: d937372cbab9aab0d44a3585a79f5b7ef47d9464 [file] [log] [blame]
= Groovy and Multiversal Equality
Paul King
:revdate: 2024-04-24T15:00:00+00:00
:keywords: equals, equality, scala, type checking
:description: This post looks at how Groovy could support multiversal equality.
== Introduction
In Scala 3, an opt-in feature called
https://docs.scala-lang.org/scala3/reference/contextual/multiversal-equality.html[_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.
[sidebar]
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
https://docs.scala-lang.org/scala3/book/ca-multiversal-equality.html[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:
[source,groovy]
----
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
https://docs.scala-lang.org/scala3/book/ca-multiversal-equality.html[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:
[source,groovy]
----
trait Book {
String title
String author
int year
}
----
A domain class for printed books:
[source,groovy]
----
@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:
[source,groovy]
----
@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:
[source,groovy]
----
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
https://www.artima.com/articles/how-to-write-an-equality-method-in-java[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.
[source,groovy]
----
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:
[source,groovy]
----
@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:
[source,groovy]
----
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
[source,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:
[source,groovy]
----
@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:
[source,groovy]
----
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:
[source,groovy]
----
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.
[source,groovy]
----
@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:
[source,groovy]
----
@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`:
[source,groovy]
----
@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
[source,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:
[source,groovy]
----
@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
https://docs.scala-lang.org/scala3/reference/contextual/multiversal-equality.html#why-two-type-parameters-1[two generic parameters].
== Further information
* https://docs.scala-lang.org/scala3/reference/contextual/multiversal-equality.html
* https://docs.scala-lang.org/scala3/book/ca-multiversal-equality.html
* https://www.artima.com/articles/how-to-write-an-equality-method-in-java
* https://github.com/paulk-asert/groovy-multiversal-equality (source code)
== 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
https://groovy-lang.org/mailing-lists.html[let us know]!