面向对象 (OOP) 的五个基本原则
在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由罗伯特·C·马丁在21世纪早期其著作《敏捷软件开发:原则、模式与实践》(Agile Software Development: Principles, Patterns, and Practices)中引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则
SOLID 原则旨在解决软件开发中常见的几个核心问题:
- 代码脆弱性:当系统中的一个微小改动引发了连锁反应,导致其他不相关部分的代码崩溃时,我们称之为“脆弱性”。SOLID 原则通过解耦和单一职责来减少这种脆弱性。
- 维护成本高:缺乏良好设计的代码往往难以理解、难以修改。SOLID 原则通过提高内聚性和降低耦合度,使得代码更易于维护和迭代。
- 可扩展性差:当业务需求发生变化时,如果现有代码无法轻松地进行扩展,而必须进行大规模的修改,则说明系统的可扩展性差。开放封闭原则直接解决了这个问题,鼓励我们在不修改现有代码的情况下添加新功能。
- 复用性低:当模块之间高度耦合时,一个模块很难被单独提取出来在其他项目中复用。SOLID 原则通过抽象和解耦,使得模块更加独立,从而提高了代码的复用性。
- 设计复杂性:当一个类承担了太多职责,或者一个接口过于庞大时,会导致设计变得复杂且难以管理。单一职责原则和接口隔离原则通过鼓励更小、更专注的设计来解决这个问题。
单一职责原则(Single-Resposibility Principle)
一个类,最好只做一件事,只有一个引起它的变化。单一职责原则可以看做是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。
如果一个类承担了多项职责,那么其中一项职责的变更可能会影响到其他不相关的职责,相互之间就产生影响,从而大大损伤其内聚性和耦合度。
通常意义下的单一职责,就是指只有一种单一功能,不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因。
案例:
反例:一个用户管理类做了太多事:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 反例:一个违反SRP的User类
class User {
private String name;
private String email;
// 职责1: 管理用户数据
public User(String name, String email) {
this.name = name;
this.email = email;
}
public void saveToDatabase() {
// 职责2: 处理数据库持久化
System.out.println("Saving user to database: " + this.name);
}
public void sendEmail(String message) {
// 职责3: 处理邮件发送
System.out.println("Sending email to " + this.email + ": " + message);
}
}
这个 User
类包含了三个不同的职责:管理用户属性、持久化到数据库、以及发送邮件。如果数据库逻辑改变,或者邮件发送服务需要更新,User
类都必须被修改。这违反了SRP。
正例:将职责分离到不同的类
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
// 正例:遵循SRP的类
class User {
private String name;
private String email;
// 只负责管理用户数据
public User(String name, String email) {
this.name = name;
this.email = email;
}
// ... 其他与用户数据相关的getter/setter方法
}
class UserPersistence {
// 只负责用户持久化
public void save(User user) {
System.out.println("Saving user to database: " + user.getName());
}
}
class EmailService {
// 只负责邮件发送
public void sendEmail(String email, String message) {
System.out.println("Sending email to " + email + ": " + message);
}
}
现在,每个类都只专注于一个职责。如果数据库逻辑需要修改,我们只需更改 UserPersistence
类;如果邮件服务改变,我们只需修改 EmailService
。这使得代码更易于维护。
开放封闭原则(Open-Closed principle)
软件实体应该是可扩展的,而不可修改的。也就是,对扩展开放,对修改封闭的。开放封闭原则主要体现在两个方面:
- 对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。
- 对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对其进行任何尝试的修改。
实现开开放封闭原则的核心思想就是对抽象编程,而不对具体编程,因为抽象相对稳定。让类依赖于固定的抽象,所以修改就是封闭的;
而通过面向对象的继承和多态机制,又可以实现对抽象类的继承,通过覆写其方法来改变固有行为,实现新的拓展方法,所以就是开放的。
当需求变更时,你应该通过添加新的代码(扩展)来实现,而不是去修改已有的、经过测试的代码。这能让你的系统更稳定、更健壮。
“需求总是变化”没有不变的软件,所以就需要用封闭开放原则来封闭变化满足需求,同时还能保持软件内部的封装体系稳定,不被需求的变化影响。
案例:
反例:一个计算器类,每次新增操作都需要修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反例:违反OCP的计算器
class Calculator {
public double calculate(char operation, double a, double b) {
if (operation == '+') {
return a + b;
} else if (operation == '-') {
return a - b;
} else if (operation == '*') {
return a * b;
}
// 如果要增加除法,就需要修改这个方法
return 0;
}
}
如果我们需要增加“除法”功能,就必须修改 calculate
方法,增加一个新的 else if
分支。这违反了对修改封闭的原则。
正例:通过接口和多态实现
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
// 正例:遵循OCP的计算器
interface Operation {
double apply(double a, double b);
}
class Addition implements Operation {
public double apply(double a, double b) {
return a + b;
}
}
class Subtraction implements Operation {
public double apply(double a, double b) {
return a - b;
}
}
class NewCalculator {
public double calculate(Operation operation, double a, double b) {
return operation.apply(a, b);
}
}
// 增加新操作时,只需新增类,而不需要修改NewCalculator
class Division implements Operation {
public double apply(double a, double b) {
if (b == 0) throw new IllegalArgumentException("Cannot divide by zero.");
return a / b;
}
}
现在,NewCalculator
类对修改是封闭的。如果我们要增加“除法”功能,只需创建一个新的 Division
类来实现 Operation
接口,NewCalculator
的代码不需要做任何改动。这正是对扩展开放的体现。
里氏替换原则(Liskov-Substituion Principle)
子类必须能够替换其基类。这一思想体现为对继承机制的约束规范,LSP 强调了继承的正确性,确保了多态的实现不会破坏程序的正确性。
在父类和子类的具体行为中,必须严格把握继承层次中的关系和特征,将基类替换为子类,程序的行为不会发生任何变化。
同时,这一约束反过来则是不成立的,子类可以替换基类,但是基类不一定能替换子类。
里氏替换的具体使用和实现原理可参考Java 中的多态能力
案例:
反例:正方形继承长方形
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 反例:违反LSP的继承
class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) { this.width = width; }
public void setHeight(double height) { this.height = height; }
}
class Square extends Rectangle {
// 正方形的边长必须相等
public void setWidth(double width) {
this.width = width;
this.height = width;
}
public void setHeight(double height) {
this.height = height;
this.width = height;
}
}
这个设计违反了LSP。考虑一个使用 Rectangle
的方法:
1
2
3
4
5
6
7
public void test(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
// 期望:面积是 20
System.out.println("Expected area: " + (5 * 4));
System.out.println("Actual area: " + (r.width * r.height));
}
当传入 Rectangle
对象时,结果是 20
。但如果传入 Square
对象,setWidth(5)
会将 height
也设置为 5
,setHeight(4)
会将 width
也设置为 4
,最终面积是 16
,而不是预期的 20
。子类替换父类后,程序的行为发生了改变,导致了不期望的结果。
正例:不使用继承,使用独立的类
更合理的设计是让 Square
和 Rectangle
两个类互不继承,或者都继承自一个更通用的 Shape
接口。
1
2
3
4
5
6
7
8
9
10
11
12
// 正例:遵循LSP
interface Shape {
double getArea();
}
class Rectangle implements Shape {
// ...
}
class Square implements Shape {
// ...
}
接口隔离原则(Interface-Segregation Principle)
使用多个小的专门的接口,而不要使用一个大的总接口。
具体而言,接口隔离原则体现在:接口应该是内聚的,应该避免”胖接口”。一个类对另外一个类的依赖应该建立在最小的接口上, 这鼓励我们创建更小、更具体的接口,这样可以避免一个类因为实现了它不需要的方法而承担多余的职责,实现类实现了胖接口中过多不需要的方法和属性, 这是一种接口污染。
接口有效地将细节和抽象隔离,体现了对抽象编程的一切好处,接口隔离强调接口的单一性。而胖接口存在明显的弊端,会导致实现的类型必须完全实现接口的所有方法、属性等;
而某些时候,实现类型并非需要所有的接口定义,在设计上这是“浪费”,而且在实施上这会带来潜在的问题,对胖接口的修改将导致一连串的客户端程序需要修改,有时候这是一种灾难。在这种情况下,将胖接口分解为多个特点的定制化方法,使得客户端仅仅依赖于它们的实际调用的方法,从而解除了客户端不会依赖于它们不用的方法。
分离的手段主要有以下两种:
- 委托分离,通过增加一个新的类型来委托客户的请求,隔离客户和接口的直接依赖,但是会增加系统的开销。
- 多重继承分离,通过接口多继承来实现客户的需求,这种方式是较好的。
案例:
反例:一个庞大的设备接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 反例:违反ISP的“胖接口”
interface MultiFunctionDevice {
void print();
void scan();
void fax();
}
class SimplePrinter implements MultiFunctionDevice {
public void print() {
System.out.println("Printing...");
}
public void scan() {
// 这个简单的打印机不支持扫描,被迫实现一个空方法或抛出异常
throw new UnsupportedOperationException("Scanning not supported.");
}
public void fax() {
// 这个简单的打印机不支持传真,被迫实现一个空方法或抛出异常
throw new UnsupportedOperationException("Faxing not supported.");
}
}
MultiFunctionDevice
接口过于庞大,迫使 SimplePrinter
实现了它根本不需要的方法。这违反了ISP。
正例:将接口拆分为更小的部分
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
// 正例:遵循ISP
interface Printer {
void print();
}
interface Scanner {
void scan();
}
interface FaxMachine {
void fax();
}
// 简单的打印机只实现它需要的接口
class SimplePrinter implements Printer {
public void print() {
System.out.println("Printing...");
}
}
// 多功能一体机实现所有接口
class AllInOnePrinter implements Printer, Scanner, FaxMachine {
public void print() {
System.out.println("Printing...");
}
public void scan() {
System.out.println("Scanning...");
}
public void fax() {
System.out.println("Faxing...");
}
}
现在,客户端(使用这些类的代码)可以根据自己的需求,只依赖于它需要的接口。SimplePrinter
只依赖 Printer
接口,从而避免了不必要的依赖。
依赖倒置原则(Dependecy-Inversion Principle)
高层模块不应该依赖于低层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象
我们知道,依赖一定会存在于类与类、模块与模块之间。当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现:在依赖之间定义一个抽象的接口使得高层模块调用接口,而底层模块实现接口的定义,以此来有效控制耦合关系,达到依赖于抽象的设计目标。
抽象的稳定性决定了系统的稳定性,因为抽象是不变的,依赖于抽象是面向对象设计的精髓,也是依赖倒置原则的核心。
依赖于抽象是一个通用的原则,而某些时候依赖于细节则是在所难免的,必须权衡在抽象和具体之间的取舍,方法不是一层不变的。依赖于抽象,就是对接口编程,不要对实现编程。
案例:
反例:高层模块直接依赖低层模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 反例:违反DIP
class MySqlDatabase {
public void save(String data) {
System.out.println("Saving " + data + " to MySQL.");
}
}
class ShoppingCart {
private MySqlDatabase database;
public ShoppingCart() {
this.database = new MySqlDatabase(); // 直接依赖具体实现
}
public void checkout(String data) {
database.save(data);
}
}
ShoppingCart
是高层模块(业务逻辑),它直接依赖于 MySqlDatabase
这个低层模块(具体实现)。如果将来要换成其他数据库(例如 MongoDB),ShoppingCart
的代码就必须被修改,这使得系统缺乏灵活性。
正例:依赖抽象而不是具体实现
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
// 正例:遵循DIP
interface Database {
void save(String data);
}
class MySqlDatabase implements Database {
public void save(String data) {
System.out.println("Saving " + data + " to MySQL.");
}
}
class MongoDatabase implements Database {
public void save(String data) {
System.out.println("Saving " + data + " to MongoDB.");
}
}
class ShoppingCart {
private Database database; // 依赖抽象接口
// 通过构造函数进行依赖注入
public ShoppingCart(Database database) {
this.database = database;
}
public void checkout(String data) {
database.save(data);
}
}
现在,ShoppingCart
只依赖于 Database
这个抽象接口。在创建 ShoppingCart
实例时,我们可以通过依赖注入(如构造函数注入)来传入任何实现了 Database 接口的对象。这使得高层模块和低层模块之间完全解耦,你可以轻松地在 MySqlDatabase 和 MongoDatabase 之间切换,而无需改动 ShoppingCart
的代码。
权衡与取舍
在实际开发中,SOLID原则并非教条,而是一套指导我们设计高内聚、低耦合软件的权衡准则。答案很明确:你不需要将它们执行到最极致,过度设计和“YAGNI”(You Ain’t Gonna Need It,你不会需要它)原则同样重要。
过度应用SOLID原则会带来新的问题,例如:
- 代码爆炸:为了实现单一职责,你可能会创建大量的、只包含一两个方法的类。这导致项目文件数量急剧增加,难以导航和理解。
- 复杂性增加:为了遵循DIP,你可能会引入过多的抽象(接口、抽象类),导致代码结构过于复杂,反而降低了可读性和可维护性。简单的功能被拆分成多个文件,追踪业务逻辑变得困难。
- 开发效率降低:前期花费大量时间进行“完美”的设计,可能会拖慢开发进度,尤其是在需求变化频繁的敏捷开发环境中。
那么,在实际中,应该如何应用这些原则呢?
单一职责原则:
- 适度原则:一个类应该只有一个变更原因。这个“原因”的粒度是关键。如果一个类既负责业务逻辑,又负责数据持久化,那么它就违反了SRP。但如果一个类包含了多个
getter
/setter
方法,这通常是可以接受的,因为它们都服务于一个核心职责——管理数据。 - 何时拆分:当一个类的方法开始变得越来越多,并且其中一些方法属于不同的“概念”时,就是考虑拆分的好时机。一个很好的信号是,你发现自己正在为这个类写一个很长的注释,解释它为什么做了这么多不同的事情。
开放封闭原则:
- 适度原则:OCP不意味着你永远不能修改旧代码。它主要针对那些频繁变动且可能影响其他模块的核心业务逻辑。对于稳定的、不太可能改变的代码,你不需要为了遵守OCP而引入不必要的抽象。
- 何时应用:当你的代码中出现大量的
if/else if/else
或switch/case
语句,并且你预见到未来会新增更多分支时,这就是一个使用OCP(通过策略模式、工厂模式等)来重构的好机会。
里氏替换原则:
- 适度原则:LSP更多的是一个对继承关系进行约束的原则。它提醒我们在使用继承时要谨慎,确保子类行为的合法性。
- 何时应用:在设计继承体系时,始终问自己一个问题:“子类替换父类后,程序的功能和预期行为是否会改变?”如果答案是“是”,那么你的继承设计就可能存在问题,需要考虑使用组合(Composition)或委托(Delegation)来替代继承。
接口隔离原则:
- 适度原则:不要为了拆分而拆分。如果一个接口的实现者只有一个,或者所有实现者都需要使用这个接口的所有方法,那么就没有必要将它拆分成多个小接口。
- 何时应用:当一个接口变得庞大,且一些类只需要使用其中一部分方法时,就应该考虑将接口拆分。ISP的实践可以有效防止客户端被迫依赖它们不需要的功能,减少不必要的耦合。
依赖倒置原则:
- 适度原则:DIP的关键是依赖抽象。但并非所有依赖都需要抽象。如果你的代码依赖于一个稳定且广为人知的库(如Java的
List
),你通常不需要为它创建一个接口。 - 何时应用:当你依赖的模块是一个经常变动的实现(如一个具体的数据库连接、一个第三方API客户端),或者你需要进行单元测试(测试时可以注入一个模拟对象),DIP就变得至关重要。
SOLID原则是平衡的艺术。它们是经验的结晶,而不是必须遵守的铁律。在实际开发中,你需要:
- 从简单开始:先实现功能,如果发现代码变得难以维护或扩展,再考虑使用SOLID原则进行重构。
- 考虑项目的规模和阶段:在一个小型或短期项目中,过度设计可能会浪费时间;而在一个长期维护的大型项目中,遵循SOLID原则能让你受益匪浅。
- 团队共识:确保你的团队对这些原则有共同的理解,并能一起讨论何时应用、何时简化。
好的设计是演化出来的,而不是一开始就完美无缺的。 SOLID原则为这个演化过程提供了清晰的方向。