这章内容不多,都是一些很简单的概念。
过程式编程 vs 面向对象的编程
过程式编程
过程式编程,aka Procedual Programming,编程范式基本都是描述任务该如何完成。例如你写过的很多Python程序,不出意外都是 “描述程序过程”。
过程式编程首先会自上而下思考:完成这个任务要做什么?随后继续向下拆分子任务,随后将这些任务做成函数。到最后,整个程序看起来就像是一堆函数在操作维护一些数据。比如说:
1 | main program |
整个系统看起来就像是一颗任务分解树,主打的就是一个从大问题一路往下拆。
例如我们要创建一个银行系统,按照过程思维,某个流程可以很自然的总结成下面的链条:
createAccount() -> deposit() -> withdraw() -> calculateInterest() -> printStatement()
其中每一步基本上都是在想,我需要做哪些事情?这基本上就是过程式编程的主要思路。
过程式编程的问题
但如果过程式编程能够胜任软件开发中的一切应用条件,就不可能会提出新的编程范式了,例如OOP。那么过程式编程到底哪里有问题?
- 变量的使用与维护
在过程式编程中,变量很可能被多个函数共享和修改。因此一旦出现了bug,想要追踪问题来源就不是那么容易了。
或者即便我们设置了极其严格的变量使用范例,下面这个问题同样致命:如果这些变量的数据结构发生更改,那么下游操作使用这些变量的函数该如何进行批量化修改?
具体来说,假如说你的系统里有一堆函数:
1 | int balance; |
这些函数都可以操作变量 balance。假如有一天你惊讶地发现:
balance = -100000000
除了去一行行读trace来定位当时发生的问题,只看业务代码几乎是无法定位问题根源的。你怎么能确定是一个函数的问题,还是说某些函数通过某些顺序调用出现了这个问题?
或者说某一天你突然决定将 balance 的变量类型改成 BigDecimal -> BigDecimal balance,那么你使用这个变量的其他函数都可能要做批量修改。
- 代码复用性差
如果我们写了某个业务函数,它的逻辑很完美,在目前的数据和结构上用没有任何问题。我们叫他函数A吧。
但某天客户来了新的需求,经过技术团队的评估,发现这个业务的实现逻辑和刚才的函数A都差不多,但每一个关键逻辑都只差一点点,导致你没办法复用之前的操作逻辑。你该怎么办?
你的唯一思路基本就是复制一份这个函数,写一个新的变体,然后在需要使用这个业务的地方,call新的函数使用:即便说大部分的行都是重复的,为了这些小变化你也别无他法。
但如果你们银行的这样相似情况发生的非常多,你的解决方案还是去写新的函数,让整个源文件里塞满多余的重复行嘛?
过程式编程会先把问题从上到下拆成多个任务,再把这些任务写成函数。但问题是这些函数可能直接操作共享数据和变量,因此数据的变化来源难以追踪,维护和修改也会比较困难。
面向对象的编程
而 面向对象的编程 (Object Oriented Programming, OOP) 就很有差别,基本上是反过来了。
OOP与其说是 “我要强调哪些任务”,更强调 “系统里有哪些实体”。一个问题会被拆成多个 对象 (Object),而对象是 类 (Class) 的实例化成果。类用来描述对象的数据,状态和行为。
与其说讲道理,不如举个例子吧:
假如说你正在写某个游戏的玩家数据统计系统。如果用过程式编程的思路,整个系统可能看起来会是:
1 | str playerName; |
而在OOP中,我们会思考一个玩家究竟会包含什么。下面的伪代码:
1 | class Player: |
将player封装成一个微型世界。具体来说,之前我们是 一些函数去维护一些共有可以访问的数据,而现在是 某个对象来拥有自己的数据,同时也拥有操作自己数据的方法。
如果将这个Player类实例化成为一个对象,你就可以通过 player.printProfile()来去调用Player类定义的一个方法。查到的信息是player的,且负责吐出数据的方法也是player的。
封装成类和对象能够给予我们一个易于维护的所有权系统。例如如果A和B都是属于Player类,你就可以直接通过 A.updateElo(500) 来直接更新 A 的天梯分,且B保持不变,井水不犯河水。如果是之前的过程式编程,就会出现一个类似 updateElo(A, 500) 的函数。这样一个函数基本上将所有玩家的Elo分暴露在外,因此缺失的所有权意识会导致出了问题难以追踪病灶。
对象
实则刚才的内容多少讲了什么是对象,不过在这里我们来详细说一说。
一个 对象 (Object) ,可以理解为程序中的一个实例。这样一个实例包含两个部分:state 和 behavior。其中:
state就是对象维护的数据,就像刚才player对象的username等。这些数据是这个对象维护的,不是整个程序中的所有函数全部共享的(当然你也可以选择共享,这个后面有说)behavior就是对象的工具箱,也就是对象能够执行的操作。类似于之前的updateElo()。在这里,我们会将这些“操作”称之为 方法 (Method) 。
而这样一个对象是一个 类 (Class) 的 实例 (Instance) 。类就是对象的蓝图。好比说
1 | class Player { |
就是Java中定义一个类的方法。随后我们可以选择:
1 | Player alice = new Player(); |
为alice和bob两个变量实例化,现在它们都是由类 Player 衍生出来的新对象了。其中:
Player声明 alice和bob 这两个变量名代指的变量是一个Player数据类型。alice/bob就是我们熟知的变量名。new是Java中的关键字,会在堆内存中创建一个新的对象实例。Player();就是调用Player类的 构造方法 (Constructor) 。构造方法负责初始化对象,比如给属性赋初始值等其他必要工作。
同一个类创建出来的不同实例对象一定拥有 同一套实例变量,同一套实例方法。但是实例中变量的 实际值可以不同 。例如,由于 alice 和 bob 都属于 Player 类,则它们都包含elo。我们完全可以定义:
1 | alice.elo = 6000 |
但是 elo 的值的内容属于每个对象自己的state。
OOP四大要素
计算机科学家将 OOP 的要素提纯出来。包含:
抽象
抽象 (Abstraction) 关注的不是一个对象该怎么实现,相反,我们在乎的是一个对象该实现什么东西。
如果一个动物摸起来毛茸茸,长尖尖耳,听起来是猫,会哈气,那么它就是猫。
回到刚才的银行系统,对于每一个理财账户,我们都可以有:
1 | balance |
对于一个电视来说,我们都可以:
1 | isOn |
这就是将一个东西抽象化了。也就是说,在不同的应用场景下,你可以想象成将一个物品仅仅保留最外层可见的 “接口”。我们在乎的是哪些特征对这个问题是重要的。
封装
封装 (Encapsulation) 与抽象几乎刚好相反。我们在乎的是如何将这些抽象出来的理念实现到系统中。例如,哪些对象中的哪些信息可以被广播出来使用?我们该如何构建类,才能让每一个类的实现做到原子化,不重复实现,但同时能够尽可能概括整个应用场景?
用刚才的银行系统,我们的关注核心是:
1 |
|
也就是我刚说的 “实现该怎么做”。
继承
继承 (Inheritence) 主要决定类与类之间的逻辑是否能够沿用,决定某些近似的方法是否可以在不同的类之间得到延续。
例如我们手头上有一个 Animal 类,里面定义了:
1 | class Animal { |
意思是只要是动物,都可以发出声音,同时都需要吃东西。
而我们需要一个新的类,好比说 Cat。且我们知道 Cat 属于动物。他一定可以发出声音,或者吃东西。因此我们可以选择:
1 | class Cat extends Animal { |
我们可以在其中写一些专属于Cat的新数据和方法,但同时由于 Cat 由 Animal 继承而来,Cat 同时拥有了 makeSound 和 eatFood 方法,且这些方法的实现由它的父类决定。
或者如果你想要在子类中,对某些父类继承方法进行一些覆盖操作?
1 | class Animal { |
通过使用 @override,你就可以将父类的默认 makeNoise() 方法覆盖,写入子类自己的逻辑。
这就是继承,继承表达的是 A 是一种 B 的关系。这样写下来,程序在对 Animal 兼容的接口上,你可以随便带入任何由 Animal 继承而来的子类对象,也能够正常使用。
多态性
多态性 (Polymorphism) 大概是这四个要素里最难理解的一个了。它主要想要表达的是,OOP中应当可以接受一个类的多重形态,课件上写的是 The ability to take more than one form,不过直接一句话非常难以理解。
这个和刚才的继承很有关联。如果 Cat 是 Animal 的子类,Dog 也是,那么在需要 Animal 特征的地方,可以接受 Cat,亦或者接受 Dog。
例如
1 | void feed(Animal a) { |
由于OOP存在多态性,你可以做什么呢?
1 | feed(new Cat()); |
因为一个接受 Animal 参数的方法,也能接受 Cat。
Java?
而要讲述OOP这样一个编程理念,当下最适合的语言就一定是 Java 了。
在Java中,程序结构高度围绕类和对象组织。
一次编写,随处运行
Java有一个特点在于不挑平台,在任何平台上都能够运行(当然不是原生运行虽说)
当你写了Java源代码,好比说 myProgram.java,Java会调用编译器,在终端中打入 javac ,将你的源代码编译成 bytecode ,在一个叫做 .class 的文件中存储。bytecode不是面向处理器底层的机器码,例如 x86 / ARM machine code 那样,而是一个中间形式。这个形式能够被一个叫做 Java虚拟机 (Java Virtural Machine, JVM) 来进行处理,最后转换成机器码在目标机器上运行。
说白了就是加了一个中间层JVM,专门用来负责处理目标机器的机器码适配。你可以在终端中使用 java 命令,选择目标文件来运行。
Java程序范式
一个Java程序存在目标范式。所有的Java程序都应该遵循这个规律。
在每一个源文件中,最上层一定是类,随后是类里面的方法,在方法中有一个新的朋友,叫做 语句 (Statement)。
我也不解释了,直接看例子吧:
1 | public class Example { |
一个 .java 文件承载一个 class definition,而且 class 本体必须在 {} 里面。
里面的 statement 语句占位符是“真正执行动作”的基本单位。在方法的 {} 里面,语句定义这个方法应该如何执行。
此外,每一个Java应用都必须 至少有一个类 ,且 至少有一个 main() 方法。
main() 和C++的主函数类似,就是程序运行的入口。当一个Java要被运行时,JVM会先找到你生命的应用,然后再去找到这个应用的主函数开始运行。
例如,这就是一个很简单的 Java 应用:
1 | public class MyFirstApp { |
至于 public, static 关键字的内容后面会讲。
Java基础语法
用最快的速度直接覆盖一波Java基础语法规则:
声明,赋值与调用
1 | int x = 3; |
分支
1 | if (x == 10) { |
循环
while:
1 | int x = 0; |
for:
1 | for (int x = 0; x < 10; x = x + 1) { |
do-while:
1 | int x = 0; |
注释
1 | // this is a comment line |
Java中的布尔判断
在其它语言中,例如Python,我们完全可以在某些地方使用非零值来通过布尔检验。例如:
1 | x = 100 |
但是在Java中,每一个需要布尔判断的地方,都只能使用布尔值进行判断:
1 | boolean isHot = true; |
没东西了。