再探 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class person {
String name;
boolean gender;
int age;
int height;
int weight;

Person(String _name, ...) { ... } // constructor

private int getBMI() {
return weight / height;
}

public boolean isHealthy() {
int BMI = getBMI();
return BMI >= 18.5 && BMI <= 24.9;
}
}

我们将人这一实体的特征提取出来 (名字,年龄,性别……),用这些特征的集合来描述人,这就是所谓的抽象。抽象允许我们忽略实体的 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
2
3
4
5
6
7
Garbage p1 = new Garbage("banana skin");
Garbage p2 = p1;

p1 = new Garbage("watermelon peel");
p2 = new Garbage("grape skin");

Garbage p3;

一开始,我们声明了两个 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 of null


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
2
3
4
5
6
7
8
9
10
11
String[][] names = {
{"Mr.", "Mrs.", "Ms."}.
{"Smith", "Jones"}
};

String[][] names2 = new String[2][3];
names[0][0] = "Mr.";
names[0][1] = "Mrs.";
names[0][2] = "Ms.";
names[1][0] = "Smith";
names[1][1] = "Jones";

注意,以上代码中的 namesnames2 是不同的数组。对于有 C/C++ 经验的人来说确实很容易混淆。这是因为在 Java 中,多维数组每一维的长度可以是不同的。我们不妨将 Java 中的二维数组视作 vector<vector<int>>,这样,每一维中的 vector 长度不同就是很自然的事了。


Inheritance v.s. Subtyping

同样也是困扰了我很久的一个问题,又称 子类 (subclassing) v.s. 子类型 (subtyping)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Citizen {
public Citizen() {}
public void eat() {}
}

public class Police extends Citizen {
public Police() {}
public void gun() {}
}

Citizen policeAsCitizen = new Police();
Police policeAsPolice = new Police();

policeAsCitizen.gun(); // missing method error
policeAsPolice.gun(); // no problem

这是个能够完美解释 subclassing v.s. subtyping 之间区别的例子。我自己想的,是不是很妙?

从行为上来讲,调用 policeAsCitizen 对象的 gun() 方法是完全没有问题的,因为 policeAsCitizen 对象的确是定义了 gun() 方法的。

当然,policeAsCitizen 也可以调用其继承的 eat() 方法:这是因为 subclassing 规定了对象的行为。Police 类继承了 Citizen 类,同时又新增了 gun(),因此任何 Police 类的实例都能调用 eat()gun() 方法。

既然如此,问题的原因又出在哪里?明明只是一个很普通的 polymorphism 应用,为什么类型为 CitizenPolice 类实例无法调用它本身拥有的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class A {
private String secret = "I want to escape!";
A() {}
public String getSecret() {
return secret;
}
public String setSecret(String secret) {
this.secret = secret;
}
}

public class B extends A {
B() {}
public void show() {
println("My secret: " + this.secret); // missing field error
println("My secret: " + super.secret); // missing field error
println("My secret: " + getSecret()); // no problem
}
}

虽然老师轻描淡写的带过了这个知识点,但我总感觉有哪里不对。

不觉得这一句话很矛盾吗?既然子类不继承父类的私有实例变量,那理论上来说它并不会储存任何关于这些变量的信息;但我们却能够通过某种方法访问或修改这些“不存在”的变量。

实际上,这些变量是存在的;从代码复用的角度来看,子类拥有父类的全套代码,只不过并不是所有代码都能被子类所使用。父类定义中的公有成员能被子类直接访问,我们称其被子类所「继承」;私有成员对子类隐藏,我们称其无法被继承。这就是公有继承的实质。

按照这个逻辑,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.athis.a 本质上是完全一致的。

虽然逻辑上我们可以理解为每个子类在实例化时都伴随着一系列父类对象的创建,但实际上这一过程并不存在。子类只是复用了父类的所有代码,根据父类定义的访问权限有选择地继承一部分内容并初始化自身。

  • this.hashCode()super.hashCode() 相等。这两个指针指向的是同一块空间。
  • constructor chaining: 当子类对象实例化时,它的构造函数将立即调用其父类的无参构造函数来初始化自身;这个过程将一直追溯到 Object 类。


Overriding v.s. Hidding

tutorial 6 上讲的一个问题,考察了对 Java method lookup 规则的理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class A {
int n;
public A() {
this.n = 1;
}
public void calculate() {
this.n = 4 * this.n;
}
public void print() {
calculate();
System.out.println("In A: " + this.n);
}
}

class B extends A {
int n;
public B() {
this.n = 10;
}
public void calculate() {
this.n = 4 * super.n;
}
public void print() {
this.calculate();
System.out.println("In B: " + this.n);
}
}

class C extends B {
int n;
public C() {
this.n = 100;
}
public void calculate() {
this.n = 4 * this.n;
}
public void print() {
super.print();
System.out.println("In C: " + this.n);
}
}

public class Main {
public static void main(String[] args) {
A x1 = new A();
x1.print();
B x2 = new B();
x2.print();
C x3 = new C();
x3.print();
}
}

首先是第一个问题:为什么可以使用 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

In Java, the static type of a reference is the class in which it is defined. The dynamic type of a reference is the actual type of the object that refers to at runtime.

再看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Father {
public int a;
Father() {
a = 1;
}

public int get() { return a; }
}

public class Son extends Father {

public int a;
Son() {
a = 2;
}

public static void main(String[] args) {
Son son1 = new Son();
Father son2 = new Son();

System.out.println(son1.get());
System.out.println(son2.get());

System.out.println(son1.a);
System.out.println(son2.a);
}
}

这个程序的输出是 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
2
3
4
5
6
7
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader inData = new BufferedReader(isr);

String line;
while ((line = inData.readLine()) != null) {
// dealing with line
}

调用 inDatareadLine() 方法一行行的读入数据,每一行数据储存在一个 String 对象中。

Scanner 速度略慢于 InputStreamReader + BufferedReader,但胜在多种读入模式。

1
2
3
4
5
6
Scanner inData = new Scanner(System.in);

while (inData.hasNextLine()) {
String line = inData.nextLine();
// dealing with line
}

inData 拥有各种各样方便的读入方法,如 nextInt(), nextLine(), nextBoolean() 等等。

File Input FileReader + BufferedReader 或 File + Scanner.

1
2
3
4
5
FileReader fr = new FileReader("path/to/file.txt");
BufferedReader inData = new BufferedReader(fr);

File file = new File("path/to/file.txt");
Scanner inData = new Scanner(file);

File Output FileWriter + BufferedWriter.

1
2
3
4
5
FileWriter fileWriter = new FileWriter("path/to/file.txt");
BufferedWriter writer = new BufferedWriter(fileWriter);

writer.write("");
writer.close();


Serialization

Serialization is the process of translating a data structure or object state into a format that can be stored or transmitted and reconstructed later.

最直观的理解:存储 object 的 state (即其所有的 instance variables)。

serialization 四部曲 (脱水!):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. Make a FileOutputStream
FileOutputStream fileStream = new FileOutputStream("MyGame.sav");

// 2. Make an ObjectOutputStream
ObjectOutputStream os = new ObjectOutputStream(fileStream);

// 3. Write the objects
os.writeObject(elf);
os.writeObject(troll);
os.writeObject(magician);

// 4. Close the ObjectOutputStream
os.close();

这是很常见的 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
2
3
4
5
public class Pond implements Serializable {
private Duck duck = new Duck();
}

public class Duck { ... }

当我们 serialize 一个 Pond 对象时,其 instance variable duck 也将被 serialize。但 Duck 类并没有实现 Serializable 接口,此时 NotSerializableException 错误将会弹出。

该问题有两种解决方法:

  • Duck 类实现 Serializable 接口。
  • 添加 transient 关键词。
1
2
3
public class Pond implements Serializable {
private transient Duck duck = new Duck();
}

当对象的某些 instance variables CANNOT or SHOULD NOT (如某些 runtime-specific information) 被 serialized 时,使用 transient 关键词跳过它。


Deserialization

deserialization 四部曲 (泡水!):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. Make a FileInputStream
FileInputStream fileStream = new FileInputStream("MyGame.sav");

// 2. Make an ObjectInputStream
ObjectInputStream os = new ObjectInputStream(fileStream);

// 3. Read and cast the objects
GameCharacter elf = (GameCharacter) os.readObject();
GameCharacter troll = (GameCharacter) os.readObject();
GameCharacter magician = (GameCharacter) os.readObject();

// 4. Close the ObjectInputStream
os.close();

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
2
3
public class Something {
public static final long serialVersionUID = 2396L;
}

如果我们在类中显式地对 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.

-----------------------------------そして、次の曲が始まるのです。-----------------------------------