面向对象基本概念
类与对象
- 类(Class):类是具有相同属性和行为的对象的蓝图或模板。在类中定义了数据成员(即属性或状态)和成员函数(即方法或操作),描述了该类所有对象共有的特征和功能。
- 对象(Object):对象是类的一个实例,每个对象有自己的属性值,并且可以执行类中定义的方法。例如,在现实世界中,一个“狗”是一个类,而“波比”是一只具体的狗,就是这个类的一个对象。
抽象
抽象是指从具体实现中提取出共同特征的过程,通常通过接口(Interface)或抽象类(Abstract Class)实现。抽象类不能被实例化,但可以包含抽象方法(没有具体实现的方法),要求子类必须提供其实现。
消息传递
在面向对象系统中,对象之间通过消息传递进行交互。一个对象向另一个对象发送消息请求服务,实际上就是调用目标对象的方法。
组合与聚合
- 组合(Composition)是强关联关系,表示一个对象包含其他对象作为其内部部分,当外部对象不存在时,内部对象也随之不存在。
- 聚合(Aggregation)也是部分与整体的关系,但与组合相比,它是较弱的关联,代表整体拥有部分,但部分可以独立存在。
三大特征
- 封装:封装是将数据和处理这些数据的函数绑定在一起,对外隐藏内部实现细节,仅通过公共接口(公有方法)访问和修改对象的状态。这样能够保护数据安全,防止外部代码直接篡改对象内部状态。
- 继承:继承允许子类(Derived Class)继承父类(Base Class)的属性和方法,同时还可以添加新的属性、覆盖或扩展已有的方法。这有助于代码复用和层次化的设计结构。
- 多态:多态是指同一种类型的引用可以指向不同类型的具体对象,或者不同类的对象对同一消息作出不同的响应。包括静态多态(编译时多态,如函数重载)和动态多态(运行时多态,如虚函数机制)。
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)要求一个类或者模块只负责完成一个职责(或者功能)。 假设我们有一个简单的厨师类,它负责烹饪和洗碗两个职责:
class Chef {
cookDish(dish) {
// 烹饪菜品的具体实现
}
washDishes() {
// 洗碗的具体实现
}
}
这个类违反了单一职责原则,因为它有两个职责:烹饪和洗碗。这样的设计可能导致以下问题:
- 如果厨师的烹饪逻辑变化,需要修改 cookDish 方法,这可能会影响洗碗的部分。
- 如果洗碗的逻辑变化,需要修改 washDishes 方法,这可能会影响烹饪的部分。
按照单一职责原则,我们应该将这两个职责分开,分别由不同的类负责:
class Chef {
cookDish(dish) {
// 烹饪菜品的具体实现
}
}
class Dishwasher {
washDishes() {
// 洗碗的具体实现
}
}
这样,Chef 类专注于烹饪,而 Dishwasher 类专注于洗碗。每个类都有一个单一的职责,使得代码更清晰、易于理解,并且在未来的变更中更具弹性。
开放封闭原则
开关封闭原则(Open/Closed Principle,OCP)要求软件实体(例如类、模块、函数等)应该对扩展开放,对修改关闭。简而言之,一个模块在扩展新功能时不应该修改原有的代码,而是通过添加新的代码来实现扩展。
考虑一个动物园的场景。我们有一些动物,每个动物都会发出叫声。初始设计如下:
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 方法来实现。
现在,假设我们要添加一些新的动物,比如鹦鹉和狗,按照开放/封闭原则,我们应该扩展而不是修改现有的代码:
class Parrot extends Animal {
makeSound() {
console.log("Squawk!");
}
}
class Dog extends Animal {
makeSound() {
console.log("Bark!");
}
}
这样,我们通过扩展 Animal 类,而不是修改它,来添加新的功能(新的动物)。这符合开放/封闭原则,因为我们对于现有代码的修改是关闭的,我们只是通过扩展来引入新的功能。
使用开放/封闭原则可以使代码更加稳定,降低对现有代码的影响,同时也更容易应对变化和扩展。
里式替换原则
里氏替换原则(Liskov Substitution Principle,LSP) 是 SOLID 原则之一,它强调子类型(派生类或子类)必须能够替换掉它们的基类型(基类或父类)并出现在基类能够工作的任何地方,而不破坏程序的正确性。
通俗地说,如果一个类是基类的子类,那么在任何需要基类的地方,都可以使用这个子类而不产生错误。子类应该保持基类的行为,并且可以扩展或修改基类的行为,但不应该破坏基类原有的约定。
假设我们有一个表示矩形的基类 Rectangle:
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,表示正方形。在正方形中,宽和高应该始终相等。
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 原则之一,它强调一个类不应该被强迫实现它不需要的接口。简而言之,一个类对另一个类的依赖应该建立在最小的接口上。
在通俗的语言中,接口隔离原则告诉我们不应该让一个类依赖它不需要的接口,否则会导致类需要实现一些它根本不需要的方法。
举例说明,假设我们有一个动物园的系统,其中有两种动物,一种会飞,一种会游泳:
// 不遵循接口隔离原则的设计
class Bird {
fly() {
// 实现飞行逻辑
}
swim() {
// 这是一个鸟类不需要的方法,违反接口隔离原则
}
}
class Fish {
swim() {
// 实现游泳逻辑
}
fly() {
// 这是一个鱼类不需要的方法,违反接口隔离原则
}
}
在这个例子中,Bird 类实现了 fly 和 swim 两个方法,而 Fish 类也实现了 swim 和 fly 两个方法。这违反了接口隔离原则,因为每个类都被迫实现了它们不需要的方法。
为了符合接口隔离原则,我们可以将接口拆分成更小的部分,让每个类只实现它们需要的方法:
// 遵循接口隔离原则的设计
class Bird {
fly() {
// 实现飞行逻辑
}
}
class Fish {
swim() {
// 实现游泳逻辑
}
}
这样,每个类都只依赖于它们需要的接口,不再强迫实现不必要的方法。接口隔离原则的目标是使接口更具体,更贴近类的实际需求,从而提高系统的灵活性和可维护性。
依赖反转原则
依赖反转原则(Dependency Inversion Principle,DIP) 是 SOLID 原则之一,它强调高层模块不应该依赖于低层模块,而两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。简而言之,依赖反转原则倡导通过抽象来解耦高层和低层模块之间的依赖关系。
举例说明,考虑一个简单的报告生成系统,有一个高层模块 ReportGenerator 负责生成报告:
// 不遵循依赖反转原则的设计
class ReportGenerator {
constructor() {
this.pdfGenerator = new PDFGenerator(); // 依赖于具体的 PDF 生成器
}
generateReport() {
// 生成报告的逻辑
this.pdfGenerator.generatePDF();
}
}
class PDFGenerator {
generatePDF() {
// 具体的 PDF 生成逻辑
}
}
在这个设计中,ReportGenerator 直接依赖于具体的 PDFGenerator 类,这违反了依赖反转原则。如果我们想使用其他类型的报告生成器,例如 HTMLGenerator,就需要修改 ReportGenerator 类。
为了符合依赖反转原则,我们应该通过抽象来解耦高层和低层模块:
// 遵循依赖反转原则的设计
class ReportGenerator {
constructor(generator) {
this.generator = generator; // 依赖于抽象的报告生成器接口
}
generateReport() {
// 生成报告的逻辑
this.generator.generate();
}
}
class PDFGenerator {
generate() {
// 具体的 PDF 生成逻辑
}
}
class HTMLGenerator {
generate() {
// 具体的 HTML 生成逻辑
}
}
现在,ReportGenerator 不再直接依赖于具体的实现,而是依赖于抽象的报告生成器接口。这使得我们可以轻松地扩展系统,例如添加新的报告生成器,而不需要修改 ReportGenerator 类。这样的设计更加灵活,符合依赖反转原则。