面向对象 OOP
英文缩略语概览:
- 面向对象编程:OOP Object Oriented Programming
- 面向对象编程语言:OOPL Object Oriented Programming Language
- 面向对象分析:OOA Object Oriented Analysis
- 面向对象设计:OOD Object Oriented Design
OOA、OOD、OOP 三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段。
四大特性
面向对象的四大特性:继承、多态、封装、抽象。但有些人认为抽象不算特性之一。
封装
信息隐藏或者数据访问保护,类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(函数)来访问内部信息或数据,保证数据的一致性。
获取类的信息时,我们尽量只能通过对象变量来访问这个对象的变量或方法,不通过引用变量就无法访问其中的变量或方法。对于访问者而言,这个对象是封装成一个整体的,这正体现了面向对象的程序设计的「封装性」。
对于封装的特性,编程语言需要一定的语法机制支持。Java 中的修饰符 public
,private
,protected
可实现封装。
封装的意义在于保护数据不被修改,提高代码的可维护性。仅暴露有限的必要接口,提供更少的承诺,提高类的易用性。这样的封装并不是在自己防自己,只要多一个人开发就要设防,防止傻瓜操作影响到自己的类。
抽象
隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。
抽象可以通过接口类或者抽象类来实现,但其实也并不需要特殊的语法机制来支持,比如一般编程语言中的函数机制也可理解为抽象。Java 中使用 interface
和 abstract
关键字实现。
意义在于提高代码的可扩展性,可维护性,修改实现不需要改变定义,减少代码的改动范围;它也是处理复杂系统的有效手段,能够有效地过滤不必要关注的信息。
面向对象中,有时并不愿意把抽象当做特性,是因为抽象是一个非常通用的设计思想,并不需要编程语言提供特殊的语法机制支持,没有很强的“特异性”。
继承
继承表示类之间的 is-a 关系,分两种模式:单继承和多继承。
- 单继承:一个子类只继承一个父类。
- 多继承:一个子类可以继承多个父类。
编程语言中的单继承与多重继承:
- 只支持单继承,不支持多重继承,比如 Java、PHP、C#、Ruby 等
- 既支持单重继承,也支持多重继承,比如 C++、Python、Perl 等。
C++ 有同名覆盖原则。子类方法与父类方法同名(不需要参数类型都相同),父类方法会被屏蔽。
继承的作用在于代码的复用,但是过度使用继承,如继承层次过深过复杂,会导致代码可读性变差。在继承使用过程中开发子类的人员需要阅读父类的代码,这属于耦合了。
多态
多态即子类可以替换父类,它的基础是继承。
在实际代码运行过程中,将父类的指针(或引用)指向子类的实例。父类的引用指向不同的子类实例时,根据子类实例中方法的更新版本去调用。父类尽可能要少说,定义父类时可以不实现方法。Java 中所有的非 final
/static
/private
方法都可以实现多态。
多态的特性需要编程语言提供特殊的语法机制来实现,主要实现方式:
- 继承 + 方法重写。Java 中为
extends
和@Override
- 利用接口类语法。比如 Java 中的
interface
和implements
关键字 - duck-typing 鸭子类型:它是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由「当前方法和属性的集合」决定。「当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。」使用该方式的编程语言有 Python、JavaScript。
Java 语言允许某个类型的引用变量引用子类的实例,而且可以对这个引用变量进行类型转换。
1 | Animal animal=new Dog(); |
下面给出鸭子类型实现多态的示例:
1 | class Logger: |
上面的代码中,Logger 和 DB 没有任何关系。只要都实现了 record 方法,都可以传到 test 中。
多态特性能提高代码的可扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
编程范式/编程风格
常见的编程范式/编程风格有三种:面向过程编程,面向对象编程以及函数式编程。
面向过程编程的特点是数据和操纵数据的方法是分离的。
面向对象的编程和面向对象编程语言的区别:
- 面向对象的编程是一种编程范式或编程风格,以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程的四大特性的编程语言。
- 面向对象的编程是一种思想,和具体语言没关系。用 OOPL 不一定就使用 OOP 思想,例子详看下文;使用非 OOPL 也可以运用 OOP 思想,只不过代码成本比较高。
下面列出违反面向对象编程风格的典型代码设计的案例,有一些是错误需要避免,有一些是有意的:
- 滥用
getter
、setter
方法setter
暴露了直接修改数据的方法,可能破坏数据的一致性。我们必须在使用 setter 时可以保证数据的一致。非必要的情况下尽量不要给属性定义 setter 方法。- 如果数据公开,
getter
也要限制!因为可能返回给用户的引用(一个地址),用户可以通过地址修改。- 如果变量是一个对象,
getter
应该返回这个对象的副本。 getter
可返回Collections.unmodifiableList()
方法防止数据被篡改。但这样集合容器中存储的引用地址还是可以被非法使用。
- 如果变量是一个对象,
- 滥用全局变量和全局方法,静态变量和静态方法。
- 不允许修改,只可以只读,常见于
Constants
类和Utils
类 - 将
Constants
类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到MysqlConstants
类中;跟 Redis 配置相关的常量,我们放到RedisConstants
类中。 - 不单独地设计
Constants
常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig
类用到了Redis
配置相关的常量,那我们就直接将这些常量定义在RedisConfig
中。如果能将这些类中的属性和方法,划分归并到其他业务类中,能极大地提高类的内聚性和代码的可复用性。 - 设计
Utils
类的时候,最好也能细化一下,针对不同的功能,设计不同的Utils
类,比如FileUtils
、IOUtils
、StringUtils
、UrlUtils
等,不要设计一个过于大而全的Utils
类。
- 不允许修改,只可以只读,常见于
- 定义数据和方法分离的类
- 传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。
- 在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。
- 一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的
Controller
类、Service
类、Repository
类中。 - 这就是典型的面向过程的编程风格:贫血模型
interface
接口 vs 抽象类
抽象类:
- 抽象类不允许被实例化,只能被继承
- 它可以包含属性和方法。方法既可以包含代码的实现,也可以不包含代码实现。不包含代码实现的方法叫做抽象方法。
- 子类(具体类)继承抽象类,必须实现抽象类中的所有抽象方法。
接口(指的是狭义接口,即 Java interface):
- 不包含属性
- 接口和抽象类都可以叫接口
- 接口只能声明方法,方法不能包含代码实现
- 类实现接口的时候,必须实现接口中声明的所有方法
抽象类和接口存在的意义:
- 抽象类是对成员变量和方法的抽象是一种 is-a 关系。是为了解决代码复用问题。
- 接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
抽象类和接口选用规则:
- 如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,就用抽象类;
- 如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那就用接口。
C++ 中只有抽象类,没有接口,我们可以进行用抽象类模拟:
1 | class Strategy{ |
基于接口而非实现编程
“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。
这条原则可以将接口和实现分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要改动,以此来降低耦合性,提高扩展性。
另一个表述方式:基于抽象而非实现编程。设计得越抽象就越能够适应变化。具体实践中,函数的命名使用抽象的命名方式不暴露细节(当然内部代码也应当没有特异化的实现,或直接不实现)是体现这一思想的案例。
如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口。越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。
多用组合少用继承
组合优于继承,多用组合少用继承。继承表示类之间的 is-a,支持多态特性,代码复用。但继承层次过深、过复杂,也会影响到代码的可维护性。
继承的一些作用可以被其他技术代替:
- is-a 关系:用组合和接口的 has-a 关系替代
- 支持多态:利用接口实现
- 代码复用。用组合和委托来实现
下面通过例子展示继承缺点以及替代继承的方法。这是一个关于鸟的类:
1 | public class AbstractBird{ |
由于并不是所有的鸟都会飞,所以并不是所有的鸟都应该实现 fly()
方法,所以我们把抽象的鸟再继续分类,分成会飞的和不会飞的,然后对应的子类继承正确的父类:
如果考虑到鸟会不会叫,会不会下蛋等因素,问题变得越来越复杂了…
对此,我们可以使用接口来优化:
1 | public class Ostrich implements Tweetable, EggLayable{ // 鸵鸟 |
但即使通过接口优化,我们还需要再每个类实现对应的方法。对此,我们可以通过组合 + 委托复用代码。
1 | public interface Flyable{ |
如何判断该用组合还是继承?
- 层次潜浅的使用继承;复杂的用组合代替。
- 除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
本文参考
- 本科生课程《设计模式&程序设计中级实践》课程笔记。教师:TJU - 🍐⚱️。
- 极客时间专栏 - 王争 - 设计模式之美