实际上这张东西更不多,基本就是上节课对类和对象的展开,并进行了一些实战讲解。


在过程式编程,我们更偏向于思考:程序要做什么,做的步骤是什么?随后,根据流程写代码和函数,按顺序串起来。

在OOP中,我们在意的是构建一个对象组成的世界。在这样一个世界中,有哪些东西是重要的,这些东西为何重要,有什么功能呢?这就是上一张说到的,构建一个类的思路。

当程序的规格改变时,这种差异会更加明显。流程式设计往往需要回头修改已经写好和测试过的通用流程,而OOP更容易吧某个对象的特殊情况限制在对应的类内。因此,过程式编程容易因为数据与流程分离而难以维护,而OOP的修改通常不需要影响其他已经测试好的部分。


为何继承?

当我们将所有的东西拆成类之后,难免会出现重复:

1
2
3
4
Circle.rotate()
Square.rotate()
Triangle.rotate()
Blob.rotate()

如果这些行为基本相同,且它们行为相同是有依据的,我们可以将共同部分抽离到一个父类中:

1
2
3
4
5
class Shape {
void rotate() {
...do rorate
}
}

这样后面的类可以直接自动继承这些行为。

如果我们写了一个新的形状,但是这个形状的rotation方式不一样,我们需要对它进行override。简而言之:

  • 如果子类与父类的行为一样:直接继承调用
  • 如果子类和父类的行为不同:在子类override
  • 如果子类拥有应当自己维护的独特数据或行为:在子类中自行维护新的变量和方法。

总之,善于使用继承,就能够让你的程序更易于维护:只要接口对的上,程序就能跑。


OOP的优点

上一章说了一些。总之:

因为一个对象的代码可以做到相对独立的开发和维护,我们说OOP很容易做到所谓 模块化 (Modularity) 编写及管理。同时,每一个对象的内部实现对外隐藏。这样的 Information Hiding保证数据安全可维护。

同时,一个类可以被实例化成对象,且很容易被新建对象复用。不仅如此,还能使用继承和override等功能来最大化代码可复用性。

最后,只要对外接口兼容,一个实现就能被另一个实现替换。这导致整个代码库实现比流程式更文件不容易崩。


类与对象

类和对象是OOP的基本拼图。上一张也说过了,类是一个蓝图,而对象是对这个的实例化成果。

同一个类可以创建很多的对象,且这些对象都会拥有自己独立的状态。

例如:

1
2
3
4
5
6
7
8
9
class Dog {
int size;
String breed;
String name;

void makeNoise() {
System.out.println("Woof!");
}
}

设计类

在设计一个类的时候,你需要考虑下面两个问题:

  • 类的对象应该知道什么,维护什么信息?
  • 类的对象应当要做什么操作?

其中,第一个维护的内容叫做 状态 (state),第二个叫做 行为 (behavior)。状态就是一个对象里面维护的变量,而行为就是方法。

有一个关键的思考点就是,我们该如何判定某个状态和行为,在概念上属于哪个对象?例如在俄罗斯方块中:

1
2
3
4
5
6
7
8
9
10
11
12
Piece
state:
orientation
position
shape
colour

behavior:
moveLeft()
moveRight()
rotate()
fall()

而Board负责整个棋盘层面的数据管理与棋盘维护,例如:

1
2
3
4
5
6
7
8
level
score
pieces
nextPieces

removeRow()
levelUp()
checkEndOfGame()

一切都应该顺理成章地维护起来。


创建新的对象

我们来写一个tester:

1
2
3
4
5
6
7
8
class DogTestDrive {
public static void main(String[] args) {
Dog d = new Dog();

d.size = 40;
d.makeNoise();
}
}

new关键词就是用来在内存堆存储上面创建一个对象的。.叫做点运算符,用来访问类里面维护的变量,方法,等其他信息(前提是类共享出来)。可以解读为“这个对象的xxx”。

例如刚才这行 Dog d = new Dog();,本质含义是什么?

  • Dog 代表变量 d的类型,在这里就是我们定义的 Dog 类型。
  • d就是变量名
  • new Dog() 代表创建了一个新的,Dog 类的对象
  • 等号就是让 d指向这个对象的内存指针。

因此,我们不能说 d 就是一个对象,而是说d是一个引用那个对象的一个变量名。同样的,多个引用也可以指向同一个对象:

1
2
Player p1 = new Player();
Player p2 = p1;

结果是什么?

1
2
3
p1 ──┐
├──→ [ Player object ]
p2 ──┘

因此当我们定义 p1.number = 7的时候,你运行 System.out.println(p2.number) 的值也是7。


一般为了符合encapsulation的规矩,我们在更改一个对象中的值的时候,最好不要
d.size = 40;
在很多维护正确的类中,这些內部值不允许你外部访问。如果所有的值都允许外部访问,那么我们维护这些值的意义在哪?
于是我们一般会在这个类里面写一些helper方法,例如:
d.updateSize(40);
来用对象内部的方法去改变他自己的数值。这是对的。


继承

刚才说了继承,现在说怎么继承。

1
2
3
4
5
6
7
8
9
10
class Dog {
int size;
String breed;
String name;

void makeNoise() {
System.out.println("Woof!");
}
}

这是刚才的Dog。如果你要新建一个新的类,叫Poodle,继承Dog。你可以用关键词 extends

1
2
3
4
5
class Poodle extends Dog {
void makeNoise() {
System.out.println("Ruff!");
}
}

这表示Poodle is a Dog。这就是我们做继承的意义。这允许Poodle继承dog类维护的变量和方法。

如果子类定义了与父类相同的方法,则我们叫做 override 父类实现。例如上面的makeNoise函数:父类里面也有,子类再写一遍,就是override。

不过一般来说,我们推荐使用 @override 关键词来做重载。为什么?

1
2
3
4
5
6
class Poodle extends Dog {
@override
void makeNoise() {
System.out.println("Ruff!");
}
}

在makeNoise前面加上这个关键词,我们就告诉编译器,下面的这个方法是从父类的某个方法继承而来的。如果你写错了一个字符,例如 makeNoice,编译器这里会报错,告诉你父类没找到相对应的方法。

但如果你不带这个关键词尝试直接做override,你可能就会创建一个属于子类的 makeNoice 的新方法。也许这不是你想要的。


类与多态

虽然说Poodle是Poodle,但还是从Dog继承来的。因此:

1
Dog d = new Poodle();

是可以写的。

1
2
3
4
5
6
7
8
9
10
11
class Dog {
void eat() {}
void makeNoise() {}
}

class Poodle extends Dog {
@Override
void makeNoise() {}

void styleHair() {}
}

如果在这里调用Dog p = new Poodle();,那么p.eat();p.makeNoise()都可以运行。但是p.styleHair()就无法运行。因为我们是带着 Dog的眼睛去看的,这是属于poodle的子类方法。


上下转型

这一部分在课上没讲,但可以作为补充看看:

1
Dog d = new Poodle();

我们叫做 向上转型 (Upcasting)。因为我们从一个更细化的子类去放到一个更普遍的类上。

相反:

1
Poodle p = (Poodle) d;

就叫 向下转型 (Downcasting)。如果实际的对象根本不是Poodle,例如:

1
2
Dog d = new Dog();
Poodle p = (Poodle) d;

最后运行时会出现exception。因此在生产代码时,我们的经验是:

当我们只需要父类或interface提供的某种能力时,使用抽象type;确实需要特殊或聚焦到点的API,就用子类type;亦或者我们代码出现很多的向下转型,我们也许需要考虑是不是有一个中间抽象层及值得重建了。


类的互相协作

假如我们有一个猜数游戏。结构大概是:

1
2
3
4
5
6
7
8
9
10
11
12
GameLauncher
└── main()

GuessGame
├── Player p1
├── Player p2
└── startGame()

Player
├── number
├── name
└── guess()

GameLauncher只负责启动游戏:

1
2
GuessGame g = new GuessGame();
g.startGame();

GuessGame创建两个玩家,并负责游戏进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Player p1;
Player p2;

p1 = new Player();
p2 = new Player();

p1.guess();
p2.guess();

---
// Player.guess();
public void guess() {
number = (int) (Math.random() * 10);
}

你会发现对象之间是可以进行各种互动的。我们从GuessGame启动进程,随后告诉Player来猜数字,同时GuessGame会持续维护游戏进行。


垃圾回收

每次我们运行 new SomeClass 创建某个对象时,Java都会为对象在堆中分配内存。与其说像是C++那样要求程序员显式维护生命周期,Java有一个叫做 垃圾回收器(Garbage Collector) 的东西,专门自动管理对象的内存。

当一个对象被判定为再也无法被程序访问时,它严格意义上可被回收。一旦代码中没有任何reference能够再次到达这个对象,GC才会尝试去回收这段内存。


main()

与C++一样,这里是程序的入口。Java应用总是会从启动类的

1
public static void main(String[] args)

开始执行。在大多数Java应用中,main()通常就主要做两件事:创建对象,然后调用这个对象上的某个方法。例如:

1
2
3
4
public static void main(String[] args) {
GuessGame g = new GuessGame();
g.startGame();
}

1
public static void main(String[] args)

这行我们来说文解字:

  • public: JVM可以访问
  • static: 不需要先创建这个类的对象即可调用
  • void: 不返回值
  • main: JVM约定的程序入点
  • String[] args: cli输入参数。

对于普通的实例方法,想要调用makeNoise?你必须要先创建一个 new Dog();。但在此刻JVM启动程序的时候,还没有任何对象被创建。因此标上static允许JVM直接开始执行。


String[] args?就是传入main方法的参数。类型是一个String Array。他保存的是启动程序时提供的命令行参数。例如:java Echo hello world

概念上JVM相当于给main传入了:

1
2
3
4
args[0] = "hello"
args[1] = "world"

args.length = 2

所以如果你需要在程序启动之前就要用参数决定某些性质,你可以这样做。