掌握SOLID:面向对象原则的记忆法指南
在我开发职业生涯的早期,SOLID原则会在与各种背景的开发者的对话中出现,经常被引用为面向对象编程的基石。作为一个年轻的开发者,这个缩写对我来说几乎具有神话般的地位。我假设所有高级开发者都是知道魔法咒语的巫师,无需努力,就像一个尽职的学徒,我会阅读前辈的手稿,渴望被接纳到伟人的殿堂中。
经验告诉我,虽然SOLID原则经常被频繁提及,但在现实中,代码库经常违反多个关键原则。我确信大多数阅读本文的开发者都曾不止一次地负责重构意大利面条代码。
有一些有效的情况,SOLID原则根本不适用或无法应用;也许开发者受到限制无法追求它们,或者他们可能正在使用他们没有架构且没有足够权限重构的代码库。也许他们出于必要或设计而使用函数式编程风格。关键是要理解这些本质上是面向对象原则。
大多数常用的现代语言在设计上都是多范式的。我经常会自然地混合面向对象和函数式范式,这取决于我正在使用的技术栈,或者适合手头的特定任务。库或框架可以引导你走向某种风格。例如,在现代React中,大多数组件只是函数,hooks鼓励组合而不是继承。常用的React框架如NextJS倾向于这种风格。React当然也支持类组件和继承,首选风格的选择就留给开发者了。
相反,Angular通过使用类和依赖注入自然地支持更有主见的面向对象方法,尽管其模板系统倾向于声明式和函数式。后端框架如NestJS或Laravel也主要倾向于OOP,中间还有很多例子。
应该清楚的是,编写软件没有对错之分,我们经常混合和匹配方法。然而,我会说有好代码和坏代码之分,无论范式如何。我会将好代码量化为易于理解、易于维护、易于扩展或扩展的代码。
SOLID原则帮助我们实现这一点,我相信无论你喜欢的编码风格如何,它们都值得理解。虽然它们中的大多数在字面意义上不能直接转换为更函数式的风格,但我相信可以将这些技术的精神抽象到大多数类型的编程中。
那么,本文的目的是为那些仍然难以透过词汇迷雾或可能需要复习的人揭开面纱并去神秘化这些概念。
我们将努力将"里氏替换原则"的含义嵌入到我们的脑海中,使其立即有意义,而不会首先将其误认为是物理学的基本定律。
有了这个前言,让我们进入缩写中的第一个字母。
S – 单一职责原则 (SRP)
一个类应该只对一个"参与者"或用户组负责,这意味着该类应该只有一个改变的理由
在我看来,这是最容易理解和记忆的。SRP经常与"只有一个工作"混淆,虽然这通常是正确的,也不是一个糟糕的经验法则,但该原则的真正意图是一个类(或方法)应该只被_一种类型的参与者_改变。此外,我个人对这个原则的解释还包括,一个类或方法也应该只对单一类型的领域数据负责,在可能的情况下。
假设你有一个运行一些常见企业逻辑的_BusinessManager_类:
- //业务管理器有太多职责
- class BusinessManager {
- processEmployeePayrolls(): void {
- console.log("处理员工工资...");
- }
- generateCustomerInvoices(): void {
- console.log("生成客户销售发票...");
- }
- }
这个类负责两个不同的关注点:员工工资和客户开票。它可能受到两个独立系统参与者(人力资源和财务团队)请求的更改,或两个不同数据领域(员工/客户)活动的影响。
将这种逻辑分离到它们自己的特定类中是有益的,给它们对各自领域的明确责任:
- //员工管理器处理工资
- class EmployeeManager {
- processPayrolls(): void {
- console.log("处理员工工资...");
- }
- }
- //客户管理器处理发票生成
- class CustomerManager {
- generateInvoices(): void {
- console.log("生成客户销售发票...");
- }
- }
在现实中,你更可能有工资服务和发票服务,它们会适当地接受员工类或客户类,但为了解释我们的原则,我们保持简单。
为什么这很重要?
正如我们简要提到的,这些原则旨在使代码更易维护。这意味着减少变更期间的摩擦。无论你是独立开发者还是团队的一部分,这都适用。
在我们的第一个例子中,如果人力资源部门和财务部门都要求对其逻辑进行更改,并且为每个任务分配了开发者,开发者在提交工作时将不得不协调合并冲突,导致时间浪费和错误率增加。
即使独自工作时,接触一个涉及太多领域的文件也是混乱的配方,但稍后会更多。随着这些类变得更加臃肿,你在修改一个部分时意外破坏系统另一部分的可能性呈指数级增长。
我如何记住? - 不要给狗压力
Derren Brown著名地击败了9位国际象棋大师,而他自己承认是一个完全的新手。虽然在他自己的领域是大师,这是一个真正令人印象深刻的壮举,但他所做的为记忆术世界提供了洞察。Derren"简单地"记住了国际象棋棋手的走法,并将它们回放给他的对手。实际上,大师们是在互相下棋,而不是与Derren下棋。
这个人自己在这个短视频中谈到了他使用的更简单的记忆术之一:"位置法"。正如他在视频中所说,诀窍是将你想要记住的东西与你大脑已经知道的东西结合起来(意译)。他谈到使用旅程、散步、开车,沿途有一些关键点,并"分配"(视觉印记)你想要记住的项目到那个位置在你的脑海中。你创造的图像越奇怪越好。我们实际上在六年级的心理学课上做了这个技巧,结果令人震惊,每个人都有完全的回忆。
当然,你可以为这些原则创建自己的位置旅程。然而,我发现抽象技术的基础更有益(结合两条信息,然后连接它们形成新的记忆),然后用它来印记我们的学习。想法是创建一个隐喻性的图像,概念几乎相同,减去旅程。
例如,当我对我说"单一职责原则"时,我想到的第一件事是什么?我的狗,Bow。我没有结婚,没有孩子,他是我的"单一职责"(但愿如此!)。连接的逻辑正确性无关紧要,显然我有比我的狗更多的职责,但重要的是当我读到这些词时我想到的第一件事是,Bow。
所以以类似Derren的风格,我在我的心灵之眼中在我的狗背上放了一个"S"。然后我想象Bow穿着衬衫,在一家白领公司工作,压力很大,要处理来自多个部门的文书工作。人力资源想要什么。财务想要什么。狗零食在哪里!?我记得这只可怜的狗有太多事情要做,太多人争夺他的注意力。我们必须抽象,我们必须雇佣更多的比格犬(天哪)。所以他可以只有一个,单一职责。
SRP是关于尊重边界。当每个部分只有一个工作和一个主人时,代码更容易理解和修改。 - 不要给狗压力。
O – 开闭原则 (OCP)
软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭
这个原则经常被误解,因为它听起来矛盾。我们如何在不修改的情况下扩展某些东西?答案是通过抽象和多态性。
让我们看一个例子:
- //违反OCP的代码
- class Rectangle {
- width: number;
- height: number;
- }
- class Circle {
- radius: number;
- }
- class AreaCalculator {
- calculateArea(shapes: any[]): number {
- let area = 0;
- for (let shape of shapes) {
- if (shape instanceof Rectangle) {
- area += shape.width * shape.height;
- } else if (shape instanceof Circle) {
- area += Math.PI * shape.radius * shape.radius;
- }
- }
- return area;
- }
- }
这个设计违反了OCP,因为每次我们想要添加一个新的形状时,我们都必须修改AreaCalculator类。如果我们添加一个三角形,我们需要添加另一个if语句。
符合OCP的解决方案:
- //符合OCP的代码
- interface Shape {
- calculateArea(): number;
- }
- class Rectangle implements Shape {
- constructor(private width: number, private height: number) {}
- calculateArea(): number {
- return this.width * this.height;
- }
- }
- class Circle implements Shape {
- constructor(private radius: number) {}
- calculateArea(): number {
- return Math.PI * this.radius * this.radius;
- }
- }
- class AreaCalculator {
- calculateArea(shapes: Shape[]): number {
- let area = 0;
- for (let shape of shapes) {
- area += shape.calculateArea();
- }
- return area;
- }
- }
现在,如果我们想要添加一个新的形状(比如三角形),我们只需要创建一个新的类来实现Shape接口,而不需要修改AreaCalculator。
为什么这很重要?
OCP帮助我们创建更灵活和可维护的代码。它减少了修改现有代码的风险,这可能导致意外的副作用。它还促进了代码重用,因为新的功能可以通过扩展而不是修改来添加。
我如何记住? - 外面很冷
想象一下,你在一辆货车里,外面很冷。货车是封闭的(对修改关闭),但你可以通过窗户或门向外看(对扩展开放)。窗户和门是扩展点,允许你与外部世界交互,而不需要修改货车本身的结构。
L – 里氏替换原则 (LSP)
子类型必须可以替换其基类型,而不改变程序的正确性
这个原则确保继承被正确使用。它说,如果你有一个基类的对象,你应该能够用该基类的任何子类的对象替换它,而程序应该继续正常工作。
违反LSP的例子:
- class Bird {
- fly(): void {
- console.log("飞行中...");
- }
- }
- class Penguin extends Bird {
- fly(): void {
- throw new Error("企鹅不能飞!");
- }
- }
- function makeBirdFly(bird: Bird) {
- bird.fly(); // 如果传入企鹅,这会抛出错误!
- }
这个例子违反了LSP,因为Penguin不能替换Bird而不改变程序的正确性。
符合LSP的解决方案:
- interface Flyable {
- fly(): void;
- }
- class Bird implements Flyable {
- fly(): void {
- console.log("飞行中...");
- }
- }
- class Penguin {
- // 企鹅不实现Flyable接口,因为它不能飞
- swim(): void {
- console.log("游泳中...");
- }
- }
- function makeBirdFly(bird: Flyable) {
- bird.fly(); // 只有能飞的鸟才能传入
- }
为什么这很重要?
LSP确保继承被正确使用,并防止创建脆弱的基类。它帮助我们创建更健壮和可预测的代码。
我如何记住? - 代课老师
想象一下,你有一个代课老师。代课老师应该能够做原老师能做的一切,而不改变课堂的正常运行。如果代课老师不能做原老师能做的事情,或者以不同的方式做事情,那么课堂就会出问题。同样,子类应该能够做基类能做的一切,而不破坏程序。
I – 接口隔离原则 (ISP)
客户端不应该被迫依赖它们不使用的接口
这个原则说,我们应该创建小而专注的接口,而不是大而臃肿的接口。客户端应该只依赖它们实际使用的接口。
违反ISP的例子:
- interface Worker {
- work(): void;
- eat(): void;
- sleep(): void;
- }
- class Robot implements Worker {
- work(): void {
- console.log("机器人工作...");
- }
- eat(): void {
- throw new Error("机器人不需要吃东西!");
- }
- sleep(): void {
- throw new Error("机器人不需要睡觉!");
- }
- }
这个例子违反了ISP,因为Robot被迫实现它不需要的方法。
符合ISP的解决方案:
- interface Workable {
- work(): void;
- }
- interface Eatable {
- eat(): void;
- }
- interface Sleepable {
- sleep(): void;
- }
- class Human implements Workable, Eatable, Sleepable {
- work(): void {
- console.log("人类工作...");
- }
- eat(): void {
- console.log("人类吃东西...");
- }
- sleep(): void {
- console.log("人类睡觉...");
- }
- }
- class Robot implements Workable {
- work(): void {
- console.log("机器人工作...");
- }
- }
为什么这很重要?
ISP帮助我们创建更灵活和可维护的代码。它减少了客户端之间的耦合,并促进了代码重用。
我如何记住? - 隔离燃料类型!
想象一下加油站。不同类型的车辆需要不同类型的燃料。汽油车需要汽油,柴油车需要柴油,电动车需要电力。如果加油站只有一个通用的燃料泵,它会很复杂,而且很多车辆会得到它们不需要的燃料。同样,我们应该为不同的客户端创建专门的接口,而不是一个通用的接口。
D – 依赖倒置原则 (DIP)
高级模块不应该依赖低级模块。两者都应该依赖抽象。抽象不应该依赖细节。细节应该依赖抽象。
这个原则有两个部分:
高级模块不应该依赖低级模块。两者都应该依赖抽象。
抽象不应该依赖细节。细节应该依赖抽象。
让我们看一个例子:
- //违反DIP的代码
- class ProductService {
- private repository = new ProductRepository();
- listProducts(): string[] {
- return this.repository.getAllProducts();
- }
- }
- class ProductRepository {
- getAllProducts(): string[] {
- return ["TV", "Laptop", "Phone"];
- }
- }
这个例子违反了DIP,因为ProductService直接依赖于ProductRepository的具体实现。
符合DIP的解决方案:
- interface IProductRepository {
- getAllProducts(): string[];
- }
- class ProductRepository implements IProductRepository {
- getAllProducts(): string[] {
- return ["TV", "Laptop", "Phone"];
- }
- }
- class ProductService {
- constructor(private repo: IProductRepository) {} // 现在依赖于抽象
- listProducts(): string[] {
- return this.repo.getAllProducts();
- }
- }
现在ProductService依赖于IProductRepository接口,而不是具体的实现。这意味着我们可以轻松地交换不同的实现。
为什么这很重要?
DIP帮助我们创建更灵活和可测试的代码。它减少了模块之间的耦合,并促进了代码重用。
我如何记住? - 可靠的插头适配器
想象一下,你有一个通用的插头适配器。你的笔记本电脑不关心它连接到哪个具体的电源插座,只关心它符合你的笔记本电脑能理解的接口。这就是依赖倒置。
让我们总结一下
所以,我们有了它,我理解和记住SOLID的记忆术如下:
S - 单一职责原则 - 不要给狗压力!
O - 开闭原则 - 外面很冷
L - 里氏替换原则 - 代课老师
I - 接口隔离原则 - 隔离燃料类型!
D - 依赖倒置原则 - 可靠的插头适配器
如果你觉得这篇文章有帮助,我鼓励你为SOLID创建自己的记忆术,我很想听听你想出了什么。如果你有其他帮助记住抽象概念的技术或方法,我也很想听听。
快乐编码 - Joe
深入理解SOLID原则
1. 单一职责原则 (SRP) 详解
什么是"单一职责"?
- // 违反SRP的例子
- class UserManager {
- // 用户管理
- createUser(userData: any): void {}
- updateUser(userId: string, userData: any): void {}
- deleteUser(userId: string): void {}
- // 邮件发送
- sendWelcomeEmail(userId: string): void {}
- sendPasswordResetEmail(userId: string): void {}
- // 数据验证
- validateEmail(email: string): boolean {}
- validatePassword(password: string): boolean {}
- // 日志记录
- logUserAction(userId: string, action: string): void {}
- }
这个类有多个职责:用户管理、邮件发送、数据验证和日志记录。每个职责都可能因为不同的原因而改变。
符合SRP的重构
- // 用户管理
- class UserService {
- constructor(
- private userRepository: IUserRepository,
- private emailService: IEmailService,
- private validator: IValidator,
- private logger: ILogger
- ) {}
- createUser(userData: any): void {
- if (this.validator.validateUserData(userData)) {
- const user = this.userRepository.create(userData);
- this.emailService.sendWelcomeEmail(user.email);
- this.logger.log('USER_CREATED', user.id);
- }
- }
- }
- // 邮件服务
- class EmailService implements IEmailService {
- sendWelcomeEmail(email: string): void {}
- sendPasswordResetEmail(email: string): void {}
- }
- // 验证器
- class UserValidator implements IValidator {
- validateEmail(email: string): boolean {}
- validatePassword(password: string): boolean {}
- validateUserData(userData: any): boolean {}
- }
- // 日志记录器
- class Logger implements ILogger {
- log(action: string, userId: string): void {}
- }
2. 开闭原则 (OCP) 实践
支付系统示例
- // 违反OCP的支付处理器
- class PaymentProcessor {
- processPayment(paymentType: string, amount: number): void {
- if (paymentType === 'credit_card') {
- // 处理信用卡支付
- console.log('处理信用卡支付...');
- } else if (paymentType === 'paypal') {
- // 处理PayPal支付
- console.log('处理PayPal支付...');
- } else if (paymentType === 'bitcoin') {
- // 处理比特币支付
- console.log('处理比特币支付...');
- }
- }
- }
符合OCP的重构
- interface PaymentMethod {
- process(amount: number): void;
- }
- class CreditCardPayment implements PaymentMethod {
- process(amount: number): void {
- console.log(`处理信用卡支付: ${amount}`);
- }
- }
- class PayPalPayment implements PaymentMethod {
- process(amount: number): void {
- console.log(`处理PayPal支付: ${amount}`);
- }
- }
- class BitcoinPayment implements PaymentMethod {
- process(amount: number): void {
- console.log(`处理比特币支付: ${amount}`);
- }
- }
- class PaymentProcessor {
- processPayment(paymentMethod: PaymentMethod, amount: number): void {
- paymentMethod.process(amount);
- }
- }
- // 使用
- const processor = new PaymentProcessor();
- processor.processPayment(new CreditCardPayment(), 100);
- processor.processPayment(new PayPalPayment(), 50);
3. 里氏替换原则 (LSP) 深入
集合类示例
- // 违反LSP的例子
- class Rectangle {
- protected width: number;
- protected height: number;
- setWidth(width: number): void {
- this.width = width;
- }
- setHeight(height: number): void {
- this.height = height;
- }
- getArea(): number {
- return this.width * this.height;
- }
- }
- class Square extends Rectangle {
- setWidth(width: number): void {
- this.width = width;
- this.height = width; // 正方形保持宽高相等
- }
- setHeight(height: number): void {
- this.width = height; // 正方形保持宽高相等
- this.height = height;
- }
- }
- // 这个函数期望Rectangle的行为
- function testRectangle(rectangle: Rectangle): void {
- rectangle.setWidth(5);
- rectangle.setHeight(4);
- // 期望面积是20,但如果传入Square,面积会是16
- console.log(`期望面积: 20, 实际面积: ${rectangle.getArea()}`);
- }
- // 测试
- testRectangle(new Rectangle()); // 正确
- testRectangle(new Square()); // 违反LSP!
符合LSP的解决方案
- interface Shape {
- getArea(): number;
- }
- class Rectangle implements Shape {
- constructor(protected width: number, protected height: number) {}
- getArea(): number {
- return this.width * this.height;
- }
- }
- class Square implements Shape {
- constructor(protected side: number) {}
- getArea(): number {
- return this.side * this.side;
- }
- }
- function testShape(shape: Shape): void {
- console.log(`面积: ${shape.getArea()}`);
- }
4. 接口隔离原则 (ISP) 应用
打印机接口示例
- // 违反ISP的大接口
- interface Machine {
- print(document: string): void;
- scan(document: string): void;
- fax(document: string): void;
- }
- // 老式打印机被迫实现不需要的方法
- class OldPrinter implements Machine {
- print(document: string): void {
- console.log('打印文档...');
- }
- scan(document: string): void {
- throw new Error('老式打印机不能扫描!');
- }
- fax(document: string): void {
- throw new Error('老式打印机不能传真!');
- }
- }
符合ISP的重构
- interface Printable {
- print(document: string): void;
- }
- interface Scannable {
- scan(document: string): void;
- }
- interface Faxable {
- fax(document: string): void;
- }
- // 老式打印机只实现它需要的接口
- class OldPrinter implements Printable {
- print(document: string): void {
- console.log('打印文档...');
- }
- }
- // 现代多功能打印机实现所有接口
- class ModernPrinter implements Printable, Scannable, Faxable {
- print(document: string): void {
- console.log('打印文档...');
- }
- scan(document: string): void {
- console.log('扫描文档...');
- }
- fax(document: string): void {
- console.log('传真文档...');
- }
- }
5. 依赖倒置原则 (DIP) 实现
数据库访问示例
- // 违反DIP的代码
- class UserService {
- private database = new MySQLDatabase(); // 直接依赖具体实现
- getUser(id: string): User {
- return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
- }
- }
- class MySQLDatabase {
- query(sql: string): any {
- // MySQL特定实现
- console.log('执行MySQL查询:', sql);
- return { id: '1', name: 'John' };
- }
- }
符合DIP的重构
- interface IUserRepository {
- getUser(id: string): User;
- saveUser(user: User): void;
- }
- class MySQLUserRepository implements IUserRepository {
- getUser(id: string): User {
- console.log('从MySQL获取用户:', id);
- return { id, name: 'John' };
- }
- saveUser(user: User): void {
- console.log('保存用户到MySQL:', user);
- }
- }
- class PostgreSQLUserRepository implements IUserRepository {
- getUser(id: string): User {
- console.log('从PostgreSQL获取用户:', id);
- return { id, name: 'John' };
- }
- saveUser(user: User): void {
- console.log('保存用户到PostgreSQL:', user);
- }
- }
- class UserService {
- constructor(private userRepository: IUserRepository) {} // 依赖抽象
- getUser(id: string): User {
- return this.userRepository.getUser(id);
- }
- saveUser(user: User): void {
- this.userRepository.saveUser(user);
- }
- }
- // 使用依赖注入
- const mysqlService = new UserService(new MySQLUserRepository());
- const postgresService = new UserService(new PostgreSQLUserRepository());
6. SOLID原则的组合应用
完整的用户管理系统
- // 领域模型
- interface User {
- id: string;
- name: string;
- email: string;
- }
- // 存储库接口
- interface IUserRepository {
- findById(id: string): Promise<User | null>;
- save(user: User): Promise<void>;
- delete(id: string): Promise<void>;
- }
- // 验证接口
- interface IUserValidator {
- validate(user: User): boolean;
- }
- // 通知接口
- interface IUserNotifier {
- notifyUserCreated(user: User): Promise<void>;
- }
- // 日志接口
- interface ILogger {
- log(message: string): void;
- }
- // 具体实现
- class UserRepository implements IUserRepository {
- async findById(id: string): Promise<User | null> {
- // 数据库实现
- return null;
- }
- async save(user: User): Promise<void> {
- // 保存到数据库
- }
- async delete(id: string): Promise<void> {
- // 从数据库删除
- }
- }
- class UserValidator implements IUserValidator {
- validate(user: User): boolean {
- return user.email.includes('@') && user.name.length > 0;
- }
- }
- class EmailNotifier implements IUserNotifier {
- async notifyUserCreated(user: User): Promise<void> {
- console.log(`发送欢迎邮件给 ${user.email}`);
- }
- }
- class ConsoleLogger implements ILogger {
- log(message: string): void {
- console.log(`[LOG] ${message}`);
- }
- }
- // 用户服务 - 符合所有SOLID原则
- class UserService {
- constructor(
- private userRepository: IUserRepository,
- private userValidator: IUserValidator,
- private userNotifier: IUserNotifier,
- private logger: ILogger
- ) {}
- async createUser(userData: Partial<User>): Promise<User> {
- const user: User = {
- id: this.generateId(),
- name: userData.name!,
- email: userData.email!
- };
- if (!this.userValidator.validate(user)) {
- throw new Error('无效的用户数据');
- }
- await this.userRepository.save(user);
- await this.userNotifier.notifyUserCreated(user);
- this.logger.log(`用户已创建: ${user.id}`);
- return user;
- }
- private generateId(): string {
- return Math.random().toString(36).substr(2, 9);
- }
- }
总结
SOLID原则是面向对象编程的基石,它们帮助我们创建:
关键收益:
可维护性:代码更容易理解和修改
可扩展性:新功能可以通过扩展而不是修改来添加
可测试性:代码更容易进行单元测试
可重用性:组件可以在不同的上下文中重用
灵活性:系统更容易适应变化
记忆技巧:
S - 单一职责原则:不要给狗压力!
O - 开闭原则:外面很冷
L - 里氏替换原则:代课老师
I - 接口隔离原则:隔离燃料类型!
D - 依赖倒置原则:可靠的插头适配器
实践建议:
从小开始:先应用单一职责原则
逐步改进:不要试图一次性重构所有代码
持续学习:在实践中不断学习和改进
团队协作:与团队成员分享这些原则
记住:SOLID原则不是绝对的规则,而是指导原则。在特定情况下,可能需要权衡和妥协。关键是要理解这些原则背后的思想,并在适当的时候应用它们。
本文翻译自DEV Community上的原创文章,旨在帮助中文开发者深入理解SOLID原则及其实际应用。
