[ Previous Section | Next Section | Chapter Index | Main Index ]

Section 7.4

Records


Some programming languages have two basic kinds of built-in data structures: arrays and records. An array consists of a sequence of items, where individual items are referred to by their numerical position in the sequence. In a record, on the other hand, the positions in the data structure have names instead of numbers. The items in a record are called its "fields," and the names for the items are "field names." A field is accessed using its field name. We recognize records as similar to objects, with the fields in a record playing the same role as instance variables in an object, but records existed before object-oriented programming. The actual word "record" is used in programming languages such as Pascal and Cobol. The C programming language uses the term "struct" for the same idea. The "record" terminology might have originated with databases, which are just large, organized collections of data, where a record is a (typically small) set of related data items, and a database is a collection of records.

In Java, classes can be used to represent records, but the term "record" has not traditionally been used. However, in Java 17, records have become an official part of the language in the form of a special kind of class. Java records are not really equivalent to records in other languages, since a record in Java is immutable, that is, its content cannot be modified after the record is created. However, they are similar to other records is that they are fairly simple containers for named fields.


7.4.1  Basic Java Records

A Java record is an object that belongs to a special kind of class, which I will call a record class. In the simplest case, a record class specifies nothing but the set of instance variables that represent the fields of the record. Here is an example:

public record FullName(String firstName, String lastName) { }

This is a class definition for a record class named FullName that has two instance variables of type String named firstName and lastName. These instance variables are the fields of the record. The "{ }" at the end of the definition is an empty class body. Note that the instance variables in a record class are listed in parentheses after the class name. The syntax is the same as the syntax for a list of formal parameters in a method definition, but the meaning is very different. A record of type FullName—that is, an instance of the record class—is created in the usual way, with the new operator. For example,

FullName fname = new FullName("Jane", "Doe");

This statement calls a constructor that has one parameter for each field of the record, whose effect is simply to provide a value for each field. Note that this constructor was not explicitly defined in the class. It is called the canonical constructor for the record class, and it is provided automatically by the compiler. In fact, many things are added implicitly to a record class definition by the compiler. The simple record definition of FullName, given above, is essentially equivalent to the following regular class definition:

public final class FullName {
    private final String firstName;
    private final String lastName;
    public FullName( String firstName, String lastName ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public firstName() {
        return firstName;
    }
    public lastName() {
        return lastName;
    }
    public String toString() {
       return "FullName[firstName=" + firstName 
                      + ", lastName=" + lastName + "]";
    }
    public boolean equals(Object obj) {
        // (definition omitted)
    }
    public int hashCode() {
        // (definition omitted)
    }
}

We see that a record class is automatically final, that is, it cannot be extended by subclasses. Furthermore, a record class cannot extend another class (but it is a subclass of Object, as is true for any class).

The instance variables in a record are private and final. Accessor, or "getter", methods for the instance variables are automatically defined, but instead of using the typical getXXX() naming convention for getter methods, their names are the same as the names of the instance variables. For example, if fname is a variable of type FullName, then the instance variables would be accessed as fname.firstName() and fname.lastName(). Because its instance variables are final, a record is immutable, so no "setter" methods can be defined.

Furthermore, reasonable definitions are automatically provided for three methods inherited from class Object: toString(), equals(), and hashCode(). The toString() method returns a string that includes the name of the class and the names and values of its fields. The equals() method returns true if its parameter is an object of the same type that has the same values for its fields. (We will not encounter the hashCode() method until Section 10.3.)

We will see that record class definitions can be more complex, but you should expect basic record classes, with empty class bodies, to be very common, since they provide a simple way to group together a set of related data items. For example, the CurveData class from the SimplePaint2.java example in the previous section was created to group together all the data relevant to a single curve:

private static class CurveData {
    Color color;
    boolean symmetric; 
    ArrayList<Point2D> points;
}

This nested class could be replaced by a nested record class:

private record CurveData(
    Color color,
    boolean symmetric,
    ArrayList<Point2D> points
) { }

Note that the nested CurveData record class does not have to be declared static, because nested record classes are automatically static.

After this change, when a CurveData object is created, values must be provided for its fields. For example,

currentCurve = new CurveData(currentColor, useSymmetry, new ArrayList<Point2D>());

Another change is that CurveData objects are now immutable. That happens to be OK in SimplePaint2.java, but it's not something that will work in all cases. For example, class Point2D is a simple container for xy coordinates, but it could not be a record class because points are not immutable.


7.4.2  More Record Syntax

It is not possible to add additional instance variables to a record class, beyond those that are defined in the list after the class name. But almost anything else can be added in the class body. For example, a record class can contain static items of any kind. It can define instance methods, including replacements for the default toString(), equals(), and hashCode() methods. And it can define constructors, with just a few restrictions. First of all, it is possible to extend the definition for the canonical constructor that is defined automatically, using a syntax in which the parameter list for the constructor is omitted. For example, the canonical constructor for the FullName class might throw an exception if firstName is null:

public FullName { // canonical constructor for the FullName class
    if ( firstName == null) {
        throw new IllegalArgumentException("First name can't be null.");
    }
}

This extends the canonical constructor. Although the parameter list is omitted in the definition, a call to this constructor still requires two parameters, and it still uses those parameters to initialize firstName and lastName, before the code in the constructor definition is executed.

Additional constructors can be defined, but any non-canonical constructor must begin with a call to a constructor in the same class, using the special variable this as discussed in Subsection 5.6.3. This means that the canonical constructor will be called, directly or indirectly, by any other constructor.

As an example, noting that there are people who use only a single name, we might want to provide a FullName constructor that takes just one parameter representing that name:

public FullName(String onlyName) {
    this( onlyName, null ); // call the canonical constructor
}

This constructor calls the default constructor to set the firstName field equal to onlyName and the lastName field equal to null.

We might also want to define a more natural version of toString() for the FullName record class. For a full class definition that implements all of these ideas, see the sample file FullName.java.

A final syntax note: Although a record class cannot extend another class, it can implement one or more interfaces.


7.4.3  A Complex Example

A complex number consists of two real numbers, which are called the real part and the imaginary part of the complex number. Without knowing anything about the mathematics of complex numbers, you should see that this is a natural application for records. To represent a complex number in Java, we need a simple container for two values of type double. That could be done with a basic record class

record Complex( double re, double im ) { }

But there are many things that can be done with complex numbers, and we would want to include some of those things in a class that could truly be said to represent them. Here is a record class that includes a few of those things:

/**
 * A record type for representing complex numbers, where
 * a complex number consists of two real numbers called its
 * real and imaginary parts.  The class includes methods for
 * doing arithmetic with complex numbers.
 */
public record Complex(double re, double im)  {
    
    // Some named constants for common complex numbers.

    public final static Complex ONE = new Complex(1,0);
    public final static Complex ZERO = new Complex(0,0);
    public final static Complex I = new Complex(0,1);

    /**
     * This constructor creates a complex number with a given
     * real part and with imaginary part zero.
     */
    public Complex(double re) {
        this(re,0);
    }
    
    /**
     * Creates string representations of complex number such
     * as:  3.0 + I*5.0,  -I*3.14,   2.7 - I*8.6,   3.14
     */
    public String toString() {
        if (im == 0)
            return String.valueOf(re);
        else if (re == 0) {
            if (im < 0)
                return "-I*" + (-im);
            else
                return "I*" + im;
        }
        else if (im < 0)
            return re + " - " + "I*" + (-im);
        else
            return re + " + " + "I*" + im;
    }

    // Some methods for doing arithmetic on two complex numbers
    
    public Complex plus(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }
    public Complex minus(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }
    public Complex times(Complex c) {
        return new Complex(re*c.re - im*c.im,
                re*c.im + im*c.re);
    }
    public Complex dividedBy(Complex c) {
        double denom = c.re*c.re + c.im*c.im;
        double real = (re*c.re + im*c.im)/denom;
        double imaginary = (im*c.re - re*c.im)/denom;
        return new Complex(real,imaginary);
    }
        
} // end record Complex

This class adds some static member variables, a constructor that creates a complex number from a single real number, a toString() method that prints a complex number in a reasonable format, and some instance variables that implement arithmetic operations on complex numbers. Of course, it also has the canonical constructor that creates a complex number from two real numbers. The sample program RecordDemo.java tests both Complex.java and FullName.java.


It might be worth thinking about why record classes should be final and why records should be immutable. One reason for them to be final is that it can make it possible for a complier to apply certain kinds of optimization to the code that it generates. This applies not just to record classes but to final classes in general.

Here is an example. It is common to compute complicated arithmetic expressions involving complex numbers. Consider the quadratic formula Ax2+Bx+C. If A, B, C, and x are objects of type Complex, then the value of this expression can be computed as

(A.times(x).times(x)).plus(B.times(x)).plus(C)

If you check the definitions of the times() and plus() methods, you can see that every time a method is called, it creates a new Complex object. In the quadratic formula example, five new objects are generated, but we are only interested in the one that represents the final answer. The other four objects are just scratch work: They are created, used very briefly and then immediately become eligible for garbage collection. Creating and garbage collecting large numbers of objects can be inefficient. However, in this case, the compiler might be able to avoid creating those extra objects by replacing the calls to plus() and times() with code that performs the same operations directly using temporary local variables of type double instead of objects. It can do this because it knows exactly what each method call does—but that is only the case because the Complex class is final. If that were not the case, then A, B, C, or x might actually refer to objects belonging to subclasses of Complex that have redefined plus() and times(). That is something that can only be checked at run time, not at compile time, so if the class were not final, a compiler would have no way of knowing what the calls to plus() and times() will do when the program is run.

As for immutability, it might also help with optimization, since a compiler can be sure that calling a method on an object will not modify the instance variables in that object. However, it is probably more important that immutability makes it easier to reason about a program. If you can prove that some property of an immutable object is true at some point in time, you can be sure that it won't become false later because the object has been modified.


[ Previous Section | Next Section | Chapter Index | Main Index ]