slug
type
status
category
date
summary
tags
password
icon
3.6.1
详解:
下面把这段程序按“从上到下 + 运行时会发生什么”的顺序,细致讲清楚。读完你会明白:为什么改了
a1 之后 p1 的地址也跟着变、toString() 是如何被自动调用的,以及如何用“防御性拷贝”消除这种连带效应(aliasing)。代码逐段解读
1) Person 类定义与字段
Person是公共类。
name是私有实例变量,类型String(不可变)。
addr是私有实例变量,类型是自定义的Address。注意它不是值,而是引用(reference)——指向堆上的一个Address对象。
2) Person 构造器:直接保存“引用”
- 把
initName原样赋给name。
- 关键点:
addr = initAddress;只是把“引用”直接保存起来。以后任何对这个Address对象的修改(不管从Person外还是内)都会反映到Person里,因为两者指向同一块地址对象。
- 旁边的注释已经提醒:这并不总是最佳实践(会造成别名/共享可变对象的问题)。
3) Person.toString()
- 返回一个多行字符串。
- 拼接
addr时,Java 会隐式调用addr.toString(),因为是对象与字符串相加。
4) main:创建对象、打印、修改 city、再打印
a1:在堆上创建了一个Address对象(街道/城市/州/邮编如字面值)。
p1:Person保存了同一个Address引用。
System.out.println(p1);:会自动调用p1.toString(),其中又会调用addr.toString()。
a1.setCity("changsha");:直接修改了a1指向的那个Address对象的city字段。
- 再次
System.out.println(p1);:因为p1.addr与a1是同一个对象,city已改成"changsha",所以打印出的Address也变了。
5) Address 类:可变(有 setter)
- 四个私有实例变量,都是
String(String本身不可变,但Address包含可变的字段引用——通过 setter 可以把字段指向别的字符串)。
- 简单构造,逐个赋值。
- 自定义打印格式:第一行街道,第二行“城市, 州 邮编”。
- 标准访问器,保持封装(外部不能直接访问私有字段,只能通过方法读)。
- 提供
setCity,因此Address是可变类(mutable)。
运行过程的“内存/引用”图示(抽象表意)
- 执行
Address a1 = new Address(...); - 产生一个
Address对象(假设地址为@A),a1引用@A。
- 执行
Person p1 = new Person("Skyler", a1); p1.addr也指向@A(与a1同一个对象)。
- 执行
a1.setCity("changsha"); - 修改的是
@A.city→"changsha"。
- 因为
p1.addr仍然指向@A,所以p1看到的地址自然也变了。
实际打印输出
第一次 System.out.println(p1);
p1.toString() → "Name:Skyler\nAddress:" + addr.toString() →修改后 a1.setCity("changsha"); 再打印 p1
回答注释里的问题:“Did changing a1 change p1?”是的。因为p1保存的是对同一个Address对象的引用,Address又是可变的。
知识要点总结
- 引用赋值 ≠ 拷贝对象本身
addr = initAddress; 只复制了“指针/引用”。两个变量指向同一个可变对象就会出现别名问题(aliasing)。- 可变对象 + 共享引用 = 外部修改会“渗透”
Address 有 setCity,所以任何持有该引用的代码都能改变其状态;变化对所有共享者可见。toString()的隐式调用
字符串拼接里放对象,Java 会自动调用它的
toString()。- 封装(encapsulation)
字段
private、提供 getters/setters,是基本封装做法。但一旦暴露可变对象引用,就会破坏“控制修改”的边界。3.6.2
详解:
这段代码是一个防御性拷贝(Defensive Copy)的经典示例,目的是演示对象之间的引用关系与数据封装安全性。我们来逐行深入讲解。
🧩 一、整体结构概览
这段代码定义了两个类:
Person类:表示一个人,包含name(名字)和addr(地址)。
Address类:表示一个地址,包含街道、城市、省份和邮编。
Person 持有一个 Address 对象作为实例变量。✳️ 二、Person 类分析
- 这里定义了两个实例变量:
name:String类型,存储名字;addr:Address类型,代表一个地址对象。
(1) 构造方法(Constructor)
🚩 关键点:防御性拷贝(Defensive Copy)
- 参数
initAddr是一个对象引用。
- 如果直接写
addr = initAddr;,那么Person持有的addr和外部传入的initAddr是同一个地址对象。 - 改变外部
initAddr的内容,会影响Person内部的地址。
- 为了避免这种“外部修改内部数据”的风险,我们创建了一个新的 Address 对象,并用
initAddr的 getter 方法来复制它的字段。
- 这样,
Person内部的地址就独立于外部变量。
✅ 防御性拷贝的目的是保护对象封装性(Encapsulation)。
(2) setAddress 方法
这也是一个防御性拷贝方法。
- 当我们想修改
Person的地址时,传入的Address参数可能被其他地方共享。
- 为了避免共享同一个对象(从而互相影响),这里重新创建一个新对象。
不是直接赋值
addr = otherAddr;,而是重新复制内容。(3) toString 方法
- 这会调用
Address类中的toString()方法来打印完整地址。
- 因为
addr是对象,System.out.println(addr)自动调用addr.toString()。
(4) main 方法:测试程序逻辑
第一阶段:
- 创建
a1、a2两个地址。
- 用
a1创建p1。构造函数里进行了防御性拷贝。
- 打印
p1,输出包括名字和地址。
第二阶段:修改 a1
a1的城市改成 “changsha”。
- 打印结果:
a1的 city 改变。p1的 city 不会改变。
🔍 原因:
p1 持有的是 a1 的防御性拷贝(一个独立对象)。第三阶段:修改 p1 的地址为 a2
- 调用
setAddress,此时p1的地址变成了a2的拷贝。
- 打印:
a1不受影响。p1的地址变成了 “Othertown”。
🏠 三、Address 类分析
每个字段都封装为
private,保证外部不能直接修改。(1) 构造函数
用于初始化完整地址。
(2) toString 方法
用于打印美观的地址格式。
(3) Getter 与 Setter
- Getter 用于访问私有属性;
- Setter 用于修改属性;
- 提供受控访问,符合封装原则。
🔍 四、程序输出分析(带注释)
运行结果大致如下:
解释:
- 改
a1→ 不影响p1(因为防御性拷贝)。
- 改
p1→ 不影响a1(同理)。
💡 五、核心概念总结
概念 | 含义 | 在代码中的体现 |
引用传递 | 对象变量存储的是引用(地址),而不是值 | addr = initAddr; 会让两个对象共享同一个引用 |
防御性拷贝 | 为防止外部修改内部对象,创建新的副本 | new Address(initAddr.getCity() …) |
封装 (Encapsulation) | 数据私有 + 通过getter/setter访问 | 所有 private 字段都有相应的 getter/setter |
可变性 (Mutability) | Address 是可变类(有 set 方法) | 所以才需要防御性拷贝 |
不变性 (Immutability) | 如果 Address 不可变,就不需要防御性拷贝 | (比如没有任何 setter) |
📘 六、课堂扩展练习建议
- 试着去掉防御性拷贝(直接赋值
addr = initAddr;),重新运行看看结果。
- 在 Address 中添加
setStreet,setZipcode,再测试修改。
- 将 Address 改成完全不可变类(无 set 方法),看看是否还需要防御性拷贝。
下面我将为你画出这段程序运行时的内存引用关系图,展示对象之间的连接与变化过程。
我们会分为三个阶段看清楚**防御性拷贝(defensive copy)**如何保护数据。
🧠 背景知识:Java内存模型(简化版)
在Java中:
- 局部变量(如a1, p1) 存在于「栈(Stack)」中;
- 对象(new出来的东西) 存在于「堆(Heap)」中;
- 栈变量存的是引用地址,指向堆里的对象。
🩵 阶段一:初始创建阶段
🔹 内存图 1(刚创建完 p1)
🟢 注意:
a1和p1.addr是两个不同的 Address 对象(@101 vs @102);
- 这是防御性拷贝起作用的地方。
💚 阶段二:修改 a1 的 city
🔹 内存图 2(修改 a1)
🧩 结果:
a1改变;
p1不受影响。
📄 打印结果:
✅ 说明:
p1 内部地址独立,封装被保护。💜 阶段三:改变 p1 的地址
其中:
🔹 内存图 3(修改 p1 地址后)
🟢 注意:
p1.setAddress(a2)并不是让p1.addr指向a2;
- 它创建了一个新的 Address 副本(@104);
- 所以即使后来修改
a2,p1仍然不会受影响。
🔍 对比:如果没有防御性拷贝
假设构造函数写成:
那最初的情况变成:
当你执行:
此时
p1.addr.city 也会变成 "changsha"!⚠️ 这就破坏了封装性。
🎯 总结表格
阶段 | 操作 | 对象变化 | 是否影响另一方 |
1 | 构造时使用防御性拷贝 | 生成独立副本 | ❌ 不影响 |
2 | 修改 a1 的 city | 只改 a1 | ❌ 不影响 p1 |
3 | 修改 p1 的地址 | 只改 p1 | ❌ 不影响 a1 |
假如没有防御性拷贝 | 直接共享引用 | 改一个会影响另一个 | ⚠️ 会影响 |
3.6.3
详解:
下面把这段代码「逐行+运行过程」讲清楚,并顺带点出设计意图(防御性拷贝)、可访问性细节、以及可改进之处。
一、类结构与成员
Person
private String name;
private Address addr;
addr是一个**可变(mutable)**对象的引用。构造器(防御性拷贝)
目的:避免“别处持有的
Address 引用”与 Person 内部 addr 共享同一对象而相互影响(别处改了地址,Person 的地址也跟着变)。这种“被外部修改牵连”的风险就叫别名问题(aliasing),防御性拷贝是经典解法。
copyAddressFromPerson(Person otherPerson)
关键点:
- 在同一个类内部,可以访问另一个同类对象的
private字段(Java 规则)。因此otherPerson.addr是合法的。
- 再次创建新对象并赋给
addr,从而让当前Person的地址成为“对方地址的拷贝”,不共享引用。
copyAddressFromAddress(Address otherAddr)
做法不同:这次不替换
addr 的对象实例,而是在原对象上逐字段 set。- 结果上:
this.addr的数据会与otherAddr一致,但this.addr仍然是原来那个对象(对象身份 identity 不变)。
- 好处:如果外部有人保留了对
this.addr的引用(例如放到某集合里),它的身份不变。
- 坏处:代码多、容易遗漏字段;如
addr可能暂时处于“半更新状态”(异常中断时)。
toString()
- 拼接时会自动调用
Address.toString()。
Address
- 4 个私有字段:
street/city/state/zipcode,有全参构造器、拷贝构造器、get/set、以及
- 因为有
set,Address是可变类。
二、main 的执行轨迹与打印结果
第一次打印(构造后)
p1.addr是 a1 的拷贝,内容与 a1 一致。
- 打印:
调用 p1.copyAddressFromPerson(p2);
p1.addr = new Address(p2.addr);
- 此刻
p1.addr变为p2.addr 的拷贝(是新对象,数据等于 p2 的地址)。
第二次打印
调用 p1.copyAddressFromAddress(a1);
- 在当前
p1.addr对象上逐字段set,让它的内容改回与a1一致;
- 注意:
p1.addr仍然是第二步中新建的那个对象(身份不变,内容更新)。
第三次打印
三、设计要点与对比
- 两种“拷贝地址”的方式
- 替换对象:
copyAddressFromPerson - 简洁、安全,一步完成深拷贝(这里是浅/字段拷贝,但对
String来说仍然安全,因为String不可变)。 - 对象身份会改变。
- 就地修改:
copyAddressFromAddress - 身份不变,适合外界也持有该对象引用的场景。
- 容易遗漏字段,更新过程不具备原子性。
- 为什么防御性拷贝?
Address可变;如果构造器直接this.addr = initAddr;,那么外部对initAddr的修改会“泄漏”进Person内部,破坏封装。
- 同类访问
private字段的规则 - 在
Person类内部,可以访问任意Person实例的private字段(不是“同对象”,而是“同类”)。
四、核心总结
- 代码的主线是演示防御性拷贝与可变对象的封装保护。
copyAddressFromPerson:新建对象替换,数据来自对方的地址(通过拷贝构造器)。
copyAddressFromAddress:原对象就地更新,逐字段set。
main的三次打印依次是:p1 初始地址(来自 a1)→ p2 的地址 → 又改回 a1 的地址;但第二、三步里addr对象的“身份”是否改变是不同的:第二步改变了(新对象),第三步没改变(同对象被修改)。
3.6.4
详解:
非常好,这是一段非常典型的 Java 面向对象编程示例,用于讲解“防御性拷贝 (defensive copy)”与“对象可变性 (mutability)”的概念。下面我们逐行、分层地进行详细讲解与运行逻辑分析。
🧩 第一部分:类结构概览
这段代码定义了两个类:
Person:表示一个人,包含名字 (name) 和一个地址 (addr)。
Address:表示一个地址对象,包含街道、城市、省份和邮政编码。
关系:
👉 每个
Person 对象 有一个 Address 对象(“has-a” 关系,组合关系)。🧱 第二部分:Person 类详解
name:字符串类型,存储人名。
addr:类型为Address的实例变量。
➜ 每个
Person 拥有一个地址对象。🏗️ 构造方法(constructor)
✅ 解读:
- 当创建一个
Person对象时,传入两个参数:名字和地址。
- 但是——重点在于第二行注释 “Defensive copy”:
这行代码不是直接做
addr = initAddr;,而是调用
Address 的构造函数 新建了一个独立的地址对象,并用
initAddr 的 getter 方法来复制数据。🚨 为什么要这样做?
👉 因为如果直接写:
那
Person 的地址变量 addr 和外部传入的 Address 参数 initAddr 会指向同一个对象。修改外部的
Address 对象时,会直接影响到 Person 内部的地址 —— 这是引用共享 (aliasing) 的问题。使用防御性拷贝(Defensive Copy)可以避免这种副作用。
📬 Getter 方法
- 返回地址对象。
- ⚠️ 这里并没有做防御性拷贝(也就是没有返回一个新建的
Address)。
- 因此:
getAddress()返回的对象依然是Person内部地址的引用。
如果外部拿到这个引用并修改它,会直接影响
Person 的内部状态。🖨️ toString() 方法
- 返回一个格式化字符串,用于打印
Person对象。
- 注意:
addr是一个Address对象,
所以在字符串拼接时,会自动调用它的
toString() 方法。🧪 主方法 main()
- 创建一个新的
Address对象:
- 把它传入
Person构造函数。 - 在构造函数里,
Person创建了防御性拷贝。 - 所以:
Person内部地址与传入地址是两个不同的对象。
🖨️ 打印初始状态
输出类似:
🧩 获取地址并修改
- 通过
getAddress()拿到Person的地址引用。
- 修改
a的邮编为"11111"。
💡 由于
getAddress() 返回的引用直接指向 p1.addr,修改
a 其实就是修改了 p1 内部的地址对象。🔁 再次打印
输出变为:
🧠 小结:关于防御性拷贝的局限性
- 构造函数里:✅ 做了防御性拷贝(安全)
getAddress():❌ 没有防御性拷贝(不安全)
因此:
- 改变外部原始的
Address对象,不会影响Person
- 但通过
p1.getAddress()拿到引用并修改,会影响Person的内部数据
🧱 第三部分:Address 类详解
这四个属性构成一个可变的地址对象。
🏗️ 构造方法
简单地初始化所有字段。
📬 Getter / Setter 方法
- Getter 用于读取字段。
- Setter 用于修改字段。
- 有了 Setter,就说明这个类是可变的 (mutable)。
这使得外部代码可以轻易改变对象内部状态。
🖨️ toString()
用于打印美观格式的地址。
🧾 程序整体运行结果
完整输出为:
🧭 总结表
概念 | 示例 | 结果 |
构造函数防御性拷贝 | addr = new Address(...) | ✅ 安全 |
Getter 返回原始引用 | return addr; | ⚠️ 不安全 |
修改原始参数 Address | 不影响 Person | ✅ |
修改 getAddress() 返回的对象 | 会影响 Person | ❌ |
Address 类可变性 | 有 setter | ⚠️ 可被修改 |
3.6.8
详解:
下面把这段代码从“类设计—构造—方法—main 调用—输出—隐患与改进”逐层拆给你。
1) 两个类与它们的职责
Address(地址)- 4 个私有字段:
street/city/state/zipcode。
- 构造器把 4 个入参逐一赋值给字段。
toString()以两行的格式输出:
- Getter 提供只读访问。
- 两个 Setter(
setCity/setZipcode)让对象可变(mutable)。
Person(人)- 2 个私有字段:
name与addr(类型就是上面的Address)。
- 构造器做了防御性拷贝(defensive copy):不是直接存参
initAddr的引用,而是用它的 getters 取出各字段,再new Address(...)复制一份保存到addr。这样外部以后就算修改传入的Address对象,也不会影响到Person内部保存的地址。
getAddress()直接返回内部addr的引用(这一点后面会讨论利弊)。
toString()把name和addr拼起来打印;注意addr会调用它自己的toString(),因此是多态/委托式输出。
2) main 的执行过程(逐行)
- 右边先创建一个临时的
Address对象(称作 A0)。
- 调用
Person构造器: name = "Kay"- 关键:
addr = new Address(initAddr.getStreet(), ...)再创建一个新的地址对象(称作 A1)。 - 结果:
kay.addr指向 A1,而传入的临时 A0 与 A1 内容相同但不是同一个对象。
- 调用链从左到右:
kay.getAddress()—— 返回kay内部保存的 A1 的引用;- 取该地址的
state—— 字符串"Massachusetts"; - 对字符串做
substring(0, 2)—— 取前两个字符"Ma"; println输出。
程序的实际输出:
注意:这里没有调用 System.out.println(kay);,所以不会打印 Person 或 Address 的多行字符串,只会输出 Ma 和一个换行。
3) 为何在构造器里要“防御性拷贝”?
- 如果写成
addr = initAddr;(直接引用赋值),那么外部代码后续对initAddr的任何修改都会反映到Person内部,这叫别名问题(aliasing),破坏封装。
- 现在的做法是深拷贝一层:新建一个
Address,把字段值拷进来,从而与外部的Address脱钩。
但也要看到一个不对称:
- 构造时做了拷贝(安全 ✅);
- 但
getAddress()把内部引用直接暴露出去了(不安全 ❌)。调用者拿到引用后仍可setCity/setZipcode改动从而间接修改Person的内部状态。
4) 可能的风险点与边界情况
substring(0, 2)要求state.length() >= 2。本例"Massachusetts"足够长;若state只有 1 个字符,运行时会抛StringIndexOutOfBoundsException。
getAddress()返回可变对象的活引用(live reference),任何外部kay.getAddress().setCity("...")都会改变kay的内部地址。
- 构造器里没做
null检查:若initAddr == null会 NPE;若initName == null也可能导致后续字符串拼接出现"Name:null"的语义问题。
5) 复杂度 & 调用链说明
getAddress()、getState()都是 O(1)。
substring(0, 2)对于 Java 8+ 的实现,会创建一个新字符串对象,长度为 2,时间/空间近似 O(1)。
- 整个打印语句是一次方法链式调用(method chaining),读作:“从
kay取地址 → 取州名 → 取前两个字符 → 打印”。
小结
- 这段代码示范了封装、组合(
Person包含Address)、以及防御性拷贝。
- 当前实现“构造器安全 / getter 暴露”的不一致会留下可变别名风险。若作作业/面试题,常见加分点就是:让
Address不可变或让getAddress()返回拷贝,并补上null校验与必要的更新方法。
- 本程序的控制台输出仅为:
Ma。
3.6.9
详解:
下面把这段代码从结构、每行在做什么、运行时会发生什么,以及改进点都讲清楚。代码分成两个类:
Date(自定义的日期类)和 Friend(朋友,带生日)。代码逐段解读
Date 类
作用:存储一个日期(以月/日/年的整型数字),并提供读写与打印方法。
- 实例变量
三个私有字段,封装(encapsulation)做得对:外部不能直接改,必须通过方法。
- 构造器
用传入的 3 个参数初始化对象。这里没有做有效性校验(比如 1≤month≤12),这既简单也意味着可能存入非法日期(改进点见下文)。
- getters
典型取值方法,配合私有字段实现只读访问。
- setter(示例)
只给了
year 的 setter,说明这个类是可变的(mutable)。如果提供更多 setter,外部就能随时改日期的各个部分。toString
打印形如
11/6/2007。注意:没有补零(如 03/09/2025),只是简单的数字拼接。Friend 类
作用:保存朋友的名字与生日,并能判断“今天是不是他生日”。
- 实例变量
生日使用自定义的
Date 类,而不是 Java 内建的日期类(比如 LocalDate)。- 构造器(防御性拷贝)
关键点:防御性拷贝(defensive copy)。因为
Date 是可变对象,如果直接保存传进来的引用,那么外部改了原来的 Date,Friend 里的生日也会被“牵连”。这里用 getXxx() 重新 new 了一个 Date,确保 Friend 内部保存的是自己的副本,不受外部影响。toString
这里拼接时会隐式调用
birthdate.toString(),所以会显示为 Name: Alex, Birthday: 11/6/2007。isBirthday
只比较月和日,忽略年(现实中也确实是这样判断生日)。如果需要“今天是否是出生那一年的那一天”就不对了,但作为生日判定是合理的。
main测试- 新建一个生日为 2007/11/06 的
Date。 - 用它创建
Friend("Alex", …)。构造器会拷贝这个日期进去。 - 新建一个“今天”的日期 2025/11/06。
- 打印朋友信息(调用
toString)。 - 调用
isBirthday(today):由于月=11、日=6 相同,返回true。
运行输出(按当前代码)
关键知识点与设计意图
- 封装(Encapsulation)
私有字段 + 公有 getter/setter 是最基础的封装方式,控制数据读写。
- 可变对象的风险
Date 提供了 setYear,说明它是 mutable。一旦对象可变,共享引用就可能带来“外部修改影响内部”的问题。- 防御性拷贝(Defensive Copy)
- 如果将来添加
getBirthdate(),也应该返回一个副本,而不是内部引用:
Friend 构造器里对传入的 Date 做了拷贝,避免上面的问题。否则调用者拿到内部引用后,就可以直接
setYear 改掉朋友的生日。isBirthday的判定逻辑
现实里过生日比的是“月日”,不比“年”,所以忽略年份是合理选择。
一步步追踪一次“可变性”的对比实验
看清防御性拷贝的价值,可做这个小实验(想象运行):
结论:
Friend 内部生日不会变,因为 Friend 保存的是自己的副本。打印仍是 11/6/2007。总结:
这段代码展示了两个核心 OOP 概念:封装与防御性拷贝。因为
Date 是可变对象,Friend 在接收它时做了拷贝,避免了共享可变状态带来的“外部改、内部跟着变”的隐患。isBirthday 正确地只比较月日。想把代码进一步工程化,建议引入输入校验、不可变对象(或直接使用 LocalDate)、以及更完善的 toString/equals/hashCode。- 作者:现代数学启蒙
- 链接:https://www.math1234567.com/article/codeexplained
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章









