Learn Java

Lesson 29Phase 3: Abstraction and design

Records: data classes in one line

Classes whose job is to hold data, with the constructor, accessors, equals, hashCode and toString written for you.

By Updated 40 min

  • record
  • components
  • canonical constructor
  • compact constructor
  • accessor
  • immutability

Some classes exist only to carry a few values around together, such as a point on a map or a book in a catalogue. Written the classic way, even a point made of two numbers needs fields, a constructor, getters, and methods for printing and comparing, about thirty lines that look the same in every class of this kind. A record is Java's short form for such a class. You list the values once, and Java writes the rest.

By the end of this lesson
  • Declare a record, create one with new, and read its values with accessors such as p.x().
  • Name everything Java writes for a record: the fields, the canonical constructor, the accessors, equals, hashCode and toString.
  • Predict what printing, equals and == give for two records that hold the same values.
  • Write a compact constructor that rejects bad values or tidies them before they are stored.
  • Add methods, static factory methods and with methods to a record, and make it implement an interface.
  • Choose between a record and a class, and protect a record that holds a list.
From before
You can build objects with constructors and keep fields private behind getters. You know from the references lesson that ==asks “same object?” while equalsis meant to ask “same contents?”, and you wrote your own toString in OOP practice. final comes from static and final. Last lesson showed that an enum is a class with its own short syntax. A record is another one.

A class that only carries data#

Picture a drawing app that stores the points a user taps on the screen. A point is two whole numbers, x and y, and nothing else. To turn that into a well-behaved class with what you know so far, you would write:

  • Two private final fields, so nobody can change a point by accident.
  • A constructor that fills them in.
  • A getter for each field.
  • toString, so that printing a point shows its numbers.
  • equals, so that two points with the same numbers count as equal.
  • hashCode, a partner method that has to agree with equals. You will see why shortly.

Here is that class. It is long, so skim it rather than reading every line, then press Run.

LongPoint.java
1public class Main {
2 public static void main(String[] args) {
3 Point a = new Point(1, 2);
4 Point b = new Point(1, 2);
5
6 System.out.println(a);
7 System.out.println(a.getX() + ", " + a.getY());
8 System.out.println(a.equals(b));
9 }
10}
11
12class Point {
13 private final int x;
14 private final int y;
15
16 Point(int x, int y) {
17 this.x = x;
18 this.y = y;
19 }
20
21 int getX() { return x; }
22 int getY() { return y; }
23
24 @Override
25 public boolean equals(Object o) {
26 if (!(o instanceof Point)) return false;
27 Point other = (Point) o;
28 return x == other.x && y == other.y;
29 }
30
31 @Override
32 public int hashCode() {
33 return 31 * x + y;
34 }
35
36 @Override
37 public String toString() {
38 return "Point[x=" + x + ", y=" + y + "]";
39 }
40}
Prints
›Point[x=1, y=2]
›1, 2
›true

It prints Point[x=1, y=2], then 1, 2, then true. Everything works. Now count: the Pointclass is about thirty lines, and all it says is “a point is two numbers”. A Book or a Price would need the same thirty lines again with other names in them. Code like this, typed over and over with only the names changed, is called boilerplate.

Boilerplate is also where bugs hide. Suppose a point later gains a third number, z. You add the field and the constructor parameter, and you forget the one line in equals. The program still compiles and runs. Two points that differ only in z now count as equal, and nothing warns you.

The same point as a record#

Here is the same program with the whole class replaced by a single line.

Point.java
1public class Main {
2 public static void main(String[] args) {
3 Point a = new Point(1, 2);
4 Point b = new Point(1, 2);
5
6 System.out.println(a);
7 System.out.println(a.x() + ", " + a.y());
8 System.out.println(a.equals(b));
9 }
10}
11
12record Point(int x, int y) { }
Prints
›Point[x=1, y=2]
›1, 2
›true

The output is the same, line for line. The only change in main is that the values are read with a.x() and a.y() instead of getX() and getY(). Step through it and look at the objects: each point is an ordinary object holding an x and a y, just like the long version.

Read the declaration from left to right:

  • record starts the declaration, where a class starts with class.
  • Point is the name of the new type.
  • (int x, int y) is the record header. Each entry in it is a component: one value the record carries, with a type and a name. A point has two components.
  • { } is the body. It can stay empty, because Java writes everything this record needs. Later in this lesson you put your own methods in it.
Key idea

A record is a class whose job is to carry a fixed set of values. You name the values once, in the header, and Java writes the constructor, the accessors, equals, hashCode and toString from them.

What Java writes for you#

From the one line record Point(int x, int y) { }, the compiler builds a class with these members:

MemberFor record Point(int x, int y)What it does
A field for each componentprivate final int x; and the same for yHolds the value. It is private, so outside code cannot touch it, and final, so it never changes after the constructor.
The canonical constructorPoint(int x, int y)Takes one argument per component, in header order, and stores each one in its field.
An accessor for each componentpublic int x() and public int y()Returns the component's value. It has the component's name.
equalsa.equals(b)True when b is also a Point and every component is equal.
hashCodea.hashCode()An int built from all the components. Equal records always get the same one.
toStringPoint[x=1, y=2]The record's name, then each component's name and value in square brackets.
Every record gets these members, all built from its header.

Canonicalmeans “the standard one”. The canonical constructor is the constructor whose parameters match the header exactly: the same types in the same order. Every record has one.

The widget below shows the same thing at full length. The top line is a record, and underneath is the ordinary class Java writes for it, close to what the compiler produces. Add a component, for example the int z from the bug story above, and see every place it has to appear. Tap a component to light up the lines that use it.

What Java writes for youPoint.java

Your record

record Point(int x, int y) { }
Start from

Components (tap one to light up its lines)

The same record, written out as an ordinary class

1final class Point {
2 private final int x;
3 private final int y;
4
5 Point(int x, int y) {
6 this.x = x;
7 this.y = y;
8 }
9
10 public int x() { return x; }
11 public int y() { return y; }
12
13 @Override
14 public boolean equals(Object o) {
15 if (!(o instanceof Point)) return false;
16 Point other = (Point) o;
17 return x == other.x
18 && y == other.y;
19 }
20
21 @Override
22 public int hashCode() {
23 int h = 0;
24 h = 31 * h + Integer.hashCode(x);
25 h = 31 * h + Integer.hashCode(y);
26 return h;
27 }
28
29 @Override
30 public String toString() {
31 return "Point[x=" + x
32 + ", y=" + y
33 + "]";
34 }
35}
Your record is one line. The class Java writes for it has 30 lines of code.

For a two-component point the class runs to 30 lines, and each component you add costs six more: a field, a line in the constructor, an accessor, and a line each in equals, hashCode and toString. In a record it costs a type and a name in the header.

Accessors: p.x(), not p.getX()#

To read a component you call its accessor, a method with the same name as the component and no parameters: p.x() and p.y(). Records leave off the get prefix you used for getters in the encapsulation lesson, so the header tells you which methods exist.

Accessors.java (does not compile)
read along
1public class Main {
2 public static void main(String[] args) {
3 Point p = new Point(1, 2);
4
5 System.out.println(p.x()); // fine: prints 1
6 System.out.println(p.getX()); // error: cannot find symbol
7 System.out.println(p.x); // error: x has private access in Point
8 }
9}
10
11record Point(int x, int y) { }

The compiler rejects the last two lines. There is no method called getX, and p.x without parentheses names the field, which is private to the record. From outside the record, the accessor is the only way to read a value.

Quick check
Given Book b = new Book("Dune", "Frank Herbert", 1965); for record Book(String title, String author, int year), which expression gives you the title?

Printing and comparing records#

Two of the generated methods decide how a record prints and how it compares. Both are built from the components.

toString: readable from the start#

Printing a record shows its name and each component with its value. In OOP practice you wrote a toString yourself so that objects stopped printing as a class name and an address. A record has a readable one from the start.

Book.java
1public class Main {
2 public static void main(String[] args) {
3 Book b = new Book("Dune", "Frank Herbert", 1965);
4
5 System.out.println(b);
6 System.out.println(b.title() + " came out in " + b.year());
7 }
8}
9
10record Book(String title, String author, int year) { }
Prints
›Book[title=Dune, author=Frank Herbert, year=1965]
›Dune came out in 1965

This prints Book[title=Dune, author=Frank Herbert, year=1965] and then Dune came out in 1965. String components print without quotation marks, so the title shows up as title=Dune.

Meant for people to read
The Java documentation says the exact layout of a record's toString may change in a future version. Use it for printing and debugging. When a program needs a value, it should call the accessor instead of cutting the text apart.

equals compares components, == compares objects#

Two points below hold the same numbers, and a third variable copies one of them. Decide what the program prints before you run it.

Predict the outputEquals.java
1public class Main {
2 public static void main(String[] args) {
3 Point a = new Point(3, 4);
4 Point b = new Point(3, 4);
5 Point c = a;
6
7 System.out.println(a.equals(b));
8 System.out.println(a == b);
9 System.out.println(a == c);
10 }
11}
12
13record Point(int x, int y) { }

What do the three lines print?

a.equals(b) is true: both are points, and each component matches, 3 with 3 and 4 with 4. a == b is false, because == still asks whether two variables point at the same object, and new ran twice to make two objects. a == c is true because c = a copied the reference, so both names point at one point.

Key idea

Two records are equal when they are the same record type and every component is equal. == keeps its old meaning, “the same object”, so compare records with equals, as you do with strings.

hashCode: the partner of equals#

hashCode turns an object into a single int. Lookup structures such as HashSet and HashMap use that number to decide where to file an object, much as the first letter of a surname decides which drawer a paper file goes in. For that to work, two equal objects must always produce the same number. A record keeps this rule without any effort from you, because its hashCode is built from the same components as its equals.

HashCodes.java
1public class Main {
2 public static void main(String[] args) {
3 Point a = new Point(3, 4);
4 Point b = new Point(3, 4);
5 Point d = new Point(4, 3);
6
7 System.out.println("a equals b: " + a.equals(b));
8 System.out.println("same hash code: " + (a.hashCode() == b.hashCode()));
9 System.out.println("a equals d: " + a.equals(d));
10 }
11}
12
13record Point(int x, int y) { }
Prints
›a equals b: true
›same hash code: true
›a equals d: false

Equal points, equal hash codes. Unequal points usually get different numbers, but nothing promises that: two different points may share a hash code, and that is allowed.

Why this page never prints a hash code
The documentation leaves the exact number up to Java and says it may change between versions, and even from one run of a program to the next. So compare hash codes with each other, and never write code that depends on their actual value.
More on equals and hashCode
Why hash codes matter so much, and how to write equals and hashCode by hand for an ordinary class, come later in HashMap and HashSet and equals, hashCode and toString.

Records do not change#

Look back at the table: every field is final, and a record has accessors but no setters. Once a record is built, its components never change. An object that cannot change after it is built is called immutable, the word you met in the strings lesson.

Move.java (does not compile)
read along
1public class Main {
2 public static void main(String[] args) {
3 Point p = new Point(1, 2);
4 p.setX(5); // error: cannot find symbol
5 p.x = 5; // error: x has private access in Point
6 }
7}
8
9record Point(int x, int y) {
10 void moveRight() {
11 x = x + 1; // error: cannot assign a value to final variable x
12 }
13}

There is no setter to call, the field is private to the record, and even a method inside the record cannot give a final field a new value.

This is what makes a record safe to share. In the references lesson, two variables pointed at one object, and a change made through one of them surprised the other. With a record that cannot happen: nobody can change the object, so it does not matter how many variables point at it.

To change a record, make a new one#

Real data still changes: a book is reprinted, or a point moves. With a record you build a new one that holds the new value. The usual way is a small method named withSomething that returns a copy with one component replaced. Java does not write these for you, but each one is short: a single return of a new record.

Reprint.java
1public class Main {
2 public static void main(String[] args) {
3 Book first = new Book("Dune", "Frank Herbert", 1965);
4 Book reprint = first.withYear(2005);
5
6 System.out.println(first);
7 System.out.println(reprint);
8 }
9}
10
11record Book(String title, String author, int year) {
12 Book withYear(int newYear) {
13 return new Book(title, author, newYear);
14 }
15}
Prints
›Book[title=Dune, author=Frank Herbert, year=1965]
›Book[title=Dune, author=Frank Herbert, year=2005]

first.withYear(2005) calls the canonical constructor with the same title and author and the new year, and returns that new book. first is untouched, so the output shows 1965 and then 2005. Step through it and you will find two separate book objects. It is the same arrangement as toUpperCase(), which gives you a new string and leaves the old one as it was.

Predict the outputReprint.java
1public class Main {
2 public static void main(String[] args) {
3 Book b = new Book("Dune", 1965);
4 b.withYear(2005);
5 System.out.println(b.year());
6
7 b = b.withYear(2005);
8 System.out.println(b.year());
9 }
10}
11
12record Book(String title, int year) {
13 Book withYear(int newYear) {
14 return new Book(title, newYear);
15 }
16}

The result of the first withYear call is never stored. What do the two lines print?

It prints 1965 and then 2005. The first call builds a book from 2005 and throws it away, because nothing stores it. The second call stores the new book back in b. So a with method is only useful when you keep its result, in a new variable or in the old one.

Checking values: the compact constructor#

The constructor Java writes accepts any values at all. new Range(9, 2) would build a range that starts at 9 and ends at 2, which makes no sense. In the encapsulation lesson a class guarded its fields inside its setters. A record has no setters, so the constructor is the one place to stand guard, and records have a short way to write it.

Range.java
1public class Main {
2 public static void main(String[] args) {
3 Range week = new Range(1, 7);
4 System.out.println(week);
5
6 Range broken = new Range(9, 2);
7 System.out.println(broken);
8 }
9}
10
11record Range(int lo, int hi) {
12 Range {
13 if (lo > hi) {
14 throw new IllegalArgumentException("lo " + lo + " is above hi " + hi);
15 }
16 }
17}
Prints
›Range[lo=1, hi=7]
!Exception in thread "main" java.lang.IllegalArgumentException: lo 9 is above hi 2
! at Range.<init>(Main.java:14)
! at Main.main(Main.java:6)
Stops on line 14

IllegalArgumentException: lo 9 is above hi 2

A method was given a value it does not accept.

The block Range { ... }, with no parentheses after the name, is a compact constructor: the canonical constructor written without its parameter list. The parameters still exist and have the components' names, lo and hi; you just do not write them out. Java runs your block first, then stores each parameter in its field.

The first range passes the check and prints Range[lo=1, hi=7]. For the second, lo > hi is true, so the throw line runs. throw new IllegalArgumentException(...) stops the program at once with that message, here lo 9 is above hi 2, and the broken range is never built. IllegalArgumentExceptionis the standard Java error for “you passed a value I cannot accept”.

Throwing and catching
Here, throwmeans “refuse and stop”. Catching an exception so that the program can carry on is the subject of exception handling.

Tidying values before they are stored#

A compact constructor can also change a parameter. Give it a new value, and the new value is what Java stores. Suppose a website treats usernames as the same whatever their capital letters or stray spaces. The record can store one tidy form of each name:

Username.java
1public class Main {
2 public static void main(String[] args) {
3 Username typed = new Username(" MayaK ");
4 Username saved = new Username("mayak");
5
6 System.out.println(typed);
7 System.out.println(typed.equals(saved));
8 }
9}
10
11record Username(String name) {
12 Username {
13 name = name.trim().toLowerCase();
14 }
15}
Prints
›Username[name=mayak]
›true

Inside the compact constructor, name is the parameter. The line name = name.trim().toLowerCase(); replaces it with a tidied copy, using two string methods you already know, and when the block ends Java stores that copy in the field. Turning values into one standard form like this is called normalising. The output is Username[name=mayak] and true: every username goes through the same tidying, so two names that differ only in case or spaces end up equal.

The rules of a compact constructor#

  • There are no parentheses after the name. Range() { } would be a different constructor, one with no parameters.
  • The parameters are there, with the components' names. You may read them and give them new values.
  • You may not assign the fields yourself. this.lo = lo; is a compile error (“cannot assign a value to final variable lo”), because Java does that step after your block ends.
  • A return statement is not allowed. The block runs to its end, or stops with an exception.
Put the lines in orderSwap.java
This Range repairs a backwards range instead of refusing it: its compact constructor swaps lo and hi through a temporary variable. Put the lines in order so that the program prints Range[lo=2, hi=9].

Tap a line, then tap where it should go. Or use the arrows.

The full form, and extra constructors#

You can also write the canonical constructor out in full, with its parameter list, the way you would in a class. Then storing the fields is your job:

FullRange.java
1public class Main {
2 public static void main(String[] args) {
3 Range week = new Range(1, 7);
4 System.out.println(week);
5 }
6}
7
8record Range(int lo, int hi) {
9 Range(int lo, int hi) {
10 if (lo > hi) {
11 throw new IllegalArgumentException("lo " + lo + " is above hi " + hi);
12 }
13 this.lo = lo;
14 this.hi = hi;
15 }
16}
Prints
›Range[lo=1, hi=7]

It does the same job as the compact version and prints Range[lo=1, hi=7]. The compact form is shorter and stores the fields for you, so it is the usual choice.

A record may also have extra constructors with other parameters. Each one must hand over to another constructor with this(...), as you saw in the constructors lesson, so every record still passes through the canonical constructor and its checks:

FirstFive.java
1public class Main {
2 public static void main(String[] args) {
3 Range firstFive = new Range(5);
4 System.out.println(firstFive);
5 }
6}
7
8record Range(int lo, int hi) {
9 Range {
10 if (lo > hi) {
11 throw new IllegalArgumentException("lo " + lo + " is above hi " + hi);
12 }
13 }
14
15 Range(int hi) {
16 this(1, hi);
17 }
18}
Prints
›Range[lo=1, hi=5]

new Range(5) runs the one-parameter constructor. It calls this(1, 5), which runs the compact constructor's check before the fields are stored, and the program prints Range[lo=1, hi=5].

Key idea

A compact constructor runs before the fields are stored. Throw an exception to refuse a bad value, or give a parameter a new value to tidy it.

ExerciseRating.java
A film site stores ratings as record Rating(String film, int stars). Add a compact constructor that trims spaces from the film name and keeps stars between 1 and 5: anything below 1 becomes 1, and anything above 5 becomes 5.

Adding your own methods#

The body of a record can hold more than a compact constructor. Anything you can work out from the components can be a method, written as in a class. Inside the record, the components are plain field names.

Teens.java
1public class Main {
2 public static void main(String[] args) {
3 Range teens = new Range(13, 19);
4
5 System.out.println(teens.length());
6 System.out.println(teens.contains(15));
7 System.out.println(teens.contains(21));
8 }
9}
10
11record Range(int lo, int hi) {
12 int length() {
13 return hi - lo + 1;
14 }
15
16 boolean contains(int n) {
17 return n >= lo && n <= hi;
18 }
19}
Prints
›7
›true
›false

teens.length() counts the whole numbers from 13 to 19, ends included, which is 7. contains checks whether a number falls inside the range: true for 15 and false for 21.

Static fields and static factory methods#

A record may have static fields and static methods, because those belong to the type rather than to any one record. A static factory method is a static method that builds an object and returns it. Its name can say something that new cannot, such as which unit a number is in:

Temperature.java
1public class Main {
2 public static void main(String[] args) {
3 Temperature boiling = Temperature.fromFahrenheit(212);
4
5 System.out.println(boiling);
6 System.out.println(Temperature.FREEZING);
7 System.out.println(boiling.isWarmerThan(Temperature.FREEZING));
8 }
9}
10
11record Temperature(double celsius) {
12 static final Temperature FREEZING = new Temperature(0);
13
14 static Temperature fromFahrenheit(double fahrenheit) {
15 return new Temperature((fahrenheit - 32) * 5 / 9);
16 }
17
18 boolean isWarmerThan(Temperature other) {
19 return celsius > other.celsius;
20 }
21}
Prints
›Temperature[celsius=100.0]
›Temperature[celsius=0.0]
›true

Temperature.fromFahrenheit(212) converts to Celsius and calls the constructor, which gives Temperature[celsius=100.0]. Someone reading new Temperature(212)could not tell which unit the 212 is in; the factory's name answers that. FREEZING is a static constant shared by the whole program, and isWarmerThan is an ordinary method that compares two temperatures, so the last line prints true.

Static fields are fine, but a record cannot declare an extra instance field in its body. Its state is its components and nothing else. If a point needs a z, the z goes in the header.

Writing your own toString#

Any of the generated methods can be replaced: write one yourself and Java uses yours instead. A playing card reads better as Q of hearts than as Card[rank=Q, suit=hearts]:

Card.java
1public class Main {
2 public static void main(String[] args) {
3 Card queen = new Card("Q", "hearts");
4
5 System.out.println(queen);
6 System.out.println(queen.equals(new Card("Q", "hearts")));
7 }
8}
9
10record Card(String rank, String suit) {
11 @Override
12 public String toString() {
13 return rank + " of " + suit;
14 }
15}
Prints
›Q of hearts
›true

Your toString must be public, like the one every object inherits, and @Override asks the compiler to check that it really replaces that method. Only toString changed. equals is still Java's, so the second line prints true.

Implementing an interface#

A record cannot extend a class, but it can implement interfaces, which lets different records share a type. Here a square and a rectangle are both shapes:

Shapes.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Square(3), new Rectangle(2, 5) };
4
5 for (Shape s : shapes) {
6 System.out.println(s + " has area " + s.area());
7 }
8 }
9}
10
11interface Shape {
12 int area();
13}
14
15record Square(int side) implements Shape {
16 public int area() {
17 return side * side;
18 }
19}
20
21record Rectangle(int width, int height) implements Shape {
22 public int area() {
23 return width * height;
24 }
25}
Prints
›Square[side=3] has area 9
›Rectangle[width=2, height=5] has area 10

The loop only knows that it has Shape values. Each record supplies its own area(), which is polymorphism at work: the output is Square[side=3] has area 9 and then Rectangle[width=2, height=5] has area 10. The area methods are public because interface methods are public, so whatever implements them must make them public too.

Next: closing the family
Right now anyone could write another class that implements Shape. The next lesson, sealed types and pattern matching, shows how to close the list, so that only the shapes you name can implement Shape, and how a record pattern such as shape instanceof Rect(double w, double h) takes a record apart into its components in one step.
ExerciseCart.java
A line in a shopping cart is the record Item(String name, int priceInCents, int quantity). Finish total() so that it returns the price times the quantity, and withQuantity so that it returns a new item with a different quantity and everything else the same.

Records that hold other objects#

A component can have any type, including another record or a list. How the record behaves then depends on the objects inside it.

A record made of records#

A line segment on the canvas is two points, so it can be a record whose components are records:

Line.java
1public class Main {
2 public static void main(String[] args) {
3 Line line = new Line(new Point(0, 0), new Point(3, 4));
4
5 System.out.println(line);
6 System.out.println(line.end().x());
7 System.out.println(line.equals(new Line(new Point(0, 0), new Point(3, 4))));
8 }
9}
10
11record Point(int x, int y) { }
12
13record Line(Point start, Point end) { }
Prints
›Line[start=Point[x=0, y=0], end=Point[x=3, y=4]]
›3
›true

The generated methods reach inside. Line's toString prints each point with Point's own toString, giving Line[start=Point[x=0, y=0], end=Point[x=3, y=4]]. Line's equals compares the points with Point's equals, so two separately built lines with the same ends are equal. line.end().x() is two accessors in a row: end() gives a point, and x()gives that point's x, which is 3.

Records inside a class#

A record can also be declared inside another class, which suits a record that only that class uses. A record declared this way is automatically static, so it never carries a hidden link to an object of the outer class (the difference between static nested and inner classes from the inner classes lesson).

ScoreTable.java
1public class Main {
2
3 record Score(String player, int points) { }
4
5 public static void main(String[] args) {
6 Score[] table = { new Score("Ana", 12), new Score("Ben", 9) };
7
8 for (Score s : table) {
9 System.out.println(s.player() + ": " + s.points());
10 }
11 }
12}
Prints
›Ana: 12
›Ben: 9

This prints Ana: 12 and Ben: 9. You can even declare a record inside a method, as a local record, when one method needs to group a few values for a moment.

Records in a list#

A robot on a grid keeps each square it visits as a Point in an ArrayList. The list methods that search, such as contains, indexOf and remove, compare with equals:

Visited.java
1import java.util.ArrayList;
2
3public class Main {
4 public static void main(String[] args) {
5 ArrayList<Point> visited = new ArrayList<>();
6 visited.add(new Point(0, 0));
7 visited.add(new Point(1, 0));
8 visited.add(new Point(1, 1));
9
10 System.out.println(visited);
11 System.out.println(visited.contains(new Point(1, 0)));
12 System.out.println(visited.indexOf(new Point(1, 1)));
13
14 visited.remove(new Point(0, 0));
15 System.out.println(visited);
16 }
17}
18
19record Point(int x, int y) { }
Prints
›[Point[x=0, y=0], Point[x=1, y=0], Point[x=1, y=1]]
›true
›2
›[Point[x=1, y=0], Point[x=1, y=1]]

visited.contains(new Point(1, 0)) is true. The point built on that line is a different object from the one in the list, but the two are equal, and equal is what contains looks for. indexOf reports position 2, and remove takes out the equal point at the front. With a class that has no equals of its own, all three would find nothing, since the inherited equals only matches the very same object.

A list inside a record: shallowly immutable#

Immutability has a catch. A final field always points at the same object, but that object can still change if it is the changeable kind. Strings and numbers cannot change, and neither can a record built only from them. A list can grow. Step through this playlist and watch the list in the memory view:

Playlist.java
1import java.util.ArrayList;
2import java.util.List;
3
4public class Main {
5 public static void main(String[] args) {
6 List<String> songs = new ArrayList<>();
7 songs.add("Intro");
8 songs.add("Sunrise");
9
10 Playlist mix = new Playlist("Morning", songs);
11 System.out.println(mix);
12
13 songs.add("Surprise");
14 System.out.println(mix);
15
16 mix.songs().add("Another one");
17 System.out.println(mix);
18 }
19}
20
21record Playlist(String name, List<String> songs) { }
Prints
›Playlist[name=Morning, songs=[Intro, Sunrise]]
›Playlist[name=Morning, songs=[Intro, Sunrise, Surprise]]
›Playlist[name=Morning, songs=[Intro, Sunrise, Surprise, Another one]]

The playlist changes twice without anyone touching the record. First, the record stored the very list it was given, so main's songs variable and the record share one list: the aliasing from the references lesson. Adding Surprise through songs changes the playlist. Second, mix.songs() hands out that same list, so any code can add to it.

The record's own fields never changed. They point at the same name and the same list from start to finish. The Java documentation calls a record shallowly immutable for this reason: its fields are fixed, but the objects they point to may not be.

The fix is to store a copy that cannot change. List.copyOf(list) builds a new list with the same items, and like the lists made by List.of in the ArrayList lesson, it is unmodifiable: adding, removing or replacing an item throws an UnsupportedOperationException. Make the copy in the compact constructor, so that every playlist gets one. The last line of main tries to sneak a song in through the accessor; watch what happens.

SafePlaylist.java
1import java.util.ArrayList;
2import java.util.List;
3
4public class Main {
5 public static void main(String[] args) {
6 List<String> songs = new ArrayList<>();
7 songs.add("Intro");
8 songs.add("Sunrise");
9
10 Playlist mix = new Playlist("Morning", songs);
11 songs.add("Surprise");
12 System.out.println(mix);
13
14 mix.songs().add("Another one");
15 System.out.println(mix);
16 }
17}
18
19record Playlist(String name, List<String> songs) {
20 Playlist {
21 songs = List.copyOf(songs);
22 }
23}
Prints
›Playlist[name=Morning, songs=[Intro, Sunrise]]
!Exception in thread "main" java.lang.UnsupportedOperationException
! at Main.main(Main.java:14)
Stops on line 14

UnsupportedOperationException

This collection cannot be changed. List.of and similar methods make read-only lists.

main's later add now changes only its own list, so the record still prints two songs: Playlist[name=Morning, songs=[Intro, Sunrise]]. The attempt through mix.songs() is refused, and the program stops with an UnsupportedOperationException. Either way, the playlist stays as it was built.

Copy changeable components on the way in
When a component is a list, copy it with List.copyOf in the compact constructor. Components that cannot change, such as strings, numbers and enums, need no copy, and neither does a record built only from them. List.copyOf also refuses a list that contains null, throwing a NullPointerException, which for most records is a welcome extra check.
Quick check
Which line, placed in the compact constructor of record Team(String name, List<String> members), stops outside code from changing a team's members?

Record or class?#

A record is a class with one narrow job, and its limits come from that job.

What a record cannot do#

  • Extend another class. Every record already extends java.lang.Record, and a Java class has only one parent. It can implement as many interfaces as it needs.
  • Be extended. A record is final.
  • Declare instance fields beyond its components. Static fields are fine.
  • Change after it is built. There are no setters, and the fields are final.
Limits.java (does not compile)
read along
1class Shape {
2 String label = "shape";
3}
4
5record Circle(double radius) extends Shape { } // error: '{' expected
6
7record Square(int side) { }
8
9class BigSquare extends Square { // error: cannot inherit from final Square
10 BigSquare() {
11 super(10);
12 }
13}
14
15record Point(int x, int y) {
16 int z; // error: field declaration must be static
17}

Each marked line breaks a rule, and its comment shows the compiler's message. The first message is an odd one, '{' expected, because the compiler does not accept the word extends after a record header at all.

Choosing between them#

A good test is to ask whether the thing changes over time or is a fact. A bank account changes every time money moves, so it is a class. Each transaction on that account is a fact: an amount, a date and a description that never change once the transaction has happened. A transaction suits a record.

Use a record when
Use a class when
The object carries a fixed group of values.
The object looks after state that changes over time.
Nothing should change after it is built.
Methods such as deposit or setName need to change fields.
Two objects with the same values should be equal.
Each object is its own thing, even when its fields match another's.
Every value can be visible through an accessor.
Some fields should stay hidden, with no getter at all.
It needs no parent class. Interfaces are fine.
It extends another class, or other classes must extend it.
Examples: Point, Range, Book, a transaction, a search result.
Examples: BankAccount, ShoppingCart, Game, Counter.
Key idea

Use a record for a value: a fixed group of facts, equal when the facts are equal. Use a class for something with a life of its own: state that changes, or details it keeps to itself.

Quick check
Which of these is the best fit for a record?

Try it

Answer each one in your head or on paper first, then open the answer.

1
Without running anything, list every member Java writes for record Song(String title, int seconds) { }.
2
What does this program print? Work it out first, then open it full screen and step through it.
3
A teammate writes this compact constructor and gets “cannot assign a value to final variable lo”: Range { if (lo > hi) { this.lo = hi; this.hi = lo; } }. What is wrong, and what should they write instead?
4
What does this print, and why are the two usernames equal?
5
A teammate models a bank account as record Account(String owner, double balance) and wants a deposit(double amount) method that adds to the balance. Why will that not work, and what would you suggest?
6
What does this print, and why is the answer not 1?

Practice

Work through these on your own. Each one builds on this page.

1
In OOP practice you wrote a Student class with a name, an id and a GPA. Rewrite it as a record. Add a compact constructor that refuses a GPA below 0.0 or above 4.0 with an IllegalArgumentException, and an isHonorRoll() method that returns true for a GPA of 3.5 or more. Count how many lines you saved.
2
Write record Fraction(int numerator, int denominator). Its compact constructor should refuse a denominator of 0 and move a minus sign from the denominator to the numerator, so that new Fraction(1, -2) prints Fraction[numerator=-1, denominator=2]. Add a method double value(), and check that new Fraction(1, -2).equals(new Fraction(-1, 2)) is true.
3
A robot starts on square (0, 0) and follows the moves in the string "RRULLD": R is right (x plus 1), L is left, U is up (y plus 1) and D is down. Keep every square it stands on as a Point record in an ArrayList, adding a square only if the list does not already contain it. Print how many different squares the robot visited, the start included. You should get 6.
4
Add record Triangle(int base, int height) implements Shape to the shapes example, with area() returning base * height / 2. Put a triangle with base 3 and height 5 in the array. What area does it report, and why is it not 7.5? Change the program so it reports 7.5, and say what else had to change.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email