JEP draft: Record Classes

OwnerGavin Bierman
TypeFeature
ScopeSE
StatusSubmitted
Componentspecification / language
Created2020/06/08 16:07
Updated2020/09/21 16:12
Issue8246771

Summary

Enhance the Java programming language with record classes, which are a special kind of class that act as transparent carriers for immutable data. Record classes can be thought of as nominal tuples.

History

Record classes were proposed by JEP 359 and delivered in JDK 14 as a preview feature.

Following feedback, the design was refined and proposed by JEP 384 and delivered in JDK 15 as a preview feature for the second time.

The refinements for the second preview were as follows:

  1. In the first preview canonical constructors were required to be public. This was changed in the second preview. Now, if the canonical constructor is implicitly declared then its access modifier is the same as the record class. If it is explicitly declared then its access modifier must provide at least as much access as the record class.

  2. The meaning of the @Override annotation has now been extended to include the case that the annotated method is an explicitly declared accessor method for a record component.

  3. To enforce the intended use of compact constructors, it is now a compile-time error to assign to any of the instance fields in the constructor body.

  4. The JEP was extended to support in addition to local record classes, local enum classes and local interfaces.

This JEP proposes to finalize the feature in JDK 16. The following refinement is being proposed:

Other refinements will be incorporated based on any further feedback.

Goals

Non-Goals

Motivation

It is a common complaint that "Java is too verbose" or has "too much ceremony". Some of the worst offenders are classes that are nothing more than immutable data carriers for a handful of values. Properly writing such a data-carrier class involves a lot of low-value, repetitive, error-prone code: constructors, accessors, equals, hashCode, toString, etc. For example, a class to carry x and y coordinates inevitably ends up like this:

class Point {
    private final int x;
    private final int y;

    Point(int x, int y) { 
        this.x = x;
        this.y = y;
    }

    int x() { return x; }
    int y() { return y; }

    public boolean equals(Object o) { 
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return other.x == x && other.y = y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() { 
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}

Developers are sometimes tempted to cut corners by omitting methods such as equals, leading to surprising behavior or poor debuggability, or by pressing an alternate but not entirely appropriate class into service because it has the "right shape" and they don't want to declare yet another class.

IDEs help us to write most of the code in a data-carrier class, but don't do anything to help the reader distill the design intent of "I'm a data carrier for x and y" from the dozens of lines of boilerplate. Writing Java code that models a handful of values should be easier to write, to read, and to verify as correct.

While it is superficially tempting to treat record classes as primarily being about boilerplate reduction, we instead choose a more semantic goal: modeling data as data. (If the semantics are right, the boilerplate will take care of itself.) It should be easy and concise to declare data-carrier classes that by default make their data immutable and provide idiomatic implementations of methods that produce and consume the data.

Description

Record classes are a new kind of class in the Java language. Record classes help to model plain data aggregates with less ceremony than normal classes.

The declaration of a record class primarily consists of a declaration of its state; the record class then commits to an API that matches that state. This means that record classes give up a freedom that normally classes usually enjoy -- the ability to decouple a class's API from its internal representation -- but in return, record class declarations become significantly more concise.

More precisely, a record class declaration consists of a name, optional type parameters, a header, and a body. The header lists the components of the record class, which are the variables that make up its state. (This list of components is sometimes referred to as the state description.) For example:

record Point(int x, int y) { }

Because record classes make the semantic claim of being transparent carriers for their data, a record class acquires many standard members automatically:

In other words, the header of a record describes its state (the types and names of its components), and the API is derived mechanically and completely for that state description. The API includes protocols for construction, member access, equality, and display. (We expect a future version to support deconstruction patterns to allow powerful pattern matching.)

Constructors for Record Classes

The rules for constructors are different in a record class than in a normal class. A normal class without any constructor declarations is automatically given a default constructor. In contrast, a record class without any constructor declarations is automatically given a canonical constructor that assigns all the private fields to the corresponding arguments of the new expression which instantiated the record. For example, the record declared earlier -- record Point(int x, int y) { } -- is compiled as if it were:

record Point(int x, int y) { 
    // Implicitly declared fields
    private final int x;
    private final int y;

    // Other implicit declarations elided ...

    // Implicitly declared canonical constructor
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

The canonical constructor may be declared explicitly with a list of formal parameters which match the record header, as shown above, or it may be declared in a more compact form that helps the developer focus on validating and normalizing parameters without the tedious work of assigning parameters to fields. A compact canonical constructor elides the list of formal parameters; they are declared implicitly, and the private fields corresponding to record components cannot be assigned in the body but are automatically assigned to the corresponding formal parameter (this.x = x;) at the end of the constructor. For example, here is a compact canonical constructor that validates its (implicit) formal parameters:

record Range(int lo, int hi) {
    Range {
        if (lo > hi)  // referring here to the implicit constructor parameters
            throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
    }
}

A more complicated example where the canonical constructor body normalizes its formal parameters is the following:

record Rational(int num, int denom) { 
    Rational {
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
    }
}

This declaration is equivalent to the following one that uses the conventional constructor form:

record Rational(int num, int denom) { 
    Rational(int num, int demon) {
        // Normalization
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
        // Initialization
        this.num = num;
        this.denom = denom;
    }
}

Rules for Record Classes

There are numerous restrictions on the declaration of a record class in comparison to a normal class:

Beyond the restrictions above, a record class behaves like a normal class:

Local Record Classes

A program that produces and consumes instances of a record class is likely to deal with many intermediate values that are themselves simple groups of variables. It will often be convenient to declare record classes to model those intermediate values. One option is to declare "helper" record classes that are static and nested, much as many programs declare helper classes today. A more convenient option would be to declare a record inside a method, close to the code which manipulates the variables. Accordingly, this JEP proposes local record classes, akin to the traditional construct of local classes.

In the following example, the aggregation of a merchant and a monthly sales figure is modeled with a local record class, MerchantSales. Using this record class improves the readability of the stream operations which follow:

List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
    // Local record
    record MerchantSales(Merchant merchant, double sales) {}

    return merchants.stream()
        .map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
        .sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
        .map(MerchantSales::merchant)
        .collect(toList());
}

Local record classes are a particular case of nested record classes. Like nested record classes, local record classes are implicitly static. This means that their own methods cannot access any variables of the enclosing method; in turn, this avoids capturing an immediately enclosing instance which would silently add state to the record class. The fact that local record classes are implicitly static is in contrast to local classes, which are not implicitly static. In fact, local classes are never static -- implicitly or explicitly -- and can always access variables in the enclosing method.

Local Enum Classes and Local Interfaces

The support for local record classes gives an opportunity to support other local declarations that should be implicitly static (until this point, local declarations such as local variables and local classes were not static).

As nested enum classes and nested interfaces are implicitly static, then local enum classes and local interfaces should be implicitly static.

Accordingly, this JEP also proposes, for consistency, the support of local enum classes and local interfaces, in addition to the support of local record classes. Like local record classes, they are implicitly static.

Static Members of Inner Classes

It is currently specified as a compile-time error if an inner class declares a member that is explicitly or implicitly static, unless the member is a constant variable. This means that, for example, an inner class can not declare a record class member, as nested record classes are implicitly static.

It is proposed in this JEP to relax this restriction and to allow an inner class to declare members that are either explicitly or implicitly static. In particular, this allows an inner class to declare a static member that is a record class.

Annotations on Record Components

Record components have multiple roles in record declarations. A record component is a first-class concept, but each component also corresponds to a field of the same name and type, an accessor method of the same name and return type, and a formal parameter of the canonical constructor of the same name and type.

This raises the question, when a component is annotated, what actually is being annotated? And the answer is, "all of these that are applicable for this particular annotation." This enables classes that use annotations on their fields, constructor parameters, or accessor methods to be migrated to records without having to redundantly declare these members. For example, a class such as the following

public final class Card {
    private final @MyAnno Rank rank;
    private final @MyAnno Suit suit;
    @MyAnno Rank rank() { return this.rank; }
    @MyAnno Suit suit() { return this.suit; }
    ...
}

can be migrated to the equivalent, and considerably more readable, record declaration:

public record Card(@MyAnno Rank rank, @MyAnno Suit suit) { ... }

The applicability of an annotation is declared using a @Target meta-annotation. Consider the following:

@Target(ElementType.FIELD)
    public @interface I1 {...}

This declares the annotation @I1 and that it is applicable to a field declaration. We can declare that an annotation is applicable to more than one declaration; for example:

@Target({ElementType.FIELD, ElementType.METHOD})
    public @interface I2 {...}

This declares an annotation @I2 and that it is applicable to both a field declaration and a method declaration.

Returning to annotations on a record component, these annotations appear at the corresponding program points where they are applicable. In other words, the propagation is under the control of the programmer using the @Target meta-annotation. The propagation rules are systematic and intuitive, and all that apply are followed:

If a public accessor method or (non-compact) canonical constructor is declared explicitly, then it only has the annotations which appear on it directly; nothing is propagated from the corresponding record component to these members.

It is also possible to declare that an annotation came from one defined on a record component using a new annotation declaration @Target(RECORD_COMPONENT). These annotations can be retrieved by reflection as detailed in the Reflection API section below.

Compatibility and Migration

The abstract class java.lang.Record is the common superclass of all record classes. A Java source file automatically imports the java.lang.Record class, as well as all other types in the java.lang package (implicitly with the imports java.lang.*; statement), regardless of whether you enabled or disabled preview features. However, if your application imports another class named Record from a different package, you might get a compiler error.

Consider the following class declaration of com.myapp.Record:

package com.myapp;

public class Record {
    public String greeting;
    public Record(String greeting) {
        this.greeting = greeting;
    }
}

The following example, org.example.MyappPackageExample, imports com.myapp.Record with a wildcard but doesn't compile:

package org.example;
import com.myapp.*;

public class MyappPackageExample {
    public static void main(String[] args) {
       Record r = new Record("Hello world!");
    }
}

The compiler generates an error message similar to the following:

./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
       Record r = new Record("Hello world!");
       ^
  both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match

./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
       Record r = new Record("Hello world!");
                      ^
  both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match

Both Record in the com.myapp package and Record in the java.lang package are imported with a wildcard. Consequently, neither class takes precedence, and the compiler generates an error when it encounters the use of the simple name Record.

To enable this example to compile, the import statement can be changed so that it imports the fully qualified name of Record:

import com.myapp.Record;

Note: The introduction of classes in the java.lang package is rare but necessary from time to time, such as Enum in Java SE 5, Module in Java SE 9, and Record in Java SE 14.

The class java.lang.Class has two new methods related to record classes:

  1. RecordComponent[] getRecordComponents(): Returns an array of java.lang.reflect.RecordComponent objects, which correspond to the components of the record class.

  2. boolean isRecord(): Similar to isEnum() except that it returns true if the class was declared as a record class.

Java Grammar

RecordDeclaration:
  {ClassModifier} `record` TypeIdentifier [TypeParameters]
    RecordHeader [SuperInterfaces] RecordBody

RecordHeader:
 `(` [RecordComponentList] `)`

RecordComponentList:
 RecordComponent { `,` RecordComponent}

RecordComponent:
 {Annotation} UnannType Identifier
 VariableArityRecordComponent

VariableArityRecordComponent:
 {Annotation} UnannType {Annotation} `...` Identifier

RecordBody:
  `{` {RecordBodyDeclaration} `}`

RecordBodyDeclaration:
  ClassBodyDeclaration
  CompactConstructorDeclaration

CompactConstructorDeclaration:
  {ConstructorModifier} SimpleTypeName ConstructorBody

Class-file representation

The class file of a record uses a Record attribute to store information about the record's components:

Record_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 components_count;
    record_component_info components[components_count];
}

record_component_info {
    u2 name_index;
    u2 descriptor_index;
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

If the record component has a generic signature that is different from the erased descriptor, there must be a Signature attribute in the record_component_info structure.

Reflection API

The following public methods will be added to java.lang.Class:

The method getRecordComponents() returns an array of java.lang.reflect.RecordComponent objects. The elements of this array correspond to the record’s components, in the same order as they appear in the record declaration. Additional information can be extracted from each element in the array, including its name, annotations, and accessor method.

The method isRecord returns true if the given class was declared as a record. (Compare with isEnum.)

Alternatives

Record classes can be considered a nominal form of tuples. Instead of record classes, we could implement structural tuples. However, while tuples might offer a lightweight means of expressing some aggregates, the result is often inferior aggregates:

Dependencies

Record classes work well with another feature currently in preview: sealed classes (JEP 360). For example, a family of record classes can be explicitly declared to implement the same sealed interface:

package com.example.expression;

public sealed interface Expr
    permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}

public record ConstantExpr(int i)       implements Expr {...}
public record PlusExpr(Expr a, Expr b)  implements Expr {...}
public record TimesExpr(Expr a, Expr b) implements Expr {...}
public record NegExpr(Expr e)           implements Expr {...}

The combination of record classes and sealed classes is sometimes referred to as algebraic data types. Record classes allow us to express products, and sealed classes allow us to express sums.

In addition to the combination of record classes and sealed classes, record classes lend themselves naturally to pattern matching. Because record classes couple their API to their state description, we will eventually be able to derive deconstruction patterns for record classes as well, and use sealed type information to determine exhaustiveness in switch expressions with type patterns or deconstruction patterns.