再探 Java & OOP
与 COMP2119A Problem Set 一样,这篇笔记的目的主要是为了查漏补缺,因此记录的会比较随性。部分内容也来源于在学习 Programming Languages, UW 相关课程中产生的疑问。
This article is a self-administered course note.
It will NOT cover any exam or assignment related content.
Abstraction v.s. Encapsulation
这两个概念本身我就有点没搞清,在中文语境下更容易混淆了 (abstraction: 抽象, encapsulation: 封装)。
- Abstraction is the process of generalization.
- Encapsulation is hiding the implementatin details from outside access.
还是能发现这两个概念是完全不同的,考虑下面这个例子:
1 | class person { |
我们将人这一实体的特征提取出来 (名字,年龄,性别……),用这些特征的集合来描述人,这就是所谓的抽象。抽象允许我们忽略实体的 background details,专注于其 necessary and common properties.
而当我们将一个人的一系列特征数据封装在某个对象中
(调用 constructor) 后,我们重新“创造”出了这个人
(尽管失去了一些不重要的细节)。并且,这些被封装的数据将向外部隐藏。我们在与对象交互时
(如调用
isHealthy()
),并不能,也不关心它的内部实现;这是封装带来的,也是
OOP 的灵魂所在。
抽象使实体变得 generalized,封装又使其重新 specified。这两个概念 goes hand in hand,共同奠定了 OOP 大厦的基石。当我们创建并初始化一个对象时,往往同时完成了抽象与封装。
Object References
在写 Algorithms I & II, Princeton 这门课作业的时候困扰了我很久的一个问题:在了解了 object references 这一概念后迎刃而解。
在 Java 中,data type 只有两种:
- Primitives (
int
,float
,double
,boolean
...) - fundamental values. - Object references - references to objects.
也就是说,变量中存储的并不是对象本身,而是 (可以视作是) 指向对应对象的一个指针!当一个 object reference 类型的变量并不指向任意一个对象时,其值为 0。
真正的对象都储存在堆空间 (heap memory) 中。heap memory 具有 garbage
collection 机制:也就是说,Java
自动为我们管理空间!这也是为什么我们不需要像在 C/C++ 中那样使用
free()
等函数手动释放内存。
Java 的 heap memory 也因此被称为 Garbage-collectible heap. 考虑下面这个例子:
1 | Garbage p1 = new Garbage("banana skin"); |
一开始,我们声明了两个 object
references,它们均指向香蕉皮对象;但随后 p1
转而指向西瓜皮对象,p2
指向葡萄皮对象。此时没有任何 object
reference 指向香蕉皮对象;它将会被送入 garbage collection 进程。
- An object (in heap memory) that is not pointed to by any object reference will be eligiable for garbage collection.
- An object reference to no object (
p3
) will have a value ofnull
。
Parameters v.s. Arguments
一句话:parameters 是形参,arguments 是实参。
- A parameter is a variable defined in the method signature.
- An argument is the actual value passed to a method when it is called.
因此,pass-by-value 即意味着 argument 中的值被复制到了 parameter 中。
更多概念性的知识复习见 Ch4_State_And_Behavior slide 末尾的 Revision Exercise。
Multidimensional Arrays
1 | String[][] names = { |
注意,以上代码中的 names
与 names2
是不同的数组。对于有 C/C++ 经验的人来说确实很容易混淆。这是因为在 Java
中,多维数组每一维的长度可以是不同的。我们不妨将 Java 中的二维数组视作
vector<vector<int>>
,这样,每一维中的 vector
长度不同就是很自然的事了。
Inheritance v.s. Subtyping
同样也是困扰了我很久的一个问题,又称 子类 (subclassing) v.s. 子类型 (subtyping)。
1 | public class Citizen { |
这是个能够完美解释 subclassing v.s. subtyping 之间区别的例子。我自己想的,是不是很妙?
从行为上来讲,调用 policeAsCitizen
对象的
gun()
方法是完全没有问题的,因为
policeAsCitizen
对象的确是定义了 gun()
方法的。
当然,policeAsCitizen
也可以调用其继承的
eat()
方法:这是因为 subclassing
规定了对象的行为。Police
类继承了
Citizen
类,同时又新增了 gun()
,因此任何
Police
类的实例都能调用 eat()
与
gun()
方法。
既然如此,问题的原因又出在哪里?明明只是一个很普通的 polymorphism
应用,为什么类型为 Citizen
的 Police
类实例无法调用它本身拥有的 gun()
函数呢?
这就来到 subtyping 的问题了:我想将其解释为 subtyping
规范了对象的行为。policeAsCitizen
的类型为
Citizen
,而任何类型为 Citizen
的对象是不该调用
Citizen
类中没有定义的行为的。
规定与规范这两个词有根本上的不同。 警察相比普通市民拥有持枪的权利,他持枪这一行为是被允许的,也是 subclassing 所声明的。然而在职务以外,作为普通市民的警察不能拔枪,这又是 subtyping 所规范的。
至于 subtyping 为何要约束对象的一些本身看似合法的行为,这就要追溯到 statically-typed language 与 dynamically-typed language 之间最本质的区别了,在此不再过多赘述。
详情可参见我的笔记 Programming Languages, UW。
contd. remote control
上述例子的另外一种解释 (但本质是相同的):The compiler decides whether you can call a method based on the reference type (static type), not the actual object type (dynamic type).
很喜欢 2396 对该问题的一个浅显比喻。object 是一个复杂的机器,而一个 object 能够对应多个 references。reference 就像该机器的遥控器 (remote control),每一种类型的 reference 能在不同程度上控制该机器。
自底向上,沿着 class hierarchy:从 actual object type 到
Object
type,不同类型的 reference 对 object
的控制程度越来越低。
More Inheritance:
super
子类是无法继承父类中的 private members 的!
但是,子类却能够通过其继承的 public getter/setters 来间接的访问/修改父类中定义的 private instance variables。这是为了保证父类对其 private members 拥有绝对的掌控权。
1 | public class A { |
虽然老师轻描淡写的带过了这个知识点,但我总感觉有哪里不对。
不觉得这一句话很矛盾吗?既然子类不继承父类的私有实例变量,那理论上来说它并不会储存任何关于这些变量的信息;但我们却能够通过某种方法访问或修改这些“不存在”的变量。
实际上,这些变量是存在的;从代码复用的角度来看,子类拥有父类的全套代码,只不过并不是所有代码都能被子类所使用。父类定义中的公有成员能被子类直接访问,我们称其被子类所「继承」;私有成员对子类隐藏,我们称其无法被继承。这就是公有继承的实质。
按照这个逻辑,method overriding 也并不意味着子类方法对父类同名方法的覆盖;父类的同名方法仍然是存在的,只是无法直接被子类调用。
这一切的证据以及解决方法就是 super
关键字的存在;我们通过它来访问父类的代码。
对于 super.a
( a
可以是 instance variable
或 method ):
- 在父类定义中
a
是 private 的:不可访问,弹出 field missing 错误。 a
是某个被子类 overridden 的方法:super.a
将访问父类定义中的同名方法。a
是某个被子类 hidden [stay tuned] 的实例变量:super.a
将访问父类定义中的同名变量。a
为 public 并且没有被子类 overridden/hidden:super.a
正常访问a
成员;在此情况下super.a
与this.a
本质上是完全一致的。
虽然逻辑上我们可以理解为每个子类在实例化时都伴随着一系列父类对象的创建,但实际上这一过程并不存在。子类只是复用了父类的所有代码,根据父类定义的访问权限有选择地继承一部分内容并初始化自身。
this.hashCode()
与super.hashCode()
相等。这两个指针指向的是同一块空间。- constructor chaining:
当子类对象实例化时,它的构造函数将立即调用其父类的无参构造函数来初始化自身;这个过程将一直追溯到
Object
类。
Overriding v.s. Hidding
tutorial 6 上讲的一个问题,考察了对 Java method lookup 规则的理解。
1 | class A { |
首先是第一个问题:为什么可以使用 super.n
直接在子类中访问父类的 instance variable?在未声明的情况下,父类的
instance variable 不应该是默认为 private 的吗?
答案:未声明的情况下,instance variable 的 accessibility 默认为 package-private。处于同一个 package 下的其他类是可以直接访问,修改或继承的。
第二个问题,也就是问题本身:该程序的输出是什么?
这个问题很值得深究,特别是 x3.print()
的部分。在不同的语言中,这份代码将表现出完全不同的行为。
- Java:
In B: 10; In C: 400.
- C++:
In B: 4; In C: 100.
为什么会有这样的区别?在 OOP 语言中,针对 class hierarchy 中同名 field 存在多种实现的情况,有两种不同的 method-lookup mechanisms (这里不考虑 static overload)。
- hiding: The method chosen for execution depends on the static type of the reference used to invoke it at compile-time.
- overriding: This is determined by the dynamic type (actual type) of the object at runtime.
Java 对 instance variables 采用的是 hiding 机制,对 methods
采用的则是 overriding 机制。C++ 则同时对 member variables 与 member
functions 应用了 hiding 机制。(C++ 允许使用 virtual
关键字,通过 double dispatch 的方式实现 member functions 的 overriding
机制)。
这两个机制的最本质区别在于它们根据引用 (reference) 的静态或动态 (实际) 类型来决定被调用的方法。
x3
中的 print()
首先调用了父类中的
print()
函数:
this.calculate()
:发现calculate()
方法在子类与父类中有不同的实现,此时 Java 采用 overriding 机制,依据this
的实际类型来选择被执行的实现。很显然,this
指向的当前对象x3
的实际类型为C
;Java 决定执行C
类中的calculate()
实现。所以会有输出In C: 400
。println("In B: " + this.n)
:发现n
实例变量在子类与父类中均有定义,此时 Java 采用 hiding 机制,依据this
的静态类型来选择被访问的实例变量。这是在编译时就被确定的,this
的静态类型为B
;Java 决定访问B
类中定义的n
。所以会有输出In B: 10
。
再看一个例子:
1 | public class Father { |
这个程序的输出是 1 1 2 1
。能想清楚吗?(hint:
son1
的静态类型为 Son
, son2
的静态类型为 Father
;而它们的动态类型均为
Son
)。
若在 Son
类中添加
public int get() { return a; }
结果又是怎样?(method
overriding: 2 2 2 1
)
File Input/Output
midterm 小复习之常用的 Java I/O。有这一节纯粹是因为
System.in
太难用了。
InputStreamReader + BufferedReader 适合快速读入大规模的数据。用法如下:
1 | InputStreamReader isr = new InputStreamReader(System.in); |
调用 inData
的 readLine()
方法一行行的读入数据,每一行数据储存在一个 String 对象中。
Scanner 速度略慢于 InputStreamReader + BufferedReader,但胜在多种读入模式。
1 | Scanner inData = new Scanner(System.in); |
inData
拥有各种各样方便的读入方法,如
nextInt()
, nextLine()
,
nextBoolean()
等等。
File Input FileReader + BufferedReader 或 File + Scanner.
1 | FileReader fr = new FileReader("path/to/file.txt"); |
File Output FileWriter + BufferedWriter.
1 | FileWriter fileWriter = new FileWriter("path/to/file.txt"); |
Serialization
最直观的理解:存储 object 的 state (即其所有的 instance variables)。
serialization 四部曲 (脱水!):
1 | // 1. Make a FileOutputStream |
这是很常见的 Chain Stream 机制:
- connection stream (low-level, core of the nested stream):
FileOutputStream
. - chain stream (higher-level):
ObjectOutputStream
.
以 serialization 为例,对象将会在 ObjectOutputStream
中被 serialized,接下来将会在 OutputStream
中转成 bytes
并写入对应的 file 或 socket。
当对象被 serialized 时,其 instance variables 所指向的对象也将被 serialized。这就是所谓的 serialization saves the entire object graph。并且 Java 实现的 serialization 相当智能,若两个 instance variables 指向的是同一个对象,那么该对象仅会被存储一次。
Serializable Interface
1 | public class Something implements Serializable { ... } |
- marker/tag interface. no need to implemenet any methods.
- inheritance. if the superclass is serializable, the subclass is automatically serializable.
之前我们提到,serialization saves the entire object graph.
1 | public class Pond implements Serializable { |
当我们 serialize 一个 Pond
对象时,其 instance variable
duck
也将被 serialize。但 Duck
类并没有实现
Serializable
接口,此时
NotSerializableException
错误将会弹出。
该问题有两种解决方法:
- 对
Duck
类实现Serializable
接口。 - 添加
transient
关键词。
1 | public class Pond implements Serializable { |
当对象的某些 instance variables CANNOT or SHOULD NOT (如某些
runtime-specific information) 被 serialized 时,使用
transient
关键词跳过它。
Deserialization
deserialization 四部曲 (泡水!):
1 | // 1. Make a FileInputStream |
serialization 是如何将一个对象 bring to life 的:
- constructor chaining 将从 inheritance tree 上的首个 non-serializable
superclass 开始一直追溯到
Object
类。这意味着对象的 constructor 将不会被调用。 - 对象的 instance variables 以 serialized state 中储存的值初始化。
- transient instance variables 以默认值初始化。
Version Control
serialized object 带有特定的
serialVersionUID
,这个值是根据 class structure
而计算得出的。若在对象被 serialized 之后类的定义发生了改变,Java
将不允许我们进行 deserialization。(Java 认为
serialVersionUID
不一致的对象和类是 incompatible 的)
然而,我们可以通过这种方式欺骗编译器:
1 | public class Something { |
如果我们在类中显式地对 serialVersionUID
进行了定义,那么即使类的结构发生了改变,serialVersionUID
也将保持我们所赋予的值。
如果我们要使用这种方法,确保类与对象转化之间的 compatibility 这项任务就落到了程序员头上。假设某个对象已经被 serialized,哪些对对应类结构的更改是 tolerable 的呢?
lead to incompatibility | generally OK |
---|---|
delete instance variables | add new instance variables |
move a class up/down the inheritance tree | add/remove classes to/from the inheritance tree |
change the declared type of instance variables | change the access level of instance variables |
change non-transient variables to transient | change transient variables to non-transient |
change some serializable class to non-serializable | |
change non-static variables to static |
Reference
This article is a self-administered course note.
References in the article are from corresponding course materials if not specified.
Course info. Code: COMP2396, Lecturer: Dr. T.M. Chim.