认识面向对象,带你通往高质量编码之路

425 阅读8分钟

什么是依赖注入,什么是控制反转,这两者的区别是什么?什么是高内聚低耦合,如何做到?开闭原则的概念是什么?各种设计模式背后的理念是什么...如果你能回答上面的问题,那么你可以不用往下阅读了~

笔者最近在极客时间上学习王争老师的《设计模式之美》,这个课程在正式讲解各种设计模式(单例模式、工厂模式等)之前,从面向对象、设计原则、编程规范和代码重构等方面介绍了一些重要概念和基础编码知识,从而使读者真正做到知其然知其所以然。本文将会对课程中最基础的概念──面向对象,做一个阶段性总结,方便后面更好地理解各种设计模式背后的理念和原则。

面向对象

当我们谈论“面向对象”时,我们究竟在谈什么?直接上结论:谈的通常是面向对象编程和面向对象编程语言。先做一个简短的概念介绍:

  • 面向对象编程是一种编程范式或者编程风格。它以类或对象作为组织代码的基本单元,并拥有封装、抽象、继承和多态四个特性。
  • 面向对象编程语言是支持类和对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程(封装、抽象、继承、多态)的编程语言。

可以看到,在面向对象的概念里,核心就是封装、抽象、继承和多态四个特性。接下来将从 what/why/how 三个维度对这些概念进行说明。

封装

封装也叫信息隐藏或者数据访问保护。

  • why:保护数据不被随意修改,提高代码的可维护性;仅暴露有限的必要接口,提高类的易用性。
  • how:类通过暴露有限的访问接口,授权外部仅能通过类提供的方式访问内部信息或者数据(通常是通过 privatepublicprotected 等关键字控制)。

看个例子:

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()
  }
}

我们分析下这段代码是怎么做封装的:

  1. 没有暴露无需改动的属性的修改方法,保护数据不被随意修改,如 idcreateTime
  2. 只暴露了 increaseBalancedecreaseBalance 两个方法去同时修改 balancelastModifiedTime,保证数据不被随意修改的同时保证业务数据正确性。

例子使用 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 属性,这就提高了代码的复用率;同时,当需要增加一种打印的类型,只需要定义一个类实现 IExampleprint 函数完全不需要修改,这就提高了代码的可扩展性。

面向对象的编程原则

在了解完概念和特性之后,本节将讲述面向对象相关的两条编程原则:「基于接口而非实现编程」与「多用组合少用继承」。

基于接口而非实现编程

该原则能非常有效地提高代码质量,因为应用该原则可以将接口和实现分离,封装不稳定的实现,暴露稳定的接口。

一句话:接口是稳定的,实现是不稳定的。接口是功能的抽象,调用方只需要关注有哪些接口进行功能调用,而不需要关注具体的实现,这样在需求变更等导致的实现变化时,调用方的代码基本上不需要做改动,以此降低耦合性,提高扩展性。

遵守“基于接口而非实现编程”原则,我们需要做到以下三点:

  1. 函数的命名不能暴露任何实现细节。比如一个上传功能目前使用阿里云实现的,命名不应该使用 uploadToAliyun(),应该采用更抽象的方式,如 upload()
  2. 封装具体的实现细节。跟实现相关的具体流程应该在方法内部封装起来,只对外暴露统一的接口方法,记住:接口的定义只表明做什么,而不是怎么做
  3. 为实现类定义抽象的接口。比如接口方法名,接口所需的参数,接口返回的数据类型,都应该抽象出来。

多用组合少用继承

前面说到继承层次过深会导致代码的可读性变差,同时由于子类和父类高度耦合,也会导致代码的可维护性变差,因此,我们在具体编码时应该遵循多用组合少用继承的原则。

那么,什么是组合呢?我们看一个例子,对于鸟类来说,有的鸟会飞,有的鸟会叫,有的鸟会下蛋,不同的鸟具有不同的特性。那么我们可以针对“会飞”这个行为特性,定义一个 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 {
    // ...
  }
}

总结

相对于面向过程编程(以过程或方法作为组织代码的基本单元),面向对象编程(以类为组织代码的基本单元)由如下优势:

  • 面向对象编程更能应对复杂的程序开发
  • 面向对象编程具有更加丰富的特性(封装、抽象、继承和多态),利用这些特性编程出来的代码,更易扩展、易复用和易维护
  • 面向对象编程语言比面向过程语言更语义化、人性化、更高级和更加智能

面向对象编程是大多设计模式的理论基础,本文对这一概念及其特性进行了总结,并且描述了相关的两个编程原则,为后面理解设计原则和设计模式进行铺垫,敬请期待后面的文章~

参考链接