diff --git a/notes/pics/oo-vtable.png b/notes/pics/oo-vtable.png new file mode 100644 index 00000000..cdea6caf Binary files /dev/null and b/notes/pics/oo-vtable.png differ diff --git a/notes/面向对象思想.md b/notes/面向对象思想.md index c5907a88..e6a88a75 100644 --- a/notes/面向对象思想.md +++ b/notes/面向对象思想.md @@ -16,6 +16,8 @@ * [S.O.L.I.D](#solid) * [其他常见原则](#其他常见原则) * [附录:实测](#附录实测) + * [A1 方法调用的字节码](#a1-方法调用的字节码) + * [A2 构造方法中的动态分派](#a2-构造方法中的动态分派) * [参考资料](#参考资料) @@ -28,11 +30,13 @@ 优点: -- 减少耦合:可以独立地开发、测试、优化、使用、理解和修改 -- 减轻维护的负担:可以更容易被理解,并且在调试的时候可以不影响其他模块 -- 有效地调节性能:可以通过剖析来确定哪些模块影响了系统的性能 -- 提高软件的可重用性 -- 降低了构建大型系统的风险:即使整个系统不可用,但是这些独立的模块却有可能是可用的 +- 减少耦合:可以独立地开发、测试、优化、使用、理解和修改; +- 减轻维护的负担:可以更容易被理解,并且在调试的时候可以不影响其他模块; +- 有效地调节性能:可以通过剖析来确定哪些模块影响了系统的性能; +- 提高软件的可重用性; +- 降低了构建大型系统的风险:即使整个系统不可用,但是这些独立的模块却有可能是可用的。 + +Java 用访问修饰符实现封装:属性通常声明为 private,对外的方法声明为 public,四种访问权限的范围见[访问权限](Java%20基础.md#访问权限)。 以下 Person 类封装 name、gender、age 等属性,外界只能通过 get() 方法获取一个 Person 对象的 name 属性和 gender 属性,而无法获取 age 属性,但是 age 属性可以供 work() 方法使用。 @@ -65,11 +69,11 @@ public class Person { ### 继承 -继承实现了 **IS-A** 关系,例如 Cat 和 Animal 就是一种 IS-A 关系,因此 Cat 可以继承自 Animal,从而获得 Animal 非 private 的属性和方法。 +继承实现了 IS-A 关系,例如 Cat 和 Animal 就是一种 IS-A 关系,因此 Cat 可以继承自 Animal,从而获得 Animal 非 private 的属性和方法(Cat 与 Animal 不在同一个包时,也不包括没有访问修饰符的成员)。 继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。 -Cat 可以当做 Animal 来使用,也就是说可以使用 Animal 引用 Cat 对象。父类引用指向子类对象称为 **向上转型** 。 +Cat 可以当做 Animal 来使用,也就是说可以使用 Animal 引用 Cat 对象。父类引用指向子类对象称为向上转型。 ```java Animal animal = new Cat(); @@ -79,14 +83,14 @@ Animal animal = new Cat(); 多态分为编译时多态和运行时多态: -- 编译时多态主要指方法的重载 -- 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定 +- 编译时多态主要指方法的重载; +- 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定。 运行时多态有三个条件: -- 继承 -- 覆盖(重写) -- 向上转型 +- 继承或实现接口; +- 覆盖(重写); +- 向上转型(父类或接口类型的引用指向子类对象)。 下面的代码中,乐器类(Instrument)有两个子类:Wind 和 Percussion,它们都覆盖了父类的 play() 方法,并且在 main() 方法中使用父类 Instrument 来引用 Wind 和 Percussion 对象。在 Instrument 引用调用 play() 方法时,会执行实际引用对象所在类的 play() 方法,而不是 Instrument 类的方法。 @@ -94,7 +98,7 @@ Animal animal = new Cat(); public class Instrument { public void play() { - System.out.println("Instument is playing..."); + System.out.println("Instrument is playing..."); } } ``` @@ -136,10 +140,14 @@ Wind is playing... Percussion is playing... ``` +新增一种乐器只需要再写一个 Instrument 的子类,遍历和调用 play() 的循环不用修改。 + 编译器并不知道 instrument 实际指向哪个类,循环中的 instrument.play() 被编译成一条 invokevirtual 指令,参数是符号引用 Instrument.play:()V(javap 输出,见[附录 A1](#a1-方法调用的字节码))。运行时 JVM 才根据对象的实际类型选择方法:从对象所属的类开始,沿继承链向上找第一个签名相同的方法(JVM 规范 invokevirtual 一节)。这个过程称为动态分派。 每次调用都沿继承链查找太慢,HotSpot 为每个类维护一张虚方法表(vtable),按固定的槽位存放可被覆盖的方法的入口:子类的表先复制父类的表,覆盖了的方法替换对应槽位的入口,新增的方法追加在后面。play() 在 Instrument、Wind、Percussion 三张表中是同一个槽位,分别指向三个类自己的实现,调用时按槽位取出入口即可。通过接口调用的方法使用 invokeinterface,HotSpot 为它另外维护接口方法表(itable)。 +

+ 重载则在编译时就确定了:编译器根据参数的静态类型选择重载的版本,写进字节码的符号引用中,称为静态分派。例如: ```java @@ -155,9 +163,37 @@ i 实际指向 Wind 对象,但它的静态类型是 Instrument,第一次调 不能被覆盖的方法只有一个实现,调用目标在编译时就能确定:静态方法用 invokestatic,构造方法用 invokespecial;private 和 final 方法虽然也用 invokevirtual 调用(JDK 21 javac 的输出),JVM 同样能直接确定调用目标。 +需要注意的是,构造方法中调用可被覆盖的方法也会动态分派。new Guitar() 时,对象在执行构造方法之前就已分配好,实际类型已经是 Guitar;父类构造方法中的 play() 因此调用 Guitar 的版本,而这时子类的字段还没有初始化(字段的初始化在 super() 返回后才执行): + +```java +class Instrument { + Instrument() { + play(); + } + + public void play() { + System.out.println("Instrument is playing..."); + } +} + +class Guitar extends Instrument { + private String strings = "6 strings"; + + public void play() { + System.out.println("Guitar with " + strings); + } +} +``` + +```text +Guitar with null +``` + +输出中 strings 是默认值 null(JDK 21 实测,见[附录 A2](#a2-构造方法中的动态分派))。C++ 的规则不同,构造基类期间对象被视为基类,调用的是基类的版本。因此构造方法中不应直接或间接调用当前对象可被覆盖的方法。 + ## 二、类图 -每张类图后附有对应的 [PlantUML](https://www.planttext.com/) 代码,更多语法及使用请参考:http://plantuml.com/ 。 +每张类图后附有对应的 [PlantUML](https://www.planttext.com/) 代码,更多语法见 [PlantUML 官网](https://plantuml.com/)。 ### 泛化关系 (Generalization) @@ -266,11 +302,11 @@ School "1" - "*" Student ### 依赖关系 (Dependency) -和关联关系不同的是,依赖关系是在运行过程中起作用的。A 类依赖 B 类主要有三种形式: +依赖表示 A 类用到了 B 类,B 类变化时 A 类可能也要修改。类图中单独画依赖时,通常是下面三种临时的使用;A 类把 B 类作为成员变量长期持有时,画成关联关系: - B 类是 A 类方法中的局部变量; - B 类是 A 类方法的参数; -- A 类向 B 类发送消息(调用 B 类的方法),从而影响 B 类发生变化。 +- A 类调用 B 类的静态方法。

@@ -320,7 +356,7 @@ Vehicle .. N > 类应该对扩展开放,对修改关闭。 -扩展就是添加新功能的意思,因此该原则要求在添加新功能时不需要修改代码。 +扩展就是添加新功能的意思,因此该原则要求在添加新功能时通过新增代码实现,不需要修改已有的代码。 符合开闭原则最典型的设计模式是装饰者模式,它可以动态地将责任附加到对象上,而不用去修改类的代码。 @@ -344,7 +380,7 @@ Vehicle .. N 高层模块包含一个应用程序中重要的策略选择和业务模块,如果高层模块依赖于低层模块,那么低层模块的改动就会直接影响到高层模块,从而迫使高层模块也需要改动。 -依赖于抽象意味着: +依赖于抽象可以落实为下面几条启发式规则。它们不是绝对的,例如 String 这类稳定、不会变化的具体类可以直接依赖,创建和组装对象的代码也总要用到具体类: - 任何变量都不应该持有一个指向具体类的指针或者引用; - 任何类都不应该从具体类派生; @@ -412,12 +448,28 @@ show(Wind) 101: invokevirtual #70 // Method fixed:()V ``` +### A2 构造方法中的动态分派 + +环境:JDK 21.0.13。正文 Instrument、Guitar 两个类加上 main() 中的 new Guitar(),运行输出和 javap -c Instrument 中构造方法的字节码: + +```text +Guitar with null + + Instrument(); + Code: + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: aload_0 + 5: invokevirtual #7 // Method play:()V + 8: return +``` + ## 参考资料 -- Java 编程思想 +- Eckel B. Java 编程思想 [M]. 机械工业出版社, 2002. - [The Java Virtual Machine Specification, Java SE 21 Edition:invokevirtual](https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-6.html#jvms-6.5.invokevirtual) - 周志明. 深入理解 Java 虚拟机:第 3 版 [M]. 机械工业出版社, 2019. 第 8 章“方法调用”。 -- 敏捷软件开发:原则、模式与实践 +- Martin R C. 敏捷软件开发:原则、模式与实践 [M]. 清华大学出版社, 2003. - [面向对象设计的 SOLID 原则](http://www.cnblogs.com/shanyou/archive/2009/09/21/1570716.html) - [看懂 UML 类图和时序图](http://design-patterns.readthedocs.io/zh_CN/latest/read_uml.html#generalization) - [UML 系列——时序图(顺序图)sequence diagram](http://www.cnblogs.com/wolf-sun/p/UML-Sequence-diagram.html)