Skip to content

面向对象基本概念

类与对象

  • 类(Class):类是具有相同属性和行为的对象的蓝图或模板。在类中定义了数据成员(即属性或状态)和成员函数(即方法或操作),描述了该类所有对象共有的特征和功能。
  • 对象(Object):对象是类的一个实例,每个对象有自己的属性值,并且可以执行类中定义的方法。例如,在现实世界中,一个“狗”是一个类,而“波比”是一只具体的狗,就是这个类的一个对象。

抽象

抽象是指从具体实现中提取出共同特征的过程,通常通过接口(Interface)或抽象类(Abstract Class)实现。抽象类不能被实例化,但可以包含抽象方法(没有具体实现的方法),要求子类必须提供其实现。

消息传递

在面向对象系统中,对象之间通过消息传递进行交互。一个对象向另一个对象发送消息请求服务,实际上就是调用目标对象的方法。

组合与聚合

  • 组合(Composition)是强关联关系,表示一个对象包含其他对象作为其内部部分,当外部对象不存在时,内部对象也随之不存在。
  • 聚合(Aggregation)也是部分与整体的关系,但与组合相比,它是较弱的关联,代表整体拥有部分,但部分可以独立存在。

三大特征

  1. 封装:封装是将数据和处理这些数据的函数绑定在一起,对外隐藏内部实现细节,仅通过公共接口(公有方法)访问和修改对象的状态。这样能够保护数据安全,防止外部代码直接篡改对象内部状态。
  2. 继承:继承允许子类(Derived Class)继承父类(Base Class)的属性和方法,同时还可以添加新的属性、覆盖或扩展已有的方法。这有助于代码复用和层次化的设计结构。
  3. 多态:多态是指同一种类型的引用可以指向不同类型的具体对象,或者不同类的对象对同一消息作出不同的响应。包括静态多态(编译时多态,如函数重载)和动态多态(运行时多态,如虚函数机制)。

SOLID

SOLID 是一个面向对象设计和编程中的五个基本原则的缩写,它们旨在帮助开发者设计更加灵活、可维护和可扩展的软件系统。这些原则由 Robert C. Martin 等人提出,它们包括以下五个原则:

  • 单一职责原则(Single Responsibility Principle,SRP)

对一个类(对象、方法)来说,应该仅有一个引起它变化的原因,也就是说,一个对象只做一件事。

  • 开放/封闭原则(Open/Closed Principle,OCP)

一个模块在扩展性方面应该是开放的,而在更改性方面应该是封闭的,也就是对扩展开放,对修改封闭。

  • 里氏替换原则(Liskov Substitution Principle,LSP)

使用接口时,我们必须确保子类能够替换父类所出现的任何地方,也就是说,父类的接口必须确保所有子类都可以实现需求,而不是某一个子类。

  • 接口隔离原则(Interface Segregation Principle,ISP)

让高层模块不要依赖低层模块。

  • 依赖反转原则(Dependency Inversion Principle,DIP)

强调每个类继承的接口一定要保证最少,不能继承无用的接口,保证接口隔离原则的前提是要先保证职责单一原则。

这些原则共同促使开发者创建具有高内聚、低耦合、易扩展和易维护性的软件系统。遵循这些原则有助于构建更健壮的面向对象系统,提高代码质量和可维护性。

单一职责原则

单一职责原则(Single Responsibility Principle,SRP)要求一个类或者模块只负责完成一个职责(或者功能)。 假设我们有一个简单的厨师类,它负责烹饪和洗碗两个职责:

js
class Chef {
  cookDish(dish) {
    // 烹饪菜品的具体实现
  }

  washDishes() {
    // 洗碗的具体实现
  }
}

这个类违反了单一职责原则,因为它有两个职责:烹饪和洗碗。这样的设计可能导致以下问题:

  1. 如果厨师的烹饪逻辑变化,需要修改 cookDish 方法,这可能会影响洗碗的部分。
  2. 如果洗碗的逻辑变化,需要修改 washDishes 方法,这可能会影响烹饪的部分。

按照单一职责原则,我们应该将这两个职责分开,分别由不同的类负责:

js
class Chef {
  cookDish(dish) {
    // 烹饪菜品的具体实现
  }
}

class Dishwasher {
  washDishes() {
    // 洗碗的具体实现
  }
}

这样,Chef 类专注于烹饪,而 Dishwasher 类专注于洗碗。每个类都有一个单一的职责,使得代码更清晰、易于理解,并且在未来的变更中更具弹性。

开放封闭原则

开关封闭原则(Open/Closed Principle,OCP)要求软件实体(例如类、模块、函数等)应该对扩展开放,对修改关闭。简而言之,一个模块在扩展新功能时不应该修改原有的代码,而是通过添加新的代码来实现扩展。

考虑一个动物园的场景。我们有一些动物,每个动物都会发出叫声。初始设计如下:

js
class Animal {
  constructor(name) {
    this.name = name;
  }

  makeSound() {
    // 默认的叫声
    console.log("Some generic animal sound");
  }
}

class Lion extends Animal {
  makeSound() {
    console.log("Roar!");
  }
}

class Elephant extends Animal {
  makeSound() {
    console.log("Trumpet!");
  }
}

在这个设计中,Animal 类是一个基类,而 Lion 和 Elephant 是它的子类。每个动物都有自己的叫声,通过重写 makeSound 方法来实现。

现在,假设我们要添加一些新的动物,比如鹦鹉和狗,按照开放/封闭原则,我们应该扩展而不是修改现有的代码:

js
class Parrot extends Animal {
  makeSound() {
    console.log("Squawk!");
  }
}

class Dog extends Animal {
  makeSound() {
    console.log("Bark!");
  }
}

这样,我们通过扩展 Animal 类,而不是修改它,来添加新的功能(新的动物)。这符合开放/封闭原则,因为我们对于现有代码的修改是关闭的,我们只是通过扩展来引入新的功能。

使用开放/封闭原则可以使代码更加稳定,降低对现有代码的影响,同时也更容易应对变化和扩展。

里式替换原则

里氏替换原则(Liskov Substitution Principle,LSP) 是 SOLID 原则之一,它强调子类型(派生类或子类)必须能够替换掉它们的基类型(基类或父类)并出现在基类能够工作的任何地方,而不破坏程序的正确性。

通俗地说,如果一个类是基类的子类,那么在任何需要基类的地方,都可以使用这个子类而不产生错误。子类应该保持基类的行为,并且可以扩展或修改基类的行为,但不应该破坏基类原有的约定。

假设我们有一个表示矩形的基类 Rectangle:

js
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

现在,我们创建了一个子类 Square 继承自 Rectangle,表示正方形。在正方形中,宽和高应该始终相等。

js
class Square extends Rectangle {
  setWidth(width) {
    super.setWidth(width);
    super.setHeight(width);
  }

  setHeight(height) {
    super.setWidth(height);
    super.setHeight(height);
  }
}

这里的问题是,Square 子类在修改宽度或高度时,通过覆写 setWidth 和 setHeight 方法,强制宽和高相等,这与基类的行为不一致。如果在需要 Rectangle 的地方使用了 Square,可能会导致程序逻辑错误。

这违反了里氏替换原则,因为子类修改了父类的预期行为。为了符合里氏替换原则,可能需要重新设计类的继承结构,或者使用更精确的命名来表达实际意图。

接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP) 是 SOLID 原则之一,它强调一个类不应该被强迫实现它不需要的接口。简而言之,一个类对另一个类的依赖应该建立在最小的接口上。

在通俗的语言中,接口隔离原则告诉我们不应该让一个类依赖它不需要的接口,否则会导致类需要实现一些它根本不需要的方法。

举例说明,假设我们有一个动物园的系统,其中有两种动物,一种会飞,一种会游泳:

js
// 不遵循接口隔离原则的设计
class Bird {
  fly() {
    // 实现飞行逻辑
  }

  swim() {
    // 这是一个鸟类不需要的方法,违反接口隔离原则
  }
}

class Fish {
  swim() {
    // 实现游泳逻辑
  }

  fly() {
    // 这是一个鱼类不需要的方法,违反接口隔离原则
  }
}

在这个例子中,Bird 类实现了 fly 和 swim 两个方法,而 Fish 类也实现了 swim 和 fly 两个方法。这违反了接口隔离原则,因为每个类都被迫实现了它们不需要的方法。

为了符合接口隔离原则,我们可以将接口拆分成更小的部分,让每个类只实现它们需要的方法:

js
// 遵循接口隔离原则的设计
class Bird {
  fly() {
    // 实现飞行逻辑
  }
}

class Fish {
  swim() {
    // 实现游泳逻辑
  }
}

这样,每个类都只依赖于它们需要的接口,不再强迫实现不必要的方法。接口隔离原则的目标是使接口更具体,更贴近类的实际需求,从而提高系统的灵活性和可维护性。

依赖反转原则

依赖反转原则(Dependency Inversion Principle,DIP) 是 SOLID 原则之一,它强调高层模块不应该依赖于低层模块,而两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。简而言之,依赖反转原则倡导通过抽象来解耦高层和低层模块之间的依赖关系。

举例说明,考虑一个简单的报告生成系统,有一个高层模块 ReportGenerator 负责生成报告:

js
// 不遵循依赖反转原则的设计
class ReportGenerator {
  constructor() {
    this.pdfGenerator = new PDFGenerator(); // 依赖于具体的 PDF 生成器
  }

  generateReport() {
    // 生成报告的逻辑
    this.pdfGenerator.generatePDF();
  }
}

class PDFGenerator {
  generatePDF() {
    // 具体的 PDF 生成逻辑
  }
}

在这个设计中,ReportGenerator 直接依赖于具体的 PDFGenerator 类,这违反了依赖反转原则。如果我们想使用其他类型的报告生成器,例如 HTMLGenerator,就需要修改 ReportGenerator 类。

为了符合依赖反转原则,我们应该通过抽象来解耦高层和低层模块:

js
// 遵循依赖反转原则的设计
class ReportGenerator {
  constructor(generator) {
    this.generator = generator; // 依赖于抽象的报告生成器接口
  }

  generateReport() {
    // 生成报告的逻辑
    this.generator.generate();
  }
}

class PDFGenerator {
  generate() {
    // 具体的 PDF 生成逻辑
  }
}

class HTMLGenerator {
  generate() {
    // 具体的 HTML 生成逻辑
  }
}

现在,ReportGenerator 不再直接依赖于具体的实现,而是依赖于抽象的报告生成器接口。这使得我们可以轻松地扩展系统,例如添加新的报告生成器,而不需要修改 ReportGenerator 类。这样的设计更加灵活,符合依赖反转原则。