This is an automated email from the ASF dual-hosted git repository. paulk pushed a commit to branch asf-site in repository https://gitbox.apache.org/repos/asf/groovy-website.git
The following commit(s) were added to refs/heads/asf-site by this push: new b78dfea add multiversal equality blog post b78dfea is described below commit b78dfea57140e0d832f8ddbe66e68152ebd28469 Author: Paul King <pa...@asert.com.au> AuthorDate: Wed Apr 24 22:37:02 2024 +1000 add multiversal equality blog post --- site/src/site/blog/multiversal-equality.adoc | 387 +++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) diff --git a/site/src/site/blog/multiversal-equality.adoc b/site/src/site/blog/multiversal-equality.adoc new file mode 100644 index 0000000..d937372 --- /dev/null +++ b/site/src/site/blog/multiversal-equality.adoc @@ -0,0 +1,387 @@ += 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]!