【设计模式】面向对象设计

面向对象设计

相关文章

面向对象设计


一、什么是面向对象设计

面向对象设计(Object-Oriented Design,OOD)是一种软件设计方法,它将现实世界中的事物看作是对象,并将系统设计为一组相互作用的对象。在面向对象设计中,每个对象都包含数据和操作数据的方法,而对象之间通过消息传递来进行通信和交互。

面向对象设计的主要思想是将系统分解为多个独立的对象,每个对象都有自己的属性和方法,并且可以与其他对象进行交互,从而实现系统的功能。这种设计方式具有以下几个核心特点:

  • 封装(Encapsulation)

将数据和操作数据的方法封装在对象内部,通过限制对对象的访问来保护数据的完整性和安全性。

  • 继承(Inheritance)

允许一个对象继承另一个对象的属性和方法,并且可以通过扩展或修改已有的功能来创建新的对象。

  • 多态(Polymorphism)

允许不同的对象对同一个消息做出不同的响应,使得系统更加灵活和易于扩展。

二、面向对象设计的基本原则

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

一个类应该只有一个引起它变化的原因,或者说一个类应该只有一个职责。这可以提高代码的可维护性和可读性。

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

软件实体(类、模块、函数等)应该是可扩展的,但不可修改的。这意味着对于扩展是开放的,可以通过添加新的代码来扩展功能,但对于修改是封闭的,不应该修改现有的代码。

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

子类型必须能够替换其基类型。换句话说,派生类必须能够在不改变程序正确性的前提下替换其基类。

  • 依赖倒置原则(Dependency Inversion Principle,DIP)

高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于具体实现细节,具体实现细节应该依赖于抽象。简单来说,依赖倒置原则要求通过抽象来减少模块之间的直接依赖,从而提高系统的灵活性和可扩展性。

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

客户端不应该被迫依赖于它们不使用的接口。应该将大接口拆分成多个小接口,以便客户端只需知道与其相关的接口。

  • 合成/聚合复用原则(Composition/Aggregation Reuse Principle,CARP)

优先使用对象组合(合成)或聚合,而不是继承来达到代码复用的目的。这样做可以避免继承导致的紧耦合和类的脆弱性。

  • 最少知识原则(Law of Demeter,LoD)

一个对象应该对其他对象有尽可能少的了解,也就是说,一个对象不应该直接调用其他对象的内部方法,而应该通过其自身的方法或者通过依赖注入来实现所需的功能。

三、单一职责原则

强调一个类或模块应该只有一个引起它变化的原因,或者说一个类或模块应该只有一个职责。这意味着一个类或模块只应该负责一种类型的任务或功能,而不应该承担过多的职责。

1
2
3
4
5
6
7
8
9
10
class UserManagement {
constructor() {
this.users = [];
}

addUser(user) {
// 添加用户到系统
this.users.push(user);
}
}

反面例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class UserManagement {
constructor() {
this.users = [];
}

addUser(user) {
// 添加用户到系统
this.users.push(user);
// 发送欢迎邮件
this.sendWelcomeEmail(user);
// 更新用户统计信息
this.updateUserStatistics();
}

sendWelcomeEmail(user) {
// 发送欢迎邮件的逻辑
console.log(`Sending welcome email to ${user.email}`);
}

updateUserStatistics() {
// 更新用户统计信息的逻辑
console.log('Updating user statistics...');
}
}

const userManager = new UserManagement();
const newUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
userManager.addUser(newUser);

在这个例子中,我们有一个 UserManagement 类,它负责用户管理的各个方面,包括添加用户、发送欢迎邮件和更新用户统计信息。这个类违反了单一职责原则,因为它承担了太多的职责。

正面例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class UserManager {
constructor() {
this.users = [];
}

addUser(user) {
// 添加用户到系统
this.users.push(user);
}
}

class EmailService {
sendWelcomeEmail(user) {
// 发送欢迎邮件的逻辑
console.log(`Sending welcome email to ${user.email}`);
}
}

class StatisticsService {
updateUserStatistics() {
// 更新用户统计信息的逻辑
console.log('Updating user statistics...');
}
}

const userManager = new UserManager();
const emailService = new EmailService();
const statisticsService = new StatisticsService();

const newUser = { id: 1, name: 'Alice', email: 'alice@example.com' };

userManager.addUser(newUser);
emailService.sendWelcomeEmail(newUser);
statisticsService.updateUserStatistics();

我们将功能分解为 UserManager、EmailService 和 StatisticsService 三个类,每个类只负责一个明确的任务。UserManager 负责添加用户,EmailService 负责发送邮件,StatisticsService 负责更新用户统计信息。这样做遵循了单一职责原则,使得代码更加清晰、可维护和可扩展。

三、开放-封闭原则

强调软件实体(类、模块、函数等)应该是可扩展的(Open for extension),但不可修改的(Closed for modification)。换句话说,当需要改变系统的行为时,应该通过扩展现有代码来实现,而不是修改现有的代码。

假设有一个订单处理系统,最初只处理在线订单,但后来要求系统也能够处理电话订单。初始设计可能是这样的:

1
2
3
4
5
class OrderProcessor {
processOnlineOrder(order) {
// 处理在线订单的逻辑
}
}

反面例子

现在要求系统能够处理电话订单,我们可能会直接在 OrderProcessor 类中添加新的方法来处理电话订单:

1
2
3
4
5
6
7
8
9
class OrderProcessor {
processOnlineOrder(order) {
// 处理在线订单的逻辑
}

processPhoneOrder(order) {
// 处理电话订单的逻辑
}
}

这种修改违反了开放-封闭原则,因为它修改了现有的类,而不是通过扩展现有类的行为来添加新的功能。如果系统需要处理更多类型的订单,就需要不断地修改 OrderProcessor 类,导致类变得臃肿、复杂,同时增加了修改的风险。

正面例子

为了遵循开放-封闭原则,我们可以使用抽象类和多态性来实现。我们可以创建不同类型的订单处理器类,每个类负责处理特定类型的订单,例如 OnlineOrderProcessor 和 PhoneOrderProcessor:

1
2
3
4
5
6
7
8
9
10
11
class OnlineOrderProcessor extends OrderProcessor {
process(order) {
// 处理在线订单的逻辑
}
}

class PhoneOrderProcessor extends OrderProcessor {
process(order) {
// 处理电话订单的逻辑
}
}

这种设计遵循了开放-封闭原则,因为它通过扩展抽象类 OrderProcessor 来添加新的功能,而不是修改现有的类。这样做使得系统更加灵活,易于扩展,同时减少了修改现有代码的风险。

四、里氏替换原则

该原则是对继承的一种扩展,强调子类必须能够替换其基类(父类)而不影响程序的正确性。换句话说,父类的实例可以被替换为子类的实例,而程序的行为仍然保持一致。

假设我们有一个汽车类 Car,它有一个 startEngine() 方法用于启动引擎:

1
2
3
4
5
class Car {
startEngine() {
console.log("Car engine started.");
}
}

反面例子

假设我们有一个飞机类 Airplane,继承自 Car 类,但是它的 startEngine() 方法是无法启动的:

1
2
3
4
5
class Airplane extends Car {
startEngine() {
throw new Error("Airplane engine cannot be started like a car.");
}
}

正面例子

假设我们有一个特斯拉类 Tesla,也继承自 Car 类,并且重写了 startEngine() 方法:

1
2
3
4
5
class Tesla extends Car {
startEngine() {
console.log("Tesla engine started silently.");
}
}

现在,特斯拉的引擎是静音启动的,所以 Tesla 类重写了 startEngine() 方法来反映这一点。在这种情况下,如果我们用 Tesla 的实例来替换 Car 的实例,程序的行为仍然是合理的,因为 Tesla 类仍然遵循了启动引擎的行为。

五、依赖倒置原则

强调高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于具体实现细节,而具体实现细节应该依赖于抽象。

假设我们有一个报告生成器,它用于生成各种格式的报告,比如 PDF 格式和 CSV 格式。我们可以定义一个报告接口 Report,然后让不同格式的报告类来实现这个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 抽象报告接口
class Report {
generate() {
throw new Error('generate() must be implemented');
}
}

// PDF 格式的报告类
class PDFReport extends Report {
generate() {
console.log('Generating PDF report...');
// 生成 PDF 格式的报告
}
}

// CSV 格式的报告类
class CSVReport extends Report {
generate() {
console.log('Generating CSV report...');
// 生成 CSV 格式的报告
}
}

反面例子

1
2
3
4
5
6
7
8
9
10
11
class ReportGenerator {
constructor() {
this.pdfReport = new PDFReport();
}

generateReport() {
this.pdfReport.generate();
}
}

new ReportGenerator().generateReport()

如果我们在 ReportGenerator 类中直接实例化具体的报告类,那么 ReportGenerator 类就会依赖于具体的实现细节,违反了依赖倒置原则:

正面例子

1
2
3
4
5
6
7
8
9
10
class ReportGenerator {
constructor(report) {
this.report = report;
}

generateReport() {
this.report.generate();
}
}
new ReportGenerator(new PDFReport())

我们的报告生成器类 ReportGenerator 将依赖于 Report 接口,而不是具体的报告类:

六、接口隔离原则

强调客户端不应该依赖于它不需要的接口,应该将接口细分成更小的、更具体的部分,以便客户端只需要知道与其相关的接口。

假设我们有一个电子设备接口 ElectronicDevice,它定义了一系列操作电子设备的方法,包括开机、关机和调节音量等:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ElectronicDevice {
powerOn() {
throw new Error('powerOn() must be implemented');
}

powerOff() {
throw new Error('powerOff() must be implemented');
}

adjustVolume() {
throw new Error('adjustVolume() must be implemented');
}
}

反面例子

然后我们有一个电视类 Television,它实现了 ElectronicDevice 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Television extends ElectronicDevice {
powerOn() {
console.log('TV is powered on.');
}

powerOff() {
console.log('TV is powered off.');
}

adjustVolume() {
console.log('TV volume is adjusted.');
}
}

现在,假设我们引入了一个只有音响功能的音响类 Speaker,但是它不需要开机和关机功能,那么我们的 Speaker 类还是不得不实现这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Speaker extends ElectronicDevice {
powerOn() {
console.log('Speaker is powered on.');
}

powerOff() {
console.log('Speaker is powered off.');
}

adjustVolume() {
console.log('Speaker volume is adjusted.');
}
}

在这个例子中,虽然 Speaker 类只需要调节音量的功能,但是它仍然不得不实现 powerOn() 和 powerOff() 这两个方法,这就违反了接口隔离原则。

正面例子

现在,我们将 ElectronicDevice 接口拆分成更小的接口,分别是 Powerable 和 VolumeAdjustable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Powerable {
powerOn() {
throw new Error('powerOn() must be implemented');
}

powerOff() {
throw new Error('powerOff() must be implemented');
}
}

class VolumeAdjustable {
adjustVolume() {
throw new Error('adjustVolume() must be implemented');
}
}

然后我们的 Television 类只需实现 Powerable 和 VolumeAdjustable 接口中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Television extends Powerable, VolumeAdjustable {
powerOn() {
console.log('TV is powered on.');
}

powerOff() {
console.log('TV is powered off.');
}

adjustVolume() {
console.log('TV volume is adjusted.');
}
}

而 Speaker 类只需实现 VolumeAdjustable 接口中的方法:

1
2
3
4
5
class Speaker extends VolumeAdjustable {
adjustVolume() {
console.log('Speaker volume is adjusted.');
}
}

七、合成/聚合复用原则

它是对代码重用的一种指导方针,强调在设计类时应优先使用合成(Composition)或聚合(Aggregation)而不是继承(Inheritance)。

假设我们有一个 Person 类,它需要使用头、手和腿来描述一个人的属性。我们可以使用继承来设计这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BodyPart {
// 一些通用的属性和方法
}

class Head extends BodyPart {
// 头部的属性和方法
}

class Hand extends BodyPart {
// 手部的属性和方法
}

class Leg extends BodyPart {
// 腿部的属性和方法
}

class Person extends Head, Hand, Leg {
// Person 类的其他属性和方法
}

反面例子

假设我们有一个 Person 类,它需要使用头、手和腿来描述一个人的属性。我们可以使用继承来设计这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BodyPart {
// 一些通用的属性和方法
}

class Head extends BodyPart {
// 头部的属性和方法
}

class Hand extends BodyPart {
// 手部的属性和方法
}

class Leg extends BodyPart {
// 腿部的属性和方法
}

class Person extends Head, Hand, Leg {
// Person 类的其他属性和方法
}

在这个例子中,Person 类与 Head、Hand 和 Leg 类之间存在继承关系,这导致了类之间的高耦合,而且 Person 类可能会受到 Head、Hand 和 Leg 类的改变而影响。

正面例子

现在,让我们使用合成/聚合复用原则来重新设计 Person 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class BodyPart {
// 一些通用的属性和方法
}

class Head extends BodyPart {
// 头部的属性和方法
}

class Hand extends BodyPart {
// 手部的属性和方法
}

class Leg extends BodyPart {
// 腿部的属性和方法
}

class Person {
constructor(head, hands, legs) {
this.head = head;
this.hands = hands;
this.legs = legs;
}

// Person 类的其他属性和方法
}

在这个例子中,Person 类通过合成的方式将 Head、Hand 和 Leg 对象组合到一起,而不是通过继承,这降低了类之间的耦合度,使得系统更加灵活、可扩展和易于维护。

八、最少知识原则

它指导我们设计类时应该尽量减少对象之间的交互,即一个类应该尽可能少地了解其他类的内部细节。这个原则的目标是降低系统的耦合度,使得系统更加灵活、可维护和可扩展。

反面例子

假设我们有一个购物车类 ShoppingCart,它需要获取用户信息,并根据用户信息显示相应的购物车内容。但是 ShoppingCart 类直接访问了用户类的内部信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class User {
constructor(name) {
this.name = name;
}

getName() {
return this.name;
}
}

class ShoppingCart {
constructor(user) {
this.user = user;
}

displayCart() {
const userName = this.user.getName(); // 直接访问用户类的内部信息
console.log(`Shopping cart for user ${userName}: ...`); // 根据用户信息显示购物车内容
}
}

在这个例子中,ShoppingCart 类直接调用了用户类的 getName() 方法来获取用户的姓名,然后根据用户信息显示购物车内容。这违反了最少知识原则,因为 ShoppingCart 类不应该直接访问用户类的内部信息,而应该通过用户类提供的接口来获取用户的姓名。

正面例子

现在,让我们使用最少知识原则来重新设计 ShoppingCart 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class User {
constructor(name) {
this.name = name;
}

getName() {
return this.name;
}
}

class ShoppingCart {
constructor(userName) {
this.userName = userName;
}

displayCart() {
console.log(`Shopping cart for user ${this.userName}: ...`); // 使用传入的用户名显示购物车内容
}
}

在这个例子中,ShoppingCart 类的构造函数接收用户的姓名作为参数,并将其保存在类的实例变量中。在显示购物车内容时,ShoppingCart 类直接使用保存的用户名,而不需要直接访问用户类的内部信息。这样做符合最少知识原则,因为 ShoppingCart 类只与需要的对象进行交互,而不涉及不相关的对象。

九、总结

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

    • 概要:一个类应该只有一个引起它变化的原因,即一个类应该只有一个职责。
    • 目标:降低类的复杂度,提高类的内聚性和可维护性。
    • 实现方法:将不同的功能分离到不同的类中,每个类只负责一个职责。
  • 开放-封闭原则(Open/Closed Principle,OCP):

    • 概要:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
    • 目标:使得软件设计能够应对未来的变化,同时尽量减少对现有代码的修改。
    • 实现方法:通过抽象、接口、继承、多态等方式来实现代码的可扩展性,从而避免直接修改现有代码。
  • 依赖倒置原则(Dependency Inversion Principle,DIP):

    • 概要:高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
    • 目标:降低模块之间的耦合度,提高系统的灵活性和可维护性。
    • 实现方法:通过接口或抽象类定义模块之间的依赖关系,使得高层模块和低层模块都依赖于抽象,而不是具体实现。
  • 接口隔离原则(Interface Segregation Principle,ISP):

    • 概要:客户端不应该依赖于它不需要的接口,应该将接口细分成更小的、更具体的部分。
    • 目标:降低接口的耦合度,提高系统的灵活性和可维护性。
    • 实现方法:根据客户端的需要定义接口,将大接口细分成多个小接口,使得每个接口只包含客户端所需的方法。
  • 合成/聚合复用原则(Composition/Aggregation Reuse Principle):

    • 概要:优先使用合成(Composition)或聚合(Aggregation)而不是继承来实现代码的复用。
    • 目标:降低类之间的耦合度,使得系统更加灵活、可维护和可扩展。
    • 实现方法:通过将已有的类组合到新的类中来实现代码的复用,而不是通过继承已有的类。
  • 最少知识原则(Principle of Least Knowledge,LoD):

    • 概要:一个对象应当对其他对象有尽可能少的了解,不要直接与陌生对象通信,而应该通过对象的接口进行通信。
    • 目标:降低对象之间的依赖关系,使得系统更加灵活、可维护和可扩展。
    • 实现方法:封装对象的内部状态和行为,限制对象之间的通信,避免直接访问其他对象的内部状态。

喜欢这篇文章?打赏一下支持一下作者吧!
【设计模式】面向对象设计
https://www.cccccl.com/20240118/设计模式/面向对象设计/
作者
Jeffrey
发布于
2024年1月18日
许可协议