Language selector

Java Records tortured with Lombok again

Update April 2021: Please note this post is about Java 15, with records as preview feature. Things have changed, the update is available in another post.

Why so serious?

In one of my previous posts I was torturing Java™ Records using Lombok. After receiving some really encouraging comments (‘what a sick idea you have, respect!'), delivering a deep-dive talk “Java 15. What’s new and noteworthy”, and some discussions on JVM Poland Slack channel*, I’ve decided to keep torturing. Sorry ;-)

During the talk and discussing it after there were some, quite natural I’d say, attempts to compare records to beans, Lomboked classes, and Scala’s case classes. (Along with ‘how do you feel about Java drawing ideas from Scala’ questions.)

I don’t mind Java getting ideas to evolve from anywhere. Being also Scala developer, I really don’t see any issues with that, whether there was any inspiration or not. In my opinion (at the moment of writing) Java Records are not quite the same as Scala case classes and have somewhat different philosophies, but both can be used in the very same way: as immutable data carriers.

I prefer my data carriers to be immutable first, and then opened to mutability if that’s really necessary and justified. Also, I don’t see immutability as any sort of big issue anyway. Have you recently heard anyone complaining about immutable Strings in Java? Or immutable ZonedDateTime? Exactly. On the other hand, how many issues did we face with mutable java.util.Date…?

However, to be honest, sometimes we need to create new instances with a little “but”. Like ‘I need this timestamp to be two minutes later’. Then of course we don’t mutate the very same object, but create a new one based on the original one. Something like:

1var i1 = java.time.Instant.now();
2var i2 = i1.plusSeconds(120)

This is something Java Records are missing, at least now.

Meanwhile in Scala

In Scala the case classes have a very handy copy method generated. Basically, it’s a method with the same signature as the primary constructor and if an argument/field is missing in the call, the value for such an argument/field is copied from the object on which we’re calling this method.

Whoa! How can an argument to a method be missing in the call? Do you set null there (a fellow Java developer may ask)? Not really. Scala 2 has two nice features when it comes to methods: parameters might have default values and arguments to these parameters can be provided in any order, because they can be called by name, not only by position (like in Java). Therefore, having a case class like:

1case class CaseClass(uno: String, dos: String, tres: String)

we can create an instance of it:

2val cc1 = CaseClass("u", "d", "t")

and then create an almost identical copy

3val cc2 = cc1.copy(dos="two")

Yes, that makes cc2’s fields uno and tres the same as in cc1, but dos keeps "two". That makes creating new instances which share a lot of field values really nice.

Let’s say we’d like to have something similar… We can’t have anything identical, for Java doesn’t have named parameters.

hold my beer

Hold my beer

What comes to mind almost instantly is ‘how about Lombok's @With’? Please follow the documentation of @With, as I don’t want to repeat it with full details here.

In short: if we have a Java Bean with field name and we prepend this field or annotate the bean class with @With, in the generated bytecode there will be a method withName which will return a new instance with all the fields being the same, except name set to whatever we passed to withName.

Yeap, you’re right, let’s try to use Lombok with Java Record :-D The idea looks tempting, but annotating a record with @With looks a bit weird in IDEA™. The following code:

1@With
2public record RecordWithWith(String uno, String dos, String tres) {}

results with a notification on the annotation. You can take a look on your own:

RecordWithWith

Records always have the “full” canonical constructor. Even adding one explicitly doesn’t make this note vanish. However, maybe we can happily ignore it, because the generated bytecode seems okay…? Decompiled it looks like this:

 1  // IntelliJ API Decompiler stub source generated from a class file
 2  // Implementation of methods is not available
 3
 4package dev.softwaregarden.records.torture.lombok;
 5
 6public final class RecordWithWith extends java.lang.Record {
 7    private final java.lang.String uno;
 8    private final java.lang.String dos;
 9    private final java.lang.String tres;
10
11    public RecordWithWith(java.lang.String uno, java.lang.String dos, java.lang.String tres) { /* compiled code */ }
12
13    public dev.softwaregarden.records.torture.lombok.RecordWithWith withUno(java.lang.String uno) { /* compiled code */ }
14
15    public dev.softwaregarden.records.torture.lombok.RecordWithWith withDos(java.lang.String dos) { /* compiled code */ }
16
17    public dev.softwaregarden.records.torture.lombok.RecordWithWith withTres(java.lang.String tres) { /* compiled code */ }
18
19    public final java.lang.String toString() { /* compiled code */ }
20
21    public final int hashCode() { /* compiled code */ }
22
23    public final boolean equals(java.lang.Object o) { /* compiled code */ }
24
25    public java.lang.String uno() { /* compiled code */ }
26
27    public java.lang.String dos() { /* compiled code */ }
28
29    public java.lang.String tres() { /* compiled code */ }
30}

Let’s use these with... methods, shall we? Oopsie, red alert in IDEA:

Red Alert

Hmmm… can we ignore it? Let’s check the bytecode:

 1//
 2// Source code recreated from a .class file by IntelliJ IDEA
 3// (powered by FernFlower decompiler)
 4//
 5
 6package dev.softwaregarden.records.torture.lombok;
 7
 8public class LombokAndRecordsCheck {
 9    public LombokAndRecordsCheck() {
10    }
11
12    public static void main(String[] args) {
13        new LombokedBean(1, "one");
14        RecordWithWith rww1 = new RecordWithWith("1", "a", "Z");
15        RecordWithWith rrw2 = rww1.withDos("A").withTres("z");
16        System.out.println(rrw2);
17    }
18}

It should be working then. Let’s give it a spin. What do we see in the output window? This:

1Called constructor with [1], [a], [Z]
2Called constructor with [1], [A], [Z]
3Called constructor with [1], [A], [z]
4RecordWithWith[uno=1, dos=A, tres=z]

Despite being red in IDE’s editor, the code compiles and works. It looks like we could do that, but I’m not sure we should. I know, the “red alert” is not coming from IDEA’s core code, the Lombok IDE plugin doesn’t come from JetBrains and most probably it got fooled by the record. The real compiler down below handled things as expected and made them run… However, please read the output with some extra care. I added the print statement in the constructor to demonstrate such a naive approach with subsequent with... calls results with subsequent objects being created. Not so nice for the performance, not so nice for this planet.

‘Hey, maybe we could experiment with @Builder!’ I’m not saying ‘no’, but maybe we should simply:

1RecordWithWith rww1 = new RecordWithWith("1", "a", "Z");
2RecordWithWith rrw2 = new RecordWithWith(rr1.uno(), "A", "z");

Maybe it’s not that bad? Maybe it’s somewhat verbose, but easier to read? Maybe one day Java Records with have with methods generated by the compiler? We shall see in next 25 years. ;-)

If your fingers are itchy, and you’d like to torture records on your own, the code is on Github.

*) You’re more than welcome to join JVM Poland Slack channel. There’s one caveat: the whole transmission is encrypted. We speak Polish. ;-)

Language selector