15. Records#

15.1. Basics#

One of the strongest points of criticism of the Java language is its verbosity - so much code is used to specify simple data constructs. Up to Java 14 this was certainly a justified criticism. Consider this class, which models the properties of a biological Species:

package snippets.records;

public class Species {
    private String englishName;
    private String latinName;
    private String status;

    public Species(String englishName, String latinName, String status) {
        this.englishName = englishName;
        this.latinName = latinName;
        this.status = status;
    }

    public String getEnglishName() {
        return englishName;
    }

    public String getLatinName() {
        return latinName;
    }

    public String getStatus() {
        return status;
    }

    @Override
    public String toString() {
        return "Species{" +
                "englishName='" + englishName + '\'' +
                ", latinName='" + latinName + '\'' +
                ", status=" + status +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Species species = (Species) o;

        if (getEnglishName() != null ? !getEnglishName().equals(species.getEnglishName()) : species.getEnglishName() != null)
            return false;
        if (getLatinName() != null ? !getLatinName().equals(species.getLatinName()) : species.getLatinName() != null)
            return false;
        return getStatus() == species.getStatus();
    }

    @Override
    public int hashCode() {
        int result = getEnglishName() != null ? getEnglishName().hashCode() : 0;
        result = 31 * result + (getLatinName() != null ? getLatinName().hashCode() : 0);
        result = 31 * result + (getStatus() != null ? getStatus().hashCode() : 0);
        return result;
    }
}

That is a lot of code to for a class that simply models the data associated with a Species entity - a data class. We call this boilerplate code, and we don’t like it. Of course, in modern IDEs like IntelliJ you can autogenerate almost all of this code - I only typed the class name and properties and the rest was generated though the keyboard shortcut Cmd + N - but all this code also makes it harder to understand what this class really models: a Species entity with English name, Latin name and conservation status. Moreover, when a new field is added or an existing field is changed (e.g. status from String to enum), this requires a careful refactoring of the class.

Biolerplate code

In computer programming, boilerplate code, or simply boilerplate, are sections of code that are repeated in multiple places with little to no variation (Wikipedia). Wikipedia omits to say “and without the possibility to abstract this code away into functions or other constructs”.

Some would maybe suggest to simply take out all boilerplate, and access modifiers, like in the next example.

public class Species {
    String englishName;
    String latinName;
    String status;

    @Override
    public String toString() {
        return "Species{" +
                "englishName='" + englishName + '\'' +
                ", latinName='" + latinName + '\'' +
                ", status='" + status + '\'' +
                '}';
    }
}

This way, you simply set and get the properties directly, and rely on tha class Object equals() and hashCode() definitions. Pause a moment to think why this would be a bad idea before continuing.

The huge difference lies of course in immutability and encapsulation!

Since Java 14, this has been overcome with addition of the new record type.

Record

Records are immutable data classes that require only specification of the type and name of its fields.
By using records, all boilerplate code is still created, but behind the scenes by the compiler. The equals(), hashCode(), and toString() methods as well as the private, final instance variables, getters for each of these, and a public constructor are generated by the compiler.

Here is the Species class as a record:

public record Species(String englishName, String genus, String species, String status) { }

This single line of code will generate the same class as the first example, but only in its compiled form.

Here you see the auto-generated constructor and toString methods in action.

Species bonobo = new Species("Bonobo", "Pan paniscus", "Endangered");
System.out.println("bonobo = " + bonobo);

outputs

bonobo = Species[englishName=Bonobo, latinName=Pan paniscus, status=Endangered]

The test method below passes both assertions since the equals() and hashCode() methods have been implemented as well. The Object methods would have failed here, of course.

@Test
void testEqualsAndHashCode() {
    Species bonobo1 = new Species("Bonobo", "Pan paniscus", "Endangered");
    Species bonobo2 = new Species("Bonobo", "Pan paniscus", "Endangered");

    assertEquals(bonobo1, bonobo2);
    assertEquals(bonobo1.hashCode(), bonobo2.hashCode());
}

Standard getter names have changed

Note that the compiler-provided getter names have changed to the name of the instance variable themselves. In records the compiler-generated getter for englishName will be englishName() instead of getEnglishName().

15.2. Customization#

The default implementation of the constructor is simply this:

public Species(String englishName, String latinName, String status) {
    this.englishName = englishName;
    this.latinName = latinName;
    this.status = status;
}

But you can override this by supplying your own. This can serve two purposes. The first is that you want to add some checks or initialization, and the second is to provide more than one constructor, publishing different argument lists.

This constructor adds null-checking to two of the fields. Note that there is no parameter-list here: it is no regular constructor - it merely adds behavior to the auto-generated constructor.

public Species {
    Objects.requireNonNull(englishName);
    Objects.requireNonNull(latinName);
}

So the first of these assertions passes and the second fails with a NullPointerException:

@Test
void testNullArguments() {
    //passes
    Species bonobo1 = new Species("Bonobo", "Pan paniscus", null);
    System.out.println("bonobo = " + bonobo1);

    //fails
    Species bonobo2 = new Species("Bonobo", null, "Endangered");
    System.out.println("bonobo = " + bonobo2);
}

This extra, custom constructor provides a means to set a default conservation status at construction time (this one is with an argument list).

public Species(String englishName, String latinName) {
    this(englishName, latinName, "Least Concern");
}

So Species objects can be instantiated with only values for English and Latin names provided.

Immutability is not optional

There are two things not allowed: changing the fields that are defined in the record parameter list, or adding new instance variables. The class below attempts to do both these illegal things; they will be spotted and forbidden by the compiler.

public record Species(String englishName, String genus, String species, String status) {
    //instance field is not allowed in records
    private String[] countries = null;

    public void setEnglishName(String newName) {
        // cannot assign a value to a final variable
        this.englishName = newName;
    }
}

Finally, you can add static and nonstatic method and fields, just as regular classes.

import java.util.Objects;

public record Species(String englishName, String latinName, String status) {
    public static final String DEFAULT_CONSERVATION_STATUS = "Least Concern";

    // Overrides constructor behavior
    public Species {
        Objects.requireNonNull(englishName);
        Objects.requireNonNull(latinName);
    }

    // Adds an extra constructor
    public Species(String englishName, String latinName) {
        this(englishName, latinName, DEFAULT_CONSERVATION_STATUS);
    }

    // Publishes a "derived" property 
    public String description() {
        return englishName + " [" + latinName + " ]: " + status;
    }

    // A static factory method
    public static Species fromGenusAndSpecies (String genus, String species) {
        String latinName = genus + " " + species;
        return new Species("NO ENGLISH NAME", latinName);
    }
}

but when you are going in this direction, you lose the clean purpose of records and it may be advisable to go back to a regular class.

Design rule

In summary, whenever you need a simple data class, use the record type. When your class starts to show serious logic, use regular classes.