实际上这张东西更不多,基本就是上节课对类和对象的展开,并进行了一些实战讲解。
在过程式编程,我们更偏向于思考:程序要做什么,做的步骤是什么?随后,根据流程写代码和函数,按顺序串起来。
在OOP中,我们在意的是构建一个对象组成的世界。在这样一个世界中,有哪些东西是重要的,这些东西为何重要,有什么功能呢?这就是上一张说到的,构建一个类的思路。
当程序的规格改变时,这种差异会更加明显。流程式设计往往需要回头修改已经写好和测试过的通用流程,而OOP更容易吧某个对象的特殊情况限制在对应的类内。因此,过程式编程容易因为数据与流程分离而难以维护,而OOP的修改通常不需要影响其他已经测试好的部分。
为何继承?
当我们将所有的东西拆成类之后,难免会出现重复:
1 | Circle.rotate() |
如果这些行为基本相同,且它们行为相同是有依据的,我们可以将共同部分抽离到一个父类中:
1 | class Shape { |
这样后面的类可以直接自动继承这些行为。
如果我们写了一个新的形状,但是这个形状的rotation方式不一样,我们需要对它进行override。简而言之:
- 如果子类与父类的行为一样:直接继承调用
- 如果子类和父类的行为不同:在子类override
- 如果子类拥有应当自己维护的独特数据或行为:在子类中自行维护新的变量和方法。
总之,善于使用继承,就能够让你的程序更易于维护:只要接口对的上,程序就能跑。
OOP的优点
上一章说了一些。总之:
因为一个对象的代码可以做到相对独立的开发和维护,我们说OOP很容易做到所谓 模块化 (Modularity) 编写及管理。同时,每一个对象的内部实现对外隐藏。这样的 Information Hiding保证数据安全可维护。
同时,一个类可以被实例化成对象,且很容易被新建对象复用。不仅如此,还能使用继承和override等功能来最大化代码可复用性。
最后,只要对外接口兼容,一个实现就能被另一个实现替换。这导致整个代码库实现比流程式更文件不容易崩。
类与对象
类和对象是OOP的基本拼图。上一张也说过了,类是一个蓝图,而对象是对这个的实例化成果。
同一个类可以创建很多的对象,且这些对象都会拥有自己独立的状态。
例如:
1 | class Dog { |
设计类
在设计一个类的时候,你需要考虑下面两个问题:
- 类的对象应该知道什么,维护什么信息?
- 类的对象应当要做什么操作?
其中,第一个维护的内容叫做 状态 (state),第二个叫做 行为 (behavior)。状态就是一个对象里面维护的变量,而行为就是方法。
有一个关键的思考点就是,我们该如何判定某个状态和行为,在概念上属于哪个对象?例如在俄罗斯方块中:
1 | Piece |
而Board负责整个棋盘层面的数据管理与棋盘维护,例如:
1 | level |
一切都应该顺理成章地维护起来。
创建新的对象
我们来写一个tester:
1 | class DogTestDrive { |
new关键词就是用来在内存堆存储上面创建一个对象的。.叫做点运算符,用来访问类里面维护的变量,方法,等其他信息(前提是类共享出来)。可以解读为“这个对象的xxx”。
例如刚才这行 Dog d = new Dog();,本质含义是什么?
Dog代表变量d的类型,在这里就是我们定义的Dog类型。d就是变量名new Dog()代表创建了一个新的,Dog类的对象- 等号就是让 d指向这个对象的内存指针。
因此,我们不能说 d 就是一个对象,而是说d是一个引用那个对象的一个变量名。同样的,多个引用也可以指向同一个对象:
1 | Player p1 = new Player(); |
结果是什么?
1 | p1 ──┐ |
因此当我们定义 p1.number = 7的时候,你运行 System.out.println(p2.number) 的值也是7。
一般为了符合encapsulation的规矩,我们在更改一个对象中的值的时候,最好不要
d.size = 40;
在很多维护正确的类中,这些內部值不允许你外部访问。如果所有的值都允许外部访问,那么我们维护这些值的意义在哪?
于是我们一般会在这个类里面写一些helper方法,例如:d.updateSize(40);
来用对象内部的方法去改变他自己的数值。这是对的。
继承
刚才说了继承,现在说怎么继承。
1 | class Dog { |
这是刚才的Dog。如果你要新建一个新的类,叫Poodle,继承Dog。你可以用关键词 extends:
1 | class Poodle extends Dog { |
这表示Poodle is a Dog。这就是我们做继承的意义。这允许Poodle继承dog类维护的变量和方法。
如果子类定义了与父类相同的方法,则我们叫做 override 父类实现。例如上面的makeNoise函数:父类里面也有,子类再写一遍,就是override。
不过一般来说,我们推荐使用 @override 关键词来做重载。为什么?
1 | class Poodle extends Dog { |
在makeNoise前面加上这个关键词,我们就告诉编译器,下面的这个方法是从父类的某个方法继承而来的。如果你写错了一个字符,例如 makeNoice,编译器这里会报错,告诉你父类没找到相对应的方法。
但如果你不带这个关键词尝试直接做override,你可能就会创建一个属于子类的 makeNoice 的新方法。也许这不是你想要的。
类与多态
虽然说Poodle是Poodle,但还是从Dog继承来的。因此:
1 | Dog d = new Poodle(); |
是可以写的。
1 | class Dog { |
如果在这里调用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 | Dog d = new Dog(); |
最后运行时会出现exception。因此在生产代码时,我们的经验是:
当我们只需要父类或interface提供的某种能力时,使用抽象type;确实需要特殊或聚焦到点的API,就用子类type;亦或者我们代码出现很多的向下转型,我们也许需要考虑是不是有一个中间抽象层及值得重建了。
类的互相协作
假如我们有一个猜数游戏。结构大概是:
1 | GameLauncher |
GameLauncher只负责启动游戏:
1 | GuessGame g = new GuessGame(); |
GuessGame创建两个玩家,并负责游戏进程:
1 | Player p1; |
你会发现对象之间是可以进行各种互动的。我们从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 | public static void main(String[] args) { |
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 | args[0] = "hello" |
所以如果你需要在程序启动之前就要用参数决定某些性质,你可以这样做。