程序规模很小时,几组变量和几个方法就能把事情做完。等需求变成“同时管理很多账户、订单或学生”,问题就出现了:哪些数据属于同一个实体?哪个方法有权修改它?怎样保证余额不会被随手改成负数?如果这些边界只存在于程序员脑中,代码一长就会失去约束。
类与对象提供的是一种组织方式。类定义一种新类型及其可用操作,对象则是在运行期间创建出来的具体实例。每个对象有自己的状态,实例方法围绕这个状态工作。我们真正要学的,不是一句“类是模板”的比喻,而是怎样用类把数据、规则和操作放在清楚的边界里。
这一章会沿着一条完整路线展开:先建立类、对象、字段和实例方法的心智模型,再拆开 new、引用、null 与别名;随后自己实现一个能维护不变量的类,理解构造与初始化顺序、this 和构造器委托;最后处理对象参数、返回对象、标准 API 协作、包、调试和测试。
代码约定:带 public class 的完整代码块应分别保存为同名 .java 文件;较短的片段只聚焦当前机制。示例以 Java SE 26 的正式规则为准。
Java 变量不会把一个类实例“装进自身”。引用类型变量保存的是引用值,引用值可以指向某个对象,也可以是 null。理解这一点,才能解释赋值、参数传递、== 和空指针异常。
假设我们要同时处理两名会员的积分。只用零散变量,可以这样写:
String memberName1 = "小林";
int memberPoints1 = 120;
String memberName2 = "阿青";
int memberPoints2 = 80;如果会员再增加“等级、注册时间、是否冻结”,每个字段都要复制一组;如果还要增加第三名、第三百名会员,变量之间的对应关系只能靠命名维持。更麻烦的是,任何代码都可以直接写 memberPoints1 = -900;,业务规则没有真正落在程序结构里。
对象把“属于同一个实体的状态”和“允许对这些状态执行的操作”放在同一个类型边界中:
Member xiaolin = new Member("小林", 120);
Member aqing = new Member("阿青", 80);
xiaolin.addPoints(30);
System.out.println(xiaolin.getPoints()); // 150
System.out.println(aqing.getPoints()); // 80这里有两个 Member 对象。它们拥有相同的字段结构和方法集合,却保存各自的状态。对 xiaolin 调用 addPoints,不会自动修改 aqing。
我们以前见过 String、Scanner、StringBuilder。它们都是类名,也都能出现在变量声明的类型位置:
String title;
Scanner input;
StringBuilder result;自定义类也一样。写出 Member 后,Java 的类型系统就知道哪些变量可以保存指向 Member 对象的引用,哪些方法能被调用,调用时需要什么参数。
类还会约束所有实例的共同结构。你不会为每一位会员重新写一套 addPoints;类中只定义一次方法,运行时由接收调用的具体对象提供自己的字段状态。
设计类时,可以先读需求中的名词和动作,但不要机械地“见名词就建类”。更有效的判断是:
例如“银行账户”有持有人和余额,还要保证初始余额非负、存款为正、取款不透支。把这些规则集中到 BankAccount 中,比让各个调用者分别记住规则可靠得多。
先看一个最小但完整的类:
public class Counter {
private int value;
public void increment() {
value++;
}
public int getValue() {
return value;
}
}value 是实例字段。每创建一个 Counter 对象,就会为这个对象创建一份 value。increment 和 getValue 是实例方法,它们需要通过某个对象调用:
public class CounterDemo {
public static void main(String[] args) {
Counter left = new Counter();
Counter right = new Counter();
left.increment();
left.increment();
right.increment();
System.out.println(left.getValue()); // 2
System.out.println
调用表达式 left.increment() 中,left 指定了接收者。方法体里的 value++ 实际修改的是 left 所指对象的字段。换成 right.increment(),同一段方法代码就作用于另一个对象。

类定义共同结构;实例方法根据接收者找到本次调用应该读取和修改的对象状态。
字段适合保存对象跨多次调用仍要记住的信息。例如账户需要记住持有人和余额。一次计算中临时使用的折扣率,则更适合做局部变量:
public class BankAccount {
private String owner; // 实例字段:对象需要长期保存
private double balance; // 实例字段:对象需要长期保存
public void deposit(double amount) {
double newBalance = balance + amount; // 局部变量:只服务本次调用
balance = newBalance;
}
}局部变量 newBalance 在方法结束后就离开作用域。它不是对象公开或持久状态的一部分。
private 限制的是“类边界外的代码”,不是“只能由某个单独方法访问”。同一个类的实例方法可以读取和修改该类对象的私有字段:
public boolean canTransferTo(BankAccount other, double amount) {
return other != null
&& amount > 0
&& this.balance >= amount;
}这里既访问当前接收者的 balance,也能通过 other.balance 访问另一个 BankAccount 对象的私有字段,因为代码仍写在 BankAccount 类内部。访问控制按声明所在的类判断,不是按“是不是同一个对象”判断。
加载类、声明引用变量、创建对象是三个不同动作:
BankAccount account; // 只声明变量
account = new BankAccount(); // 才创建对象第一行没有账户对象,也没有可供读取的余额。局部变量 account 在赋值前甚至不能使用。第二行求值 new BankAccount(),得到一个新对象的引用,再把引用保存进 account。
引用类型变量保存引用值。引用值要么指向某个对象,要么是 null。先看一组连续操作:
BankAccount a = new BankAccount("小林", 300);
BankAccount b = a;
BankAccount c = null;当前只创建了一个 BankAccount 对象:
a 指向这个对象。b = a 复制的是 a 中的引用值,所以 b 也指向同一个对象。c 保存 null,不指向任何对象。
赋值可以让两个变量成为同一对象的别名;null 则表示当前没有对象可供解引用。
因为 a 和 b 指向同一个对象,通过任一引用修改字段,另一个引用再读取时会看见同一结果:
b.deposit(50);
System.out.println(a.getBalance()); // 350.0
System.out.println(b.getBalance()); // 350.0这不是“修改从 b 同步给 a”,而是内存中本来就只有一个账户对象。a 和 b 只是到达它的两条路径。
重新绑定变量不会改写另一个变量:
b = new BankAccount("阿青", 80);
System.out.println(a.getOwner()); // 小林
System.out.println(b.getOwner()); // 阿青第二次 new 创建了另一个对象,再把 b 改为指向新对象。a 中保存的引用值没有变化。
final 引用变量初始化后不能改为保存另一个引用值:
final BankAccount primary = new BankAccount("小林", 300);
primary.deposit(50); // 合法:修改对象状态
// primary = new BankAccount("阿青", 80); // 编译错误:不能重绑 primary被限制的是变量 primary,不是它所指对象。对象是否可变取决于类提供的操作和字段设计,不能看到 final 引用就推断对象内容永远不变。
null 表示没有对象。可以安全地比较:
if (c == null) {
System.out.println("尚未绑定账户");
}但不能通过 null 访问字段或调用实例方法:
System.out.println(c.getBalance()); // 运行时抛出 NullPointerException点号操作需要先通过引用找到对象。引用为 null 时没有接收者,因此解引用失败。判断是否允许“缺少对象”应该是设计决定:如果账户参数必须存在,就在边界处拒绝 null;如果缺失是合法状态,就在使用前明确分支处理。
对引用使用 ==,问的是“两个引用是否指向同一个对象”:
BankAccount first = new BankAccount("小林", 300);
BankAccount alias = first;
BankAccount sameData = new BankAccount("小林", 300);
System.out.println(first == alias); // true
System.out.println(first == sameData); // falsesameData 的字段内容碰巧相同,但它来自另一次 new,身份不同。equals 可以由类定义“什么叫内容相等”;如果类没有覆盖它,继承到的默认实现仍按同一对象判断。本章先把身份和内容两个问题分清,完整的 equals / hashCode 契约会在后续高级类主题中处理。
字符串是常见例外场景:比较字符内容应使用 equals,判断是否为 null 才使用 == null。
不要把引用描述成可以被程序直接读取和运算的“内存地址”。Java 语言只承诺引用能指向对象或为 null;具体表示方式由运行环境决定。
下面用 BankAccount 把字段、构造器和实例方法组合起来。它负责维护三条规则:
public class BankAccount {
private final String owner;
private double balance;
public BankAccount(String owner, double initialBalance) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("持有人名称不能为空");
}
if (initialBalance < 0) {
throw new
调用方放在另一个文件中:
public class BankAccountDemo {
public static void main(String[] args) {
BankAccount account = new BankAccount(" 小林 ", 300);
account.deposit(50);
boolean success = account.withdraw(120);
System.out.println("取款成功:" + success);
输出为:
取款成功:true
小林,余额 230.0 元调用者只需要知道公开接口:
new BankAccount(owner, initialBalance)。getOwner()、getBalance()、describe()。deposit(amount)、withdraw(amount)。IllegalArgumentException,余额不足则返回 false。实现者则负责保证每条公开路径都不破坏规则。调用者无法直接执行 account.balance = -1;,因为字段是 private。
public class BankAccount 应放在 BankAccount.java。BankAccountDemo 放在 BankAccountDemo.java。两者位于同一目录且没有显式包声明时,可以一起编译:
javac BankAccount.java BankAccountDemo.java
java BankAccountDemo这不是“一个对象一个文件”。文件保存的是类定义;运行时可以从同一个类创建任意多个对象。
与其暴露 setBalance,账户更适合提供 deposit 和 withdraw。前者只表达“把字段替换成某个数”,后者表达业务动作,也给规则校验留下了自然位置。方法名越接近调用者真正要做的事,对象边界越稳定。
封装经常被简化成“字段全部 private,再给每个字段生成 getter 和 setter”。这只做对了一半。真正的问题是:对象允许外界观察什么、允许通过哪些动作改变状态,以及这些动作怎样保证对象一直有效。

私有字段不是终点;公开操作要成为状态变化的检查入口。
类成员可以使用以下访问级别:
访问控制是编译期规则。它控制代码是否有资格引用某个成员,不等于运行时的账号权限,也不自动保证线程安全。
只读查询通常可以用 getter:
public double getBalance() {
return balance;
}返回 double 会复制一个基本类型值,调用者不能通过返回结果反向改写私有字段。若 getter 返回可变对象的引用,边界就更复杂:调用者可能通过该引用修改内部对象。完整的防御性复制会在高级类主题中继续处理。
不是每个字段都需要 getter。内部缓存、临时计数、实现细节如果对调用者没有稳定意义,就不应该因为“字段存在”而公开。
持有人名称在创建后不允许修改,因此示例把 owner 声明为 final,且没有 setOwner。余额只能通过 deposit 和 withdraw 改变。
如果某个属性确实允许整体替换,setter 也应校验:
public void setDisplayName(String displayName) {
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("显示名称不能为空");
}
this.displayName = displayName.strip();
}假设余额永远不能为负,那么构造器、存款、取款以及未来新增的转账都要维护这条规则。只在其中一个方法里检查,其他入口仍可能制造非法状态。
private 只能阻止类外直接访问,不能阻止类内部自己写错。封装给了集中校验的位置,正确性仍来自每条状态变化路径都遵守同一规则。
构造器负责建立对象可以对外使用时的初始状态。它的名字与类名相同,没有返回类型,连 void 也不能写:
public class Temperature {
private double celsius;
public Temperature(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("温度低于绝对零度");
}
this.celsius = celsius;
}
}new Temperature(20) 会创建新对象、调用匹配的构造器,并把指向该对象的引用作为整个表达式的结果。

对象先获得默认状态,再沿构造器链完成字段初始化和构造器剩余逻辑,最后才把引用交给调用者。
新对象的每个实例字段会先得到默认值:
字段声明也可以给出显式初始化式:
public class Session {
private String status = "NEW";
private int retryCount;
}status 最终先被设为 "NEW",retryCount 若没有其他赋值则保持 0。局部变量不同:方法内声明的局部变量必须在读取前沿所有可能路径明确赋值,不能依赖字段默认值规则。
如果类中一个构造器都没有声明,编译器会提供一个无参的默认构造器。只要你声明了任意构造器,编译器就不再额外补无参构造器:
public class Ticket {
private final String eventName;
public Ticket(String eventName) {
this.eventName = eventName;
}
}此时 new Ticket() 无法通过编译,因为类中只有需要一个 String 参数的构造器。自己写出的无参构造器通常称“无参构造器”,不要和编译器生成的“默认构造器”混为一谈。
把创建过程压缩成初学者可观察的顺序:
super()。如果构造过程中抛出异常,new 表达式不会正常得到可用引用。构造器因此是建立不变量的好位置。
旧规则常被概括成“this() 或 super() 必须是构造器第一条语句”。Java SE 26 中,这个说法已经不准确。构造器可以在显式构造器调用前包含受限的前置语句:
public class UserProfile {
private final String name;
private final int level;
public UserProfile(String rawName) {
String normalized = normalize(rawName);
this(normalized, 1);
}
public UserProfile(String name, int level) {
if (level < 1) {
throw
normalized 是局部变量,normalize 是静态方法,因此前置区不需要访问尚未构造完成的当前对象。前置区处在“早期构造上下文”中,对 this、super、实例字段和实例方法的使用受到严格限制。
一条构造器体至多包含一次显式 this(...) 或 super(...) 调用;不能把两个调用放进不同的 if 分支。没有显式调用且当前类不是 Object 时,构造器会隐式从 super() 开始。
在实例方法和构造器中,this 表示当前接收者,也就是本次方法调用所作用的对象,或当前正在构造的对象。
构造器参数常常与字段同名:
public class Product {
private final String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
}赋值右侧的 name、price 优先匹配局部参数;左侧 this.name、this.price 明确指向当前对象的字段。如果写成 name = name;,只是把参数赋回参数,字段不会得到期望值。
在没有同名遮蔽时,balance 与 this.balance 都能表示当前对象的字段。通常只在需要消除歧义或强调接收者时显式写 this,不用给每一处字段访问都机械添加。
this 是对当前对象的引用,可以传给其他方法:
public void printWith(AccountPrinter printer) {
if (printer == null) {
throw new IllegalArgumentException("打印器不能为空");
}
printer.print(this);
}也可以返回当前对象来支持链式调用,不过这会影响 API 风格,应在确实能让调用更清楚时使用:
public BankAccount depositAndReturn(double amount) {
deposit(amount);
return this;
}同一个类可以声明多个参数列表不同的构造器:
public class BankAccount {
private final String owner;
private double balance;
public BankAccount(String owner) {
this(owner, 0.0);
}
public BankAccount(String owner, double initialBalance) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException
this(owner, 0.0) 委托同类的另一个构造器,避免把校验和字段赋值复制两遍。构造器委托必须最终结束在一个不再调用同类构造器的构造器上;直接或间接形成循环会编译失败。
如果多个入口有不同默认值,可以让短构造器只负责补默认参数,把不变量校验集中在主构造器中。这样新增规则时只有一处需要修改。
this(...) 是构造器调用语法,不是普通实例方法调用;this 表达式则是当前对象引用。它们写法相近,但用途和允许出现的位置不同。
Java 的参数传递只有一种:按值传递。对于引用类型,复制的值恰好是引用,所以形参和实参变量可以暂时指向同一个对象。

方法拿到引用值的副本;它能通过副本访问同一对象,却不能直接改写调用者变量里保存的引用。
public class AccountMethods {
static void depositBonus(BankAccount account) {
account.deposit(50);
}
static void replaceLocally(BankAccount account) {
account = new BankAccount("临时账户", 0);
}
}调用:
BankAccount mine = new BankAccount("小林", 300);
AccountMethods.depositBonus(mine);
System.out.println(mine.getBalance()); // 350.0
AccountMethods.replaceLocally(mine);
System.out.println(mine.getOwner()); // 小林depositBonus 中的形参 account 与 mine 保存相同的引用值,因此能到达同一对象并修改余额。replaceLocally 只是让自己的局部形参改指向新对象,mine 本身没有被重新赋值。
可以用等价的赋值过程理解调用:
BankAccount account = mine; // 复制引用值给形参
account = new BankAccount("临时账户", 0);
// mine 从未被赋值如果方法要求账户存在,可以立刻拒绝:
static void printBalance(BankAccount account) {
if (account == null) {
throw new IllegalArgumentException("账户不能为空");
}
System.out.println(account.getBalance());
}如果 null 表示“尚未选择账户”,方法则可以返回可理解的缺省结果。关键是把策略写进契约,不要让随机一处解引用才暴露问题。
类名可以出现在返回类型位置:
static BankAccount openStarterAccount(String owner) {
return new BankAccount(owner, 100);
}调用者接到的仍是引用值:
BankAccount starter = openStarterAccount("阿青");工厂方法适合给一类创建过程起业务名字、集中默认值,或在构造前执行额外选择。基础场景直接使用清楚的构造器就够了,不需要把每次 new 都包装成工厂。
假设 Team 内部保存一个可变列表,直接把列表引用返回给外部,调用者就可能绕过 Team 的方法修改它。这个问题不来自“返回对象一定危险”,而来自共享可变状态。本章先建立警觉:返回引用时,要问调用者拿到后能否破坏对象规则;复制与不可变视图会在后续继续展开。
会自定义类之后,再看标准 API,语法就不再神秘。使用一个类通常只需要回答四个问题:
import?Scanner 位于 java.util 包,需要导入:
import java.util.Scanner;
public class ProfileInput {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
System.out.print("姓名:");
String name = input.nextLine();
System.out.print("初始等级:");
int level = Integer.parseInt
input 是引用变量,new Scanner(System.in) 创建扫描器对象,连续调用 nextLine 会推进对象内部的读取位置。实例方法不仅能计算返回值,也可能改变对象状态。
示例没有关闭包装 System.in 的扫描器,是因为关闭它也会关闭进程级标准输入,后续代码便无法再读。处理文件、网络连接等由程序自己取得的资源时,应按相应 API 契约及时关闭,通常使用 try-with-resources;完整异常与资源管理会在异常主题中展开。
字符串对象不可变,StringBuilder 则让一段字符序列在同一个对象中追加:
StringBuilder report = new StringBuilder();
report.append("用户:").append("小林");
report.append(",状态:").append("正常");
String text = report.toString();
System.out.println(text);append 返回当前构建器引用,所以可以链式调用;toString 返回一个 String 结果。阅读 API 时,返回类型能告诉你一个调用是否可继续参与表达式。
下面的类位于 com.example.account 包:
package com.example.account;
public class BankAccount {
// ...
}另一个包使用它时可以导入:
package com.example.app;
import com.example.account.BankAccount;
public class Main {
public static void main(String[] args) {
BankAccount account = new BankAccount("小林", 300);
System.out.println(account.getBalance());
}
}import 不会复制类,不会创建对象,也不会让私有成员变公开。它只是让当前编译单元能用简单名 BankAccount,不用每次写完全限定名 com.example.account.BankAccount。
java.lang 中的 String、System、Math 等类型会被自动导入;java.util.Scanner、java.time.LocalDate 等其他包中的类型通常需要显式导入或使用完全限定名。
OrderService 可以接收 BankAccount 和 Order,调用它们的公开方法完成付款。协作不等于让服务直接摸到两个对象的私有字段。每个对象维护自己的规则,服务只编排流程,这会让职责更容易测试。
设计类最常见的失误,是看到需求后立刻列字段,再为字段生成方法。更稳妥的顺序是:先写对象要负责什么,再找它为完成责任必须保存的状态。
假设需求是:
购物车可以加入商品、计算小计,并拒绝数量小于 1 的条目;结账服务负责使用账户支付并生成结果。
可以先分出两个责任:
Cart:维护商品条目和数量规则,计算小计。CheckoutService:协调购物车与支付账户,不拥有账户余额。Cart 不应该同时负责从键盘读取、保存数据库、发送短信和打印所有报表。那些动作变化原因不同,硬塞在一个类中会让任何修改都触碰同一份代码。
类也不必越小越好。若一组字段必须一起维护,相关行为也围绕同一规则变化,把它们放在一个类中反而更清楚。判断标准是责任是否连贯,不是行数。
先不写字段,可以从调用者角度草拟接口:
Cart cart = new Cart();
cart.addItem("A-100", 2, 39.9);
cart.addItem("B-020", 1, 16.0);
double total = cart.subtotal();
PaymentResult result = checkoutService.pay(cart, account);这段代码暴露出几个设计问题:
addItem 应怎样拒绝空商品编号、非法数量和负价格?subtotal 是否只计算,不修改购物车?这些问题比“字段要不要叫 items”更早决定类是否好用。
一个新对象应该在构造完成时就满足最基本规则。不要先创建半成品,再要求调用者按正确顺序补五次 setter:
// 边界脆弱:忘记任一步都会留下不完整对象
Customer customer = new Customer();
customer.setName("小林");
customer.setEmail("lin@example.com");更清楚的入口是:
Customer customer = new Customer("小林", "lin@example.com");并不是构造器参数越多越好。如果一个类需要十几个互不相关的必填参数,可能说明责任过多,或需要引入更贴合领域的小类型。当前阶段先保证创建入口能建立有效状态。
可以把公开领域类型放在 com.example.shop.domain,流程服务放在 com.example.shop.application。包内辅助类不一定需要 public。使用默认包适合最小练习,但多个文件和模块开始协作后,命名包能避免类型冲突并表达归属。
完成类草图后逐项追问:
对象代码出错时,不要只盯着最后一次输出。更有效的办法是追踪三类信息:
null,是否存在别名?看下面的代码:
BankAccount selected = new BankAccount("小林", 300);
BankAccount backup = selected;
selected = new BankAccount("阿青", 80);
backup.deposit(50);最终“阿青”的余额仍是 80,“小林”的余额变成 350。若只按变量名猜,很容易误以为 backup 是副本。画两行箭头就能发现:第二次 new 后,selected 改指新对象,backup 仍指向原对象。
调试器中的变量视图也应按这个思路读:对象标识相同通常说明两个变量当前指向同一实例;字段节点展示的是对象状态,不是引用变量自身的内容。
可以先用一个轻量测试程序验证 BankAccount:
public class BankAccountTest {
public static void main(String[] args) {
depositsAndWithdrawalsChangeBalance();
overdraftKeepsBalance();
aliasesSeeTheSameState();
invalidDepositIsRejected();
System.out.println("全部测试通过");
}
static void depositsAndWithdrawalsChangeBalance() {
BankAccount account = new BankAccount("小林",
这里测试的是可观察行为,而不是强行读取私有字段。每个测试创建自己的对象,避免前一个测试留下的状态影响下一个测试。
以 withdraw 为例:
还要检查失败是否具有原子性:如果操作被拒绝,对象不能只改了一半。先校验、后修改通常更容易做到这一点。
对象测试的核心问题很具体:给定一个初始状态,执行一个公开操作后,可观察状态和返回结果是否符合契约。按“准备—执行—检查”拆开,失败位置会清楚很多。
现在把整章知识放进一个小项目。目标是实现 RentalDevice,表示一台可租借设备。
assetId 和 name。null。borrow(String borrower):设备可用且借用者名称非空白时借出,返回 true;设备已借出时返回 false。returnDevice():设备已借出时归还并返回 true;本来就可用时返回 false。先写调用者期望看到的代码:
RentalDevice camera = new RentalDevice("CAM-07", "微单相机");
System.out.println(camera.isAvailable()); // true
boolean first = camera.borrow("小林");
boolean second = camera.borrow("阿青");
System.out.println(first); // true
System.out.println(second); // false
System.out.
先写不变量:资产编号和名称始终非空白;“可用”时借用者必须为 null,“已借出”时借用者必须是非空白名称。后续每个方法都要维持这组关系。
再决定字段。至少需要两个 final 字符串保存资产编号和名称,并需要一个可变引用字段保存当前借用者。是否可用可以由 borrower == null 推导,不必同时保存一个可能与它矛盾的 boolean。
构造器校验并规范化 assetId 与 name,让对象一创建就处于“可用且信息完整”的状态。不要先创建空对象,再要求调用者补 setter。
实现 borrow。先拒绝空白借用者,再检查当前是否已借出;所有条件满足后才修改 borrower。这样失败路径不会产生部分状态。
先自己完成,再展开核对:
完成基础版后,可以继续扩展:
describe(),返回适合人阅读的当前状态,但不要暴露可修改内部数据。RentalDeviceTest,至少覆盖 8 条断言,并检查失败操作后状态未变。static boolean transfer(RentalDevice device, String from, String to),先明确契约,再考虑它应留在工具类还是成为设备行为。== 与内容相等 equals?实现 returnDevice 和查询方法。查询方法不应偷偷改变状态;归还成功时把 borrower 设为 null。
最后分别测试正常借还、重复借用、重复归还、空白借用者,以及两个变量指向同一设备时的别名行为。
实现只保存 borrower,用 borrower == null 推导可用状态,因此不会出现“available 为 true,但 borrower 又有名字”的矛盾组合。