设计模式 - 七大原则认识
# 1. 设计模式七大原则概述
设计模式的七大原则是程序设计中的基本准则,是设计模式存在的基础。它们指导开发者在设计系统时如何保持代码的灵活性、可维护性以及可扩展性。七大设计原则如下:
- 单一职责原则(Single Responsibility Principle, SRP)
- 接口隔离原则(Interface Segregation Principle, ISP)
- 依赖倒置原则(Dependency Inversion Principle, DIP)
- 里氏替换原则(Liskov Substitution Principle, LSP)
- 开闭原则(Open/Closed Principle, OCP)
- 迪米特法则(Law of Demeter, LoD)
- 合成复用原则(Composite Reuse Principle, CRP)
这些原则通过控制系统内部组件的设计,避免常见的设计问题,如紧耦合、高复杂度和难以扩展等问题。
# 2. 单一职责原则(SRP)
# 2.1 基本介绍
单一职责原则(Single Responsibility Principle, SRP)是设计模式的基础原则之一。其核心思想是:一个类应该有且仅有一个引起其变化的原因。通俗来说,单一职责原则要求一个类只负责一项职责或功能。违反这个原则会导致类的职责过多,一旦某个职责发生变化,可能会影响其他不相关的功能,增加了代码维护的复杂性和风险。
职责 指的是类的功能或用途,变化的原因可能是业务需求的改变或功能调整。如果一个类承担了多项职责,那么每个职责的变化都会影响这个类,从而可能导致代码变得难以维护。
拆分类 是遵循单一职责原则的常见手段。如果一个类负责多个职责,应该将其拆分为多个类,每个类只专注于一个职责。这样能够提高系统的可维护性和扩展性。
# 2.2 单一职责原则的重要性
降低类的复杂度:一个类只处理一项职责,其逻辑更加简单易懂,从而降低了类的复杂度。
提高类的可读性:职责单一的类更加容易理解,代码清晰,方便开发人员快速定位和理解类的功能。
提高系统的可维护性:当一个类的职责明确时,如果某个功能发生变化,只需要修改相关的类,其他类不受影响,减少了修改引发错误的风险。
降低变更引起的风险:遵循单一职责原则的设计可以确保当一个职责发生变化时,不会影响到其他功能,降低了系统修改时的连锁反应。
# 2.3 为什么要遵循单一职责原则
违反单一职责原则 的设计往往会导致以下问题:
职责间相互干扰:一个类承担多项职责,当其中一个职责发生变化时,可能会影响到其他职责的正确执行。比如,如果类 A 负责处理数据库的读写操作和业务逻辑,当业务逻辑发生变化时,可能会影响到数据库操作的部分。
增加维护难度:类的职责过多会使代码过于复杂,难以理解和维护。开发人员在修改代码时,需要同时理解多个职责的运作,导致代码的维护成本上升。
冗余代码:当客户端只需要类的部分功能时,却不得不包含该类的所有职责,导致代码冗余或浪费。
单一职责原则的优点
类的复杂度降低:一个类只负责一项职责,类的内部逻辑自然要比负责多项职责时简单得多。
提高代码的可读性:类的功能单一,代码更加清晰,开发人员能够更容易地理解类的用途和工作方式。
提升系统的可维护性:当一个类的职责明确时,系统发生变化时,修改代码的范围小,不会牵连到其他无关的部分。
降低变更引起的风险:类的职责单一,当修改某项功能时,修改范围集中,减少了对其他功能的影响,降低了系统出现错误的风险。
# 2.4 实际应用中的单一职责原则
单一职责原则的应用场景非常广泛。无论是在类的设计中,还是在模块化的设计中,都应该遵循这一原则。通过合理划分职责,能够确保系统的模块之间互不干扰,增加系统的稳定性和可扩展性。
# 🚫 违反单一职责原则
在下面的代码中,Vehicle
类同时承担了处理陆地交通工具和空中交通工具的职责。这样的设计违反了单一职责原则:
public class SingleResponsibility1 {
public static void main(String[] args) {
Vehicle vehicle = new Vehicle();
vehicle.run("摩托车");
vehicle.run("汽车");
vehicle.run("飞机");
}
}
class Vehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在公路上运行....");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
问题:在 run
方法中,Vehicle
类处理了包括汽车、摩托车和飞机的运行逻辑。这意味着当我们需要修改汽车的逻辑时,飞机的运行逻辑也可能受到影响。这显然违反了单一职责原则。
# ✅ 遵守单一职责原则
我们可以通过将 Vehicle
类分解为三个独立的类,分别处理不同类型的交通工具。这种设计符合单一职责原则。
public class SingleResponsibility2 {
public static void main(String[] args) {
RoadVehicle roadVehicle = new RoadVehicle();
roadVehicle.run("摩托车");
roadVehicle.run("汽车");
AirVehicle airVehicle = new AirVehicle();
airVehicle.run("飞机");
}
}
class RoadVehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在公路上运行...");
}
}
class AirVehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在天空上运行...");
}
}
class WaterVehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在水中运行...");
}
}
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
改进:这里我们将 Vehicle
类拆分为 RoadVehicle
、AirVehicle
和 WaterVehicle
,每个类只负责处理一种交通工具的运行逻辑。这样,当修改某类交通工具的逻辑时,不会影响到其他类的交通工具。
# ✅ 方法级别的单一职责
在某些情况下,完全拆分类可能会导致过多的类,增加代码的复杂度。在这种情况下,可以在方法级别应用单一职责原则。下面的例子展示了在方法级别上如何遵循单一职责原则:
public class SingleResponsibility3 {
public static void main(String[] args) {
Vehicle2 vehicle2 = new Vehicle2();
vehicle2.run("汽车");
vehicle2.runWater("轮船");
vehicle2.runAir("飞机");
}
}
class Vehicle2 {
public void run(String vehicle) {
System.out.println(vehicle + " 在公路上运行....");
}
public void runAir(String vehicle) {
System.out.println(vehicle + " 在天空上运行....");
}
public void runWater(String vehicle) {
System.out.println(vehicle + " 在水中运行....");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
分析:在 Vehicle2
类中,我们通过为不同的交通工具提供独立的方法来遵守单一职责原则。虽然我们没有拆分类,但通过将不同职责的逻辑放入独立的方法中,我们在方法层面遵循了单一职责原则。
注意事项与实践要点
类的复杂度应当尽量降低:遵循单一职责原则可以降低类的复杂度,使每个类专注于一项功能,提升系统的可维护性。
保持系统的可读性和可维护性:类的职责越单一,代码越容易理解,开发者在维护时也能更加清晰地理解每个类的功能和作用。
权衡实际需求与设计原则:虽然单一职责原则是理想的设计标准,但在实际开发中,完全遵守这一原则可能会导致类数量过多。因此,应该在逻辑简单时允许适当的灵活性,将重点放在方法级别的单一职责上。
# 3. 接口隔离原则(ISP)
# 3.1 基本介绍
接口隔离原则(Interface Segregation Principle, ISP)是面向对象设计的核心原则之一。其核心思想是:客户端不应该依赖它不需要的接口。换句话说,一个类对另一个类的依赖应该建立在最小的接口上。每个接口都应当是针对特定需求的最小功能集合,而不是一个“巨型接口”,包含大量无关的功能。
最小接口 是指接口中的方法不应包含太多与不同需求相关的方法。这样可以保证每个接口只包含一个或多个相关类需要的功能,避免不必要的依赖。
例如,如果一个类 A
通过接口 Interface1
依赖于另一个类 B
,那么 Interface1
应该只包含 A
所需的方法。如果 Interface1
包含了 A
和其他类不相关的方法,那么 B
将不得不实现它不需要的功能,这违反了接口隔离原则。
来看一个违反接口隔离原则的例子:
在上图中,类 A
通过接口 Interface1
依赖类 B
,类 C
通过同样的接口依赖类 D
。然而,由于接口 Interface1
不是最小接口,它包含了 A
和 C
不需要的功能,因此类 B
和 D
也不得不实现它们不需要的方法,这违反了接口隔离原则。
# 3.2 接口隔离原则的核心思想
接口隔离原则的核心思想是:每个接口都应该专注于为一个或多个类提供最小必要的方法集合,避免接口中的方法过于庞杂,以防止类实现不需要的功能。
与单一职责原则类似,接口隔离原则的目的在于提高系统的内聚性、降低耦合性。两者的区别在于:
- 单一职责原则 主要关注类的职责划分和实现细节;
- 接口隔离原则 则强调接口的设计,避免客户端依赖不必要的功能。
接口隔离原则的优点
遵循接口隔离原则可以带来以下几个显著的好处:
预防变更扩散,提升灵活性:将臃肿的接口分解为多个小接口后,类不再依赖不相关的功能,接口的变更影响范围更小,系统更加灵活。
提高内聚性,减少耦合性:多个小接口使得每个接口都专注于提供特定功能,内聚性更强。类与接口的依赖关系变得更加清晰,有助于减少耦合性。
提升系统的稳定性:合理设计的接口能使系统更加稳定。如果接口粒度过大,系统将变得难以扩展;而如果接口设计合理,类与接口的依赖关系将更加清晰、简单,系统也将更加易于维护。
体现对象层次:使用多个小接口能更好地体现对象之间的层次关系。通过接口的继承结构,可以实现更加清晰的层次化设计。
减少代码冗余:大接口通常包含大量不需要的方法,导致类实现接口时出现不必要的代码冗余。通过将大接口拆分为多个小接口,可以避免这种冗余问题,提高代码的清晰度和可维护性。
# 3.3 实际应用中的接口隔离原则
为了更好地理解接口隔离原则,我们来看一个应用实例。
# 🚫没有使用接口隔离原则
在下面的例子中,Interface1
是一个庞大的接口,包含了多个不相关的操作方法。类 A
通过该接口依赖 B
类,但 A
只需要用到 operation1
、operation2
和 operation3
。同样,类 C
通过同样的接口依赖 D
类,而它只需要 operation1
、operation4
和 operation5
。
public class Segregation1 {
public static void main(String[] args) {
// 示例代码,无输出
}
}
// 庞大的接口,包含多个操作方法
interface Interface1 {
void operation1();
void operation2();
void operation3();
void operation4();
void operation5();
}
// B 实现了所有方法,虽然它只需要 operation1、operation2、operation3
class B implements Interface1 {
public void operation1() {
System.out.println("B 实现了 operation1");
}
public void operation2() {
System.out.println("B 实现了 operation2");
}
public void operation3() {
System.out.println("B 实现了 operation3");
}
public void operation4() {
System.out.println("B 实现了 operation4");
}
public void operation5() {
System.out.println("B 实现了 operation5");
}
}
// D 类同样实现了所有方法,虽然只需要 operation1、operation4、operation5
class D implements Interface1 {
public void operation1() {
System.out.println("D 实现了 operation1");
}
public void operation2() {
System.out.println("D 实现了 operation2");
}
public void operation3() {
System.out.println("D 实现了 operation3");
}
public void operation4() {
System.out.println("D 实现了 operation4");
}
public void operation5() {
System.out.println("D 实现了 operation5");
}
}
// A 类只需要 operation1, operation2, operation3
class A {
public void depend1(Interface1 i) {
i.operation1();
}
public void depend2(Interface1 i) {
i.operation2();
}
public void depend3(Interface1 i) {
i.operation3();
}
}
// C 类只需要 operation1, operation4, operation5
class C {
public void depend1(Interface1 i) {
i.operation1();
}
public void depend4(Interface1 i) {
i.operation4();
}
public void depend5(Interface1 i) {
i.operation5();
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
问题分析:在这个例子中,B
和 D
类都需要实现整个 Interface1
,即使它们实际上并不需要其中的某些方法。例如,B
类不需要 operation4
和 operation5
,而 D
类不需要 operation2
和 operation3
。这显然违反了接口隔离原则。
# ✅ 使用接口隔离原则改进后
为了遵循接口隔离原则,我们可以将 Interface1
拆分为几个独立的接口。这样,类 A
和类 C
只需依赖它们需要的接口,而不会被迫实现不需要的方法。
public class Segregation2 {
public static void main(String[] args) {
// A 通过拆分后的接口依赖 B
A a = new A();
a.depend1(new B());
a.depend2(new B());
a.depend3(new B());
// C 通过拆分后的接口依赖 D
C c = new C();
c.depend1(new D());
c.depend4(new D());
c.depend5(new D());
}
}
// 拆分后的接口
interface Interface1 {
void operation1();
}
interface Interface2 {
void operation2();
void operation3();
}
interface Interface3 {
void operation4();
void operation5();
}
// B 类只实现它需要的接口
class B implements Interface1, Interface2 {
public void operation1() {
System.out.println("B 实现了 operation1");
}
public void operation2() {
System.out.println("B 实现了 operation2");
}
public void operation3() {
System.out.println("B 实现了 operation3");
}
}
// D 类只实现它需要的接口
class D implements Interface1, Interface3 {
public void operation1() {
System.out.println("D 实现了 operation1");
}
public void operation4() {
System.out.println("D 实现了 operation4");
}
public void operation5() {
System.out.println("D 实现了 operation5");
}
}
// A 类通过最小接口依赖 B 类,只依赖 operation1, operation2, operation3
class A {
public void depend1(Interface1 i) {
i.operation1();
}
public void depend2(Interface2 i) {
i.operation2();
}
public void depend3(Interface2 i) {
i.operation3();
}
}
// C 类通过最小接口依赖 D 类,只依赖 operation1, operation4, operation5
class C {
public void depend1(Interface1 i) {
i.operation1();
}
public void depend4(Interface3 i) {
i.operation4();
}
public void depend5(Interface3 i) {
i.operation5();
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
改进分析:通过将 Interface1
拆分为多个更小的接口,A
和 C
类只依赖它们真正需要的接口,而 B
和 D
类只需要实现它们需要的功能。这使得每个类只依赖与它相关的接口,实现了接口隔离原则。
接口隔离原则的注意事项
接口设计应当合理:接口的粒度应根据具体需求进行定义。粒度过大会导致接口中包含过多不相关的方法,违反接口隔离原则;而粒度过小则可能导致系统中的接口数量过多,增加系统复杂度。
避免接口臃肿:大接口中包含大量不必要的方法会增加类的实现复杂度。通过拆分大接口为小接口,避免类实现不需要的方法,提升系统的可维护性。
接口隔离与单一职责的区别:接口隔离原则强调接口层次的设计,避免客户端依赖不必要的接口;而单一职责原则关注类的职责划分,强调每个类只应承担一个职责。
# 4. 依赖倒转原则(DIP)
# 4.1 基本介绍
依赖倒转原则(Dependence Inversion Principle, DIP)是面向对象设计中的重要原则之一。它的核心思想是通过让高层模块依赖抽象(接口或抽象类)而不是具体实现,从而降低系统的耦合度,提升系统的灵活性和稳定性。
依赖倒转原则的关键点有以下几条:
高层模块不应该依赖低层模块,二者都应该依赖其抽象(接口或抽象类)。高层模块和低层模块指的是系统中不同层次的类或模块,通常高层模块包含业务逻辑,而低层模块负责实现具体的功能。
抽象不应该依赖细节,细节应该依赖抽象。也就是说,接口或抽象类应该作为依赖的核心,而具体实现应该依赖接口,而不是直接依赖其他具体实现。
依赖倒转的核心思想是面向接口编程。通过让各个模块依赖于接口,开发者可以轻松替换实现类,而不需要修改依赖这些接口的代码。
依赖倒转原则 是基于这样的设计理念:相对于细节的多变性,抽象的东西更为稳定。以抽象为基础的架构比以细节为基础的架构更稳定。在 Java 中,抽象可以指接口或抽象类,而细节就是指具体的实现类。通过依赖接口或抽象类,可以隔离变化,让系统更加灵活。
举个例子,在编写一个业务逻辑时,如果直接依赖具体的实现类,当实现类的功能发生变化时,业务逻辑也需要相应调整。如果依赖的是接口或抽象类,那么我们可以轻松替换实现,而不影响高层逻辑。
# 4.2 依赖倒转原则的好处
依赖倒转原则为系统设计带来了以下几个重要的好处:
降低类与类之间的耦合性:高层模块和低层模块都依赖于抽象,降低了系统中类与类之间的直接依赖关系,从而使得系统更加松耦合。
提高系统的稳定性:由于抽象类和接口相对稳定,具体实现发生变化时不需要修改高层模块,因此高层模块不容易受到低层模块变更的影响,从而提高了系统的稳定性。
增强系统的可扩展性:依赖抽象而不是具体实现,能够让系统更容易扩展。新的实现类可以随时替换原有实现,而不影响依赖接口的代码。
减少并行开发引起的风险:各个模块都依赖抽象而不是具体实现,使得不同团队可以并行开发具体实现,不会因实现类的修改而影响其他团队的进度。
提高代码的可读性和可维护性:通过接口或抽象类,系统设计变得更加清晰。接口定义了系统的核心功能,具体实现细节可以独立维护和修改,提高了代码的可读性和维护性。
# 4.3 简单应用实例
让我们通过一个实例来理解依赖倒转原则的基本思想。
需求:Person
类需要接收消息。消息可以通过不同的方式接收,比如电子邮件(Email)或者微信(Weixin)。
# 🚫 违反依赖倒转原则的设计
在下面的例子中,Person
类直接依赖于具体的 Email
类。这种设计使得 Person
类与 Email
类紧密耦合。如果未来我们想要增加其他消息接收方式,比如微信消息,就需要修改 Person
类来增加新的接收方法。
public class DependecyInversion {
public static void main(String[] args) {
Person person = new Person();
person.receive(new Email());
}
}
class Email {
public String getInfo() {
return "电子邮件信息: hello, world!";
}
}
class Person {
public void receive(Email email) {
System.out.println(email.getInfo());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
问题分析:Person
类直接依赖于 Email
类,如果以后我们要支持其他消息接收方式(如微信),就不得不修改 Person
类,违反了依赖倒转原则。
# ✅ 使用依赖倒转原则的设计
我们可以引入一个抽象的 IReceiver
接口,Person
类依赖于该接口,而不是具体的 Email
类。Email
和 Weixin
类实现了 IReceiver
接口,这样可以实现依赖倒转原则。
public class DependecyInversion {
public static void main(String[] args) {
// 客户端代码无需改变
Person person = new Person();
person.receive(new Email());
person.receive(new Weixin());
}
}
// 抽象接口,表示消息接收者
interface IReceiver {
public String getInfo();
}
// 具体的消息接收方式:电子邮件
class Email implements IReceiver {
public String getInfo() {
return "电子邮件信息: hello, world!";
}
}
// 具体的消息接收方式:微信
class Weixin implements IReceiver {
public String getInfo() {
return "微信消息: hello, ok!";
}
}
// Person 类依赖于抽象接口 IReceiver
class Person {
public void receive(IReceiver receiver) {
System.out.println(receiver.getInfo());
}
}
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
改进分析:现在 Person
类依赖的是 IReceiver
接口,而不是具体的 Email
或 Weixin
实现类。这样,添加新的消息接收方式只需要实现 IReceiver
接口,而不需要修改 Person
类,符合依赖倒转原则。
# 4.4 依赖关系传递的三种方式
依赖倒转原则的核心思想是让高层模块和低层模块都依赖于抽象。那么在实际的开发中,我们如何传递这种依赖关系呢?有三种常见的依赖传递方式:
- 接口传递
- 构造方法传递
- setter 方法传递
# 1. 接口传递
在接口传递的方式下,依赖关系通过方法的参数传递。高层模块通过调用方法并传递实现了抽象接口的具体对象,来实现对低层模块的调用。
public class DependencyPass {
public static void main(String[] args) {
Kele kele = new Kele();
OpenAndClose1 openAndClose1 = new OpenAndClose1();
openAndClose1.open(kele); // 通过接口传递实现依赖
}
}
// 抽象接口,表示电视机
interface ITV1 {
public void play();
}
// 具体的电视机实现
class Kele implements ITV1 {
public void play() {
System.out.println("可乐电视机,打开");
}
}
// 开关接口,通过接口传递依赖
interface IOpenAndClose1 {
public void open(ITV1 tv);
}
// 具体的开关实现
class OpenAndClose1 implements IOpenAndClose1 {
public void open(ITV1 tv) {
tv.play();
}
}
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
在这个例子中,OpenAndClose1
类通过方法参数 ITV1
实现了对 Kele
电视机的控制。依赖关系通过接口传递,符合依赖倒转原则。
# 2. 构造方法传递
在构造方法传递的方式下,依赖关系在对象创建时通过构造方法传递。高层模块在实例化时,通过构造器传入实现了抽象接口的对象,从而实现对低层模块的依赖。
public class DependencyPass {
public static void main(String[] args) {
BingTang bingTang = new BingTang();
OpenAndClose2 openAndClose2 = new OpenAndClose2(bingTang); // 通过构造方法传递依赖
openAndClose2.open();
}
}
// 抽象接口
interface ITV2 {
public void play();
}
// 具体的电视机实现
class BingTang implements ITV2 {
public void play() {
System.out.println("冰糖电视机,打开");
}
}
// 通过构造方法传递依赖
interface IOpenAndClose2 {
public void open();
}
class OpenAndClose2 implements IOpenAndClose2 {
private ITV2 tv;
public OpenAndClose2(ITV2 tv) {
this.tv = tv;
}
public void open() {
this.tv.play();
}
}
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
35
36
在这里,OpenAndClose2
通过构造方法接收 ITV2
接口的实现类 BingTang
。这种设计使得 OpenAndClose2
类的依赖关系在实例化时被注入,符合依赖倒转原则。
# 3. setter 方法传递
在 setter 方法传递的方式下,依赖关系通过 setter 方法在运行时进行注入。高层模块调用 setter 方法,将依赖的低层模块对象注入。
public class DependencyPass {
public static void main(String[] args) {
XueLi xueLi = new XueLi();
OpenAndClose3 openAndClose3 = new OpenAndClose3();
openAndClose3.setTv(xueLi); // 通过 setter 方法传递依赖
openAndClose3.open();
}
}
// 抽象接口
interface ITV3 {
public void play();
}
// 具体的电视机实现
class XueLi implements ITV3 {
public void play() {
System.out.println("雪梨电视机,打开");
}
}
// 通过 setter 方法传递依赖
interface IOpenAndClose3 {
public void open();
public void setTv(ITV3 tv);
}
class OpenAndClose3 implements IOpenAndClose3 {
private ITV3 tv;
public void setTv(ITV3 tv) {
this.tv = tv;
}
public void open() {
this.tv.play();
}
}
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
35
36
37
38
在这个例子中,OpenAndClose3
类通过 setter 方法接收 ITV3
接口的实现类 XueLi
。这种设计允许在运行时注入依赖,提供了更大的灵活性。
依赖倒转原则的注意事项和细节
尽量使用抽象类或接口:低层模块尽量通过抽象类或接口提供功能,而不是具体类。这样可以确保高层模块依赖抽象,系统更加灵活和稳定。
变量的声明类型应尽量是接口或抽象类:这样,变量的引用和实际对象之间会有一个抽象层,便于未来的扩展和优化。通过接口或抽象类,系统可以实现不同的具体实现,降低耦合。
遵循里氏替换原则:在依赖抽象的设计中,任何实现类都应可以替换抽象接口或抽象类。这要求所有实现类能够提供一致的功能,不破坏系统的稳定性。
# 5. 里氏替换原则(LSP)
# 5.1 基本介绍
里氏替换原则(Liskov Substitution Principle, LSP)是面向对象设计中非常重要的原则,由麻省理工学院的 Barbara Liskov 在 1987 年提出。其核心思想是:子类必须能够替换其父类并且不会导致程序出错或产生不正确的行为。换句话说,在使用父类的地方,能够安全地替换为子类而不改变程序的正确性。
具体来说,如果某个程序可以通过父类的对象来运行,那么在同样的地方替换为子类的对象时,程序的行为应该保持一致,而不应当因为子类的差异导致程序出错或产生异常行为。
Liskov 在其论文中提到:“继承必须确保超类所拥有的性质在子类中仍然成立(Inheritance should ensure that any property proved about supertype objects also holds for subtype objects)。”也就是说,子类继承父类后,不能破坏父类原有的功能和行为。
# 5.2 为什么需要里氏替换原则
继承是面向对象编程中最常用的手段之一,它允许我们通过复用父类的代码来减少代码重复,提高代码的可维护性。然而,继承带来便利的同时也可能引发问题,因为继承增加了父类与子类之间的耦合度。如果子类对父类的方法进行了不合适的重写或修改,就有可能导致系统的稳定性和正确性受到影响,破坏整个继承体系的设计。
里氏替换原则的提出正是为了解决继承带来的潜在问题。它确保了子类对父类的行为进行扩展时,不会破坏父类的功能,能够维护系统的健壮性和正确性。
主要规则:
- 子类必须完全实现父类的功能,而不应改变父类已有功能的行为。
- 子类可以有自己的额外功能,但这些功能不应影响父类功能的正确性。
- 子类不应随意重写父类中已实现的功能,尤其是那些对父类整体行为至关重要的方法。
里氏替换原则的好处
保证系统的健壮性:遵循里氏替换原则,确保子类不会破坏父类的行为,使得系统在继承和扩展时能够保持一致性和健壮性。即使子类替换了父类,程序仍然可以正常工作。
提升代码的复用性:里氏替换原则鼓励子类复用父类的功能而不改变它们,从而实现代码的更高复用性。通过扩展父类的功能而不是改变它们,程序可以实现更多的功能。
降低代码的维护成本:子类可以安全地替代父类,而不需要修改调用父类的代码。这减少了代码的修改次数,降低了维护成本。
有助于实现开闭原则:里氏替换原则是实现开闭原则的重要手段之一。通过遵循里氏替换原则,子类可以通过扩展父类的功能而不是修改父类,保持系统对扩展开放,对修改关闭。
# 5.3 实际应用中的里氏替换原则
场景描述:假设我们有两个类 A
和 B
。A
类提供了一个计算两个数相减的功能,而 B
类继承了 A
类,并重写了 A
类的减法运算。然而,B
类无意中将减法变成了加法,导致程序中原本依赖减法功能的地方出现了错误。
# 🚫 违反里氏替换原则
public class Liskov {
public static void main(String[] args) {
A a = new A();
System.out.println("11-3=" + a.func1(11, 3)); // 输出 11-3=8
System.out.println("1-8=" + a.func1(1, 8)); // 输出 1-8=-7
System.out.println("-----------------------");
B b = new B();
System.out.println("11-3=" + b.func1(11, 3)); // 本意是 11-3,但结果变成了 11+3=14
System.out.println("1-8=" + b.func1(1, 8)); // 本意是 1-8,但结果变成了 1+8=9
System.out.println("11+3+9=" + b.func2(11, 3)); // 新增功能,计算 11+3+9
}
}
class A {
// 返回两个数的差
public int func1(int num1, int num2) {
return num1 - num2;
}
}
// B 类继承了 A 类,但无意中重写了 A 类的减法方法
class B extends A {
// 重写了 A 类的 func1 方法,将减法改为加法
public int func1(int a, int b) {
return a + b;
}
// 新增功能:两个数相加后再加上 9
public int func2(int a, int b) {
return func1(a, b) + 9;
}
}
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
问题分析
在上面的例子中,B
类继承了 A
类并重写了 func1
方法。然而,B
类将 func1
的行为从减法改为了加法。这样,原本依赖 A
类的减法功能的代码在使用 B
类时就会出现错误。这违反了里氏替换原则,因为子类 B
没有保持父类 A
的行为一致性。
# ✅ 遵循里氏替换原则
为了遵循里氏替换原则,我们可以通过引入一个更加基础的基类 Base
,将 A
和 B
都继承自 Base
。同时,B
类不再直接继承 A
,而是通过组合的方式使用 A
类的方法,从而避免重写父类的行为导致的问题。
改进后的设计如下:
public class Liskov {
public static void main(String[] args) {
A a = new A();
System.out.println("11-3=" + a.func1(11, 3)); // 输出 11-3=8
System.out.println("1-8=" + a.func1(1, 8)); // 输出 1-8=-7
System.out.println("-----------------------");
B b = new B();
// 由于 B 类不再继承 A 类,func1 的行为与 A 类不冲突
System.out.println("11+3=" + b.func1(11, 3)); // 本意是 11+3
System.out.println("1+8=" + b.func1(1, 8)); // 本意是 1+8
System.out.println("11+3+9=" + b.func2(11, 3)); // 新功能,计算 11+3+9
// B 类通过组合关系仍然可以使用 A 类的减法功能
System.out.println("11-3=" + b.func3(11, 3)); // 本意是 11-3
}
}
// 创建一个更加基础的基类
class Base {
// 基础功能可放在这里
}
// A 类继承自 Base 类,保留原有减法功能
class A extends Base {
// 返回两个数的差
public int func1(int num1, int num2) {
return num1 - num2;
}
}
// B 类也继承自 Base 类,但不再重写 A 类的功能
class B extends Base {
// 组合 A 类,使用 A 类的功能
private A a = new A();
// 新增加法功能
public int func1(int a, int b) {
return a + b;
}
// 新增功能,两个数相加后再加上 9
public int func2(int a, int b) {
return func1(a, b) + 9;
}
// 仍然可以使用 A 类的减法功能
public int func3(int a, int b) {
return this.a.func1(a, b);
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
改进分析
在改进后的设计中,A
类和 B
类都继承了一个基础的 Base
类,但 B
类不再直接继承 A
类。B
类通过组合的方式使用 A
类的功能,而不是通过继承来重写父类方法。这避免了 B
类对 A
类行为的破坏,符合里氏替换原则。
里氏替换原则的注意事项
子类应该扩展父类的功能,而不是改变父类的功能:当一个子类继承父类时,它应当保留父类的行为,而不是重写或修改父类的重要功能。否则,继承会导致系统行为的不一致性。
遵循“合成复用原则”:当子类需要使用父类的某些功能时,尽量通过组合(即将父类作为成员变量)而不是继承。这样可以避免子类对父类功能的破坏,同时保持系统的灵活性。
使用抽象类和接口进行继承:如果父类提供的是抽象功能(通过抽象类或接口),子类可以自由实现这些功能而不会破坏父类的行为。这样可以减少子类对父类行为的干扰。
尽量避免对父类已有方法的重写:重写父类的方法会增加系统的复杂性,并且可能导致难以预料的行为变化。除非必要,尽量避免对父类的方法进行重写。
子类应确保能够替换父类:在使用继承时,确保子类能够完全替换父类,而不会影响系统的功能。通过单元测试可以验证子类是否符合这一原则。
# 6. 开闭原则(OCP)
# 6.1 基本介绍
开闭原则(Open Closed Principle, OCP)是面向对象设计中最重要的设计原则之一,由 Bertrand Meyer 在 1988 年提出。它的核心思想是:软件实体(如类、模块、函数)应对扩展开放,对修改关闭。这一原则要求系统能够在不修改原有代码的基础上,允许功能扩展。简言之,系统应该允许通过增加新功能来扩展已有的模块,而不是通过修改已有的代码来实现变化。
- 对扩展开放:系统允许在不修改现有代码的前提下添加新功能。
- 对修改关闭:在系统已经经过测试并发布后,任何对已有代码的修改都有可能引入错误。因此,我们应该尽量避免直接修改已有代码,而是通过扩展的方式来增加功能。
开闭原则的精髓在于通过 抽象 来设计系统架构,让具体的实现类在不改变已有代码的基础上进行扩展。这可以通过接口和抽象类来实现,保证软件的灵活性和稳定性。
# 6.2 开闭原则的好处
开闭原则在软件开发中至关重要,因为它直接影响软件的可维护性、可扩展性和稳定性。具体来说,它有以下几方面的好处:
减少软件变更的风险:遵循开闭原则的代码,在增加新功能时不需要修改原有代码,因此不会影响已经经过测试的代码部分,减少了引入新错误的可能性。
提升代码的可维护性:由于开闭原则倡导对修改关闭,所以开发者可以在不修改原有代码的情况下轻松地进行功能扩展,这提高了系统的可维护性。
增强代码的可扩展性:在设计阶段就考虑到未来可能的扩展,通过抽象和接口设计,能够更方便地扩展系统的功能。
降低开发成本和时间:当系统已经实现了一些核心功能,通过开闭原则,我们可以快速在不影响核心功能的前提下,添加新的功能,节省了开发成本和时间。
# 6.3 实际应用中的开闭原则
我们通过一个绘图软件的例子来说明开闭原则如何应用。
# 🚫 违反开闭原则的设计
在这个例子中,我们有一个 GraphicEditor
类用于绘制不同的图形。最初系统支持绘制矩形(Rectangle
)和圆形(Circle
)。为了区分不同的图形,Shape
类中包含一个 m_type
属性,用来表示图形的类型,GraphicEditor
类根据 m_type
的值来决定调用哪种绘图方法。
public class Ocp {
public static void main(String[] args) {
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
}
}
// 这是一个用于绘图的类 [使用方]
class GraphicEditor {
public void drawShape(Shape s) {
if (s.m_type == 1)
drawRectangle(s);
else if (s.m_type == 2)
drawCircle(s);
}
// 绘制矩形
public void drawRectangle(Shape r) {
System.out.println("绘制矩形");
}
// 绘制圆形
public void drawCircle(Shape r) {
System.out.println("绘制圆形");
}
}
// Shape 类,基类
class Shape {
int m_type;
}
// 矩形类
class Rectangle extends Shape {
Rectangle() {
super.m_type = 1;
}
}
// 圆形类
class Circle extends Shape {
Circle() {
super.m_type = 2;
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
问题分析
上述代码在实际应用中存在以下问题:
- 违反了开闭原则:每次增加新的图形类型(例如三角形),我们都需要修改
GraphicEditor
类,增加新的绘图方法,并且在drawShape
方法中添加新的if
判断。这违背了开闭原则,因为每次添加新功能都要修改原有代码,容易引入新的错误。 - 可扩展性差:如果以后系统要支持更多的图形类型,每次都需要修改
GraphicEditor
类的代码,系统的可扩展性较差。
# ✅ 遵循开闭原则设计
我们可以通过 将 Shape
类设计为抽象类,并让每个图形类实现自己的 draw
方法 来遵循开闭原则。这样,当需要支持新的图形时,只需要增加新的子类,不需要修改 GraphicEditor
类。
public class Ocp {
public static void main(String[] args) {
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
graphicEditor.drawShape(new Triangle());
graphicEditor.drawShape(new OtherGraphic());
}
}
// 这是一个用于绘图的类 [使用方]
class GraphicEditor {
// 直接调用 Shape 的 draw 方法
public void drawShape(Shape s) {
s.draw();
}
}
// Shape 类,基类
abstract class Shape {
int m_type;
// 抽象方法,子类需要实现自己的绘图逻辑
public abstract void draw();
}
// 矩形类
class Rectangle extends Shape {
Rectangle() {
super.m_type = 1;
}
@Override
public void draw() {
System.out.println("绘制矩形");
}
}
// 圆形类
class Circle extends Shape {
Circle() {
super.m_type = 2;
}
@Override
public void draw() {
System.out.println("绘制圆形");
}
}
// 新增三角形类
class Triangle extends Shape {
Triangle() {
super.m_type = 3;
}
@Override
public void draw() {
System.out.println("绘制三角形");
}
}
// 新增一个新的图形类
class OtherGraphic extends Shape {
OtherGraphic() {
super.m_type = 4;
}
@Override
public void draw() {
System.out.println("绘制其它图形");
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
改进后的设计分析
遵循开闭原则:在这个设计中,我们不需要再修改
GraphicEditor
类来支持新的图形类型。当需要添加新的图形时,只需创建一个新的类,继承Shape
并实现draw
方法即可。这确保了系统对扩展开放,对修改关闭。提高了系统的可扩展性:随着新的图形类型不断增加,我们只需编写新的图形类,不会影响现有的代码。任何扩展都可以通过增加类的方式实现,减少了修改已存在代码的风险。
增强了代码的可维护性:代码更加模块化,图形的绘制逻辑被封装在各自的类中,
GraphicEditor
类不再关心具体的图形细节,维护起来更简单。
开闭原则的注意事项
抽象化设计:为了遵循开闭原则,系统应该尽量使用接口和抽象类进行设计。通过定义抽象接口,系统可以提供扩展点,允许新的实现类加入而不影响现有代码。
避免滥用开闭原则:虽然开闭原则非常重要,但它也不应被滥用。在设计阶段不应过度抽象,应根据实际需求合理使用开闭原则。如果系统的变化较少或不存在频繁的扩展需求,则不必过早引入过多的抽象。
保持代码的简洁性:在遵循开闭原则的同时,也要注意代码的简洁性和可读性。过度的抽象可能会使代码变得难以理解,影响团队协作。
# 6.4 开闭原则的其他应用场景
除了绘图系统外,开闭原则还可以广泛应用于其他系统中。以下是一些常见的应用场景:
支付系统:在设计支付系统时,最初可能只支持一种支付方式(如信用卡支付)。随着需求增加,可能需要添加更多支付方式(如微信支付、支付宝支付)。为了避免修改现有支付逻辑,可以使用开闭原则,通过接口或抽象类来定义支付行为,每种支付方式实现自己的逻辑。
日志记录系统:假设系统初期只支持控制台输出日志,后续需要支持文件输出和数据库输出。通过开闭原则,可以将日志输出抽象为接口,不同的输出方式实现各自的输出逻辑。
数据格式转换:在数据处理系统中,最初可能只需要处理 JSON 格式的数据,后续可能需要支持 XML、CSV 等格式。通过开闭原则,开发者可以创建一个数据格式转换的抽象类,针对不同的数据格式扩展具体的处理方式。
# 7. 迪米特法则(Demeter Principle, DP)
# 7.1 基本介绍
迪米特法则(Demeter Principle),也称为最少知道原则(Least Knowledge Principle),是面向对象设计中的一条重要原则。它强调一个对象应尽量少地了解和依赖其他对象,即一个类应与尽可能少的其他类产生直接关系。核心思想是通过减少类与类之间的耦合度,从而提升系统的模块化、灵活性和可维护性。
核心定义:
- 一个对象应该只与它的直接朋友通信,而不应依赖于过多的其他对象。
- 直接朋友是指类的成员变量、方法参数、方法返回值中的类。局部变量不应被视为直接朋友。
迪米特法则的基本原则是:只与直接的朋友通信,不与非直接的朋友通信。也就是说,一个类应当通过公开的方法与其他类进行交互,而不需要知道这些类的内部细节。
迪米特法则的目的是通过减少类之间的相互依赖,达到代码高内聚、低耦合的效果。
高内聚:高内聚是指一个类应当承担单一的职责,类中的方法和属性应当紧密关联、共同完成某一特定功能。高内聚的类修改范围小,功能集中,修改时不容易影响到其他类。
松耦合:松耦合是指类与类之间的依赖关系应当尽可能简单、清晰。一个类的修改不会导致大量其他类的修改。迪米特法则通过减少类之间的通信和依赖,促进了系统的松耦合。
高内聚与松耦合的相辅相成
- 高内聚促成松耦合:高内聚的类职责单一,功能明确,因此类与类之间的依赖关系减少,系统的耦合性就降低了。
- 低内聚导致紧耦合:如果类承担了过多的职责,它与其他类的关系就会变得复杂,任何变动都会牵一发动全身,导致系统难以维护。
# 7.2 为什么需要迪米特法则
在复杂的软件系统中,类与类之间的依赖关系会随着功能的扩展变得越来越多,导致系统的耦合度增加。过度的依赖关系会使得系统的维护和扩展变得困难。例如,当修改某个类时,如果该类依赖了过多的其他类,可能会引发连锁反应,导致依赖的类也需要被修改,甚至引入新的错误。
迪米特法则的提出,就是为了减少这种依赖性,通过降低类之间的耦合来提升系统的稳定性、可扩展性和可维护性。
迪米特法则的优势
降低耦合性: 迪米特法则要求类之间的交互应尽量通过少量的公开接口进行,避免依赖于过多的其他类。通过减少类之间的直接依赖,降低了系统的耦合度,使系统各个模块相对独立。当一个模块发生变化时,其影响范围较小,其他模块受影响的可能性也较低。
提高系统的可维护性和扩展性: 系统的各个模块相互独立,不会因为一个模块的修改而导致其他模块发生变化,从而提高了代码的可维护性和扩展性。这种模块化的设计有助于快速定位问题,并降低修改代码时引入新问题的风险。
提升代码的复用性: 由于类的职责更加单一、清晰,类的功能更容易复用。系统可以通过增加新的类或模块来扩展功能,而不需要对现有的类进行大规模修改,从而提高了代码的复用性。
# 7.3 迪米特法则中的直接朋友
直接朋友是迪米特法则中的一个重要概念。所谓的直接朋友,是指类的成员变量、方法参数、方法返回值中出现的类。两个类之间如果有这些关系中的一种,我们就称它们是直接朋友。迪米特法则认为,类只应该与直接朋友进行通信和交互,避免与非直接朋友产生过多的依赖。
非直接朋友是指那些在类的局部变量中出现的类,或者是通过方法内部创建的对象。根据迪米特法则,这些非直接朋友不应成为类的依赖对象,类的行为应尽量封装在自身内部,避免依赖外部类的复杂行为。
示例:直接朋友
public class A {
// 成员变量
private B b; // B 类是 A 的直接朋友
// 方法参数
public void method1(B b) {
// 使用 B 类的对象
}
// 方法返回值
public B method2() {
return new B(); // B 作为方法返回值是直接朋友
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的例子中,A
类通过成员变量、方法参数和返回值与 B
类进行交互,因此 A
和 B
是直接朋友。
示例:非直接朋友
public class A {
public void method() {
// 局部变量中的类 B 不是直接朋友
B b = new B();
}
}
2
3
4
5
6
在这个例子中,B
类仅作为 A
类方法中的局部变量出现,因此 A
和 B
不是直接朋友。根据迪米特法则,类 A
不应频繁依赖这种非直接朋友的类。
# 7.4 实际应用中的迪米特法则
# 🚫 违反迪米特法则的设计
以下代码展示了一个违反迪米特法则的实例。在该代码中,SchoolManager
类直接与 CollegeEmployee
类进行交互,尽管 CollegeEmployee
并不是 SchoolManager
的直接朋友。这违反了迪米特法则,增加了类之间的耦合度,降低了系统的扩展性和可维护性。
// 违反迪米特法则的代码
public class Demeter1 {
public static void main(String[] args) {
SchoolManager schoolManager = new SchoolManager();
schoolManager.printAllEmployee(new CollegeManager());
}
}
class Employee {
private String id;
public void setId(String id) { this.id = id; }
public String getId() { return id; }
}
class CollegeEmployee {
private String id;
public void setId(String id) { this.id = id; }
public String getId() { return id; }
}
class CollegeManager {
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
}
class SchoolManager {
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
// 违反迪米特法则,SchoolManager 不应直接操作 CollegeEmployee
public void printAllEmployee(CollegeManager sub) {
List<CollegeEmployee> list1 = sub.getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list1) {
System.out.println(e.getId());
}
List<Employee> list2 = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
在上述代码中,SchoolManager
直接获取并操作了 CollegeEmployee
的数据,尽管 CollegeEmployee
是一个局部变量,这违反了迪米特法则。SchoolManager
应该只与 CollegeManager
进行交互,而不应直接访问 CollegeEmployee
的细节。
# ✅ 遵守迪米特法则的设计
通过封装 CollegeManager
的内部逻辑,避免 SchoolManager
直接操作 CollegeEmployee
,可以遵循迪米特法则。改进后,SchoolManager
不再直接处理学院员工的数据,而是通过 CollegeManager
处理。这样做减少了类之间的耦合,提升了系统的扩展性。
// 改进后的代码
public class Demeter2 {
public static void main(String[] args) {
SchoolManager schoolManager = new
SchoolManager();
schoolManager.printAllEmployee(new CollegeManager());
}
}
class Employee {
private String id;
public void setId(String id) { this.id = id; }
public String getId() { return id; }
}
class CollegeEmployee {
private String id;
public void setId(String id) { this.id = id; }
public String getId() { return id; }
}
class CollegeManager {
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
// 封装学院员工的打印逻辑
public void printEmployee() {
List<CollegeEmployee> list = getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list) {
System.out.println(e.getId());
}
}
}
class SchoolManager {
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
// 通过 CollegeManager 操作学院员工,遵循迪米特法则
public void printAllEmployee(CollegeManager sub) {
sub.printEmployee();
List<Employee> list = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list) {
System.out.println(e.getId());
}
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
在这个改进版本中,SchoolManager
只依赖 CollegeManager
,而不是直接操作 CollegeEmployee
。通过封装 CollegeManager
内部的逻辑,系统遵循了迪米特法则,减少了类之间的依赖。
迪米特法则的注意事项
降低类之间的依赖关系:类的设计应尽量减少与外部类的直接耦合,特别是避免与非直接朋友类进行通信。这样可以减少系统中的依赖链条,提高系统的稳定性。
封装类的内部逻辑:类的内部实现细节应尽量封装,对外只暴露必要的公共接口。通过隐藏内部复杂性,类之间的依赖关系减少,代码的维护和扩展变得更加容易。
提高代码的可维护性:通过减少类之间的耦合,可以提升代码的可维护性。代码的修改范围局限在特定的模块或类中,减少了修改时引发的连锁反应,降低了引入新错误的风险。
谨慎使用局部变量中的非直接朋友:尽量避免在类中使用非直接朋友类(局部变量中的类)。如果确实需要使用,考虑通过依赖注入、组合或接口的方式进行交互,以降低类之间的耦合度。
优先使用公开接口而非内部实现:在类与类之间的交互中,尽量依赖于公开的接口,而不是依赖内部实现。公开接口可以更加稳定,减少外部类对内部细节的依赖。
# 8. 合成复用原则(Composite Reuse Principle, CRP)
合成复用原则(Composite Reuse Principle,简称 CRP),也叫组合/聚合复用原则(Composition/Aggregate Reuse Principle, CARP),是面向对象设计中非常重要的设计原则之一。它要求在进行软件设计时,优先使用组合(Composition)或聚合(Aggregation)等关联关系来实现代码的复用,尽量避免直接使用继承(Inheritance)。只有在组合或聚合不能很好地解决问题时,才考虑使用继承,并且在使用继承时,必须严格遵循里氏替换原则(Liskov Substitution Principle)。
合成复用原则的核心思想是通过组合和聚合的方式将现有的类作为新类的一部分,从而实现代码的复用,而不是通过继承的方式将父类的行为直接暴露给子类。组合和聚合可以更好地保持类的封装性,并减少类与类之间的耦合,提高代码的灵活性和可扩展性。
# 8.1 合成复用原则的三种复用方式
合成复用原则强调通过组合、聚合等方式实现代码复用,而非继承。我们可以通过三种主要方式来实现合成复用:
# 1. 继承关系
继承 是面向对象编程中的一种常见关系,通过继承,子类可以直接复用父类的属性和方法。继承使得子类与父类之间具有is-a关系,意味着子类是父类的一种特殊形式。尽管继承能够实现代码复用,但由于子类与父类之间耦合紧密,继承可能破坏类的封装性,导致子类容易受到父类变化的影响。
继承关系图示:
在上图中,类 B
通过继承类 A
来实现代码复用。虽然这种方式简单直观,但类 B
完全依赖于类 A
的实现,且 B
对 A
的变化非常敏感。
# 2. 聚合关系
聚合 是指类 B
通过与类 A
建立一种松散的依赖关系。类 B
不继承类 A
,而是通过构造函数、方法参数、setter 方法等方式,将类 A
作为组件注入到类 B
中使用。聚合表示类 B
和类 A
之间是has-a的关系。通过聚合,类 B
可以复用类 A
的功能,而不会产生过于紧密的耦合,类 A
的变化不会直接影响类 B
。
聚合关系图示:
在此图中,类 B
并没有继承类 A
,而是通过构造器或方法参数等方式获取了类 A
的实例,从而实现了代码复用。类 B
可以调用类 A
的方法,但并不会与类 A
紧密耦合。
# 3. 组合关系
组合 是另一种常见的复用方式,它比聚合更紧密。类 B
将类 A
的实例直接作为其成员变量,并通过 new A()
的方式生成类 A
的实例。在组合中,类 A
是类 B
的组成部分,意味着类 B
对类 A
的控制权较高,但依然保持了一定的松耦合。组合关系通常用于类 B
需要对类 A
的生命周期有更多控制的场景。
组合关系图示:
在此图中,类 B
直接通过 new A()
创建了类 A
的实例,从而复用类 A
的功能。组合关系意味着类 B
可以完全控制类 A
的创建和销毁,但依然保持类 A
的封装性。
# 8.2 合成复用原则的核心设计思想
合成复用原则在软件设计中的核心思想是:尽量将变化的部分独立出来,通过组合或聚合来实现代码复用,而不是依赖继承来复用已有的功能。它包括以下几个关键点:
分离变化:找出系统中可能会发生变化的部分,将它们与系统其他部分分离开来,避免将变化的代码与稳定的代码混在一起。通过将变化部分独立出来,减少修改某个功能时对系统其他部分的影响。
面向接口编程:软件设计应当针对接口或抽象类进行编程,而不是针对具体实现类进行编程。通过依赖于接口或抽象类,系统的可扩展性和灵活性得到提高,因为我们可以随时更换具体的实现类,而不会影响系统的整体设计。
松耦合设计:通过组合或聚合的方式,类之间的耦合性得到降低。系统的模块间依赖更为清晰,功能模块可以独立开发、测试和维护,减少了系统变化带来的风险。
# 8.3 继承复用的局限性
尽管继承是实现代码复用的简单方式,但它也存在一些明显的局限性:
破坏封装性:继承会暴露父类的内部细节,子类可以直接访问父类的属性和方法,这样父类的实现细节对子类是透明的。这种设计破坏了类的封装性,使得子类过度依赖父类的实现。
高耦合度:子类与父类之间的耦合度非常高,父类的任何变化都可能影响到子类的行为。这种高耦合度导致子类难以独立修改和维护。
复用的灵活性差:继承是静态复用方式,在编译时就已经确定了子类与父类之间的关系,运行时无法动态地更换父类的行为。继承的这种静态复用方式,限制了系统的灵活性和可扩展性。
# 8.4 组合和聚合复用的优势
与继承复用相比,组合和聚合的方式具有以下优势:
保持封装性:通过组合或聚合,类
B
复用类A
的功能,但A
的实现细节对B
是不可见的。类B
只能通过类A
的公共接口来访问其功能,从而保持了类的封装性。组合和聚合的复用被称为**“黑箱复用”**,因为类的内部细节是对外部类不可见的。低耦合度:组合和聚合之间的依赖关系较为松散。类
B
仅依赖于类A
的接口,而不依赖于具体的实现。因此,类A
的修改不会直接影响类B
,这使得系统的可维护性和可扩展性更强。更高的灵活性:组合和聚合可以在运行时动态更换所依赖的类,系统的灵活性和可扩展性大大提高。通过依赖注入、接口编程等技术,可以轻松地更换组合或聚合的对象,实现多态性和动态行为切换。
# 8.5 合成复用原则的实际应用
在实际项目中,合成复用原则广泛应用于各种场景,例如:
服务注入与依赖管理:在 Spring 框架中,依赖注入(Dependency Injection,DI)通过组合和聚合的方式将依赖对象注入到类中,从而实现了松耦合的设计。服务的实现类可以动态替换,符合合成复用原则。
策略模式:在策略模式中,通过将不同的策略组合到上下文中,可以动态地改变系统的行为,而不需要修改现有的代码。这种设计也符合合成复用原则。
装饰者模式:在装饰者模式中,新的功能通过组合的方式动态添加到对象中,而不需要修改原有类的代码,符合开闭原则和合成复用原则。
合成复用原则的注意事项
优先使用组合和聚合:在设计代码复用时,应该优先考虑通过组合和聚合的方式来实现,只有在确定继承能很好地解决问题时,才选择继承,并且继承时要严格遵循里氏替换原则。
避免滥用继承:继承虽然是一种常见的复用方式,但由于它会增加类与类之间的耦合性,因此应尽量减少不必要的继承关系,避免系统变得难以维护。
关注类的职责:当使用组合或聚合复用时,要确保每个类的职责明确,遵循单一职责原则。这样可以使类更加内聚,便于维护和扩展。
# 9. 设计原则总结
- 单一职责原则:一个类或一个方法只负责一件事情
- 接口隔离原则:一个接口的所有抽象方法能被一个类全部实现
- 依赖倒转原则:通过接口、构造器、setter 来降低类与类之间的依赖
- 里氏替换原则:子类中尽量不要重写父类的方法,应该将父类的方法(以后不会改)放到一个抽象类,由父类和子类共同继承
- 开闭原则:对扩展开放(对提供方),对修改关闭(对使用方)
- 迪米特法则:不要在方法里 new 其他的类,而是用过方法参数、全局变量引用其他类
- 合成复用原则:尽量使用合成/聚合的方式引用其他类,而不是使用继承
设计原则 | 一句话归纳 | 目的 |
---|---|---|
开闭原则 | 对扩展开放,对修改关闭 | 降低维护带来的新风险 |
依赖倒置原则 | 高层不应该依赖低层,要面向接口编程 | 更利于代码结构的升级扩展 |
单一职责原则 | 一个类只干一件事,实现类要单一 | 便于理解,提高代码的可读性 |
接口隔离原则 | 一个接口只干一件事,接口要精简单一 | 功能解耦,高聚合、低耦合 |
迪米特法则 | 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 | 只和朋友交流,不和陌生人说话,减少代码臃肿 |
里氏替换原则 | 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 | 防止继承泛滥 |
合成复用原则 | 尽量使用组合或者聚合关系实现代码复用,少使用继承 | 降低代码耦合 |
实际上,这些原则的目的只有一个:降低对象之间的耦合,增加程序的可复用性、可扩展性和可维护性。