本节目标:能说清一个对象里有什么、方法怎么访问它、实例成员和静态成员差在哪,并知道默认的
toString、equals会给你什么。
适用版本:JDK 25 LTS(Temurin 25.0.5);本节语法与行为在 JDK 21 上一致。
上一节留下的问题
第 6 章讲的是方法:怎么定义、怎么传参、局部变量活多久。但方法总得属于某个东西,字段也总得放在某个地方。回到 library,Book 和 Member 现在还是两个「字段袋子」:
public class Book {
public String isbn;
public String title;
public String author;
public int publishedYear;
public boolean available = true;
}
任何拿到 Book 引用的代码都能写 book.publishedYear = -1、book.available = false,对象没有任何机会拒绝。要修这个问题,先得把「类是什么、对象是什么、字段和方法各自负责什么」讲清楚;封装是 7.3 的事,这一节先把地基打好。
类描述状态和行为
类(class)是对象的模板,里面主要放两样东西:
- 字段(field):对象的状态。
Book的title、publishedYear是字段。 - 方法(method):对象能做的事。
describe()把字段拼成一行文本,isSameEdition()判断两本书是不是同一版。
字段和方法写在同一个类里,方法可以直接访问字段,不需要把字段作为参数传进来:
public class Book {
public String isbn;
public String title;
public String author;
public int publishedYear;
public boolean available = true;
public Book(String isbn, String title, String author) {
this.isbn = isbn;
this.title = title;
this.author = author;
}
public String describe() {
return title + " / " + author + " (" + publishedYear + ")";
}
public boolean isSameEdition(Book other) {
return other != null && isbn.equals(other.isbn);
}
}
describe() 里的 title、author、publishedYear 没有写 this.,编译器自动把它们当成「当前对象的字段」。构造器里的 this.isbn = isbn 则必须写 this.:左边是字段,右边是参数,同名时必须区分。this 指向「正在执行这个方法的那个对象」,也叫接收者(receiver)。
new 创建独立实例
Book first = new Book("978-7-111-1", "Effective Java", "Joshua Bloch");
Book second = new Book("978-7-115-2", "Java Concurrency in Practice", "Brian Goetz");
new Book(...) 做三件事:在堆上分配对象、调用构造器初始化字段、返回对象的引用。first 和 second 是两个不同的对象,各有自己的一份 title、author、publishedYear。改 first.publishedYear 不会影响 second。
方法通过接收者区分操作哪个对象:first.describe() 里的 this 是 first,second.describe() 里的 this 是 second。同一个方法代码,作用在不同对象上,结果不同。
方法可以返回值,也可以只做事
describe() 返回 String,调用方可以把它赋给变量或打印。isSameEdition(Book other) 返回 boolean,适合放在 if 里。方法也可以返回 void,只做副作用,比如往列表里加元素:
public void markBorrowed() {
this.available = false;
}
void 方法不产出值,调用方不能写 boolean ok = book.markBorrowed();。需要知道成功与否时,返回 boolean 或抛异常,不要用「返回 void + 改一个字段」让调用方猜。
实例字段与静态字段
字段前面加 static,它就从「每个对象一份」变成「整个类一份」:
static final class Book {
static int created; // 所有 Book 共享
final String title; // 每个 Book 一份
Book(String title) {
this.title = title;
created++;
}
}
本机 JDK 25 创建三本书后打印 Book.created:
created = 3
created 不属于任何一本书,它属于 Book 这个类。三本书的构造器各自执行一次 created++,加的是同一个变量。访问时写 Book.created,不需要实例;在实例方法里也可以直接写 created,编译器知道它是静态字段。
静态字段适合什么
- 常量:
public static final int LOAN_DAYS = 14;,所有实例共享同一个值,且不可变。 - 计数器、缓存、注册表:确实需要全局一份的状态。
- 工具方法:
public static boolean isDigits(String s),不依赖任何实例状态。
静态字段的危险
静态可变状态是全局状态。测试之间会互相污染,多线程会竞争,生命周期和 JVM 一样长。上面 created 计数器在单线程演示里没问题,但在真实系统里,它会让「创建了多少本书」依赖测试执行顺序。
经验规则:能写成实例字段就不要写静态字段;能写成 static final 常量就不要写可变静态字段。 第 16 章讲测试时,还会遇到静态状态带来的麻烦。
对象的默认行为
每个类都默认继承 Object,也就默认拥有 toString()、equals()、hashCode() 三个方法。不重写时,它们的行为都基于对象身份,不是字段内容。
默认 toString 打印的是地址样式
Book first = new Book("978-7-111-1", "Effective Java", "Joshua Bloch");
System.out.println(first);
本机 JDK 25 的一次运行输出:
com.plume.library.Book@7e9e5f8a
@ 后面是 hashCode() 的十六进制表示,默认实现通常与对象地址有关,每次运行可能不同。这个输出对人没有意义,调试时也看不出是哪本书。需要可读文本时,自己写 describe(),或者重写 toString()。重写 toString() 后,System.out.println(book)、字符串拼接、日志都会用新格式;第 9 章换成 record 时会自动生成一个包含字段的 toString。
默认 equals 比较的是不是同一个对象
Book copy = new Book("978-7-111-1", "Effective Java", "Joshua Bloch");
System.out.println(first == copy); // false
System.out.println(first.equals(copy)); // false
两个对象字段完全一样,但 == 和默认 equals 都返回 false,因为它们是两个不同的对象。== 比较引用,默认 equals 也是比较引用(Object.equals 的实现就是 this == obj)。
想按 ISBN 判断「是不是同一版」,需要显式写一个方法:
public boolean isSameEdition(Book other) {
return other != null && isbn.equals(other.isbn);
}
调用 first.isSameEdition(copy) 返回 true。这里先判 other != null,再调 isbn.equals(other.isbn),避免 NPE;isbn 假定非空,如果允许为空,还要再判断一次。
重写 equals 是另一件事:它会影响 List.contains、Set 去重、Map 查找。第 7.3 节会讲 equals/hashCode 的契约,第 9 章的 record 会自动生成基于字段的实现。在那之前,不要随手重写 equals 只改一半——只重写 equals 不重写 hashCode,会让 HashSet 和 HashMap 行为错乱。
字段有默认值,局部变量没有
字段不初始化也有值:
static final class Defaults {
int publishedYear;
boolean available;
String title;
}
本机 JDK 25 输出:
year = 0
available = false
title = null
规则是:整数 0,浮点 0.0,boolean false,引用 null。这和 6.3 讲的局部变量不同——局部变量必须先赋值,否则编译失败。
字段默认值很方便,也很危险。publishedYear 忘了赋值就是 0,title 忘了赋值就是 null;程序照常运行,直到某处打印出 0 或抛出 NPE。7.2 会用构造器强制「创建时就赋好值」,让「忘了初始化」变成编译错误或构造期异常,而不是运行到一半才发现。
在 library 里落地
Book 加上 describe() 和 isSameEdition(),Member 加上 contactLine():
package com.plume.library;
public class Member {
public String id;
public String name;
public String email;
public Member(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public String contactLine() {
return name + " <" + email + ">";
}
}
LibraryApp 的 list 命令不再自己拼字段,而是调用 book.describe():
private void handleList() {
int index = 1;
for (Book book : service.books()) {
System.out.printf("%d. %s%n", index++, book.describe());
}
System.out.println("books = " + service.count());
}
这里有两个设计点:
- 展示逻辑放在领域对象上。
Book最清楚自己有哪些字段、怎么拼成一行。LibraryApp只负责编号和缩进,不关心书名和作者之间用什么分隔符。 service.books()返回副本。 6.2 讲过返回内部集合会泄漏状态;这里用new ArrayList<>(books),调用方清空返回值不会影响服务。
编译并回放:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp
输入与输出(本机 JDK 25):
add 978-7-111-1|Effective Java|Joshua Bloch|2008
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz|2006
added: Java Concurrency in Practice
list
1. Effective Java / Joshua Bloch (2008)
2. Java Concurrency in Practice / Brian Goetz (2006)
books = 2
bye
describe() 的输出格式只在一个地方定义。将来要加「是否可借」标记,只改 Book.describe(),所有调用方自动生效;如果格式散落在 CLI、日志、测试里,就要改很多处,还容易漏。
一个对象,多条引用
service.books() 返回的列表里装的是 Book 引用。LibraryApp 拿到的 book 和服务内部列表里的元素指向同一个对象。所以:
- 通过
book.describe()读字段,看到的是最新状态。 - 如果
LibraryApp写book.publishedYear = 0,服务里的那本书也会被改。
这正是 6.2 讲的引用语义,也是 7.3 要解决的问题:只要字段公开,任何持有引用的代码都能绕过业务规则改状态。
常见错误与边界
把静态字段当成实例字段。 static int created 被所有对象共享,first.created 和 second.created 读到的是同一个值。想让每个对象有独立计数,就不要加 static;想统计总数,才用静态字段,并接受它是全局状态。
以为 equals 默认比较内容。 默认实现比较引用。两个字段相同的 Book,equals 返回 false。需要按业务键比较时,显式写方法(如 isSameEdition),或者按契约重写 equals/hashCode。
直接打印对象。 默认 toString 输出 类名@哈希,对排查问题几乎没有帮助。给领域对象写 describe() 或重写 toString(),日志和调试输出才有可读性。
依赖字段默认值。 publishedYear 默认 0、email 默认 null。如果业务上「年份必须大于 0」「邮箱不能为空」,默认值就不该被当成合法状态。7.2 的构造器会把这些约束前移。
对 null 字段调用方法。 本机 JDK 25 对 member.email.length() 的输出:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>.email" is null
at com.plume.library.NullFieldDemo.main(NullFieldDemo.java:6)
字符串拼接 name + " <" + email + ">" 遇到 null 不会抛异常,会拼出 Ada <null>;但只要对 null 调用方法,就立刻 NPE。两者行为不同,别把「拼接没报错」当成「字段没问题」。
在静态方法里直接使用实例字段。 6.3 已经给过报错。main 是静态的,实例字段属于某个对象;先 new 出对象,再通过对象访问。
公开字段让对象进入非法状态。 book.publishedYear = -1、book.title = ""、book.available = false 都能编译、能运行,对象无法拒绝。这是本章故意保留的简化,7.3 会把字段收成 private,用方法守住不变量。
小结
- 类把字段(状态)和方法(行为)放在一起;
new创建独立实例,方法通过this操作当前对象。 - 实例字段每个对象一份,静态字段整个类一份;静态可变状态是全局状态,能不用就不用。
- 默认
toString输出类名@哈希,默认equals比较引用;按内容比较要显式实现。 - 字段有默认值(0、false、null),局部变量没有;默认值可能把「忘了初始化」伪装成合法状态。
library的Book.describe()把展示逻辑收进领域对象,Member.contactLine()同理;service.books()返回副本,避免泄漏内部列表。- 公开字段仍然是缺口:任何持有引用的人都能改状态,7.2 和 7.3 会分别从构造器和封装两侧收紧。
下一节先解决「对象一创建就合法」:构造器怎么重载、this(...) 怎么委托、字段初始化块和构造器谁先执行,以及 JDK 25 的灵活构造器体允许在 super() 之前做什么。
阅读导航:上一节:6.3 作用域、生命周期与栈帧 · 下一节:7.2 构造器、this 与初始化顺序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。