什么是依赖注入,什么是控制反转,这两者的区别是什么?什么是高内聚低耦合,如何做到?开闭原则的概念是什么?各种设计模式背后的理念是什么...如果你能回答上面的问题,那么你可以不用往下阅读了~
笔者最近在极客时间上学习王争老师的《设计模式之美》,这个课程在正式讲解各种设计模式(单例模式、工厂模式等)之前,从面向对象、设计原则、编程规范和代码重构等方面介绍了一些重要概念和基础编码知识,从而使读者真正做到知其然知其所以然。本文将会对课程中最基础的概念──面向对象,做一个阶段性总结,方便后面更好地理解各种设计模式背后的理念和原则。
面向对象
当我们谈论“面向对象”时,我们究竟在谈什么?直接上结论:谈的通常是面向对象编程和面向对象编程语言。先做一个简短的概念介绍:
- 面向对象编程是一种编程范式或者编程风格。它以类或对象作为组织代码的基本单元,并拥有封装、抽象、继承和多态四个特性。
- 面向对象编程语言是支持类和对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程(封装、抽象、继承、多态)的编程语言。
可以看到,在面向对象的概念里,核心就是封装、抽象、继承和多态四个特性。接下来将从 what/why/how 三个维度对这些概念进行说明。
封装
封装也叫信息隐藏或者数据访问保护。
- why:保护数据不被随意修改,提高代码的可维护性;仅暴露有限的必要接口,提高类的易用性。
- how:类通过暴露有限的访问接口,授权外部仅能通过类提供的方式访问内部信息或者数据(通常是通过
private、public和protected等关键字控制)。
看个例子:
class Wallet {
private id: string // 钱包id
private balance: number // 钱包余额
private createTime: number // 创建时间戳
private lastModifiedTime: number // 余额上次修改时间戳
constructor() {
this.id = '' // 通过id生成器生成一个id,代码省略
this.createTime = new Date().valueOf()
this.lastModifiedTime = new Date().valueOf()
this.balance = 0
}
getId() {
return this.id
}
getCreateTime() {
return this.createTime
}
getBalance() {
return this.balance
}
getLastModifiedTime() {
return this.lastModifiedTime
}
increaseBalance(amount: number) {
this.balance = this.balance + amount
this.lastModifiedTime = new Date().valueOf()
}
decreaseBalance(amount: number) {
this.balance = this.balance + amount
this.lastModifiedTime = new Date().valueOf()
}
}
我们分析下这段代码是怎么做封装的:
- 没有暴露无需改动的属性的修改方法,保护数据不被随意修改,如
id和createTime。 - 只暴露了
increaseBalance和decreaseBalance两个方法去同时修改balance和lastModifiedTime,保证数据不被随意修改的同时保证业务数据正确性。
例子使用 private 关键字来表示属性是私有的,以此来实现访问权限控制。试想一下所有属性都使用 public 进行公有化,那么就可以通过 wallet.id = 123 来随意修改属性了,这肯定不是我们想要的结果。
抽象
抽象就是隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,而不需要知道功能如何实现。
- why:提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;是处理复杂系统的有效手段,能有效过滤无用信息。
- how:通过接口(如
interface关键字)或者抽象类(如abstract关键字)实现。
这个很容易理解了,比如对数据库操作进行抽象(增删查改):
interface IDBOperation {
create(data: any): any // 创建
update(data: any): any // 更新
retrieve(id: string): any // 查询
delete(id: string): any // 删除
}
class DBOperation implements IDBOperation {
create(data: any) { // ... }
update(data: any) { // ... }
retrieve(id: string) { // ... }
delete(id: string) { // ... }
}
上述例子中,当我们想进行进行数据库操作时,只需要关注 IDBOperation 接口暴露了哪些方法,而不用查看 DBOperation 类中的具体实现。
继承
表示类之间的父子关系,或者
is-a关系
比如代码中有一个哺乳动物类,有一个猫类,猫属于哺乳动物,这就是一个 is-a 关系,哺乳动物类是猫类的父类,猫类是哺乳动物类的子类。
- why:解决代码复用问题。
- how:需要编程语言提供特殊语法机制实现,如 Java/TypeScript 的
extends等。
继承最大的意义就是代码复用,子类可以重用父类中的代码。但是继承也是一个有争议的特性,比如你想要了解一个类的功能,你不仅需要查看该类的代码,还需要查看该类的父类甚至是“祖先”类的代码;同时,子类和父类高度耦合,修改父类代码,会直接影响到子类。因此,相比于继承,我们应该多用组合的思想来进行编码。
多态
子类替代父类,在运行时调用子类的实现。
- why:提高代码扩展性和复用性。
- how:需要编程语言提供特殊语法机制实现,如继承和接口等。
看个例子:
interface IExample {
prop: string
setProp(): void
}
function print(instance: IExample) {
instance.setProp()
console.log(instance.prop)
}
class Class1 implements IExample {
prop: string
setProp(): void {
this.prop = '123'
}
}
class Class2 implements IExample {
prop: string
setProp(): void {
this.prop = '456'
}
}
print(new Class1()) // 打印123
print(new Class2()) // 打印456
这个例子中仅仅使用一个 print 函数就能打印不同类的 prop 属性,这就提高了代码的复用率;同时,当需要增加一种打印的类型,只需要定义一个类实现 IExample,print 函数完全不需要修改,这就提高了代码的可扩展性。
面向对象的编程原则
在了解完概念和特性之后,本节将讲述面向对象相关的两条编程原则:「基于接口而非实现编程」与「多用组合少用继承」。
基于接口而非实现编程
该原则能非常有效地提高代码质量,因为应用该原则可以将接口和实现分离,封装不稳定的实现,暴露稳定的接口。
一句话:接口是稳定的,实现是不稳定的。接口是功能的抽象,调用方只需要关注有哪些接口进行功能调用,而不需要关注具体的实现,这样在需求变更等导致的实现变化时,调用方的代码基本上不需要做改动,以此降低耦合性,提高扩展性。
遵守“基于接口而非实现编程”原则,我们需要做到以下三点:
- 函数的命名不能暴露任何实现细节。比如一个上传功能目前使用阿里云实现的,命名不应该使用
uploadToAliyun(),应该采用更抽象的方式,如upload()。 - 封装具体的实现细节。跟实现相关的具体流程应该在方法内部封装起来,只对外暴露统一的接口方法,记住:接口的定义只表明做什么,而不是怎么做。
- 为实现类定义抽象的接口。比如接口方法名,接口所需的参数,接口返回的数据类型,都应该抽象出来。
多用组合少用继承
前面说到继承层次过深会导致代码的可读性变差,同时由于子类和父类高度耦合,也会导致代码的可维护性变差,因此,我们在具体编码时应该遵循多用组合少用继承的原则。
那么,什么是组合呢?我们看一个例子,对于鸟类来说,有的鸟会飞,有的鸟会叫,有的鸟会下蛋,不同的鸟具有不同的特性。那么我们可以针对“会飞”这个行为特性,定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。同理,对会叫和会下蛋的行为特性也可定义相似的接口。
// 会飞接口
interface Flyable {
fly(): void
}
// 会叫接口
interface Tweetable {
tweet(): void
}
interface EggLayable {
layEgg(): void
}
/**
* 鸵鸟只会叫和生蛋
*/
class Ostrich implements Tweetable, EggLayable {
tweet(): void {}
layEgg(): void {}
}
/**
* 麻雀会飞,会叫和会生蛋
*/
class Sparrow implements Flyable, Tweetable, EggLayable {
fly(): void {}
tweet(): void {}
layEgg(): void {}
}
我们知道,接口只声明方法,不定义实现,那如果每个会飞的鸟都要实现一遍 fly() 方法,并且实现的逻辑是一样的,这就会导致代码重复问题,我们该怎么解决呢?
我们可以针对三个接口定义三个实现类,分别是:实现了 fly() 方法的 FlyAbility 类,实现了 tweet() 方法的 TweetAbility 类,实现 layEgg() 方法的 EggLayAbility 类,通过这样的委托技术来消除代码重复。
// 会飞接口
interface Flyable {
fly(): void
}
class FlyAbility implements Flyable {
fly(): void { // ... }
}
// 省略其他实现
/**
* 麻雀会飞,会叫和会生蛋
*/
class Sparrow implements Flyable, Tweetable, EggLayable {
private flyAbility: FlyAbility = new FlyAbility()
fly(): void {
this.flyAbility.fly()
}
tweet(): void {
// ....
}
layEgg(): void {
// ...
}
}
总结
相对于面向过程编程(以过程或方法作为组织代码的基本单元),面向对象编程(以类为组织代码的基本单元)由如下优势:
- 面向对象编程更能应对复杂的程序开发
- 面向对象编程具有更加丰富的特性(封装、抽象、继承和多态),利用这些特性编程出来的代码,更易扩展、易复用和易维护
- 面向对象编程语言比面向过程语言更语义化、人性化、更高级和更加智能
面向对象编程是大多设计模式的理论基础,本文对这一概念及其特性进行了总结,并且描述了相关的两个编程原则,为后面理解设计原则和设计模式进行铺垫,敬请期待后面的文章~