C# 中面向对象编程的 SOLID 原则
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
SOLID 原则是一套自 2000 年代初以来被面向对象开发人员广泛遵循的黄金法则。它们不仅奠定了面向对象编程语言的标准,如今更应用于敏捷开发等领域。遵循 SOLID 原则的程序具有更好的可扩展性、更低的维护成本,并且能够更轻松地应对变化。雇主始终会优先考虑那些对 SOLID 原则有深刻理解的候选人。
今天,我们将深入探讨这 5 个原则,并通过代码、插图和通俗易懂的描述来解释它们是如何运作的。
今天我们将讨论以下内容:
掌握 C# 中的面向对象编程基础知识
亲身实践所有顶尖的面向对象编程概念。
什么是SOLID原则?
SOLID 是一个助记符,代表面向对象编程 (OOP) 的 5 个设计原则,这些原则能够编写出可读性强、适应性高、可扩展性强的代码。SOLID 可以应用于任何 OOP 程序。
SOLID原则的5个要素是:
- 单一责任原则
- 开放式封闭原理
- 利斯科夫替代原理
- 界面隔离原理
- 依赖性倒置原理
SOLID 原则由计算机科学讲师兼作家罗伯特·C·马丁(有时被称为“鲍勃叔叔”)于 2000 年提出,并迅速成为现代面向对象设计 (OOD) 的基石。随着这些原则在编程界的广泛普及,SOLID 这个缩写也变得司空见惯。
现在,SOLID 原则也被敏捷开发和自适应软件开发所采用。
理解SOLID原则的最佳方法是逐一拆解这5个原则,并看看它们在代码中是如何体现的。那么,让我们开始吧!
S:单一职责原则
“一个类应该只承担单一职责,也就是说,软件规范中某一部分的更改不应该影响到该类的规范。”
单一职责原则(SRP)指出,程序中的每个类、模块或函数都应该只负责一项任务。换句话说,每个类、模块或函数都应该完全负责程序的单一功能。类应该只包含与其功能相关的变量和方法。
各个类可以协同完成更复杂的任务,但每个类必须先从头到尾完成一个函数,然后才能将输出传递给另一个类。
Martin解释说,“一个类应该只有一个修改的理由”。这里的“理由”是我们想要改变这个类所追求的唯一功能。如果我们不想改变这个单一功能,我们就永远不会修改这个类,因为类的所有组成部分都应该与该功能相关。
因此,我们可以在不破坏原有类的情况下,更改程序中除一个类之外的所有类。
SRP 使得遵循面向对象编程中另一个备受推崇的原则——封装——变得容易。当一项任务的所有数据和方法都位于同一个单一职责类中时,就很容易对用户隐藏数据。
如果在一个单一职责类中添加 getter 和 setter 方法,该类就满足了封装类的所有标准。
遵循单一职责原则 (SRP) 的程序的好处在于,你可以通过编辑负责该函数的单个类来改变该函数的行为。此外,如果某个功能出现问题,你可以立即知道错误在代码中的哪个位置,并且可以确信只有该类会受到影响。
这也有助于提高可读性,因为你只需要阅读类直到确定其功能即可。
执行
让我们来看一个例子,了解如何应用单一职责原则(SRP)来使我们的RegisterUser类更具可读性。
// does not follow SRP
public class RegisterService
{
public void RegisterUser(string username)
{
if (username == "admin")
throw new InvalidOperationException();
SqlConnection connection = new SqlConnection();
connection.Open();
SqlCommand command = new SqlCommand("INSERT INTO [...]");//Insert user into database.
SmtpClient client = new SmtpClient("smtp.myhost.com");
client.Send(new MailMessage()); //Send a welcome email.
}
}
上面的程序不符合 SRP 原则,因为RegisterUser它执行了三个不同的任务:注册用户、连接到数据库和发送电子邮件。
这种类型的类在大型项目中会造成混乱,因为在同一个类中同时进行电子邮件生成和注册是不寻常的。
还有许多因素会导致这段代码发生变化,例如数据库架构的变更或采用新的电子邮件 API 来发送电子邮件。
相反,我们需要将这个类拆分成三个专门的类,每个类负责一项特定的任务。以下是将所有其他任务重构到单独的类之后,同一个类的样子:
public void RegisterUser(string username)
{
if (username == "admin")
throw new InvalidOperationException();
_userRepository.Insert(...);
_emailService.Send(...);
}
这样就实现了单一职责原则 (SRP),因为RegisterUser它只注册用户,只有当添加更多用户名限制时才会改变。程序中的所有其他行为都保持不变,但现在是通过调用 `getUser()`userRepository和 ` getUser()` 来实现的emailService。
O:开闭原理
“软件实体……应该允许扩展,但不允许修改。”
乍一看,这句话似乎自相矛盾,因为它要求你编写的实体(类/函数/模块)既要开放又要封闭。开闭原则(OCP)要求实体既能被广泛应用,又能保持不变。这就导致我们通过多态性创建具有特定行为的重复实体。
通过多态性,我们可以扩展父实体以满足子实体的需求,同时保持父实体不变。
我们的父实体将作为抽象基类,可通过继承进行重用并添加特化功能。但是,原始实体是锁定的,以允许程序同时具备开放和封闭两种特性。
OCP 的优势在于,当为实体添加新用途时,它可以最大限度地降低程序风险。无需为了适应正在开发的功能而重写基类,只需创建一个与程序中现有类分离的派生类即可。
这样我们就可以放心地在这个独特的派生类上工作,因为对它所做的任何更改都不会影响父类或任何其他派生类。
执行
OCP(开放封闭原则)的实现通常依赖于多态性和抽象,在类级别编写行为代码,而不是针对特定情况进行硬编码。
让我们看看如何修改一个面积计算器程序以使其符合OSP(开放结构原则):
// Does not follow OSP
public double Area(object[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
if (shape is Rectangle)
{
Rectangle rectangle = (Rectangle) shape;
area += rectangle.Width*rectangle.Height;
}
else
{
Circle circle = (Circle)shape;
area += circle.Radius * circle.Radius * Math.PI;
}
}
return area;
}
public class AreaCalculator
{
public double Area(Rectangle[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Width*shape.Height;
}
return area;
}
}
这个程序不符合开放源代码保护(OSP)原则,因为Area()它不支持扩展,只能处理基本Rectangle形状Circle。如果我们想添加对其他形状的支持Triangle,就必须修改方法,所以它并非封闭的。
我们可以通过添加一个Shape所有形状类型都继承的抽象类来实现 OSP。
public abstract class Shape
{
public abstract double Area();
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double Area()
{
return Width*Height;
}
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area()
{
return Radius*Radius*Math.PI;
}
}
public double Area(Shape[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Area();
}
return area;
}
现在,每种形状子类型都通过多态性处理自身的面积计算。这使得该类Shape易于扩展,因为可以轻松添加具有自身面积计算方式的新形状而不会出错。
此外,程序中的任何操作都不会修改原始形状,将来也不需要修改。因此,该程序现在符合单一控制原则(OCP)。
继续学习 C 语言中的面向对象编程 (OOP)。
不要仅仅满足于SOLID原则。要掌握C#面向对象编程的所有基础知识。
Educative 的互动式文本课程能够提供持久的学习体验,掌握每位 C# 面试官都希望学生掌握的技能。
L:里氏替换原理
“程序中的对象应该能够被其子类型的实例替换,而不会改变程序的正确性。”
里氏替换原则(LSP)是由芭芭拉·里氏和珍妮特·温格提出的子类型关系的具体定义。该原则指出,任何类都必须能够被其任何子类直接替换而不会出错。
换句话说,每个子类都必须保留基类的所有行为,以及子类特有的任何新行为。子类必须能够处理与其父类相同的所有请求并完成相同的所有任务。
在实践中,程序员通常基于行为来开发类,并随着类的日益具体化而逐步扩展其行为能力。LSP 的优势在于,它能加快新子类的开发速度,因为同一类型的所有子类都具有一致的用途。
您可以放心,所有新建的子类都能与现有代码兼容。如果您需要创建新的子类,无需修改现有代码即可创建。
一些批评家认为,这一原则并不适用于所有程序类型,因为没有实现的抽象超类型不能被设计用于实现的子类所取代。
执行
大多数 LSP 实现都涉及多态性,以便为相同的调用创建特定于类的行为。为了演示 LSP 原理,让我们看看如何修改一个水果分类程序来实现 LSP。
这个例子不符合LSP原则:
namespace SOLID_PRINCIPLES.LSP
{
class Program
{
static void Main(string[] args)
{
Apple apple = new Orange();
Debug.WriteLine(apple.GetColor());
}
}
public class Apple
{
public virtual string GetColor()
{
return "Red";
}
}
public class Orange : Apple
{
public override string GetColor()
{
return "Orange";
}
}
}
这不符合LSP原则,因为该类Orange不能Apple在不改变程序输出的情况下替换原类。该GetColor()方法已被该类重写Orange,因此会返回“苹果是橙子”的错误信息。
为了改变这一点,我们将添加一个抽象类,Fruit两者Apple都Orange将实现该类。
namespace SOLID_PRINCIPLES.LSP
{
class Program
{
static void Main(string[] args)
{
Fruit fruit = new Orange();
Debug.WriteLine(fruit.GetColor());
fruit = new Apple();
Debug.WriteLine(fruit.GetColor());
}
}
public abstract class Fruit
{
public abstract string GetColor();
}
public class Apple : Fruit
{
public override string GetColor()
{
return "Red";
}
}
public class Orange : Fruit
{
public override string GetColor()
{
return "Orange";
}
}
}
现在,由于类的特定行为,该类的任何子类型(Apple或Orange)都可以替换为另一个子类型而不会出错。因此,该程序现在实现了 LSP 原则。FruitGetColor()
I:界面隔离原理
“多个针对特定客户端的接口比一个通用接口要好。”
接口隔离原则(ISP)要求类只能执行那些有助于实现其最终功能的行为。换句话说,类不会包含它们不使用的行为。
这与我们的第一个SOLID原则相关,因为这两个原则结合起来,会剔除类中所有与其功能没有直接关联的变量、方法或行为。方法必须完整地为最终目标做出贡献。
该方法中任何未使用的部分都应该删除或拆分成一个单独的方法。
ISP 的优势在于它将大型方法拆分成更小、更具体的方法。这使得程序更容易调试,原因有三:
-
类之间传递的代码更少。代码越少,bug 就越少。
-
单个方法负责的行为种类较少。如果某个行为出现问题,你只需要检查这些较小的方法即可。
-
如果将具有多种行为的通用方法传递给不支持所有行为的类(例如,调用该类没有的属性),则当该类尝试使用不支持的行为时,将会出现错误。
执行
为了了解 ISP 原则在代码中是如何体现的,让我们看看程序在遵循和不遵循 ISP 原则的情况下会发生怎样的变化。
首先,是不遵循ISP的程序:
// Not following the Interface Segregation Principle
public interface IWorker
{
string ID { get; set; }
string Name { get; set; }
string Email { get; set; }
float MonthlySalary { get; set; }
float OtherBenefits { get; set; }
float HourlyRate { get; set; }
float HoursInMonth { get; set; }
float CalculateNetSalary();
float CalculateWorkedSalary();
}
public class FullTimeEmployee : IWorker
{
public string ID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public float MonthlySalary { get; set; }
public float OtherBenefits { get; set; }
public float HourlyRate { get; set; }
public float HoursInMonth { get; set; }
public float CalculateNetSalary() => MonthlySalary + OtherBenefits;
public float CalculateWorkedSalary() => throw new NotImplementedException();
}
public class ContractEmployee : IWorker
{
public string ID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public float MonthlySalary { get; set; }
public float OtherBenefits { get; set; }
public float HourlyRate { get; set; }
public float HoursInMonth { get; set; }
public float CalculateNetSalary() => throw new NotImplementedException();
public float CalculateWorkedSalary() => HourlyRate * HoursInMonth;
}
该程序不符合ISP原则,因为该类FullTimeEmployee不需要该CalculateWorkedSalary()函数,而且ContractEmployee该类也不需要CalculateNetSalary().
这两种方法都无助于实现这些类的目标。它们之所以被实现,仅仅是因为它们是该IWorker接口的派生类。
以下是如何重构程序以遵循ISP原则的方法:
// Following the Interface Segregation Principle
public interface IBaseWorker
{
string ID { get; set; }
string Name { get; set; }
string Email { get; set; }
}
public interface IFullTimeWorkerSalary : IBaseWorker
{
float MonthlySalary { get; set; }
float OtherBenefits { get; set; }
float CalculateNetSalary();
}
public interface IContractWorkerSalary : IBaseWorker
{
float HourlyRate { get; set; }
float HoursInMonth { get; set; }
float CalculateWorkedSalary();
}
public class FullTimeEmployeeFixed : IFullTimeWorkerSalary
{
public string ID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public float MonthlySalary { get; set; }
public float OtherBenefits { get; set; }
public float CalculateNetSalary() => MonthlySalary + OtherBenefits;
}
public class ContractEmployeeFixed : IContractWorkerSalary
{
public string ID { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public float HourlyRate { get; set; }
public float HoursInMonth { get; set; }
public float CalculateWorkedSalary() => HourlyRate * HoursInMonth;
}
在这个版本中,我们将通用接口拆分IWorker为一个基本接口,IBaseWorker和两个子接口IFullTimeWorkerSalary和IContractWorkerSalary。
通用接口包含所有工作线程共享的方法。子接口则根据工作线程类型(FullTime例如领取固定薪水或Contract按小时计薪)对方法进行分类。
现在,我们的类可以实现该类型工作线程的接口,从而访问基类和工作线程特定接口中的所有方法和属性。
最终类现在只包含有助于实现其目标的方法和属性,从而实现 ISP 原则。
D:依赖倒置原理
“我们应该依赖抽象概念,而不是具体事物。”
依赖倒置原理(DIP)包含两个部分:
- 高层模块不应该依赖于底层模块。相反,两者都应该依赖于抽象(接口)。
- 抽象概念不应依赖于细节,细节(例如具体实现)应依赖于抽象概念。
该原则的第一部分颠覆了传统的面向对象编程(OOP)软件设计。如果没有依赖注入原则(DIP),程序员通常会构建程序,使其包含显式连接的高级(细节较少、更抽象)组件和低级(具体)组件,以完成特定任务。
DIP(分布式接口)将高层组件和底层组件解耦,而是将它们都连接到抽象层。高层组件和底层组件仍然可以相互受益,但其中一个组件的更改不应直接导致另一个组件失效。
DIP 的这一部分优势在于,解耦程序所需的修改工作量更小。程序中错综复杂的依赖关系意味着单个更改可能会影响多个独立的部分。
如果尽量减少依赖关系,变更将更加局部化,并且查找所有受影响组件所需的工作量也会减少。
第二部分可以理解为“即使细节发生变化,抽象层也不会受到影响”。抽象层是程序面向用户的部分。
细节指的是程序在后台的具体实现方式,正是这些方式使得程序的行为对用户可见。在DIP程序中,我们可以完全改变程序在后台实现其行为的方式,而用户却毫不知情。
这个过程被称为重构。
这意味着您无需为了仅配合当前细节(实现)而对接口进行硬编码。这保持了代码的松耦合性,并允许我们以后灵活地重构实现。
执行
我们将创建一个包含界面、高级组件、低级组件和详细组件的通用业务程序。
首先,我们来创建一个包含该getCustomerName()方法的接口。这将面向我们的用户。
public interface ICustomerDataAccess
{
string GetCustomerName(int id);
}
现在,我们将实现一些取决于ICustomerDataAccess接口的细节。这样做就实现了DIP原则的第二部分。
public class CustomerDataAccess: ICustomerDataAccess
{
public CustomerDataAccess() {
}
public string GetCustomerName(int id) {
return "Dummy Customer Name";
}
}
现在我们将创建一个工厂类,该类实现抽象接口ICustomerDataAccess并以可用形式返回。返回的CustomerDataAccess类就是我们的底层组件。
public class DataAccessFactory
{
public static ICustomerDataAccess GetCustomerDataAccessObj()
{
return new CustomerDataAccess();
}
}
CustomerBuisnessLogic最后,我们将实现一个也实现了该接口的高级组件ICustomerDataAccess。请注意,我们的高级组件并没有实现我们的低级组件,而只是使用了它。
public class CustomerBusinessLogic
{
ICustomerDataAccess _custDataAccess;
public CustomerBusinessLogic()
{
_custDataAccess = DataAccessFactory.GetCustomerDataAccessObj();
}
public string GetCustomerName(int id)
{
return _custDataAccess.GetCustomerName(id);
}
}
以下是完整的程序代码和可视化图表:
public interface ICustomerDataAccess
{
string GetCustomerName(int id);
}
public class CustomerDataAccess: ICustomerDataAccess
{
public CustomerDataAccess() {
}
public string GetCustomerName(int id) {
return "Dummy Customer Name";
}
}
public class DataAccessFactory
{
public static ICustomerDataAccess GetCustomerDataAccessObj()
{
return new CustomerDataAccess();
}
}
public class CustomerBusinessLogic
{
ICustomerDataAccess _custDataAccess;
public CustomerBusinessLogic()
{
_custDataAccess = DataAccessFactory.GetCustomerDataAccessObj();
}
public string GetCustomerName(int id)
{
return _custDataAccess.GetCustomerName(id);
}
}
接下来要学什么?
SOLID 原则是改进代码、简化修改的绝佳方法。如果你是新手,想要在一个程序中完全遵循所有原则可能比较困难,所以建议一次专注于一个原则。最终,你会养成编写符合 SOLID 原则的程序的习惯。
然而,SOLID 原则只是成为优秀的面向对象开发者的第一步。接下来,你应该熟练掌握面向对象编程的四个组成部分:
- 多态性
- 抽象
- 遗产
- 封装
Educative 的“C# 面向对象编程入门”课程将深入讲解每个组成部分,并通过交互式项目帮助您学习。您将学习如何在自己的代码中实现这些面向对象编程 (OOP) 概念,以及为什么要这样做。课程结束时,您将对最先进的 OOP 概念有深刻的理解,并能够将其应用于 C# 中。
学习愉快!