本节目标:能写出「创建即合法」的构造器,能预测静态字段、实例字段、初始化块和构造器的执行顺序,并知道 JDK 25 的灵活构造器体比 JDK 21 多允许什么。
适用版本:JDK 25 LTS(Temurin 25.0.5);灵活构造器体是 JDK 25 的正式特性,JDK 21 不支持。
上一节留下的问题
7.1 的 Book 还是公开字段,创建一本书之后,谁都能把它改成非法状态:
Book book = new Book("978-7-111-1", "Effective Java", "Joshua Bloch");
book.publishedYear = -1;
book.title = "";
更隐蔽的是,三参构造器根本不接收年份,publishedYear 保持默认值 0。调用方看到 (0),分不清是「公元 0 年」还是「没填」。
要解决这类问题,第一步是把「赋值」从「随手写字段」变成「创建时一次确定」。这正是构造器的职责:对象在离开构造器时,必须处于合法状态。 7.2 先把构造器和初始化顺序讲透,7.3 再把字段收成 private,让外部根本没有机会绕过。
构造器定义「怎样创建一个合法对象」
构造器(constructor)有三个形式特征:
- 名字必须和类名完全一致。
- 没有返回值类型,连
void都不写。 - 只能通过
new调用,不能像普通方法那样写book.Book(...)。
public Book(String isbn, String title, String author, int publishedYear) {
this.isbn = isbn;
this.title = title;
this.author = author;
this.publishedYear = publishedYear;
}
声明了构造器,就没有默认构造器
如果类里一个构造器都没写,编译器会生成一个无参的默认构造器。只要写了任意一个,默认构造器就不再生成。本机 JDK 25 对 new Book() 的报错:
错误: 对于Book(没有参数), 找不到合适的构造器
Book book = new Book();
^
构造器 Book.Book(String,String)不适用
(实际参数列表和形式参数列表长度不同)
构造器 Book.Book(String,String,String)不适用
构造器 Book.Book(String,String,String,int)不适用
编译器把现有的三个重载都列了出来,说明「没有无参版本」。需要无参创建时,自己写一个 public Book() { ... },并想清楚各字段的默认值是否合法。
用 this(...) 委托,避免复制校验逻辑
多个构造器常常只是「参数少几个」。如果每个都写一遍字段赋值和校验,逻辑迟早分叉。this(...) 让短版本委托给长版本:
public static final int UNKNOWN_YEAR = 0;
public Book(String isbn, String title) {
this(isbn, title, "unknown", UNKNOWN_YEAR);
}
public Book(String isbn, String title, String author) {
this(isbn, title, author, UNKNOWN_YEAR);
}
public Book(String isbn, String title, String author, int publishedYear) {
this.isbn = requireText(isbn, "isbn");
this.title = requireText(title, "title");
this.author = requireText(author, "author");
if (publishedYear < 0) {
throw new IllegalArgumentException("publishedYear < 0: " + publishedYear);
}
this.publishedYear = publishedYear;
}
只有四参版本真正赋值,前两个只负责补默认值。UNKNOWN_YEAR 把 0 这个魔数变成了有名字的常量:调用方看到 Book.UNKNOWN_YEAR 就知道「年份未知」,而不是猜 0 是不是公元 0 年。this(...) 调用必须是构造器的第一条语句——这是 JDK 21 的规则;JDK 25 放宽了,见下一节。
final 字段必须被赋值一次
private final String isbn 表示这个字段在对象创建后不能再指向别的字符串。编译器要求每个构造器最终都为它赋值,且只能赋一次:
static final class Book {
private final String title;
Book() {
System.out.println("no assignment");
}
}
本机 JDK 25 报:
错误: 可能尚未初始化变量title
在构造器里写两次 this.title = ... 同样编译失败(「可能已赋值」)。这条规则把「忘了初始化」从运行时的 null 提前到了编译期,是 7.1 说的「字段默认值陷阱」的解药。
注意 final 约束的是引用,不是对象内容。final List<String> emails 仍然可以 emails.add(...);7.3 会专门处理这个区别。
初始化顺序:静态一次,实例每次
一个类从加载到创建对象,初始化分两个阶段。
静态初始化:类加载时执行一次
静态字段和静态初始化块按文本顺序执行,且只执行一次,无论创建多少个对象:
static int catalog = init("static field catalog");
static {
System.out.println("static block");
}
实例初始化:每个对象执行一次
每次 new,在父类构造器之后,先执行实例字段的初始化和实例初始化块(按文本顺序),最后执行构造器体:
int year = init("instance field year");
String title = "default";
{
System.out.println("instance block");
}
Sample() {
System.out.println("constructor");
}
本机 JDK 25 创建两个对象:
--- first new ---
static field catalog
static block
instance field year
instance block
constructor
--- second new ---
instance field year
instance block
constructor
三条结论:
- 静态部分只出现一次。 类加载后,静态字段和静态块不会因为再
new而重跑。 - 实例部分每次
new都跑。 字段初始化和实例块在构造器体之前,顺序由源码中的先后决定。 - 构造器体最后。 构造器里能放心使用字段,因为前面的初始化已经完成。
如果字段既有初始值,构造器里又赋值,最终生效的是构造器:初始值先执行,构造器后执行。两者都写时,读代码的人容易误判最终值,尽量让一个字段只有一个赋值入口。静态块里不能写 this.title,因为静态初始化时还没有对象。
JDK 25:灵活构造器体
JDK 21 要求 this(...) 或 super(...) 必须是构造器第一条语句。这导致一个尴尬:想在委托之前校验参数,只能把校验塞进被委托的构造器,或者抽一个静态方法当参数。为了演示,临时给 Book 加一个只接收书名的构造器,ISBN 用占位符 "unknown",其余交给三参构造器:
public Book(String rawTitle) {
this("unknown", normalize(rawTitle), "unknown");
}
private static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("title must not be blank");
}
return raw.strip();
}
JDK 25 的灵活构造器体(Flexible Constructor Bodies,JEP 513)允许在 this(...) 之前写语句,只要不访问正在构造的对象:
public Book(String rawTitle) {
String trimmed = rawTitle == null ? "" : rawTitle.strip();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("title must not be blank");
}
this("unknown", trimmed, "unknown");
}
这段在 JDK 25 上编译通过。用 --release 21 编译同一个文件,本机报错:
错误: -source 21 中不支持 灵活构造器
this("unknown", trimmed, "unknown");
^
(请使用 -source 25 或更高版本以启用 灵活构造器)
限制仍然存在:在 this(...)/super(...) 之前,不能访问实例字段(读或写)、不能调用实例方法、不能把 this 传出去。原因是父类构造器还没跑,对象只初始化了一部分。灵活构造器体放宽的是「语句位置」,不是「对象可用性」。
如果项目还要发布到 JDK 21,这条特性不能用;卷一的主线是 25,但 --release 21 的兼容性检查会如实拦住它。第 1.2 节讲过:语法能不能用,取决于 --release 的目标版本,不只是开发机的 JDK。
在 library 里落地
Book 收成「身份字段不可变、借出状态可变」的版本。字段私有后,旧代码里的直接访问要一起改:book.isbn/book.title 改成 book.isbn()/book.title(),addBook 改用四参构造器,borrow/giveBack 改调 markBorrowed()/markReturned():
package com.plume.library;
import java.util.Objects;
public class Book {
public static final int UNKNOWN_YEAR = 0;
private final String isbn;
private final String title;
private final String author;
private final int publishedYear;
private boolean available = true;
public Book(String isbn, String title) {
this(isbn, title, "unknown", UNKNOWN_YEAR);
}
public Book(String isbn, String title, String author) {
this(isbn, title, author, UNKNOWN_YEAR);
}
public Book(String isbn, String title, String author, int publishedYear) {
this.isbn = requireText(isbn, "isbn");
this.title = requireText(title, "title");
this.author = requireText(author, "author");
if (publishedYear < 0) {
throw new IllegalArgumentException("publishedYear < 0: " + publishedYear);
}
this.publishedYear = publishedYear;
}
private static String requireText(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.strip();
}
public String isbn() { return isbn; }
public String title() { return title; }
public String author() { return author; }
public int publishedYear() { return publishedYear; }
public boolean isAvailable() { return available; }
public void markBorrowed() {
if (!available) {
throw new IllegalStateException("already borrowed: " + isbn);
}
available = false;
}
public void markReturned() {
available = true;
}
public String describe() {
return title + " / " + author + " (" + publishedYear + ")";
}
}
几个设计决策:
- 身份字段
final。 ISBN、书名、作者、出版年创建后不再变。改书名应该是一本新书或一次显式的「修订」,而不是悄悄改字段。 available不是final。 借出、归还确实会改变它。它保留在Book里是本章的过渡方案;7.3 会论证「借出状态属于借阅关系,不属于书」,把它移出去。- 校验集中在四参构造器。
requireText处理 null、空白和首尾空格;年份只接受非负。短构造器通过this(...)复用同一套规则。 markBorrowed拒绝重复借出。 已经借出的书再借,抛IllegalStateException,而不是静默把available再设一次false。第 13 章会把这类错误纳入统一的异常体系。
Member 用同样的方式收紧:
public class Member {
private final String id;
private final String name;
private String email;
public Member(String id, String name) {
this(id, name, "unknown");
}
public Member(String id, String name, String email) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id must not be blank");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
this.id = id.strip();
this.name = name.strip();
this.email = email == null || email.isBlank() ? "unknown" : email.strip();
}
public String contactLine() {
return name + " <" + email + ">";
}
}
email 允许为空,统一替换成 "unknown",避免 7.1 里 Ada <null> 那种半合法状态。这个「空值归一」策略是否合适取决于业务;如果邮箱是必填项,就应该像 id 一样拒绝空值,而不是替换。
编译并运行一个构造器示例:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.ConstructorDemo
本机 JDK 25 输出:
Effective Java / Joshua Bloch (2008)
Java Puzzlers / unknown (0)
available = true
after borrow = false
after return = true
第二行是两参构造器的结果:作者补成 unknown,年份补成 UNKNOWN_YEAR(值为 0)。0 本身仍然不能被打印成「未知」,但它现在有了一个名字,调用方不必猜这个 0 从哪来。
非法年份在构造阶段就被拒绝。本机 JDK 25 运行 new Book(..., -1):
Exception in thread "main" java.lang.IllegalArgumentException: publishedYear < 0: -1
at com.plume.library.Book.<init>(Book.java:27)
at InvalidYear.main(InvalidYear.java:5)
栈顶是 Book.<init>,说明对象没有创建成功,调用方拿不到半成品。这比「先创建、再检查、再决定要不要用」可靠:在正常构造路径上,非法状态没有机会存在。
常见错误与边界
final 字段漏赋值。 编译器报「可能尚未初始化变量」。每个构造器都必须给所有 final 字段赋值,或者用 this(...) 委托给已经赋值的构造器。
构造器互相调用成环。 Book() { this(1); } 和 Book(int) { this(); } 会报「递归构造器调用」。this(...) 必须最终落到一个真正赋值的构造器上。
在构造器里调用可被子类重写的方法。 父类构造器执行时,子类字段还没初始化;如果方法被重写,可能读到子类的默认值(0、null)。第 8 章讲继承时会展开,现在的规则是:构造器里只调用 private、final 或 static 方法。
把校验写散在多个构造器里。 三参构造器校验一次、四参再校验一次,规则很容易不一致。让短版本委托给长版本,只保留一处校验。
在 JDK 21 上使用灵活构造器体。 用 --release 21 编译会报「不支持 灵活构造器」。要么把校验抽成静态方法,要么把项目基线提到 25;不要只改开发机的 JDK 而不管发布目标。
小结
- 构造器与类同名、无返回值,只能通过
new调用;声明任意构造器后,默认无参构造器不再生成。 - 用
this(...)让短构造器委托给长构造器,校验和赋值只写一份;JDK 21 要求它是第一条语句。 final字段必须在构造器里赋值一次,编译器用「可能尚未初始化」拦住遗漏。- 初始化顺序:静态字段/静态块(类加载时一次)→ 实例字段/实例块(每次
new,按文本顺序)→ 构造器体。 - JDK 25 的灵活构造器体允许在
this(...)之前做参数校验和局部变量计算,但不能访问正在构造的对象;JDK 21 不支持。 library的Book用构造器校验 ISBN、书名、作者、年份,available仍是可变状态,留待 7.3 处理。- 非法输入在构造阶段抛
IllegalArgumentException,对象不会以半合法状态存在。
构造器让「创建时合法」成为默认,字段也已经收成 private,但外部仍然能通过 markBorrowed、markReturned 改变借出状态,Member.email 也还没有提供受控的修改方式。下一节把封装和不可变讲完整:哪些状态该留在对象里、访问器怎么设计、可变集合怎么防御性复制。
阅读导航:上一节:7.1 类、字段与方法 · 下一节:7.3 封装与不可变 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。