Blog

Java record:不写样板代码的数据类

Java record 用一行声明一个不可变的小数据类,编译器替你写好构造器、访问器、equals、hashCode 和 toString。看看 record 给了你什么、拒绝做什么,又在哪里力不从心。

record(记录类)是一种类,它唯一的任务就是携带几个值。你只需把这些值列一次,Java 就会替你写好构造器、访问器、equalshashCodetoString。如今现代 Java 代码里的小数据类,大多都是 record。

本文讲 record 会生成什么、用紧凑构造器做校验、record 拒绝做的事、它仍然允许的事、浅不可变的陷阱、“wither”方法、什么时候不该用 record,以及 record 为什么适合做 map 的键。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java

样板代码的问题

在普通的 Java 里,一个只存两个值的类,代码量多得出人意料。讲类与对象的那一篇手写了一个不可变的 Money 类。它有两个 private final 字段、一个构造器和一个 toString。可就这样还没写完。要真正好用,它还需要读取字段的访问器方法,以及 equalshashCode,这样两个都存着 12.50 EUR 的 Money 对象才算相等。两个值要写大约 40 行,每一行都可能出笔误。

同样的东西写成 record 是这样:

record Money(long cents, String currency) {}

void main() {
    var price = new Money(1250, "EUR");
    var samePrice = new Money(1250, "EUR");
    var other = new Money(999, "EUR");

    IO.println(price);
    IO.println(price.cents() + " " + price.currency());
    IO.println("equals:   " + price.equals(samePrice));
    IO.println("hashCode: " + (price.hashCode() == samePrice.hashCode()));
    IO.println("==:       " + (price == samePrice));
    IO.println("other:    " + price.equals(other));
}

输出:

Money[cents=1250, currency=EUR]
1250 EUR
equals:   true
hashCode: true
==:       false
other:    false

括号里的部分 (long cents, String currency) 是 record 的头部(header),其中每一项是一个组件(component)。编译器根据这一行写出了:

  • 每个组件对应的一个 private final 字段
  • 一个规范构造器 Money(long cents, String currency),按顺序接收各个组件并保存起来。
  • 每个组件对应的一个访问器。它叫 cents(),不叫 getCents()。record 不沿用老的 JavaBeans 命名习惯。
  • toString,打印 record 的名字和每个组件。
  • equalshashCode,基于组件的值。

pricesamePrice 是两个不同的对象,所以 ==false。但 equals 比较的是值,而值相同,所以结果是 true。它们的哈希码也相同,这是必须的。

record 在 Java 16 成为正式特性。我们验证过:用 javac --release 15 编译 record 会失败,报 records are not supported in -source 15,同一个文件用 --release 16 就能编译。

访问器是真正的方法

你可以在运行时列出 record 的组件,顺序和头部声明的一致:

record Money(long cents, String currency) {}

void main() {
    IO.println(Money.class.isRecord());
    for (var component : Money.class.getRecordComponents()) {
        IO.println(component.getType() + " " + component.getName());
    }
    IO.println(java.lang.reflect.Modifier.isFinal(Money.class.getModifiers()));
}

输出:

true
long cents
class java.lang.String currency
true

最后一行说明 record 类是 final 的。任何类都不能继承 record,本文后面还会讲到这一点。

用十岁孩子能懂的话说

record 就像一张填好的表格。问题印在表格上:“姓名”“年龄”“最喜欢的颜色”。创建 record 时,你要一次填完所有答案,而且用的是钢笔。

谁都能读这些答案。但谁也不能擦掉一个答案再写个新的。想要不同的答案,就重新填一张表。

另外,只要答案一样,两张表在检查的人看来就是同一张表。它们是两张不同的纸,这并不重要。

准确的说法

record 声明 record R(T1 c1, T2 c2) {} 会创建一个继承 java.lang.Record 的 final 类。对每个组件,它声明一个 private final 字段,以及一个与组件同名的 public 访问器方法。它有一个规范构造器,参数列表和头部相同。它还有 equalshashCodetoString,都根据组件计算。

当另一个对象是同一个 record 类、并且每一对组件都相等时,equals 返回 true。基本类型组件按值比较,引用类型组件用 Objects.equals 比较。hashCode 组合各组件的哈希码,所以相等的 record 哈希码一定相等。这些成员你都可以自己写,写了之后编译器就用你的版本。

这个比喻的局限:表格的答案写在纸上,但 record 的引用类型组件并不在 record 里面。List 类型的组件存的是一个引用,指向别处的一个列表,而那个列表仍然可以改变。讲浅不可变的那一节会具体演示。

紧凑构造器负责校验和规范化

record 的规范构造器可以写成一种简短形式,叫紧凑构造器,它没有参数列表。你只写检查逻辑,编译器在最后给字段赋值:

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        if (currency.length() != 3) {
            throw new IllegalArgumentException("bad currency code: " + currency);
        }
        currency = currency.toUpperCase();
    }
}

void main() {
    IO.println(new Money(1250, "eur"));
    IO.println(new Money(-5, "EUR"));
}

输出后停止:

Money[cents=1250, currency=EUR]
Exception in thread "main" java.lang.IllegalArgumentException: negative amount: -5

在紧凑构造器里,centscurrency 是构造器的参数,不是字段。currency = currency.toUpperCase() 这一行改的是参数。构造器体执行完后,Java 把每个参数复制到对应的字段里。所以 "eur" 存进去时变成了 "EUR",而 -5 根本没能变成一个 Money

这就是为什么紧凑构造器里不能写 this.cents = cents。我们试过,构建失败,报 cannot assign a value to final variable cents。编译器会在你的代码之后自己做这次赋值,而 final 字段只能赋值一次。

额外的构造器必须委托

record 可以有其他构造器,只要每个构造器最终都用 this(...) 调用规范构造器:

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        currency = currency.toUpperCase();
    }

    Money(long cents) {
        this(cents, "EUR");
    }

    Money(String text) {
        String[] parts = text.split(" ");
        this(Long.parseLong(parts[0]), parts[1]);
    }
}

void main() {
    IO.println(new Money(300));
    IO.println(new Money("450 usd"));
}

输出:

Money[cents=300, currency=EUR]
Money[cents=450, currency=USD]

两个额外的构造器都经过了紧凑构造器,所以 "usd" 被转成了大写,负数检查也照样生效。规则只放在一个地方。

Money(String text) 构造器在 this(...) 之前执行了两条语句。这是 Java 25 的灵活的构造器主体(flexible constructor bodies)允许的,讲类与对象的那一篇介绍过。如果额外的构造器不调用 this(...),而是想自己给字段赋值,javac 会拒绝,报 constructor is not canonical, so it must invoke another constructor of class Money

record 做不到的事

record 用灵活性换来保证,而编译器会强制执行这笔交易。下面每条规则的目的,都是让头部始终完整描述 record 的数据。

不能有额外的实例字段

record 的全部状态都在头部里。你不能在旁边偷偷加一个字段:

record Money(long cents, String currency) {
    private int timesPrinted;
}

void main() {
    IO.println(new Money(1250, "EUR"));
}

构建失败,报错:

Main.java:2: error: field declaration must be static
    private int timesPrinted;
                ^

javac 还会在下面加一句提示:(consider replacing field with record component)。既然 equalstoString 是根据头部生成的,那么头部之外的字段就是它们会悄悄忽略的数据。

不能用 extends

record 已经继承了 java.lang.Record,所以不能再继承别的类。你得到的报错会让人意外:

class Amount {
    long cents;
}

record Money(long cents, String currency) extends Amount {}

void main() {
    IO.println(new Money(1250, "EUR"));
}

构建失败,报错:

Main.java:5: error: '{' expected
record Money(long cents, String currency) extends Amount {}
                                         ^

这是语法错误,不是友好的解释。record 的语法里根本没有 extends 的位置,所以解析器期望头部后面紧接着就是类体。如果你看到 '{' expected 指向一个 record,就检查一下有没有 extends。反过来也一样:一个类如果想继承 Money,会报 cannot inherit from final Money

没有 setter

每个字段都是 final 的,所以给字段赋值的方法编译不过:

record Money(long cents, String currency) {
    void setCents(long cents) {
        this.cents = cents;
    }
}

void main() {
    var price = new Money(1250, "EUR");
    price.setCents(0);
}

构建失败,报错:

Main.java:3: error: cannot assign a value to final variable cents
        this.cents = cents;
            ^

想要不同的金额,就创建一个新的 Money。讲 wither 的那一节会展示常用的做法。

record 能做的事

除了这些限制,record 就是一个普通的类。它可以有静态字段和静态方法、实例方法,还可以实现接口:

interface Priced {
    Money price();
}

record Money(long cents, String currency) implements Comparable<Money> {
    static final String DEFAULT_CURRENCY = "EUR";

    static Money zero() {
        return new Money(0, DEFAULT_CURRENCY);
    }

    static Money euros(long whole, long cents) {
        return new Money(whole * 100 + cents, "EUR");
    }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(cents + other.cents, currency);
    }

    @Override
    public int compareTo(Money other) {
        return Long.compare(cents, other.cents);
    }

    @Override
    public String toString() {
        return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
    }
}

record Item(String name, Money price) implements Priced {}

void main() {
    var items = List.of(
        new Item("tea", Money.euros(3, 0)),
        new Item("cake", Money.euros(4, 50)),
        new Item("water", Money.euros(1, 20)));

    var total = Money.zero();
    for (Priced item : items) {
        total = total.plus(item.price());
    }
    IO.println("total: " + total);

    var cheapest = items.stream().map(Item::price).min(Money::compareTo).orElseThrow();
    IO.println("cheapest: " + cheapest);
    IO.println(items.get(0));
}

输出:

total: 8.70 EUR
cheapest: 1.20 EUR
Item[name=tea, price=3.00 EUR]

有几点值得注意:

  • Money.zero()Money.euros(...) 是静态工厂方法。它们在静态方法里调用 new Money(...)。讲类与对象的那一篇在紧凑源文件里对普通类这样做时,遇到了编译错误,因为那个类嵌套在隐藏的 Main 类里。record 嵌套时隐式就是 static 的,所以工厂方法直接就能用。
  • Item 没写 price() 就实现了 Priced接口要求一个名叫 price()、返回 Money 的方法,而 record 生成的访问器恰好就是这个方法。
  • 我们替换了 toString现在 Money 打印成 3.00 EURItem 生成的 toString 在打印 price 组件时也用了它。
  • 在 record 内部,other.currency 直接读取字段。你也可以调用 other.currency(),两种都行。

局部 record 和泛型 record

你可以在方法里声明 record,用来临时组合几个值很方便。record 也可以带类型参数:

record Pair<A, B>(A first, B second) {
    <C> Pair<A, C> withSecond(C newSecond) {
        return new Pair<>(first, newSecond);
    }
}

void main() {
    record Score(String player, int points) {}

    var scores = List.of(new Score("Ana", 15), new Score("Ben", 22), new Score("Caro", 9));
    var best = scores.stream().max((a, b) -> Integer.compare(a.points(), b.points())).orElseThrow();
    IO.println("best: " + best);

    var pair = new Pair<>("Ana", 15);
    Pair<String, String> labelled = pair.withSecond("fifteen");
    IO.println(pair + " " + pair.first().length());
    IO.println(labelled);
    IO.println(new Pair<>("x", 1).equals(new Pair<>("x", 1)));
}

输出:

best: Score[player=Ben, points=22]
Pair[first=Ana, second=15] 3
Pair[first=Ana, second=fifteen]
true

Score 只存在于 main 内部,外面谁也看不到它。局部 record 也隐式是 static 的,所以它读不到方法的局部变量。我们试过在局部 record 的方法里读取一个局部变量 currency,javac 报 non-static variable currency cannot be referenced from a static context。应该把这个值作为组件传进去。

Pair<A, B> 和任何泛型类一样用。pair.first() 返回 String,所以 .length() 不需要强制类型转换,打印出 3withSecond 返回的 Pair 第二个类型不同,编译器也会跟踪这一点。

record 天然适合做密封接口的叶子类型,switch 可以用 record 模式把它们拆开。讲密封类型和模式匹配的那一篇会介绍这些。

浅不可变:record 里的列表可以改变

record 的字段是 final 的,但 final 固定的只是引用,而不是引用指向的对象。下面这个 bug,在“不可变”的 record 里看起来不可能发生:

record Order(String id, List<String> items) {}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);
    IO.println("placed:  " + order);

    basket.add("cake");
    IO.println("later:   " + order);

    order.items().clear();
    IO.println("cleared: " + order);
}

输出:

placed:  Order[id=A1, items=[tea]]
later:   Order[id=A1, items=[tea, cake]]
cleared: Order[id=A1, items=[]]

订单变了两次,却没有一行代码直接碰过 order。record 的 items 字段和 basket 指向同一个 ArrayList。往 basket 里加东西,订单就变了。而 order.items() 交出去的也是这个列表,所以任何读到订单的人都能把它清空。

这就是浅不可变。record 不能改为指向另一个列表,但列表本身照样可变。数组、StringBuilder,以及你放进组件的任何可变类,都是如此。

在紧凑构造器里用 List.copyOf 修复

在创建 record 时复制列表,复制到一个谁都改不了的列表里:

record Order(String id, List<String> items) {
    Order {
        items = List.copyOf(items);
    }
}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);

    basket.add("cake");
    IO.println("after changing basket: " + order);

    order.items().add("biscuits");
    IO.println("never printed");
}

输出后停止:

after changing basket: Order[id=A1, items=[tea]]
Exception in thread "main" java.lang.UnsupportedOperationException

这里发生了两件事。List.copyOf 创建了一个新列表,所以之后对 basket 的修改不会再影响订单。而且这个新列表不可修改,所以 order.items().add(...) 会抛出异常,而不是改动它。这个异常没有消息,第一次见到时可能会让人摸不着头脑。

List.copyOf 还会拒绝 null 元素:我们传入一个包含 null 的列表,构造器抛出了 NullPointerException。对订单来说,这通常正是你想要的。Set.copyOfMap.copyOf 对 set 和 map 也是一样。

wither:修改一个组件

record 没有 setter,所以“修改金额”的意思是“创建一个金额不同的新 record”。Java 不会替你写这个方法,所以常见的做法是手写一个 with 方法:

record Money(long cents, String currency) {
    Money withCents(long newCents) {
        return new Money(newCents, currency);
    }

    Money withCurrency(String newCurrency) {
        return new Money(cents, newCurrency);
    }
}

void main() {
    var price = new Money(1250, "EUR");
    var discounted = price.withCents(1000);
    var inDollars = discounted.withCurrency("USD");

    IO.println(price);
    IO.println(discounted);
    IO.println(inDollars);
}

输出:

Money[cents=1250, currency=EUR]
Money[cents=1000, currency=EUR]
Money[cents=1000, currency=USD]

price 没有被改动。每个 wither 都调用规范构造器,所以紧凑构造器的检查会对新值再跑一遍。你没法通过 wither 偷偷塞进一个负数金额。

有些语言为此提供了内置语法。Java 25 没有。我们试了提案中的 price with { cents = 0; } 写法,加不加 --enable-preview 都试过,javac 两次都拒绝了,报 not a statement。目前的做法是:需要哪些 wither 就写哪些,别多写。

什么时候不该用 record

如果两个值相同的对象应该算作同一个东西,record 就是合适的工具。但有些对象不是这样。

实体有一个身份,数据变了,身份也不变。银行账户是经典例子。存了一笔钱之后,42 号账户仍然是 42 号账户;两个都存着 100 EUR 的不同账户,也不是同一个账户。record 在这两点上都不对:它不能改变,而且它认为值相等就是对象相等。这时应该写一个类,就像讲类与对象的那一篇里的 BankAccount,用 private 字段和方法来守住规则。

另一种不合适的是可变 bean。JPA(Java 把对象存进数据库的标准)这类框架需要无参构造器、setter,以及能在对象创建之后再填充的字段。这些 record 一样都没有。在需要四处传递的数据上用 record,比如请求体、查询结果和消息;框架管理的东西,还是交给框架的类。

record 用作 map 的键和 set 的元素

好的 map 键必须让 equalshashCode 保持稳定,而 record 正好提供这一点。HashMap 先用哈希码找到键,再用 equals 确认匹配。所以只要对象和存进去的键相等,就能查到,不必是同一个对象。

下面是一张以 record 为键的座位表,旁边是同样的 map,但键换成了没有重写 equals 的普通类:

record Seat(char row, int number) {}

class PlainSeat {
    final char row;
    final int number;

    PlainSeat(char row, int number) {
        this.row = row;
        this.number = number;
    }
}

void main() {
    var bookings = new HashMap<Seat, String>();
    bookings.put(new Seat('B', 7), "Ana");
    bookings.put(new Seat('B', 8), "Ben");
    IO.println("record key B7:  " + bookings.get(new Seat('B', 7)));
    IO.println("contains B8:    " + bookings.containsKey(new Seat('B', 8)));

    var plainBookings = new HashMap<PlainSeat, String>();
    plainBookings.put(new PlainSeat('B', 7), "Ana");
    IO.println("plain key B7:   " + plainBookings.get(new PlainSeat('B', 7)));

    var taken = new HashSet<Seat>();
    taken.add(new Seat('C', 1));
    taken.add(new Seat('C', 1));
    IO.println("seats in set:   " + taken.size());
}

输出:

record key B7:  Ana
contains B8:    true
plain key B7:   null
seats in set:   1

查找时的 new Seat('B', 7) 是一个全新的对象,但它和之前存入的键相等,所以 map 找到了 Ana。PlainSeat 的查找返回 null,因为没有自己的 equals 的类只匹配同一个对象。把 Seat('C', 1) 往 set 里加两次,只留下一个,原因相同。

有两点要小心。第一,record 键的稳定程度取决于它的组件。如果键里有一个可变的 List,列表一变,哈希码就跟着变,map 就再也找不到它了。上面的 List.copyOf 修复能防止这种情况。第二,数组组件按引用比较,而不是按内容比较,因为数组没有重写 equals。我们验证过:两个 record 各自持有一个独立的 String[] {"a"} 数组,它们并不相等,而且 toString 显示的是 [Ljava.lang.String;@ 加一个哈希值,而不是数组内容。如果 record 要拿来比较,就用 List

要点

  • record Money(long cents, String currency) {} 会给你 private final 字段、一个规范构造器、名为 cents()currency() 的访问器,以及基于值的 equalshashCodetoString。record 从 Java 16 起成为正式特性。
  • 把校验和规范化放在紧凑构造器里。给参数赋值,编译器会把它们存进字段。额外的构造器必须调用 this(...)
  • record 不能在头部之外声明实例字段,不能用 extends,构造完成后也不能给字段赋值。
  • record 可以有静态工厂方法、实例方法、接口、类型参数,还可以在方法内部局部声明。
  • record 是浅不可变的。在紧凑构造器里用 List.copyOf 复制可变组件。
  • 要修改一个组件,就写一个返回新 record 的 withX 方法。Java 25 没有为此提供内置语法。
  • 值用 record,状态会变化的实体用类。值相等让 record 成为可靠的 map 键和 set 元素。

record 适用于完全由值来描述的数据,其余的交给 Java 来写。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。