SOLID 原则:它们坚如磐石是有原因的!
刚开始学习面向对象编程,对SOLID有点迷茫?不用担心,在本文中,我将向您解释它,并提供如何在代码开发中使用它的示例。
什么是 SOLID?
在面向对象编程中,SOLID是五项设计原则的首字母缩写,旨在增强对软件的理解、开发和维护。
通过应用这套原则,你应该会注意到错误减少、代码质量提升、代码更有条理、耦合度降低、重构增强,以及代码复用得到促进。让我们开始吧。
1. S - 单一职责原则
SRP——单一职责原则
这一点非常简单,但非常重要:一个类应该有一个且只有一个改变的理由。
再也不用创建兼具多种功能和职责的类了,对吧?你可能遇到过,甚至创建过一个包罗万象的类,也就是所谓的“上帝类”。乍一看可能没什么问题,但当你需要修改这个类的逻辑时,问题就来了。
do
上帝类:在OOP中,这是一个包含knows
太多东西的类。
class ProfileManager {
authenticateUser(username: string, password: string): boolean {
// Authenticate logic
}
showUserProfile(username: string): UserProfile {
// Show user profile logic
}
updateUserProfile(username: string): UserProfile {
// Update user profile logic
}
setUserPermissions(username: string): void {
// Set permission logic
}
}
这个ProfileManager类违反了 SRP 原则,因为它执行了四个不同的任务。它同时执行验证和更新数据、进行呈现,以及最重要的,设置权限。
这可能导致的问题
Lack of cohesion -
一个类不应该承担不属于它自己的责任;Too much information in one place -
你的课程最终会面临许多依赖关系和变化困难;Challenges in implementing automated tests -
很难嘲笑这样的阶级。
现在,将SRP应用到ProfileManager
课堂上,让我们看看这个原则能带来什么改进:
class AuthenticationManager {
authenticateUser(username: string, password: string): boolean {
// Authenticate logic
}
}
class UserProfileManager {
showUserProfile(username: string): UserProfile {
// Show user profile logic
}
updateUserProfile(username: string): UserProfile {
// Update user profile logic
}
}
class PermissionManager {
setUserPermissions(username: string): void {
// Set permission logic
}
}
你可能会想,can I apply this only to classes?
答案是:完全不是。你也可以(也应该)将它应用于方法和函数。
// ❌
function processTasks(taskList: Task[]): void {
taskList.forEach((task) => {
// Processing logic involving multiple responsibilities
updateTaskStatus(task);
displayTaskDetails(task);
validateTaskCompletion(task);
verifyTaskExistence(task);
});
}
// ✅
function updateTaskStatus(task: Task): Task {
// Logic for updating task status
return { ...task, completed: true };
}
function displayTaskDetails(task: Task): void {
// Logic for displaying task details
console.log(`Task ID: ${task.id}, Description: ${task.description}`);
}
function validateTaskCompletion(task: Task): boolean {
// Logic for validating task completion
return task.completed;
}
function verifyTaskExistence(task: Task): boolean {
// Logic for verifying task existence
return tasks.some((t) => t.id === task.id);
}
优美、优雅、井然有序的代码。此原则是其他原则的基础;运用此原则,您可以创建高质量、可读性强且易于维护的代码。
2. O - 开放-封闭原则
OCP-开放封闭原则
对象或实体应该对扩展开放,但对修改关闭。如果需要添加功能,最好扩展它,而不是修改源代码。
想象一下,您需要一个类来计算一些多边形的面积。
class Circle {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class Square {
sideLength: number;
constructor(sideLength: number) {
this.sideLength = sideLength;
}
calculateArea(): number {
return this.sideLength ** 2;
}
}
class areaCalculator {
totalArea(shapes: Shape[]): number {
let total = 0;
shapes.forEach((shape) => {
if (shape instanceof Square) {
total += (shape as any).calculateArea();
} else {
total += shape.area();
}
});
return total;
}
}
这个areaCalculator
类的任务是计算不同多边形的面积,每个多边形都有各自的面积逻辑。如果你,小开发者,需要添加新的形状,比如三角形或矩形,你得修改这个类才能实现这些变化,对吧?这时你就会遇到问题,违反了Open-Closed Principle
……
想到什么解决方案了?可能是在类中添加另一个方法,问题就解决了🤩。不完全是,小徒弟😓,问题就在这里!
修改现有的类来添加新的行为会带来严重的风险,可能会给已经运行的程序带来错误。
记住:OCP 坚持认为一个类应该对修改关闭,对扩展开放。
看看重构代码带来的美妙之处:
interface Shape {
area(): number;
}
class Circle implements Shape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class Square implements Shape {
sideLength: number;
constructor(sideLength: number) {
this.sideLength = sideLength;
}
area(): number {
return this.sideLength ** 2;
}
}
class AreaCalculator {
totalArea(shapes: Shape[]): number {
let total = 0;
shapes.forEach((shape) => {
total += shape.area();
});
return total;
}
}
看看这个AreaCalculator
类:它不再需要知道要调用哪些方法来注册这个类。它可以通过调用接口强加的契约来正确地调用 area 方法,而这才是它唯一需要做的事情。
只要它们实现 Shape 接口,一切就可以正常运行。
将可扩展行为分离到接口后面并反转依赖关系。
Open for extension:
您可以向类添加新功能或行为,而无需更改其源代码。Closed for modification:
如果您的类已经具有正常运行的功能或行为,请不要更改其源代码来添加新内容。
3. L-里氏替换原则
LSP - 里氏替换原则
里氏替换原则指出,派生类必须可以替换其基类。
这项原则由 Barbara Liskov 于 1987 年提出,单凭她的解释可能有点难以理解。不过不用担心,我会提供另一个解释和一个例子来帮助你理解。
如果对于每个类型 S 的对象 o1,都有一个类型 T 的对象 o2,并且对于所有以 T 定义的程序 P,当 o1 替换为 o2 时,P 的行为保持不变,则 S 是 T 的子类型。
芭芭拉·利斯科夫,1987年
你明白了吧?不,可能还没明白。是啊,我第一次读的时候没明白(之后一百遍也没明白),不过等等,还有另一种解释:
如果 S 是 T 的子类型,那么程序中类型 T 的对象可以被类型 S 的对象替换,而不会改变该程序的属性。
如果你更倾向于视觉学习,请不要担心,这里有一个例子:
class Person {
speakName() {
return "I am a person!";
}
}
class Child extends Person {
speakName() {
return "I am a child!";
}
}
const person = new Person();
const child = new Child();
function printName(message: string) {
console.log(message);
}
printName(person.speakName()); // I am a person!
printName(child.speakName()); // I am a child!
父类和派生类作为参数传递,代码仍然按预期运行。神奇吗?没错,这就是我们的朋友 Barb 的神奇之处。
违规示例:
- 覆盖/实现不执行任何操作的方法;
- 从基类返回不同类型的值。
- 引发意外异常;
4. I - 接口隔离原则
ISP——接口隔离原则
这句话的意思是,不应该强迫一个类实现它不需要的接口和方法。与其创建一个庞大而通用的接口,不如创建更具体的接口。
在下面的示例中,创建了一个Book接口来抽象书籍行为,然后类实现该接口:
interface Book {
read(): void;
download(): void;
}
class OnlineBook implements Book {
read(): void {
// does something
}
download(): void {
// does something
}
}
class PhysicalBook implements Book {
read(): void {
// does something
}
download(): void {
// This implementation doesn't make sense for a book
// it violates the Interface Segregation Principle
}
}
通用Book
接口强制类PhysicalBook
具有无意义的行为(或者我们是否在 Matrix 中下载实体书籍?)并且违反了ISP和LSP原则。
使用ISP解决此问题:
interface Readable {
read(): void;
}
interface Downloadable {
download(): void;
}
class OnlineBook implements Readable, Downloadable {
read(): void {
// does something
}
download(): void {
// does something
}
}
class PhysicalBook implements Readable {
read(): void {
// does something
}
}
现在好多了。我们download()
从Book
接口中移除了该方法,并将其添加到派生接口中Downloadable
。这样,该行为就在我们的上下文中被正确隔离了,并且我们仍然遵循接口隔离原则。
5. D - 依赖倒置原则
DIP——依赖倒置原则
这个是这样的:依赖于抽象而不是实现。
高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
抽象不应该依赖于细节。细节应该依赖于抽象。
鲍勃叔叔
现在我将展示一段简单的代码来说明DIP。在这个例子中,有一个从数据库获取用户的服务。首先,让我们创建一个与数据库连接的具体类:
// Low-level module
class MySQLDatabase {
getUserData(id: number): string {
// Logic to fetch user data from MySQL database
}
}
现在,让我们创建一个依赖于具体实现的服务类:
// High-level module
class UserService {
private database: MySQLDatabase;
constructor() {
this.database = new MySQLDatabase();
}
getUser(id: number): string {
return this.database.getUserData(id);
}
}
在上面的例子中,UserService
直接依赖于 的具体实现MySQLDatabase
。这违反了DIP,因为高级类 UserService 直接依赖于低级类。
如果我们想切换到不同的数据库系统(例如,PostgreSQL),我们需要修改UserService类,它是AWFUL
!
让我们使用DIP来修复这段代码。高级类不应该依赖于具体的实现,UserService
而应该依赖于抽象。让我们创建一个Database
接口作为抽象:
// Abstract interface (abstraction) for the low-level module
interface Database {
getUserData(id: number): string;
}
现在,具体的实现MySQLDatabase
应该PostgreSQLDatabase
实现这个接口:
class MySQLDatabase implements Database {
getUserData(id: number): string {
// Logic to fetch user data from MySQL database
}
}
// Another low-level module implementing the Database interface
class PostgreSQLDatabase implements Database {
getUserData(id: number): string {
// Logic to fetch user data from PostgreSQL database
}
}
最后,UserService 类可以依赖于Database
抽象:
class UserService {
private database: Database;
constructor(database: Database) {
this.database = database;
}
getUser(id: number): string {
return this.database.getUserData(id);
}
}
这样,UserService
类就依赖于Database
抽象,而不是具体的实现,从而满足依赖倒置原则。
结论
通过采用这些原则,开发人员可以创建更能适应变化的系统,使维护更容易,并随着时间的推移提高代码质量。
本文内容源自其他多篇文章、我的个人笔记以及我在深入研究面向对象编程 (OOP)时遇到的数十个在线视频🤣。示例中使用的代码片段是基于我对这些原则的理解和诠释而创建的。我的小徒弟,我真心希望我的贡献能够帮助你加深理解,并在学习中取得进步。
我真的希望你喜欢这篇文章,别忘了关注!
注:图片取自本文
文章来源:https://dev.to/lukeskw/solid-principles-theyre-rock-solid-for-good-reason-31hn