You are currently viewing Record Types

Record Types

According to some surveys such as that of JetBrains, version 8 of Java is currently the most used by developers all over the world, despite being a 2014 release. What you are reading is one in a series of articles titled “Going beyond Java 8”, inspired by the contents of my book “Java for Aliens”. These articles will guide the reader step by step to explore the most important features introduced starting from version 9. The aim is to make the reader aware of how important it is to move forward from Java 8, explaining the enormous advantages that the latest versions of the language offer.

In this article we will see how Oracle with Java 16 has officially introduced a fifth Java type in addition to classes, interfaces, enumerations, and annotations: the record type. Records are particular classes defined with a very synthetic syntax. They are designed to implement classes that represent data. In particular, records are designed to represent immutable data containers. Record syntax helps developers focus on designing that data without getting lost in implementation details.

 

Syntax

The syntax of a record is minimal:

[modifiers] record identifier (header) {[members]}

By the term header we mean a list of variable declarations separated by commas, and which will represent the instance variables of the record. A record implicitly defines a constructor that has the header as parameter list, defines the accessor methods of all the fields declared in the header, and provides a default implementation of the toString, equals and hashCode methods.

Let’s see an example right away. So, let’s suppose we want to write an application for the sale of paintings at auction. These are to be understood as immutable objects. In fact, once they have been put up for sale, they cannot be changed. For example, a painting cannot change its title after it has been defined. We can then create the Painting record:

public record Painting(String title, String author, int price) { }

We can instantiate this record as if it were a class with a constructor defined with the list of header parameters:

Painting painting = new Painting("Camaleón", "Leonardo Furino", 1000000);

Since a record also automatically defines the toString method, the following snippet:

System.out.println(painting);

will produce the output:

Painting[title=Camaleón, author=Leonardo Furino, price=1000000]

One of the obvious advantages of the records is therefore the extremely synthetic syntax.

 

Records, Enumerations and Classes

There are clear similarities between record types and enumeration types. Both types replace classes in particular situations. Enumerations are designed to represent a defined number of constant instances of the same type. Records, on the other hand, should represent immutable data containers. Like enumerations, records also simplify the developer’s work by offering less verbose syntax than classes, and simple, clear rules.

The records were introduced only with Java 14 as a feature preview and made official with Java 16. As always, Java mitigates the impact of this new feature by delegating to the compiler the task of transforming records into classes to maintain backward compatibility with older programs. Specifically, as the enumerations are transformed by the compiler into classes that extend the abstract java.lang.Enum class, the records are transformed by the compiler into classes that extend the abstract java.lang.Record class.

As with the Enum class, the compiler will not allow the developer to create classes that directly extend the Record class. In fact, it is a special class too, created specifically to support the concept of records.

When we compile the Painting.java file, we will get the Painting.class file. In this file, the compiler will have inserted a Painting class (the result of the record conversion) that:

  • is declared final;
  • defines a constructor that takes the header as parameter list;
  • defines the accessor methods of all fields declared in the header;
  • overrides the Object methods: toString, equals and hashCode.

In fact, the JDK javap tool allows us to read through introspection the structure of the generated class Painting.class with the following command:

javap Painting.class
Compiled from " Painting.java"
public final class Painting extends java.lang.Record {
  public Painting(java.lang.String, java.lang.String, int);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String title();
  public java.lang.String author();
  public int price();
}

Note that accessor method identifiers do not follow the usual convention we have used so far. Instead of being called getTitle,  getAuthor and getPrice they are simply called title, author and price, however the functionality remains unchanged.

We can therefore have read access to the individual fields of the record using the following syntax:

String title = painting.title();
String author = painting.author();

 

If the Records did not Exist

If we had created a Painting class equivalent to the record, we would have had to manually write the following code:

public final class Painting {
    private String title;
    private String author;
    private int price;

    public Painting(String title, String author, int price) {
        this.title = title;
        this.author = author;
        this.price = price;
    }

    public String title() {
        return title;
    }

    public String author() {
        return author;
    }

    public int price() {
        return price;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((author == null) ? 0 : author.hashCode());
        result = prime * result + price;
        result = prime * result + ((title == null) ? 0 : title.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        Painting other = (Painting) obj;
        if (author == null) {
            if (other.author != null)
                return false;
        } else if (!author.equals(other.author))
            return false;
        if (price != other.price)
            return false;
        if (title == null) {
            if (other.title != null)
                return false;
        } else if (!title.equals(other.title))
            return false;
        return true;
    }

    @Override
    public String toString() {
        return "Painting [title=" + title + ", author=" + author + ", price=" 
            + price + "]" ;
    }
}

It seems evident that, in this case, defining a record rather than a class is undoubtedly more convenient, although an IDE would still have allowed us a semi-automated development of this class.

 

Inheritance and Polymorphism

Records were designed to represent objects that carry immutable data. For this reason, record inheritance is not implementable. In particular, a record cannot be extended since the records are automatically declared final. Also, a record cannot extend a class (and obviously cannot extend a record) since it already extends the Record class.

It is a choice that seems limiting, but it is consistent with the philosophy of using records. A record must be immutable and inheritance is not compatible with immutability. However, by implicitly extending the Record class, a record inherits the methods of that class. Actually, the Record class only overrides the 3 methods inherited from the Object class: toString, equals and hashCode, and does not define new methods.

Within a record, we can also override both the accessor methods and the three Object methods that the compiler would generate at compile time. In fact, it might be useful to explicitly declare them within our code to customize and optimize them if needed. For example, we could customize the toString method in the Painting record as follows:

public record Painting(String title, String author, int price) { 
    @Override 
    public String toString() {
        return "The painting " +  title + " by " + author + " costs " + price;
    }
}

We also already know that records, like enums, cannot be extended, and neither can they extend other classes or records. However, records can implement interfaces.

As with enumerations, records are also implicitly final, and therefore the abstract modifier cannot be used. So, when we implement an interface in a record, we must necessarily implement all the inherited methods.

 

Customizing a Record

It is not possible to declare instance variables and instance initializers in a record. This is in order not to violate the role of the record which should be to represent a container of immutable data.

Instead, you can declare static methods, variables, and initializers. These, in fact, being static, are shared by all instances of the record and do not have access to the instance members of a particular object.

But the most interesting part of customizing a record is being able to create constructors as well.

We know that in a class if we do not add constructors, the compiler will add a parameterless constructor called the default constructor. When we explicitly add a constructor within a class, whatever is its number of parameters, the compiler will no longer add the default constructor.

In a record, however, the constructor that automatically adds the compiler, defines the variables defined in the record header as parameters. This constructor is called the canonical constructor. Among its characteristics, it is the only constructor that is allowed to set the instance variables of a record (as we will see soon). That said, our options for defining constructors are as follows:

  • explicitly redefine the canonical constructor, preferably with its compact form;
  • define a non-canonical constructor that invokes the canonical constructor;

 

Canonical Constructor

We can explicitly declare a canonical constructor. This can be useful if, for example, we want to add consistency checks, before setting the value of the instance variables. For example, consider the following record that abstracts the concept of a photo, to which we add a canonical constructor explicitly:

public record Photo(String format, boolean color) {
    public Photo(String format, boolean color) {
        if (format.length() < 5) throw new 
            IllegalArgumentException("Format description too short");
        this.format = format;
        this.color = color;
    }
}

Note that it is mandatory to initialize the instance variables, otherwise, the compiler will report an error. If we do not initialize for example the format variable, we will get the following error:

error: variable format might not have been initialized
    }
    ^
1 error

In this case, we have explicitly created a canonical constructor that must define the same list of parameters defined in the record header. However, we can make it easier to create an explicit canonical constructor by using its compact form.

 

Compact Canonical Constructor

It is indeed possible to create a compact canonical constructor. It is characterized by the fact that it does not declare the parameter list. This does not mean that it will have an empty parameter list, but that round brackets will not be present next to the constructor’s identifier. So, let’s rewrite a constructor equivalent to that of the previous example:

public Photo {
    if (format.length() < 5) throw new IllegalArgumentException(
        "Format description too short");
}

The use of compact canonical constructors should be considered the standard way to define constructors explicitly in a record. Note that it was not even necessary to initialize the instance variables that are automatically initialized. To be more precise, if we try to initialize the instance variables in a compact canonical constructor we will get a compile-time error.

 

Non-canonical Constructor

It is also possible to define a constructor with a parameter list different from that of the canonical constructor, that is, a non-canonical constructor. In this case, we’re doing a constructor overload. In fact, unlike what happens in the case of a default constructor in a class, adding a constructor with a different list of parameters will not prevent the compiler from adding the canonical constructor anyway. Also, a non-canonical constructor must invoke another constructor as its first statement. In fact, if we add the following constructor:

public Photo(String format, boolean color, boolean msg) {
    if (format.length() < 5) throw new IllegalArgumentException(msg);
    this.format = format;
    this.color = color;
}

we will get a compile-time error:

Error: constructor is not canonical, so its first statement must invoke another constructor
    public Photo(String format, boolean color, String msg) {
           ^
1 error

Clearly, if we add another non-canonical constructors to call, sooner or later the time will come to invoke the (explicit or implicit) canonical constructor. In our example, if we then call the canonical constructor directly, we will also have to delete the instructions for setting the instance variables, since these will be set by the canonical constructor after it has been invoked in the first line of the non-canonical constructor. In fact, the following constructor:

public Photo(String format, boolean color, String msg) {
    this(format, color);
    if (format.length() < 5) throw new IllegalArgumentException(msg);
    this.format = format;
    this.color = color;
}

will cause the following compilation errors:

error: variable format might already have been assigned
        this.format = format;
            ^
error: variable color might already have been assigned
        this.color = color;
            ^
2 errors

which indicates that the two variables will already be initialized at this point. This shows that it is the canonical constructor that always has the responsibility of setting the instance variables of a record. So we just have to delete the unnecessary lines:

public Photo(String format, boolean color, String msg) {
    this(format, color);
    if (format.length() < 5) throw new IllegalArgumentException(msg);
}

At this point, we will be able to create objects from the Photo record, both with the canonical constructor and with the non-canonical one. For instance:

var photo1 = new Photo("Photo 1" , true); // canonical constructor
System.out.println(photo1);
var photo2 = new Photo("Photo 2" , false, "Error!"); // non-canonical constructor
System.out.println(photo2);
var photo3 = new Photo("Photo" , true, "Error!"); // non-canonical constructor
System.out.println(photo3);

the previous code will print the output:

Photo[format=Photo 1, color=true]
Photo[format=Photo 2, color=false]
Exception in thread "main" java.lang.IllegalArgumentException: Error!
    at Photo.<init>(Photo.java:8)
    at TestRecordConstructors.main(TestRecordConstructors.java:7)

 

When to Use Records

It should already be clear when to use a record instead of a class. As stated above, records are designed to represent immutable data containers. Records cannot always be used in place of classes, especially if these classes primarily define business methods.

However, the nature of the software is to evolve. It is therefore possible that, even if we create a record to represent a container of immutable data, it is not certain that one day it is not appropriate to transform it into a class. A clue that should lead us to prefer to rewrite a record in the form of a class, is when we have added too many methods or extended too many interfaces. In such cases, it is worth asking whether the record needs to be transformed into a class.

A record fits well with sealed interfaces, due to its immutable nature. Furthermore, it usually does not represent concepts that aggregate a large number of instance variables.

The concept of the record seems to adapt very well to the implementation of the design pattern known as DTO (acronym for Data Transfer Object).

 

Conclusion

The records represent an important step forward for the Java language. This is certainly one of the novelties that will be most appreciated by programmers over time. In fact, they will no longer be forced to add the usual access methods and method implementations inherited from Object via the IDE. Actions that are boring and usually performed absently, which can also lead to the introduction of bugs. In particular, the records allow us to focus on the design of the data without having to go into the implementation details, which we always have the possibility to customize. Furthermore, the immutable nature of records will direct us to write simpler and more efficient programs.

 

Author Notes

Even ignoring the increased security offered by the latest versions of the JDK, there are plenty of reasons to upgrade your knowledge of Java, or at least your own Java runtime installations. My book “Java for Aliens“, which inspired the ” Going beyond Java 8″ series, contains all the information you need to learn Java from scratch, and uses a well-tested teaching method that has been perfected over 20 years of experience, which makes learning simple and exciting. It is also structured to deepen the topics and have superior knowledge that can make a difference in your career.

This article is mainly inspired by section 19.3 of chapter 196 of the book “Java for Aliens

For more information visit https://www.javaforaliens.com.

Leave a Reply