设计原则是软件开发的基础,作为软件工程师,可以在工具、语言、框架、范式和模式中找到这些设计原则,它们是"优秀"、"可读"代码的核心支柱,一旦理解了这些原则,就可以在任何地方看到。
洞察和应用这些基本原则的技能是优秀工程师和差劲工程师的区别所在。如果不了解这些基本原则,任何框架或工具都无法帮助你提高编写优秀代码的质量。此外,如果不了解这些基本原则,你就会成为工具的人质。
本文并不是参考指南,而是一份将需要不断刷新的核心原则系统化的清单。
抽象(Abstraction)
[抽象](https://en.wikipedia.org/wiki/Abstraction_(computer_science "抽象")是最重要的一般性原则之一。抽象意味着只关注重要部分,而忽略其他细节。抽象与封装是相辅相成的,封装是一种隐藏被抽象部分的实现的方法。
在软件开发中,可以将其视为定义一种类型、接口或函数签名,作为工作合约。主要好处是不需要知道实现细节就可以使用某些东西,因此可以更好的专注于对开发者来说至关重要的东西。
这一原则并不局限于应用开发。作为开发者,通过语言语法从操作系统的底层操作中抽象出来。反过来,操作系统通过 CPU、内存、网卡等将语言从底层操作中抽象出来。越深入研究,你就越会明白这只是一个抽象问题。
封装变化(Encapsulate what varies)
如你所见,抽象可以表现为不同形式--从数据(实现)抽象到分层抽象。使用抽象的一般原则是"封装变化",即确定可能发生变化的部分,并为其声明具体接口。这样,即使内部逻辑发生变化,客户端仍可进行相同的交互。
假设我们需要计算货币兑换,目前只有两种货币,可以这样计算:
if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;
但将来可能会添加另一种货币,这就需要修改客户端代码。与其这样,还不如将所有逻辑抽象并封装在一个单独的方法中,并在需要时从客户端调用该方法。
function convertCurrency(amount, baseCurrency, targetCurrency) {
if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;
if (baseCurrency == "USD" and targetCurrency == "UAH") return amount * 38.24;
…
}
DRY
DRY[2](don't repeat yourself,不要重复),也被称为 DIE(duplication is evil,重复是邪恶的),指的是不应该在代码库中重复信息或知识。
"每项知识在系统中都必须有单一、明确、权威的表示"
--Andy Hunt,Dave Thomas,The Pragmatic Programmer。
减少重复代码的好处在于更改和维护的简便性。如果你在多个地方重复相同的逻辑,发现错误后,很可能会忘记修改其中的某个地方,这将导致看似相同的功能出现不同的行为。反之,找到重复功能,将其抽象为过程、类等形式,赋予有意义的名字,并在需要的地方使用。这样做可以实现单点更改,最大限度减少对功能的破坏。
KISS
KISS[3](keep it simple、stupid,保持简单、愚蠢)一词是由飞机工程师Kelly Johnson提出的,他向自己的工程团队提出挑战,要求他们设计的喷气式飞机必须能够在实战条件下由普通机械师仅使用特定工具进行维修。
其主要理念是注重系统的简洁性,在只使用真正需要的工具的同时,增加对系统的理解,减少过度设计。
YAGNI
在设计解决方案时,需要考虑两件事:如何使其更好的适应当前系统,以及如何使其具有可扩展性以满足未来可能的需求。在第二种情况下,为了更好的可扩展性而过早构建功能的愿望通常是错误的:即使你现在认为这样做可以降低集成成本,但这种代码的维护和调试可能并不容易,而且会带来不必要的复杂性。这就违反了前面的原则,增加了解决当前问题的冗余复杂性。此外别忘了,你所假定的功能很有可能在将来并不需要,而你只是在浪费资源。
这就是 YAGNI (You aren’t gonna need it,你不会需要它)的意义所在。不要误解,你应该考虑解决方案将来会有什么用途,但只有在真正需要的时候才添加代码。
LoD
得墨忒耳定律[4](LoD,Law of Demeter),也称为最少知识原则,或者"不要与陌生人说话"。由于 LoD 通常与 OOP 有关,因此在这种情况下,"陌生人"指的是与当前对象没有直接关联的任何对象。
使用 LoD 的好处在于可维护性,具体表现为避免无关对象之间的直接接触。
因此,当你与某个对象交互时,如果不符合以下情况之一,就违反了这一原则:
- 当对象是某个类的当前实例时(通过 this 访问)
- 当对象是类的一部分时
- 当对象通过参数传递给方法时
- 当对象在方法中实例化时
- 当对象在全局范围可用时
举个例子,考虑客户要向银行账户存款的情况。我们最终可能会有三个类--Wallet(钱包)、Customer(客户)和Bank(银行)。
class Wallet {
private decimal balance;
public decimal getBalance() {
return balance;
}
public void addMoney(decimal amount) {
balance += amount
}
public void withdrawMoney(decimal amount) {
balance -= amount
}
}
class Customer {
public Wallet wallet;
Customer() {
wallet = new Wallet();
}
}
class Bank {
public void makeDeposit(Customer customer, decimal amount) {
Wallet customerWallet = customer.wallet;
if (customerWallet.getBalance() >= amound) {
customerWallet.withdrawMoney(amount);
//...
} else {
//...
}
}
}
可以在 makeDeposit 方法中看到违反 LoD 的行为。从 LoD 的角度访问客户钱包是正确的(尽管从逻辑角度看这是个奇怪的行为)。但在这里,银行对象调用了客户钱包对象的 getBalance 和 withdrawMoney,因此是在与陌生人(钱包)而不是朋友(客户)对话。
下面是修复方法:
class Wallet {
private decimal balance;
public decimal getBalance() {
return balance;
}
public boolean canWithdraw(decimal amount) {
return balance >= amount;
}
public boolean addMoney(decimal amount) {
balance += amount
}
public boolean withdrawMoney(decimal amount) {
if (canWithdraw(amount)) {
balance -= amount;
}
}
}
class Customer {
private Wallet wallet;
Customer() {
wallet = new Wallet();
}
public boolean makePayment(decimal amount) {
return wallet.withdrawMoney(amount);
}
}
class Bank {
public void makeDeposit(Customer customer, decimal amount) {
boolean paymentSuccessful = customer.makePayment(amount);
if (paymentSuccessful) {
//...
} else {
//...
}
}
}
现在,与客户钱包的所有交互都通过客户对象进行。这种抽象有利于松散耦合,能更轻松的修改Wallet和Customer类内部的逻辑(Bank对象不应关心Customer的内部实现)以及测试。
一般来说,当一个对象有两个以上的点时,LoD 就会失败,比如 object.friend.stranger 而不是 object.friend。
SoC
关注点分离[5](SoC,Separation of Concerns)原则建议根据关注点的不同将系统分成较小的部分,这里的"关注点"指的是系统的某个显著特征。
例如,如果你正在为某个领域建模,那么每个对象都可以被视为一个特殊的关注点。在分层系统中,每一层都有自己的关注点。在微服务架构中,每个服务都有自己的目的。这个列表可以无限继续下去。
SoC 的主要特点是:
- 确定系统关注的问题;
- 将系统分为不同部分,独立解决这些问题;
- 通过明确定义的接口连接这些组件。
在这种方式下,关注点分离与抽象原则非常相似。遵循 SoC 原则的结果是:易于理解、模块化、可重用、基于稳定的接口和可测试代码。
SOLID
SOLID 原则是Robert Martin提出的五项设计原则,旨在明确面向对象编程的初始约束,并使程序更具灵活性和适应性。
(1) 单一责任原则(Single responsibility principle)
"类应该有且只有一个变更的理由。"
换句话说:
"把因相同原因而变化的事物聚集在一起。把因不同原因而变化的事物分开"。
和 SoC 非常像,是吧?这两个原则的区别在于,SRP 的目标是类级分离,而 SoC 则是一种通用方法,同时适用于高层(如层、系统、服务)和低层(类、函数等)抽象。
单一责任原则具有 SoC 的所有优点,尤其是促进了高内聚和低耦合,避免了"上帝对象"的反模式。
(2) 开闭原则(Open-closed principle)
"软件实体应该可以扩展,但不可修改。"
实施新功能时,应避免对现有代码进行破坏性修改。
当你可以扩展某个类并添加所需修改时,这个类就被认为是开放的。如果某个类有明确定义的接口,并且将来不会改变,即可以被别的代码所使用,那么这个类就被认为是封闭的。
试想一个经典的 OOP 继承:你创建一个父类,然后用附加了功能的子类对其进行扩展。然后出于某种原因,你决定改变父类的内部结构(例如,添加一个新字段或删除某些方法),而这个结构也可以访问或直接影响派生类。这样做就违反了这一原则,因为现在你不仅需要修改父类,还需要调整子类以适应新的变化。出现这种情况是因为没有正确应用信息隐藏。相反,如果你通过公共属性或方法给子类定义稳定的契约,那么只要不影响该契约,就可以自由改变内部结构。
这一原则鼓励客户端依赖抽象(如接口或抽象类)而非实现(具体类)。通过这种方式,依赖抽象的客户端被认为是封闭的,但同时又是开放的,因为所有符合抽象的新修改都可以无缝集成到客户端中。
再举一个例子。假设我们正在开发折扣计算逻辑。到目前为止,只有两种折扣。在应用开闭原则之前:
class DiscountCalculator {
public double calculateDiscountedPrice(double amount, DiscountType discount) {
double discountAmount = 15.6;
double percentage = 4.0;
double appliedDiscount;
if (discount == 'fixed') {
appliedDiscount = amount - discountAmount;
}
if (discount == 'percentage') {
appliedDiscount = amount * (1 - (percentage / 100)) ;
}
// logic
}
}
现在,客户端(DiscountCalculator)依赖于外部 DiscountType。如果添加新的折扣类型,就需要进入客户端逻辑并对其进行扩展。这是不可取的行为。
在应用了开闭原则之后:
interface Discount {
double applyDiscount(double amount);
}
class FixedDiscount implements Discount {
private double discountAmount;
public FixedDiscount(double discountAmount) {
this.discountAmount = discountAmount;
}
public double applyDiscount(double amount) {
return amount - discountAmount;
}
}
class PercentageDiscount implements Discount {
private double percentage;
public PercentageDiscount(double percentage) {
this.percentage = percentage;
}
public double applyDiscount(double amount) {
return amount * (1 - (percentage / 100));
}
}
class DiscountCalculator {
public double calculateDiscountedPrice(double amount, Discount discount) {
double appliedDiscount = discount.applyDiscount(amount);
// logic
}
}
这样就可以利用开闭原则和多态性,而不是添加多个 if 语句来确定某些实体的类型和未来行为。所有实现 Discount 接口的类在公共 applyDiscount 方法方面都是封闭的,但与此同时,在修改内部数据时又是开放的。
里氏替代原则(Liskov substitution)
"派生类必须可替代基类。"
或者更正式的表述:
"让 φ(x) 成为 T 类型对象 x 的可证明属性。那么对于 S 类型的对象 y,φ(y) 应该为真,其中 S 是 T 的子类型"(Barbara Liskov 和 Jeannette Wing,1994 年)
简单来说,当你扩展某个类时,不应破坏它所建立的契约。所谓"毁约",是指未能满足以下要求:
- 不要更改派生类中的参数:子类应符合父类的方法签名,即接受与父类相同的参数,或接受更抽象的参数。
- 不要改变派生类的返回类型:子类应返回与父类相同的类型,或返回更具体的(子类型)参数。
- 不要在派生类中抛出异常:除非父类抛出异常,否则子类不应在其方法中抛出异常。这种情况下,异常类型应与父类的异常类型相同或属于父类异常的子类型。
- 不要在派生类中强化先决条件:子类不应通过将其工作限制在某些条件下来改变预期客户端的行为,例如,在父类中,能够接受任意长度字符串,但在子类中,只能接受不超过 100 个字符的字符串。
- 不要弱化派生类中的后置条件:子类不应改变预期行为,允许减少某些工作,例如,操作后不清理状态,不关闭套接字等。
- 不要削弱派生类中的不变性:子类不应改变父类中定义的条件,例如,不要重新分配父类的字段,因为子类可能没有意识到围绕该字段的全局逻辑概念。
接口隔离(Interface segregation)
"提供客户端专用的细粒度接口。"
任何代码都不应依赖不需要的方法。如果客户端不使用对象的某些行为,为什么要强迫它依赖这些行为?同样,如果客户端不使用某些方法,为什么要强迫实现者提供这些功能?
将"胖"的接口分解为更具体的接口。如果更改了具体的接口,不会影响到无关的客户端。
依赖倒置(Dependency inversion)
"依赖抽象,而非实现。"
Bob大叔将这一原则描述为严格遵守 OCP 和 LSP:
"在本专栏中,我们将讨论 OCP 和 LSP 的结构化定义。严格使用这些原则所产生的结构本身可以概括为一个原则。我称之为"依赖倒置原则"(DIP,The Dependency Inversion Principle)"。- Robert Martin
依赖倒置主要包括两层概念:
- 高层模块不应依赖于低层模块,两者都应依赖于抽象。
- 抽象不应依赖细节,细节应该依赖于抽象。
举例来说,假设我们正在开发一个用户管理服务,决定使用 PostgreSQL 作为数据持久化。
class UserService {
private PostgresDriver postgresDriver;
public UserService(PostgresDriver postgresDriver) {
this.postgresDriver = postgresDriver;
}
public void saveUser(User user) {
postgresDriver.query("INSERT INTO USER (id, username, email) VALUES (" + user.getId() + ", '" + user.getUsername() + "', '" + user.getEmail() + "')");
}
public User getUserById(int id) {
ResultSet resultSet = postgresDriver.query("SELECT * FROM USER WHERE id = " + id);
User user = null;
try {
if (resultSet.next()) {
user = new User(resultSet.getInt("id"), resultSet.getString("username"), resultSet.getString("email"));
}
} catch (SQLException e) {
e.printStackTrace();
}
return user;
}
// ...
}
目前,UserService 与其依赖关系(PostgresDriver)紧密耦合。但后来我们决定迁移到 MongoDB 数据库。由于 MongoDB 与 PostgreSQL 不同,我们需要重写 UserService 类中的每个方法。
解决办法是引入接口:
interface UserRepository {
void saveUser(User user);
User getUserById(int id);
// ...
}
class UserPGRepository implements UserRepository {
private PostgresDriver driver;
public UserPGRepository(PostgresDriver driver) {
this.driver = driver;
}
public void saveUser(User user) {
// ...
}
public User getUserById(int id) {
// ...
}
// ...
}
class UserMongoRepository implements UserRepository {
private MongoDriver driver;
public UserPGRepository(MongoDriver driver) {
this.driver = driver;
}
public void saveUser(User user) {
// ...
}
public User getUserById(int id) {
// ...
}
// ...
}
class UserService {
private UserRepository repository;
public UserService(UserRepository database) {
this.repository = database;
}
public void saveUser(User user) {
repository.saveUser(user);
}
public User getUserById(int id) {
return repository.getUserById(id);
}
// ...
}
现在,高层模块(UserService)依赖于抽象模块(UserRepository),而抽象模块并不依赖于细节(PostgreSQL 的 SQL API 和 MongoDB 的 Query API),而只依赖于为客户端构建的接口。
最后提一下,要实现依赖反转,可以使用依赖注入技术:Demystifying Dependency Injection: An Essential Guide for Software Developers[6]
GRASP
[一般责任分配原则](https://en.wikipedia.org/wiki/GRASP_(object-oriented_design "一般责任分配原则"))(GRASP,General Responsibility Assignment Principles)是 Craig Larman 在其著作 Applying UML and Patterns 中提出的9项用于面向对象设计的原则。
与 SOLID 类似,这些原则并不是从零开始建立,而是在 OOP 的背景下由久经考验的编程准则组成的。
(1) 高内聚(High cohesion)
"将相关功能和职责集中在一起。"
高内聚原则的重点是保持复杂度可控。在这种情况下,内聚度是对象的职责紧密程度。如果一个类的内聚度较低,就意味着它在做与其主要目的无关的工作,或者在做可以委托给其他子系统的工作。
一般来说,高内聚设计的类只有少量方法,而且方法的功能都高度相关。这样做可以提高代码的可维护性、可读性和重用性。
(2) 低耦合(Low coupling)
"减少不稳定组件之间的联系。"
这一原则旨在降低组件之间的依赖性,从而防止代码变更带来副作用。这里的耦合度是指一个组件对另一个组件的依赖程度(知道或依赖)。
具有高耦合度的程序组件彼此依赖性非常强。如果有一个耦合度很高的类,它的变化会导致系统其他部分的局部变化,反之亦然。这样的设计限制了代码复用,并且需要花更多时间来理解。另一方面,低耦合支持设计独立性更强的类,从而减少变更带来的影响。
耦合和内聚原则是相辅相成的。如果两个类的内聚性很高,那么它们之间的联系通常很弱。同样,如果这些类之间的耦合度较低,顾名思义,它们的内聚度也较高。
(3) 信息专家(Information expert)
"以数据定责任。"
信息专家模式回答了应该如何分配了解某一信息或完成某些工作的责任这一问题。按照这种模式,可以直接获取所需信息的对象被视为该信息的信息专家。
还记得在客户和银行之间应用得墨忒耳定律的例子吗?本质上是一样的:
- Wallet 类是了解余额和管理余额的信息专家。
- Customer 类是有关其内部结构和行为的信息专家。
- Bank 类是银行领域的信息专家。
履行一项职责往往需要收集系统不同部分的信息。因此,应该有中介信息专家。有了他们,对象就能保存自己的内部信息,从而提高封装性,降低耦合度。
(4) 构建者(Creator)
"将构建对象的责任分配给密切相关的类。"
谁应该负责构建新的对象实例?根据构建者模式,要构建 x 类的新实例,构建者类应具备以下属性之一:
- 聚合 x;
- 包含 x;
- 记录 x;
- 密切使用 x;
- 拥有 x 所需的初始化数据。
这一原则促进了低耦合,因为如果你为某个对象找到了合适的构建者,即一个已经与该对象有某种关联的类,就不会增加彼此之间的关联性。
控制器(Controller)
"将处理系统信息的责任分配给特定的类。"
控制器是系统对象,负责接收用户事件并将其委托给领域层。它是第一个从用户界面接收服务请求的元素。控制器通常用于处理类似的用例,例如 UserController 用于管理用户实体交互。
记住,控制器不应从事任何业务工作。控制器应尽可能精简,应将工作委托给相应的类,而不是自己负责。
例如,我们可以在类似 MVC 的设计模式中找到控制器。MVC 引入了控制器,作为负责处理视图和模型之间交互的中间部分,而不是让模型和视图直接通信。有了这个组件,模型就不再受外部交互的影响。
(1) 间接(Indirection)
"为了降低耦合,将责任分配给中介类。"
Butler Lampson 有一句名言:"计算机科学中的所有问题都可以通过加一个间接层来解决"。
间接原则与依赖反转原则的理念相同:在两个组件之间引入中介,使它们不产生直接交互。这样做的目的是支持弱耦合,以及其他一些优点。
(2) 多态(Polymorphism)
"当类似行为因类型不同而不同时,通过多态将职责分配给不同行为的类型。"
如果看到使用 if/switch 语句检查对象类型的代码,那就说明可能欠缺对多态性的应用。当需要加入新功能来扩展代码时,如果必须在条件检查的地方添加新的 if 语句,说明这是个糟糕的设计。
对具有类似行为的不同类应用多态性原则,可以统一不同类型,构造可互换的软件组件,每个组件负责特定功能。根据语言的不同,可以有多种方法,但常见的是实现相同的接口或使用继承,特别是为不同对象的方法赋予相同的名称。最终可获得可插拔、易于扩展的组件,并且无需更改无关代码。
与开闭原则一样,正确使用多态也很重要。只有在确定某些组件将会或可能发生变化时,才需要使用多态。例如,不需要在语言内部类或框架之上创建一个实现多态的抽象概念。它们已经很稳定了,你只是在做无谓的工作。
(3) 纯粹构件(Pure Fabrication)
"为了支持高内聚,将责任分配给适当的类。"
有时,为了遵循高内聚/低耦合原则,需要实现现实世界中并不存在的实体。从这个角度看,你是在创造一个虚构的东西,一个在领域中并不存在的东西。之所以说它纯粹,是因为这个实体的职责设计得很清楚。Craig Larman 建议在信息专家应用逻辑错误时使用这一原则。
例如,控制器模式就是一种纯粹构件,DAO 或存储库也是一种构件。这些类并不是某些领域独有,但却方便了开发人员。虽然我们可以将数据访问逻辑直接放在领域类中(因为有这方面的专家),但这会违反高内聚,因为数据管理逻辑与领域对象的行为方式没有直接关系。同时因为需要依赖数据库接口,耦合度也会增加。不同领域实体的数据管理逻辑相似,代码重复率可能会很高。换句话说,这会导致在一个地方混合不同的抽象概念。
使用纯粹构件类的好处是将相关行为归类到对象中,这在现实世界中是无可替代的,可以实现有助于代码重用的良好设计,并降低对不同职责的依赖性。
(4) 受控变更(Protected variations)
"通过引入稳定的契约来保护可预测的变更。"
为了在不破坏其他部分的情况下提供未来的变化,需要引入稳定的契约来阻止不确定的影响。这一原则强调了前面讨论过的在不同对象之间分离责任的原则的重要性:需要通过间接依赖来轻松的在不同实现之间切换,需要通过信息专家来决定谁应该负责满足需求,需要在设计系统时考虑到多态性,以引入不同的可插拔解决方案,等等。
受控变更原则是一个核心概念,驱动着其他设计模式和原则。
"从一个层面上讲,开发人员或架构师的成熟体现在他们对实现受控变更的更广泛机制的了解不断增加,能够选择值得付出努力的合适的受控变更,并有能力选择合适的受控变更解决方案。在早期阶段,人们学习数据封装、接口和多态性,这些都是实现受控变更的核心机制。之后,人们会学习到基于规则的语言、规则解释器、反射和元数据设计、虚拟机等技术,所有这些技术都可以用来防止某些变更"。- Craig Larman,Applying UML and Patterns。
结论
我相信,在阅读本文之前,你已经在不知不觉中运用了其中的某些原则。现在,你知道了更多细节,沟通起来就更容易了。
你可能已经注意到,其中某些原则有相同的基本理念。本质上来说,确实如此。例如,信息专家、SoC、高内聚低耦合、SRP、接口隔离等,都有相同的观点,即分离不同软件组件之间的关注点。这样做是为了实现受控变更、依赖反转和间接依赖,以获得可维护、可扩展、可理解和可测试的代码。
与任何工具一样,这只是一个指导方针,而不是严格的规则。关键是要了解其中的利弊得失,并做出明智的决定。