In Java Futures at QCon New York, Java Language Architect, Brian Goetz took us on a whirlwind tour of some of the recent and future features in the Java Language. In the first article in this series, he looked at Local Variable Type Inference. In this article, he dives into Records.
Java SE 14 (March 2020) introduces records (jep359) as a preview feature. Records aim to enhance the language’s ability to model “plain data” aggregates with less ceremony. We can declare a simple x-y point abstraction as follows:
record Point(int x, int y) { }
which will declare a final class called Point, with immutable components for x and y and appropriate accessors, constructors, equals, hashCode, and toString implementations.
We are all familiar with the alternative – writing (or having the IDE generate) boilerplate-filled implementations of constructors, Object methods, and accessors.
These are surely cumbersome to write, but more importantly, they are more work to read; we have to read through all the boilerplate code just to conclude that we didn’t actually need to read it at all.
Contents
What are records?
A record can be best thought of as a nominal tuple; it is a transparent, shallowly immutable carrier for a specific ordered sequence of elements. The names and types of the state elements are declared in the record header, and are called the state description. Nominal means that both the aggregate and its components have names, rather than just indexes; transparent means that the state is accessible to clients (though the implementation may mediate this access); shallowly immutable means that the tuple of values represented by a record does not change once instantiated (though, if those values are references to mutable objects, the state of the referred-to objects may change).
Records, like enums, are a restricted form of classes, optimized for certain common situations. Enums offer us a bargain of sorts; we give up control over instantiation, and in return we get certain syntactic and semantic benefits. We are then free to choose enums, or regular classes, based on whether the benefits of enums outweighs the costs in the specific situation at hand.
Records offer us a similar bargain; what they ask us to give up is the ability to decouple the API from the representation, which in turn allows the language to derive both the API and implementation for construction, state access, equality comparison, and representation mechanically from the state description.
Binding the API to the representation may seem to conflict with a fundamental object-oriented principle: encapsulation. While encapsulation is an essential technique for managing complexity, and most of the time it is the right choice, sometimes our abstractions are so simple – such as an x-y point – that the costs of encapsulation exceed the benefits. Some of these costs are obvious – such as the boilerplate required to author even a simple domain class. But there is also another, less obvious cost: that relationships between API elements are not captured by the language, but instead only by convention. This undermines the ability to mechanically reason about abstractions, which in turn leads to even more boilerplate coding.
Programming with data objects in Java has, historically, requires a leap of faith. We are all familiar with the following technique for modeling a mutable data carrier:
class AnInt {
private int val;
public AnInt(int val) { this.val = val; }
public int getVal() { return val; }
public void setVal(int val) { this.val = val; }
// More boilerplate for equals, hashCode, toString
}
The name val shows up in three places in the public API – the constructor argument, and the two accessor methods. There is nothing in this code, other than naming convention, to capture or require that these three uses of val are talking about the same thing, or that getVal() will return the most recent value set by setVal() – at best, this is captured in human-readable specification (but in reality, we almost never even do this.) Interacting with such a class is purely a leap of faith.
Records, on the other hand, make a stronger commitment – that the x() accessor and the x constructor parameter are talking about the same quantity. As a result, not only is the compiler able to derive sensible default implementations of these members, but frameworks can mechanically reason about the construction and state access protocols – and their interaction – to mechanically derive behaviors such as marshalling to JSON or XML.
The fine print
As mentioned earlier, records come with some restrictions. Their instance fields (which correspond to the components declared in the record header) are implicitly final; they cannot have any other instance fields; the record class itself cannot extend other classes; and record classes are implicitly final. Other than that, they can have pretty much everything other classes can: constructors, methods, static fields, type variables, interfaces, etc.
In exchange for these restrictions, records automatically acquire implicit implementations of the canonical constructor (the one whose signature matches the state description), read accessors for each state component (whose names are the same as the component), private final fields for each state component, and state-based implementations of the Object methods equals(), hashCode(), and toString(). (In the future, when the Java language supports deconstruction patterns, records will automatically support deconstruction patterns as well.) The declaration of a record can “override” the implicit constructor and method declarations should these prove unsuitable (though there are constraints, specified in the implicit superclass java.lang.Record, that such implementations must adhere to), and can declare additional members (subject to the restrictions).
One example of where a record might want to refine the implementation of the constructor is to validate the state in the constructor. For example, in a Range class, we would want to check that the low end of the range is no higher than the high end:
public record Range(int lo, int hi) {
public Range(int lo, int hi) {
if (lo > hi)
throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
this.lo = lo;
this.hi = hi;
}
}
While this implementation is perfectly fine, it is somewhat unfortunate that, in order to perform a simple invariant check, we had to utter the names of the components five more times. One could easily imagine developers convincing themselves they don’t need to check these invariants, because they don’t want to add back so much of the boilerplate that they just saved by using a record.
Because this situation is expected to be so common, and validity checking is so important, records permit a special compact form for explicitly declaring the canonical constructor. In this form, the argument list can be omitted in its entirety (it is assumed to be the same as the state description), and the constructor arguments are implicitly committed to the fields of the record at the end of the constructor. (The constructor parameters themselves are mutable, which means that constructors that want to normalize the state – such as reducing a rational to lowest terms – can do so merely by mutating the constructor parameters.) The following is the compact version of the above record declaration:
public record Range(int lo, int hi) {
public Range {
if (lo > hi)
throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
}
}
This leads to a pleasing result: the only code we have to read is the code that is not mechanically derivable from the state description.
Example use cases
While not all classes – and not even all data-centric classes – will be eligible to become records, use cases for records abound.
A commonly requested feature for Java is multiple return – allowing a method to return multiple items at once; the inability to do this often leads us to exposing suboptimal APIs. Consider a pair of methods that scan a collection and return the minimal or maximal values:
static<T> T min(Iterable<? extends T> elements,
Comparator<? super T> comparator) { ... }
static<T> T max(Iterable<? extends T> elements,
Comparator<? super T> comparator) { ... }
These methods are easy to write, but there is something unsatisfying about them; to obtain both boundary values, we have to scan the list twice. This is less efficient than scanning it once, and may result in inconsistent values if the collections being scanned can be concurrently modified.
While it is possible that this is the API the author wanted to expose, it is more likely this is the API we got because writing a better API was too much work. Specifically, to return both boundary values in one pass means we need a way to return both values at once. We could of course do this by declaring a class, but most developers would immediately look for ways to avoid doing so – purely because of the syntactic overhead of declaring the helper class. By lowering the cost to describe a custom aggregate, we can easily transform this into the API we probably wanted in the first place:
record MinMax<T>(T min, T max) { }
static<T> MinMax<T> minMax(Iterable<? extends T> elements,
Comparator<? super T> comparator) { ... }
Another common example is compound map keys. Sometimes we want a Map keyed on the conjunction of two distinct values, such as representing the last time a given user used a certain feature. We can easily do this with a HashMap whose key combines person and feature. But if there is no handy PersonAndFeature type, we have to write one, with all the boilerplate details of construction, equality comparison, hashing, etc. Again, we can do this, but our laziness might well get in the way, and we might be tempted, for example, to key our map by concatenating the name of the person with the name of the feature, which results in harder-to-read, more error-prone code. Records let us do this directly:
record PersonAndFeature(Person p, Feature f) { }
Map<PersonAndFeature, LocalDateTime> lastUsed = new HashMap<>();
The desire to use composites often comes up with streams, just as they might with map keys – and we run into the same accidental issues that make us reach for suboptimal solutions. Suppose, for example, we want to perform a stream operation on a derived quantity, such as ranking the top-scoring players. We might write this as:
List<Player> topN
= players.stream()
.sorted(Comparator.comparingInt(p -> getScore(p)))
.limit(N)
.collect(toList());
This is easy enough, but what if finding the score requires some computation? We would then be computing the scores O(n^2) times, rather than O(n). With records, we can easily temporarily attach some derived data to the contents of the stream, operate on the joined data, and then project it back to what we want:
record PlayerScore(Player player, Score score) {
// convenience constructor for use by Stream::map
PlayerScore(Player player) { this(player, getScore(player)); }
}
List<Player> topN
= players.stream()
.map(PlayerScore::new)
.sorted(Comparator.comparingInt(PlayerScore::score))
.limit(N)
.map(PlayerScore::player)
.collect(toList());
If this logic is inside a method, the record can even be declared local to the method.
Of course, there are lots of other obvious cases for records as well: tree nodes, Data Transfer Objects (DTOs), messages in actor systems, etc.
Embracing our laziness
A common theme in the examples so far is that it was possible to get the right result without records, but we might well have been tempted to cut corners because of the syntactic overhead. We’ve all been tempted to reuse an existing abstraction improperly rather than code the correct abstraction, or cut corners by omitting implementations of the Object methods (which can cause subtle bugs when these objects are used as map keys, or make debugging harder when the toString() value is unhelpful).
Having a concise notation for saying what we want brings us two sorts of benefits. The obvious one is that code that already does the right thing benefits from the concision, but more subtly, it also means we will get more of the code that does the right thing – because we’ve lowered the activation energy to do the right thing, and therefore reduced the temptation to cut corners. We saw a similar effect with local variable type inference; when the overhead of declaring a variable is reduced, developers are more likely to factor complex calculations into simpler ones, resulting in more readable, less error-prone code.
Roads not taken
Everyone agrees that modeling data aggregates in Java – something we do often – has too much ceremony. Unfortunately, that consensus is only syntax-deep; opinions varied widely (and loudly) on how flexible records should be, what restrictions were reasonable to accept, and what use cases were most important.
The major road-not-taken was trying to extend records to replace mutable JavaBean classes. While this would have obvious benefits – specifically, broadening the number of classes that could become records – the additional costs would also have been significant. Complex, ad-hoc features are harder to reason about, and are more likely to interact in surprising ways with other features – which is what we’d get if we tried to derive the feature design from the variety of JavaBean patterns commonly used today (not to mention the debates about which use cases are common enough to deserve language support).
So, while it is superficially tempting to think about records as primarily being about boilerplate reduction, we preferred to approach it as a semantic problem; how can we better model data aggregates directly in the language, and provide a sound semantic basis for such classes that developers can easily reason about? (The approach of treating this as a semantic, rather than syntatic, problem worked quite well for enums.) And the logical answer for Java was: records are nominal tuples.
Why the restrictions?
The restrictions on records may seem arbitrary at first, but they all stem from a common goal, which we can summarize as “records are the state, the whole state, and nothing but the state.” Specifically, we want equality of records to be derived from the entirety of the state declared in the state description, and nothing else. Were mutable fields, or extra fields, or superclasses permitted, these would each introduce situations where equality of records either ignored certain state components (it is questionable to include mutable components in equality calculations), or was dependent on additional state that is not part of the state description (such as additional instance fields or superclass state). This would have greatly complicated the feature (since surely developers would demand the ability to separately specify which components are part of the equality calculation), and also undermine desirable semantic invariants (such as: extracting the state and constructing a new record from the resulting values should result in a record equal to the original).
Why not structural tuples?
Given that the design center for records is nominal tuples, one might ask why we didn’t choose structural tuples instead. Here, the answer is simple: names matter. A Person record with components firstName and lastName is clearer and safer than a tuple of String and String. Classes support state validation through their constructors; tuples do not. Classes can have additional behavior derived from their state; tuples cannot. Suitable classes can be compatibly migrated to and from records without breaking their client code; tuples cannot. And structural tuples do not distinguish between a Point and a Range (both are pairs of integers), even though the two have completely different semantics. (We’ve faced the choice between nominal and structural representation in Java before; in Java 8, we chose nominal function types over structural ones for a number of reasons; many of those same reasons apply when choosing nominal tuples over structural ones.)
Futures
JEP 355 specifies records as a standalone feature, but the design of records was influenced by the desire that records work well with several other features currently under development: sealed types, pattern matching, and inline classes.
Records are a form of product type, so called because their state space is (a subset of) the cartesian product of the state spaces of their components, and form one half of what are commonly called algebraic data types. The other half are called sum types; a sum type is a discriminated union, such as “a Shape is a Circle or a Rectangle”; this is not something we can currently express in Java (except through trickery such as non-public constructors). Sealed types will address this limitation, whereby classes and interfaces can directly declare that they can only be extended by a fixed set of types. Sums of products are a very common and useful technique for modeling complex domains in a flexible but type-safe way, such as the nodes of a complex document.
Languages with product types often support destructuring of products with pattern matching; records were designed from the ground up to support easy destructuring (the transparency requirement for records derives, in part, from this goal). The first stage of pattern matching supports only type patterns, but deconstruction patterns on records will soon follow.
Finally, records are often (but not always) a match for inline types; aggregates that meet the requirements for both records and inline types (and many of them will) can combine record-ness and inline-ness into inline records.
Summary
Records reduce the verbosity of many common classes by providing a direct way to model data as data, rather than simulating data with classes. Records can be used in a wide variety of situations to model common use cases, such as multiple return, stream joins, compound keys, tree nodes, DTOs, and more, and provide stronger semantic guarantees that allow developers and frameworks to more reliably reason about their state. While records are useful on their own, they will also interact positively with several upcoming features, including sealed types, pattern matching, and inline classes.
[“source=infoq”]