面向对象编程(Object Oriented Programming,OOP)是划时代的编程思想变革,推动了高级语言的快速发展和工业化进程。OOP 的抽象、封装、继承、多态的理念使软件大规模化成为可能,有效地降低了软件开发成本、维护成本和复用成本。面向对象编程思想完全不同于传统的面向过程编程思想,使大型软件的开发就像搭积木一样隔离可控、高效简单,是当今编程领域的一股势不可当的潮流。

面向过程让计算机有步骤地顺次做一件事情,是种过程化的叙事思维。但是在大型软件开发过程中,发现用面向过程语言开发,软件维护、软件复用存在着巨大的困难,代码开发变成了记流水账,久而久之代码、模块之间互相耦合,流程互相穿插,往往牵一发而动全身。面向对象提出一种计算机世界里解决复杂软件工程的方法论,拆解问题复杂度,从人类思维角度提出解决问题的步骤和方案。

我们一定要清楚面向对象设计的基本要素:封装、继承、多态。

封装

封装的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。从另外一个角度看,封装这种隐藏也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。封装使面向对象的世界变得单纯,对象之间的关系变得简单,各人自扫门前雪,不同对象之间的耦合度变弱,有利于代码的维护。

封装这件事情是由俭入奢易,由奢入俭难。属性值的访问与修改需要使用相应的 getter、setter 方法,而不是直接对 public 的属性进行读取和修改,可能有些程序员存在疑问,既然通过这两个方法来读取和修改,那与直接对属性进行操作有何区别?如果某一天,类的提供方想在修改属性的 setter 方法上进行鉴权控制、日志记录,这是在直接访问属性的情形中无法做到的。若是将已经公开的属性和行为直接暴力修改为 private,则依赖模块都会编译出错。因此当在不知道什么样的访问控制权限合适时,优先推荐使用 private 控制级别。

继承

继承是面向对象编程技术的基石,允许创建具有逻辑等级结构的类体系,形成一个继承树,让软件在业务多变的客观条件下,某些基础模块可以被直接复用、间接复用或增强复用。继承把枯燥的代码世界变得更有层次感,更有扩展性,为多态打下语法基础。

前面说过继承是 is-a 关系,那么如何衡量当前的继承关系是否满足 is-a 关系呢?判断标准即是否符合里氏替换原则(Liskov Substitution Principle,LSP)。LSP 是指任何父类能够出现的地方,子类都能够出现。在实际代码环境中,如果父类引用直接使用子类引用来代替,可以编译正确并执行,输出结果符合子类场景的预期,那么说明两个类之间符合 LSP 原则,可以使用继承关系。

继承的使用成本很低,一个关键字就可以使用别人的方法,似乎更加轻量简单。想复用别人的代码,跳至脑海的第一反应是继承它,所以继承像抗生素一样容易被滥用,我们传递的理念是谨慎使用继承,认清继承滥用的危害性,即方法污染和方法爆炸。

方法污染是指父类具备的行为,通过继承传递给子类,但是子类并不具备执行此行为的能力,比如鸟会飞,驼鸟继承鸟,发现飞不了,这就是方法污染。子类继承父类,则说明子类对象可以调用父类对象的一切行为。在这样的情况下,总不能在继承时,添加注释说明哪几个父类方法不能在子类中执行,更不能覆写这些无法执行的父类方法,抛出异常,以阻止别人的调用。

方法爆炸是指继承树不断扩大,底层类拥有的方法虽然都能够执行,但是由于方法众多,其中部分方法并非与当前类的功能定位相关,很容易在实际编程中产生选择困难症。比如某些综合功能的类,经过多次继承后达到上百个方法,造成了方法爆炸,因而带来使用不便和安全隐患。

综上所述,在实践中提倡组合优先原则来扩展类的能力,即优先采用组合聚合的类关系来复用其他类的能力,而不是采用继承。

多态

多态,你可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但参数不同,本质上这些方法签名是不一样的。

为了更好说明,请参考下面的样例代码:

  1. public int doSomething() {
  2. return 0;
  3. }
  4. // 输入参数不同,意味着方法签名不同,重载的体现
  5. public int doSomething(List<String> strs) {
  6. return 0;
  7. }
  8. // return类型不一样,编译不能通过
  9. public short doSomething() {
  10. return 0;
  11. }

这里你可以思考一个小问题,方法名称和参数一致,但是返回值不同,这种情况在 Java 代码中算是有效的重载吗? 答案是不是的,编译都会出错的。

多态是指在编译层面无法确定最终调用的方法体,以覆写为基础来实现面向对象特性,在运行期由 JVM 进行动态绑定,调用合适的覆写方法体来执行。重载是编译期确定方法调用,属于静态绑定,本质上重载的结果是完全不同的方法,所以多态专指覆写。

S.O.L.I.D 原则

进行面向对象编程,掌握 S.O.L.I.D 设计原则是必须的,具体如下:

单一职责(Single Responsibility)
类或者对象最好是只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。

开关原则(Open for extension, close for modification)
设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同类功能而修改已有实现,这样可以少产出些回归(regression)问题。

里氏替换(Liskov Substitution)
这是面向对象的基本要素之一,进行继承关系抽象时,凡是可以用父类或者基类的地方,都可以用子类替换。

接口分离(Interface Segregation)
我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏了程序的内聚性。对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。

依赖反转(Dependency Inversion)
实体应该依赖于抽象而不是实现。也就是说,高层次模块不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。

接口和抽象类

接口与抽象类是对实体类进行更高层次的抽象,仅定义公共行为和特征。接口与抽象类的共同点是都不能被实例化,但可以定义引用变量指向实例对象。

首先从语法上进行区分,具体如下图所示:
image.png
抽象类在被继承时体现的是 is-a 关系,接口在被实现时体现的是 can-do 关系。

接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。如果一个抽象类只有一个抽象方法,那么它就等同于一个接口。接口不能实例化,不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;同时没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。

抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。

Java 语言中类的继承采用单继承形式,避免继承泛滥、菱形继承、循环继承的出现。在 JVM 中,一个类如果有多个直接父类,那么方法的绑定机制会变得非常复杂。接口继承接口的关键字是 extends 而不是 implements,允许多重继承,是因为接口有契约式的行为约定,没有任何具体实现和属性,某个实体类在实现多重继承后的接口时,只是说明 can do many things。当纠结定义接口还是抽象类时,优先推荐定义为接口,遵循接口隔离的原则,按某个维度划分成多个接口,然后再用抽象类去 implements 某些接口,方便后续的扩展和重构。