Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Compatibility issues while converting Classes to Records

I have been working with the following class named City

@ToString
@AllArgsConstructor
public class City {
    Integer id;
    String name;
}

and tried to convert it to a record called CityRecord as

record CityRecord(Integer id, String name) {} // much cleaner!

But moving to such a representation, one of our unit tests starts failing. The tests internally deal with a list of cities read from a JSON file and mapped to an object further counting the cities while grouping them under into a Map. Simplified to something like:

List<City> cities = List.of(
        new City(1, "one"),
        new City(2, "two"),
        new City(3, "three"),
        new City(2, "two"));
Map<City, Long> cityListMap = cities.stream()
        .collect(Collectors.groupingBy(Function.identity(),
                Collectors.counting()));

The above code asserted true to contain 4 keys and each accounting for 1 of its occurrence. With the record representation, there are no more than 3 keys in the resulting Map. What is causing this and what should be the way to go around this?

like image 992
Naman Avatar asked Mar 22 '20 07:03

Naman


People also ask

Can a class inherit from a record?

Inheritance works also with record types. You can inherit your records from other records or from object . You can not inherit a record from any other class than object , and you can not inherit a class from a record. The following code snippet shows two record types.

What does the constructor method do for a record?

The constructor initializes each private field from the corresponding argument. Implementations of the equals() and hashCode() methods, which specify that two records are equal if they are of the same type and their corresponding record components are equal.

What is canonical constructor in Java?

The compiler also creates a constructor for you, called the canonical constructor. This constructor takes the components of your record as arguments and copies their values to the fields of the record class. There are situations where you need to override this default behavior.

Can a Java record implement an interface?

Record classes allow you to implement interfaces. You can implement any interface you want whether it's a single interface or multiple interfaces.


1 Answers

Cause

The reason behind the behavior observed is as documented in java.lang.Record

For all record classes, the following invariant must hold: if a record R's components are c1, c2, ... cn, then if a record instance is copied as follows:

 R copy = new R(r.c1(), r.c2(), ..., r.cn());   then it must be the case that r.equals(copy).

In short, your CityRecord class now has an equals(and hashcode) implementation that compares the two attributes and ensure if they are equal the record consisting of those components are also equal. As a result of this evaluation, the two record objects with the same attributes would be grouped together.

The result, therefore, would be correct to infer/assert that there should be three such keys with the one having id=2, name="two" counted twice.

Immediate Remedy

An immediate temporary solution to this would be to create a custom(flawed - reason explained later) equals implementation within your record representation as well. This would look like:

record CityRecord(Integer id, String name) {

    // WARNING, BROKEN CODE
    // Does not adhere to contract of `Record::equals`
    @Override
    public boolean equals(Object o) {
        return this == o;
    }

    @Override
    public int hashCode() {
        return System.identityHashCode(this);
    }
}

Now that the comparison would be between two objects as in while using the existing City class, your tests would just work fine. But you must note the caution below before using any such remedy.

Caution

As the JEP-359 reads, Records are more like "data carrier" and while choosing to migrate your existing classes, you must be aware of the standard members acquired by a record automatically.

Planning to migrate one must be aware of the complete details of the current implementation, such as in the example you quoted while you've grouped by City, there should be no reason to have two cities with same id and name data to be listed differently. They should be equal, it should be the same data after all repeated twice and hence the correct counts.

In which case, your existing implementation if representing a data model could be rectified to match the record in a way by overwriting the equals implementation to account for comparing the individual attributes as well which is where the immediate remedy stated above is contradictory and should be avoided.

like image 68
Naman Avatar answered Oct 10 '22 17:10

Naman