《Java 编程入门》7.1 类、字段与方法

回到 library 的领域对象:类把字段(状态)和方法(行为)放在一起,new 创建独立实例,this 指向当前接收者;再分清实例字段与静态字段、默认 toString/equals 的真实行为,以及字段默认值带来的静默错误,为 7.2 的构造器和 7.3 的封装打底。

本节目标:能说清一个对象里有什么、方法怎么访问它、实例成员和静态成员差在哪,并知道默认的 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 与初始化顺序 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发