面向对象编程
结构化编程中,随着程序规模的逐渐膨胀,结构化编程在解决问题上的局限也越发凸显出来。因为在它提供的解决方案中,各模块的依赖关系太强,不能有效地将变化隔离开来。这时候,面向对象编程登上了大舞台,它为我们提供了更好的组织程序的方式。
一些人可能认为面向对象就是数据加函数。虽然这种理解不算完全错误,但理解的程度远远不够。结构化编程的思考方式类似于用显微镜看世界,这种思考方式会让人只能看到局部。而想要用好面向对象编程,则需要一个更宏观的视角。
面向对象的三个特点:封装、继承和多态。
封装
封装,则是面向对象的根基。它把紧密相关的信息放在一起,形成一个单元。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,我们再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上构成一个完整的程序。
但是,这一切的前提是,每个对象都要构建好,也就是封装要做好。在对象之间只能通过消息来通信,也就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。
理解了这一点,就能知道一个错误的做法就是编写一个类的方法时,把这个类有哪些字段写出来,然后,生成一大堆getter和setter,将这些字段的访问暴露出去。这就等于把实现细节暴露了出去。正确的做法是**设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。**在方法的命名上,体现的应该是意图,而不是具体怎么做。所以,getXXX和setXXX绝对不是一个好的命名。二者更重要的差异是,一个在说做什么,一个在说怎么做。将意图与实现分离开来,这是一个优秀设计必须要考虑的问题。
不过,在真实的项目中,有时确实需要暴露一些数据,所以,等到确实需要暴露的时候,再去写getter也不迟,到时一定要问问自己为什么要加getter。至于setter,首先,大概率是用错了名字,应该用一个表示意图的名字;其次,setter通常意味着修改,这是不鼓励的。在后面的函数式编程时,会讲到不变性,可变的对象会带来很多的问题,所以,设计中更好的做法是设计不变类。
减少接口的暴露
一个统一的原则:最小化接口暴露。
之所以需要封装,就是要构建一个内聚的单元。所以,要减少这个单元对外的暴露。这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,减少对外暴露的接口。一方面面,在面对需求修改时,可以尽量减小内部修改对外部调用地方的影响和感知。另一方面,如果想改造系统去掉一些接口时,很有可能会造成故障,因为你可能根本不知道哪个地方在什么时候用到了它。所以,在软件设计中,暴露接口需要非常谨慎。
不局限于面向对象的封装
虽说封装是面向对象的一个重要特征,但是,当理解了封装之后,你同样可以把它运用于非面向对象的程序设计语言中,把代码写得更具模块性。
比如,我们知道C语言有头文件(.h 文件)和定义文件(.c 文件),在通常的理解中,头文件放的是各种声明:函数声明、结构体等等。不应该有一个函数就在头文件里加一个声明。
有了对于封装的讲解,再来看C语言的头文件,就可以让它扮演接口的角色,而定义文件就成了实现。更进一步,既然接口只有相当于public接口的函数才可以放到头文件里,那么在实现文件中的每个函数前面,加上了PUBLIC和PRIVATE,以示区分。
#define PUBLIC
#define PRIVATE static
这里将PRIVATE定义成了static,是利用了C语言static函数只能在一个文件中可见的特性。
总的来说,有了封装,对象就成了一个个可以组合的单元,也形成了一个个可以复用的单元。面向对象编程的思考方式就是组合这些单元,完成不同的功能。同结构化编程相比,这种思考问题的方式站在了一个更宏观的视角上。
了解一下迪米特法则(Law of Demeter)
继承
继承,就是一个父类可以有许多个子类。父类是干什么用的呢?就是把一些公共代码放进去,之后在实现其他子类时,可以少写一些代码。设计的一个职责就是消除重复,代码复用。所以,在很多人的印象中,继承就是一种代码复用的方式。
如果把继承理解成一种代码复用方式,更多地是站在子类的角度向上看。在代码使用的时候,面对的是子类,这种继承叫实现继承:
Child object = new Child();
还有一种看待继承的角度,就是从父类的角度往下看,使用的时候,面对的是父类,这种继承叫接口继承:
Parent object = new Child();
接口继承更多是与多态相关
组合优于继承
把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法。
一方面,继承是很宝贵的,尤其是Java这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承占据了,再想做接口继承就很难了。另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有很多程序设计语言,即使不使用继承,也有自己的代码复用方式。
比如:
做一个产品报表服务,其中有个服务是要查询产品信息,这个查询过程是通用的,别的服务也可以用,所以,我把它放到父类里面。
class BaseService {
// 获取相应的产品信息
protected List<Product> getProducts(List<String> product) {
...
}
}
// 生成报表服务
class ReportService extends BaseService {
public void report() {
List<Product> product = getProduct(...);
// 生成报表
...
}
}
如果不用继承实现呢?分析上述过程,获取产品信息和生成报表其实是两件事,只是因为在生成报表的过程中,需要获取产品信息,所以可以这样:
class ProductFetcher {
// 获取相应的产品信息
public List<Product> getProducts(List<String> product) {
...
}
}
// 生成报表服务
class ReportService {
private ProductFetcher fetcher;
public void report() {
List<Product> product = fetcher.getProducts(...);
// 生成报表
...
}
}
这就是组合的实现。在设计上,有一个通用的原则叫做:组合优于继承。也就是说,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。
要写继承的代码时,先问自己,这是接口继承,还是实现继承?如果是实现继承,那是不是可以写成组合?
面向组合编程,就相当于换了一个视角:类是由多个小模块组合而成。这样,在做功能设计的时候,可以将功能进行分解,分解成粒度很小的关注点,每一个关注点是一个独立的模块,之后组合成一个完整的功能模块。
上面的例子,更可以将生成报表也分解成独立模块
class ReportService {
private ProductFetcher fetcher;
private ReportGenerator generator;
public void report() {
List<Product> product = fetcher.getProducts(...);
// 生成报表
generator.generate(product);
}
}
面向对象面向的是“对象”,不是类。将对象理解成类的附属品,是片面的,因为Java的面向对象的实现的局限导致的。对象本身就是一个独立的个体,有些语言是可以直接在对象上进行操作的。如Ruby的一个实现:
module ProductEnhancer
def enhance
# 处理一下产品信息
end
end
service = ReportService.new
# 增加了 ProductEnhancer
service.extend(ProductEnhancer)
# 可以调用 enhance 方法
service.enhance
这样的处理只会影响这里的一个对象,而同样是这个ReportService的其他实例,则完全不受影响。这样做的好处是,我们不必写那么多类,而是根据需要在程序运行时组合出不同的对象。
推荐应该多接触不同的设计语言,学习不同设计语言提供的设计模型,也会让认识更加清晰全面。
Java只有类这种组织方式,所以,很多有差异的概念只能用类这一个概念表示出来,思维就会受到限制,而不同的语言则提供了不同的表现形式,让概念更加清晰。Java在面向组合编程方面能力比较弱,但Java社区也在尝试不同的方式。早期的尝试有Qi4j,后来Java 8加入了default method
,在一定程度上也可以支持面向组合的编程。(后面函数式编程会讲到)
总的来说,组合由于继承,面向组合编程,提供了一个不同的视角,但支撑面向组合编程的是分解。将不同的关注点进行分解,每一个关注点成为一个模块,在需要的时候组装起来。面向组合编程,在设计本身上有很多优秀的地方,可以降低程序的复杂度,更是思维上的转变。
了解一下一种叫DCI (Data,Context和 Interaction)的编程思想
多态
只使用封装和继承的编程方式,称之为基于对象(Object Based)编程,而只有把多态加进来,才能称之为面向对象(Object Oriented)编程。对于面向对象而言,多态至关重要,正是因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。
理解多态
多态(Polymorphism),顾名思义,一个接口,多种形态。同样一个方法,表现出不同的行为。继承中的接口继承,主要是给多态用的。这里面的重点在于,这个继承体系的使用者,主要考虑的是父类,而非子类。就像一个方法,只关心是做什么的,并不用考虑是怎么实现的。这种做法的好处就在于,一旦有了新的变化,只需替换初始化时使用不同的子类实现,其他的代码并不需要修改。
理解多态,要理解多态就是要构建一个抽象,需要找出不同事物的共同点。寻找共同点这件事,地基还是做分解。只有你能看出来,鸡和鸭都有羽毛,都养在家里,你才有机会识别出一个叫做“家禽”的概念。
构建出来的抽象会以接口的方式体现出来,强调一点,这里的接口不一定是一个语法,而是一个类型的约束。所以,在这个关于多态的讨论中,接口、抽象类、父类等几个概念都是等价的,为了叙述方便,这里统一采用接口的说法。(理解接口和抽象类的区别)
接口一个作用是将变的部分和不变的部分隔离开来。不变的是接口的约定,变得是子类的各自实现。这样才能在需求变更时不会因为一个变动改动全身。识别出变与不变,是一种很重要的能力。
另一个作用是清晰地界定边界。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。边界意识的欠缺,在设计接口时就会在接口中随意的添加方法,会混淆实现者和使用者之间的角色差异。最终其结果就是模块定义的随意,彼此之间互相影响在所难免。(了解Liskov替换法则)
所以,要想理解多态,首先要理解接口的价值,而理解接口,最关键的就是在于谨慎地选择接口中的方法。
至此,对多态和接口有了一个基本的认识。也就能理解一个编程原则了:面向接口编程。面向接口编程的价值就根植于多态,也正是因为有了多态,一些设计原则,比如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。
多态的实现
就是通过在运行时通过查表实现的。一个类在编译时,会给其中的函数在虚拟函数表中找到一个位置,把函数指针地址写进去,不同的子类对应不同的虚拟表。当用接口去调用对应的函数时,实际上完成的就是在对应的虚拟函数表的一个偏移,不管现在面对的是哪个子类,都可以找到相应的实现函数。运行时查表是有一定消耗的,对于Java程序员而言,可以通过给无需改写的方法添加final帮助运行时做优化。
总的来说,多态,需要找出不同事物的共同点,建立起抽象。理解多态,还要理解好接口。将变的部分和不变的部分隔离开来,在二者之间建立起一个边界。一个重要的编程原则就是要面向接口编程。
了解一下Go语言或Rust语言是如何支持多态的
总结
在这里,看到了面向对象编程的三个特点也有不同的地位:
- • 封装是面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的
- • 继承给了继承体系内的所有对象一个约束,让它们有了统一的行为
- • 多态让整个体系能够更好地应对未来的变化
重要的一点:多了解不同的编程语言,才能全面的建立起对面向对象的认识。