SOLID — 面向对象设计原则

2025-05-27

SOLID — 面向对象设计原则

SOLID 原则是编写优秀面向对象软件的宝贵工具。本文尝试使用 TypeScript 对每个原则进行简单的解释和示例,以阐明这一主题。

本文基于Samuel Oloruntoba在其文章《SOLID:面向对象设计的前 5 个原则》中所做的工作,但使用 TypeScript 而不是 PHP 作为示例。

SOLID是罗伯特·C·马丁所著《面向对象设计原则》一文中前五项原则的缩写。

运用这些原则有助于开发可维护且可扩展的代码。它们还能帮助捕捉代码异味、轻松重构代码,并实践良好的敏捷开发。

  • S代表SRP  — 单一职责原则
  • O代表OCP——  开放封闭原则
  • L代表LSP  — 里氏替换原则
  • I代表ISP  — 接口隔离原则
  • D代表DIP——  依赖倒置原则

SRP — 单一职责原则

软件实体(类、模块、函数等)应该有且仅有一个改变的原因。

这条原则意味着一个实体应该只做一件事。因此,单一职责指的是一些孤立的工作。因此,如果我们有一个执行某些计算的软件实体,那么修改它的唯一理由就是这些计算需要修改。

为了更好地理解原理,我们可以举一个例子。假设我们要实现一个应用程序,给定一些形状,计算这些形状面积的总和并打印输出。

我们开始创建形状类:

class Circle {
 public readonly radius: number;

 constructor(radius: number) {
 this.radius = radius;
 }
}

class Square {
 public readonly side: number;

 constructor(side: number) {
 this.side = side;
 }
}

现在我们创建一个 AreaCalculator 类,它将具有计算形状面积之和的逻辑。

class AreaCalculator {
 public readonly shapes: Shape[];

 constructor(shapes: Shape[]) {
 this.shapes = shapes;
 }

 public sum(): number {
 // logic to sum the areas
 }

 public output(): string {
 return `Sum of the areas of provided shapes: ${this.sum()}`
 }

要使用 AreaCalculator,我们必须创建一个形状数组,实例化类并显示输出。

const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];

const areas = new AreaCalculator(shapes);

console.log(areas.output());

但这种实现方式存在一个问题。在这个例子中,AreaCalculator 负责计算面积总和并输出数据的逻辑如果用户想要 JSON 格式的输出怎么办?

这时,单一职责原则就派上用场了。AreaCalculator 应该只在我们改变面积总和计算方式时才进行修改,而不是在我们想要不同的输出或表示方式时进行修改。

我们可以通过实现一个其唯一职责是输出数据的类来解决这个问题。

const shapes: any[] = [new Circle(2), new Circle(3), new Square(5)];

const areas = new AreaCalculator(shapes);
const output = new Outputter(areas);

console.log(output.text());
console.log(output.json());

现在我们有两个类,每个类负责一项任务,如果我们想改变计算方式,则只有 AreaCalculator 会改变,同样,要改变输出,也只会影响 Outputter。

OCP — 开放封闭原则

软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭。

我们的软件实体的一个理想特性是易于扩展其功能,而无需改变实体本身。

继续前面的例子,现在我们想介绍一个新的奇特形状:三角形。但首先,我们来仔细看看 AreaCalculator 类的求和部分。

class AreaCalculator {
 public readonly shapes: Shape[];

 constructor(shapes: Shape[]) {
 this.shapes = shapes;
 }

 public sum() {
 let sum: number = 0;

 for (let shape of this.shapes) {
 if (shape instanceof Circle) {
 sum += Math.PI \* Math.pow(shape.radius, 2);
 } else if (shape instanceof Square) {
 sum += shape.side \* shape.side;
 }
 }

 return sum;
 }
}

这里我们违反了开放/封闭原则,因为为了添加对三角形的支持,我们必须修改 AreaCalculator,添加一个新的 else if 块来处理新面积的计算。

为了解决这个问题,我们可以将计算面积的代码移动到相应的形状,并使该形状实现一个更好地描述形状功能的接口。

interface Shape {
 area(): number;
}

class Circle implements Shape {
 public readonly radius: number;

 constructor(radius: number) {
 this.radius = radius;
 }

 public area(): number {
 return Math.PI \* Math.pow(this.radius, 2);
 }
}

现在 AreaCalculator 看起来像下面的代码,它允许我们创建新类型的形状,并且只要这个新形状实现了 Shape 接口,它就会一直起作用。

class AreaCalculator {
 public readonly shapes: Shape[];

 constructor(shapes: Shape[]) {
 this.shapes = shapes;
 }

 public sum(): number {
 let sum: number = 0;

 for (let shape of this.shapes) {
 sum += shape.area();
 }

 return sum;
 }
}

LSP — 里氏替换原则

派生类必须可以替代其基类。

这条原则的意思是,程序中的对象应该可以被替换为其子类型的实例,而不会改变程序的正确性。因此,如果传递抽象的子类,则需要确保不会改变父抽象的任何行为或状态语义。

继续使用 AreaCalculator 类,现在我们要创建一个扩展 AreaCalculator 的 VolumeCalculator 类:

class VolumeCalculator extends AreaCalculator {
 public readonly shapes: Shape[];

 constructor(shapes: Shape[]) {
 this.shapes = shapes;
 }

 public sum(): number[] {
 // logic to calculate the volumes and then return
 // and array of output
 }
}

为了更好地理解这个例子,让我们制作一个更详细的 Outputter 类版本。

class Outputer {

 private calculator;

 constructor(calculator: AreaCalculator) {
 this.calculator = calculator;
 }

 public json(): string {
 return JSON.stringify({
 sum: this.calculator.sum();
 })
 }

 public text(): string {
 return `Sum of provided shapes: ${this.calculator.sum()}`;
 }
}

如果我们尝试运行这样的代码:

const areas = new AreaCalculator(shapes2D);
const volumes = new VolumeCalculator(shapes3D);

console.log('Areas - ', new Ouputter(areas).text());
console.log('Volumes - ', new Ouputter(volumes).text());

程序不会失败,但输出将不一致,因为一个输出将类似于面积 - 提供的形状的总和:42,而另一个输出将类似于体积 - 提供的形状的总和:13、15、14。这不是我们对程序的期望。

发生这种情况是因为违反了里氏替换原则,VolumeCalculator 类的 sum 方法是一个数字数组,而 AreaCalculator 只是一个数字。

为了解决这个问题,我们必须重新实现 VolumeCalculator 的 sum 方法以返回数字而不是数组。

class VolumeCalculator extends AreaCalculator {

 // constructor

 public function sum(): number {
 // logic to calculate the volumes and then return
 // and array of output
 return sum;
 }
}

ISP——接口隔离原则

制作特定于客户端的细粒度接口。

在这种情况下,我们希望保持接口尽可能小,这样客户端就不会被迫实现他们实际上不需要的方法。

那么,回到我们的形状界面,现在我们可以计算体积,我们的界面看起来类似于此:

interface Shape {
 area(): number;
 volume(): number;
}

但我们知道并非所有形状都有体积,正方形是二维形状,但由于界面的原因,我们被迫实现体积方法。

应用接口隔离原则,我们将 Shape 接口分为两个不同的接口,一个用于定义 2D 形状,另一个用于定义 3D 形状。

interface Shape2D {
 area(): number;
}

interface Shape3D {
 volume(): number;
}

class Cuboid implements Shape2D, Shape3D {
 public area(): number {
 // calculate the surface area of the cuboid
 }

 public volume(): number {
 // calculate the volume of the cuboid
 }
}

DIP — 依赖倒置原则

依赖抽象,而不是具体。

该原则的意思是,高级模块不应该依赖于低级模块,而应该依赖于抽象。

这个原则允许解耦,这个例子似乎是解释这个原则的最佳方式。让我们看一个用来保存形状的新类 ShapeManager:

class ShapeManager {
 private database;

 constructor(database: MySQL) {
 this.database = database;
 }

 public load(name: string): Shape {}
}

在这种情况下,ShapeManager 是一个高级模块,而 MySQL 是一个低级模块,但这违反了依赖倒置原则,因为我们被迫依赖 MySQL。

如果将来我们想要更改数据库,就必须编辑 ShapeManager 类,这违反了开闭原则。在这种情况下,我们不应该关心使用的是哪种数据库,因此为了依赖于抽象,我们将使用接口:

interface Database {
 connect(): Connection;
}

class MySQL implements Database {
 public connect(): Connetion {
 // creates a connection
 }
}

class ShapeManager {
 private database;

 constructor(database: Database) {
 this.database = database;
 }

 public load(name: string): Shape {}
}

现在我们的高级和低级模块都依赖于抽象。

结论

刚开始编写面向对象程序时,SOLID原则可能比较难理解,即使理解了,在何时何地应用它们也并非易事。但它们是软件开发中最重要的原则之一,实践和经验会让你以非常自然和直观的方式运用这些原则。

(这是发布在我的博客 magarcia.io 上的一篇文章。您可以点击此处在线阅读。)

文章来源:https://dev.to/magarcia/solid-principles-of-object-orient-design-5gh0
PREV
7 个 CSS 优化技巧,加速页面加载 + CSS 工具列表 全球 CSS 列表
NEXT
8 个项目助你掌握前端技能🥇🏆