SOLID 原则是设计面向对象软件时应遵循的五项指导原则。它们不过是最佳实践,使我们能够创建更易管理、更易理解且更具灵活性的软件。多亏了SOLID原则,随着应用规模的扩大,我们可以控制其复杂性,避免许多麻烦。
让我们详细看看这些原则。
SOLID一词是一个缩写,其含义为:
S:单一责任原则。
O:开闭原理。
L:里氏代值原则。
一:界面隔离原则。
D:依赖反转原则。
S – 个人责任原则
单一责任原则认为每个阶级应承担唯一且完全包含其中的责任。换句话说,每个类应该只有一个功能,因此它应该只有一个理由去改变。
举个例子,我们来看看书本课,它模拟一本书。以下是C#和LabVIEW版本。
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
public int Pages { get; set; }
// Contructors
}
个人责任原则 |LabVIEW样本 |读书课。
现在,假设我们需要在书中显示信息(C#版本在控制台上,LabVIEW版本则有消息框)。然后我们将ShowInfo方法添加到Book类中。我们得到了这个:
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
public int Pages { get; set; }
public Book(string title, string author, int pages)
{
this.Title = title;
this.Author = author;
this.Pages = pages;
}
public void PrintInfo()
{
Console.WriteLine($"{ this.Title }, {this.Author}");
}
}
个人责任原则 |LabVIEW样本 |PrintInfo 方法。
然而,这种做法违背了个人责任的原则。事实上,Book 类现在有两项能力/职责:建模 Book 实体和在屏幕上显示信息。如果我们停下来思考这个简单类可能的演化,就会发现修改它有两个原因:
数据模型的变化,即由书籍类建模的数据发生变化。例如,我们认为需要添加其他属性,比如ISBN和出版日期。
比如更改书籍数据的输出模式,比如你需要添加新的输出模式或修改现有模式。
回到单一责任原则,我们应实现一个单独的类,只处理书本数据的输出。然后我们加入了BookPrinter类:
public class BookPrinter
{
public void ShowInfoInConsole(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
public void ShowInfoInAnotherWay(Book book)
{
// Another way to output book info
}
}
个人责任原则 |LabVIEW样本 |建筑学。
这样,我们不仅开发了一个类,减轻了书本的输出处理任务,还能利用 BookPrinter 类将书本信息发送到文件、电子邮件等其他输出。
遵守个人责任原则带来以下好处:
交配减少了。只有一个特征的类允许的依赖更少。
小而组织良好的类能让代码更易维护。
类更容易扩展新功能,因为同一个类里没有无关代码。
类之间的依赖关系更容易维护,因为代码分组得更好。
类别更小,从而提升了代码的可读性。
调试更容易,因为小而紧凑的类更容易识别代码中的漏洞。
新团队成员的入职更容易,因为代码组织清晰且易于理解。
O – 开闭原理
开放/关闭原则指出,类应开放扩展,但关闭变更。修改是指更改已有类的代码,扩展则是添加新功能。不修改现有代码避免了潜在引入新漏洞。当然,唯一的例外是修复现有代码中的漏洞。
因此,根据这一原则,我们应该能够在不动用该类现有代码的情况下添加新功能。这是因为每当我们修改现有代码时,就有可能产生潜在的错误。因此,我们应尽量避免接触已经测试并正在使用的代码。
但我们如何在不触及类的情况下添加新功能呢?通常需要借助抽象接口和类。
让我们回到之前的例子。BookPrinter类不尊重开闭原则。这是因为如果我们想改变Book类数据的输出方式,就必须亲自操作BookPrinter类本身。这就是我们离开时的BookPrinter类:
public class BookPrinter
{
public void ShowInfoInConsole(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
public void ShowInfoInAnotherWay(Book book)
{
// Another way to output book info
}
}
如何重构 BookPrinter 类以满足开闭原则?我们可以把 BookPrinter 类做成接口,然后创建一个类在控制台处理输出,满足新的 BookPrinter 接口。
新的 BookPrinter 接口定义了用于输出图书数据的 Send 方法。此外,我们还有新的 BookConsole 类(LabVIEW 示例中的 BookMsgBox)满足 BookPrinter 接口的要求。代码如下:
public interface BookPrinter
{
public void Send(Book book);
}
public class BookConsole : BookPrinter
{
public void Send(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
}
如果将来需要将书籍数据写入文本文件,现有的类不会被修改,而是会创建一个新的 BookLog 类,同时满足 BookPrinter 接口的要求:
public class BookLog : BookPrinter
{
public void Send(Book book)
{
// Log book data on text file
}
}
开/闭原理 |LabVIEW样本 |建筑学。
使用BookPrinter界面允许您在不修改现有类和实体的情况下,扩展软件添加新功能。简单来说,程序不依赖具体类,而是依赖于BookPrinter接口。通过这种方式,你可以通过添加满足上述界面的类来修改软件的行为。实例化具体类的操作可以通过依赖注入机制实现。
L – 里斯科夫替换原理
Liskov替换原则指出,对象应能够被替换为其子类型,而不改变使用它们的程序的行为。这意味着由于类 B 是类 A 的子类,我们应该能够将类 B 对象传递给任何期望有类 A 对象的方法,且该方法在这种情况下不应给出任何奇怪的输出。
这是预期行为,因为当我们使用继承时,假设子类继承了超类的所有内容。女儿类延续了行为,但从不加以限制。因此,当类不遵守这些原则时,就会出现难以检测的漏洞。
让我们来看一个例子。我们有一个基础的鸟类和女儿类的嘲更鸟。Mockingbird 类覆盖了 Fly 方法:
public class Bird
{
public virtual void Fly() { }
}
public class Mockingbird : Bird
{
public override void Fly()
{
Console.WriteLine("I'm flying!");
}
}
但如果我们也有新西兰级(它不飞)呢?:
public class Kiwi : Bird
{
public override void Fly()
{
throw new Exception("I cannot fly");
}
}
这违反了 Liskov 替换原则,因为在程序中使用 Kiwi 类可能导致故障和意外行为。
在我们的情况下,符合里斯科夫原理的一种可能方法是插入中间类别的飞鸟:
public class Bird { }
public class FlyingBird : Bird
{
public virtual void Fly() { }
}
public class Mockingbird : FlyingBird
{
public override void Fly()
{
Console.WriteLine("I'm flying!");
}
}
public class Kiwi : Bird { }
里斯科夫替代原理 |LabVIEW 示例 |架构。
在这种新架构下,如果客户端程序使用 Bird 类的实例,就不能使用 Fly() 方法。在这种情况下,传递 MockingBird 或 Kiwi 类不会产生意外行为。另一方面,如果客户端程序使用 FlyingBirds 对象,即使 MockingBird 对象被传递给它,程序也应以相同方式工作。在这种情况下,你也无法传递 Kiwi 对象,因为它不是 FlyingBirds 的子类。
I – 界面隔离原则
接口隔离原则指出,许多特定接口优于通用接口。根据该原则,类不应被迫实现它们不需要的函数。因此,类不应依赖于它不使用的方法。因此,接口应多且具体且规模较小(由少数方法组成),而非少数通用且庞大。这种方法允许每个类依赖于最小的方法集,即属于其实际使用的接口。根据该原则,对象通常应实现多个接口,分别对应对象在不同上下文或与其他对象交互中扮演的角色。
假设你实施了一项点餐服务,顾客可以点第一道菜、第二道菜和甜点。因此我们决定将所有排序方法集中在单一的 OrderService 接口中:
public interface OrderService
{
public void OrderStarter(string order);
public void OrderSecondCourse(string order);
public void OrderDessert(string order);
}
现在假设你有特别促销,只点了第一道菜。接下来,我们创建一个 StarterOnlyService 类:
public class StarterOnlyService : OrderService
{
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: { order }");
}
public void OrderSecondCourse(string order)
{
throw new Exception("No second course in StarterOnlyService");
}
public void OrderDessert(string order)
{
throw new Exception("No dessert in StarterOnlyService");
}
}
StarterOnlyService 类只支持第一门课程的顺序,但通过实现 OrderService 接口,我们被迫实现剩余的方法(这些方法会抛出异常)。显然,这种解决方案违反了接口隔离的原则。
然后我们可以重构排序系统,为每种排序模式实现专用接口:
public interface OrderStarter
{
public void OrderStarter(string order);
}
public interface OrderSecondCourse
{
public void OrderSecondCourse(string order);
}
public interface OrderDessert
{
public void OrderDessert(string order);
}
StarterOnlyService类只会实现它真正需要实现的接口。在这种情况下,仅限 OrderStarter 接口:
public class StarterOnlyService : OrderStarter
{
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: { order }");
}
}
另一方面,如果我们必须管理完整的排序,则会有第二个类实现所有三个接口:
public class OrderFull : OrderStarter, OrderSecondCourse, OrderDessert
{
public void OrderDessert(string order)
{
Console.WriteLine($"Received order of dessert: {order}");
}
public void OrderSecondCourse(string order)
{
Console.WriteLine($"Received order of second course: {order}");
}
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: {order}");
}
}
界面隔离原理 |LabVIEW样本 |建筑学。
通过这种小巧且特定的接口,我们遵循了界面隔离原则。
D – 依赖关系逆转原则
依赖反转原则指的是软件模块的解耦。根据这一原则,高层模块不应依赖于低层模块。两者都必须依赖于抽象。抽象本身不必依赖细节,但细节依赖于抽象。
这听起来是个复杂的概念,但大意是类应该依赖接口或抽象类,而不是具体的类和函数。让我们来看一个例子。
假设你有一个测试平台应用,它会把测试结果保存在 mySQL 数据库中。为此,我们创建TestReport类和mySQLDatabase类:
public class TestReport
{
private MySQLDatabase database;
public TestReport(MySQLDatabase db)
{
this.database = db;
}
public void SaveReport()
{
this.database.Save();
}
}
public class MySQLDatabase
{
public void Save()
{
// Save data
}
}
一切正常,但这段代码违反了依赖反转原则,因为我们的高级 TestReport 类实际上依赖于底层的 MySQLDatabase 模块。这也违反了开闭原则,因为如果我们想要不同类型的数据库,或者更广义地说的持久化,我们必须亲自操作所有相关的类。
为了解决这个问题并符合依赖反转原则,我们使用一个抽象。然后我们创建一个持久性接口。MySQLDatabase 类满足该接口。同时,TestReport 类将依赖于 Persistency 抽象,不再依赖于 MySQLDatabase concrete 类。让我们看看新结构:
public class TestReport
{
private Persistency persistency;
public TestReport(Persistency persistency)
{
this.persistency = persistency;
}
public void SaveReport()
{
this.persistency.Save();
}
}
public interface Persistency
{
public void Save();
}
public class MySQLDatabase : Persistency
{
public void Save()
{
Console.WriteLine("Saved in mySQL DB");
}
}
依赖逆转原则 |LabVIEW样本 |建筑学。
依赖逆转原则 |LabVIEW样本 |TestReport 类和 SaveReport 方法。
通过这种方法,得益于持久性接口提供的抽象,TestReport 和 MySQLDatabase 类之间不再直接耦合。
结论
在本文中,我们了解了SOLID的设计原则。然后我们逐一分析每个原则,并附上一个相关的例子。从常识上讲,我的建议是在设计、编写和重构代码时牢记这些原则,使代码更加清晰且易于扩展。