面向对象设计原则






4.92/5 (197投票s)
本文档旨在为至少对面向对象编程有基本了解的人员提供指导。
目标受众是谁?
本文档旨在为至少对面向对象编程有基本了解的人员提供指导。他们知道类和对象之间的区别,并且可以谈论面向对象编程的基本支柱,即封装、抽象、多态和继承。
引言
在面向对象的世界里,我们只看到对象。对象之间相互交互。类、对象、继承、多态、抽象是我们日常职业生涯中经常听到的词汇。
在现代软件世界中,每个软件开发者都在使用某种面向对象的语言,但问题是,他是否真的知道面向对象编程的含义?他是否知道自己正在作为一名面向对象的程序员工作?如果答案是肯定的,他是否真的在使用面向对象编程的力量?
在本文中,我们将超越面向对象编程的基本支柱,探讨面向对象设计。
面向对象设计
这是一个规划软件系统的过程,其中对象将相互交互以解决特定问题。俗话说:“良好的面向对象设计能让开发者生活轻松,而糟糕的设计则会造成灾难。”
如何开始?
当任何人开始创建软件架构时,他们的意图都是好的。他们会尝试利用自己现有的经验来创建优雅且干净的设计。
随着时间的推移,软件开始腐烂。随着每一个功能请求或变更,软件设计都会改变其形状,最终,对应用程序最简单的更改都需要付出巨大的努力,更重要的是,会增加出现更多错误的几率。
谁应负责?
软件是为了解决现实生活中的业务问题,而业务流程在不断演变,软件也在不断变化。
变化是软件世界不可或缺的一部分。客户付费,他们理所当然会要求他们期望的东西。所以我们不能将软件设计的退化归咎于“变化”。是我们的设计本身存在问题。
软件设计恶化的最大原因之一是将非计划的依赖引入系统。系统的每个部分都依赖于其他某个部分,因此改变一个部分会影响到另一个部分。如果我们能够管理这些依赖关系,我们将能够轻松维护软件系统和软件质量。
示例
解决方案 - 原则、设计模式和软件架构
- 软件架构,如 MVC、3-Tier、MVP,告诉我们整个项目将如何构建。
- 设计模式允许我们重用经验,或者说,为经常出现的问题提供可重用的解决方案。例如——对象创建问题、实例管理问题等。
- 原则告诉我们,做这些事情,你就会达到这个目标。如何去做,取决于你自己。每个人在生活中都会给自己设定一些原则,例如,“我从不说谎”或“我从不喝酒”等。他/她遵循这些原则来让自己的生活更轻松,但如何坚持这些原则则取决于个人。
同样,面向对象设计充满了许多原则,这些原则使我们能够管理软件设计中的问题。
Robert Martin 先生(俗称 Bob 大叔)将其分为:
- 类设计原则 – 也称为 SOLID
- 包内聚原则
- 包耦合原则
在本文中,我们将通过实际示例讨论 SOLID 原则。
SOLID
这是由Robert Martin 先生(俗称 Bob 大叔)提出的五个原则的首字母缩写:单一职责、开闭、里氏替换、接口隔离和依赖倒置。据称(维基百科),当所有五个原则一起应用时,程序员更有可能创建一个易于维护和扩展的系统。让我们逐一详细讨论每个原则。
一) S - SRP - 单一职责原则
现实生活中的比较

我在印度一家软件公司担任团队负责人。业余时间我做一些写作、报纸编辑和其他各种项目。基本上,我在生活中承担多重职责。
当工作场所发生不好的事情时,比如老板因为我犯错而责骂我,我就会分心于其他工作。基本上,一件事出了问题,所有事情都会一团糟。
识别编程中的问题
在讨论这个原则之前,我想让您看一看下面的类。
![]() |
|
问题是什么?
每次改变其中一个时,另一个也有可能被改变,因为它们住在同一个地方,并且有相同的父级。我们无法控制一切。因此,一次更改会导致双重测试(甚至可能更多)。
什么是 SRP?
SRP 说道:“每个软件模块应该只有一个改变的理由。”
- 软件模块——类、函数等。
- 改变的理由——职责
不违反 SRP 的解决方案
现在,这取决于我们如何实现。我们可以做的一件事是创建三个不同的类:
Employee
– 包含属性(数据)EmployeeDB
– 执行数据库操作EmplyeeReport
– 执行报告相关任务
public class Employee { public string EmployeeName { get; set; } public int EmployeeNo { get; set; } } public class EmployeeDB { public void Insert(Employee e) { //Database Logic written here } public Employee Select() { //Database Logic written here } } public class EmployeeReport { public void GenerateReport(Employee e) { //Set report formatting } }
注意:此原则也适用于方法。每个方法都应该只有一个职责。
一个类可以有多个方法吗?
答案是肯定的。现在您可能会问,为什么
- 一个类将具有单一职责。
- 一个方法将具有单一职责。
- 一个类可能有一个以上的方法。
嗯,这个问题的答案很简单。这是上下文。在这里,职责与我们所讨论的上下文相关。当我们说类的职责时,它将处于较高的级别。例如,EmployeeDB
类将负责与数据库相关的员工操作,而 EmployeeReport
类将负责与报告相关的员工操作。
当涉及到方法时,它将处于较低的级别。例如,看下面的例子
//Method with multiple responsibilities – violating SRP
public void Insert(Employee e)
{
string StrConnectionString = "";
SqlConnection objCon = new SqlConnection(StrConnectionString);
SqlParameter[] SomeParameters=null;//Create Parameter array from values
SqlCommand objCommand = new SqlCommand("InertQuery", objCon);
objCommand.Parameters.AddRange(SomeParameters);
ObjCommand.ExecuteNonQuery();
}
//Method with single responsibility – follow SRP
public void Insert(Employee e)
{
SqlConnection objCon = GetConnection();
SqlParameter[] SomeParameters=GetParameters();
SqlCommand ObjCommand = GetCommand(objCon,"InertQuery",SomeParameters);
ObjCommand.ExecuteNonQuery();
}
private SqlCommand GetCommand(SqlConnection objCon, string InsertQuery, SqlParameter[] SomeParameters)
{
SqlCommand objCommand = new SqlCommand(InsertQuery, objCon);
objCommand.Parameters.AddRange(SomeParameters);
return objCommand;
}
private SqlParameter[] GetParaeters()
{
//Create Paramter array from values
}
private SqlConnection GetConnection()
{
string StrConnectionString = "";
return new SqlConnection(StrConnectionString);
}
测试本身就很有益,但代码变得可读也是一个额外的优点。代码越可读,它就越显而易见。
二) O - OCP – 开闭原则
现实生活中的比较

假设您想在您的两层楼房的第一层和第二层之间再加一层。您认为这可能吗?是的,可能,但可行吗?以下是一些选项:
- 您在第一次盖房子时可以做的一件事是,在建造时就建三层,把第二层留空。然后随时利用第二层。我不知道这有多可行,但这是一个解决方案。
- 拆掉现在的二楼,再建两层新楼,这不合理。
识别编程中的问题
假设 EmployeeDB
类中的 Select
方法被两个客户端/屏幕使用。一个用于普通员工,一个用于经理,而经理屏幕需要对该方法进行更改。
如果我更改 Select
方法以满足新需求,其他 UI 也会受到影响。而且,对现有经过测试的解决方案进行更改可能会导致意外的错误。
什么是 OCP?
它说道:“软件模块应该对修改关闭,对扩展开放。”这是一个正交的陈述。
不违反 OCP 的解决方案
1)使用继承
我们将从 EmployeeDB
派生一个名为 EmployeeManagerDB
的新类,并根据新需求重写 Select
方法。
public class EmployeeDB
{
public virtual Employee Select()
{
//Old Select Method
}
}
public class EmployeeManagerDB : EmployeeDB
{
public override Employee Select()
{
//Select method as per Manager
//UI requirement
}
}
注意:如果这项变更在设计时就已预见,并且已经为扩展提供了某种机制(例如,将方法设为虚拟),那么现在该设计就被认为是良好的面向对象设计。现在 UI 代码将看起来像
//Normal Screen
EmployeeDB objEmpDb = new EmployeeDB();
Employee objEmp = objEmpDb.Select();
//Manager Screen
EmployeeDB objEmpDb = new EmployeeManagerDB();
Employee objEmp = objEmpDb.Select();
2)扩展方法
如果您使用的是 .NET 3.5 或更高版本,则有一种第二种方法称为扩展方法,它允许我们在不更改现有类型的情况下为其添加新方法。
注意:可能还有其他实现所需结果的方法。正如我所说的,这些是原则而不是命令。
三) L – LSP – 里氏替换原则
什么是 LSP?
您可能想知道为什么我们在举例和讨论问题之前就定义它。简单来说,我认为在这里更有意义。
它说道:“子类应该可以替换基类。”您不觉得这个陈述很奇怪吗?如果我们总是可以写 BaseClass b=new DerivedClass()
,那么为什么还要制定这样的原则?
现实生活中的比较
![]() | 父亲是房地产商人,而他的儿子想成为一名板球运动员。 儿子不能取代他的父亲,尽管他们属于同一个家族体系。 |
识别编程中的问题
让我们来谈一个非常常见的例子。
通常,当我们谈论几何形状时,我们将矩形称为正方形的基类。让我们看一下代码片段。
public class Rectangle
{
public int Width { get; set; }
public int Height { get; set; }
}
public class Square:Rectangle
{
//codes specific to
//square will be added
}
有人可能会说:
Rectangle o = new Rectangle(); o.Width = 5; o.Height = 6;
很完美,但根据 LSP,我们应该能够用正方形替换矩形。让我们尝试这样做。
Rectangle o = new Square(); o.Width = 5; o.Height = 6;
问题是什么?正方形不能有不同的宽度和高度。
这是什么意思?这意味着我们不能用派生类替换基类。意味着我们违反了 LSP。
为什么我们不在 Rectangle 中将 Width 和 Height 设为虚拟,并在 Square 中重写它们?
代码片段
public class Square : Rectangle
{
public override int Width
{
get{return base.Width;}
set
{
base.Height = value;
base.Width = value;
}
}
public override int Height
{
get{return base.Height;}
set
{
base.Height = value;
base.Width = value;
}
}
}
我们不能这样做,因为这样做我们违反了 LSP,因为我们在派生类中改变了 Width 和 Height 属性的行为(对于 Rectangle,高度和宽度不能相等,如果它们相等,它就不是 Rectangle)。
(这不会是一种替换。)
不违反 LSP 的解决方案
应该有一个抽象类 Shape,它看起来像
public abstract class Shape
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
}
现在将有两个具体的类,彼此独立,一个是矩形,一个是正方形,它们都将派生自 Shape。
现在开发者可以这样说:
Shape o = new Rectangle(); o.Width = 5; o.Height = 6; Shape o = new Square(); o.Width = 5; //both height and width become 5 o.Height = 6; //both height and width become 6
即使在派生类中重写后,我们也没有改变宽度和高度的行为,因为当我们谈论形状时,宽度和高度没有固定的规则。它们可以相等,也可以不相等。
四) I – ISP– 接口隔离原则
现实生活中的比较
假设您购买了一台新的台式电脑。您会发现几个 USB 端口、一些串行端口、一个 VGA 端口等。如果您打开机箱,您会看到主板上有许多插槽,用于连接各种部件,这些插槽主要由硬件工程师在组装时使用。
这些内部插槽在您打开机箱之前是看不见的。总之,只有必需的接口才对您可用/可见。想象一下,如果一切都是外部的或内部的。那么硬件发生故障的可能性更大(就像计算机用户的日子已经够难过一样)。
假设我们去商店买东西(比如,买一个板球拍)。
现在想象一下,店主开始向您展示球和球门柱。我们可能会感到困惑,并最终购买了我们不需要的东西。我们甚至可能忘记我们当初为什么来这里。
识别编程中的问题
假设我们要开发一个报表管理系统。现在,第一项任务是创建一个业务层,该业务层将由三个不同的 UI 使用。
EmployeeUI
– 显示与当前登录员工相关的报表ManagerUI
– 显示与他自己和所属团队相关的报表。AdminUI
– 显示与个人员工、团队以及公司相关的报表,例如利润报表。
public interface IReportBAL
{
void GeneratePFReport();
void GenerateESICReport();
void GenerateResourcePerformanceReport();
void GenerateProjectSchedule();
void GenerateProfitReport();
}
public class ReportBAL : IReportBAL
{
public void GeneratePFReport()
{/*...............*/}
public void GenerateESICReport()
{/*...............*/}
public void GenerateResourcePerformanceReport()
{/*...............*/}
public void GenerateProjectSchedule()
{/*...............*/}
public void GenerateProfitReport()
{/*...............*/}
}
public class EmployeeUI
{
public void DisplayUI()
{
IReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
}
}
public class ManagerUI
{
public void DisplayUI()
{
IReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
objBal.GenerateResourcePerformanceReport ();
objBal.GenerateProjectSchedule ();
}
}
public class AdminUI
{
public void DisplayUI()
{
IReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
objBal.GenerateResourcePerformanceReport();
objBal.GenerateProjectSchedule();
objBal.GenerateProfitReport();
}
}
现在,在每个 UI 中,当开发人员键入“objBal
”时,将显示以下智能提示:
问题是什么?
正在处理 EmployeeUI
的开发人员可以访问所有其他方法,这可能会不必要地造成混淆。
什么是 ISP?
它指出“客户端不应被强制实现他们不使用的接口。”它也可以表述为“多个客户端特定的接口比一个通用接口更好。”简而言之,如果您的接口过于庞大,请将其拆分为多个接口。
更新代码以遵循 ISP
public interface IEmployeeReportBAL
{
void GeneratePFReport();
void GenerateESICReport();
}
public interface IManagerReportBAL : IEmployeeReportBAL
{
void GenerateResourcePerformanceReport();
void GenerateProjectSchedule();
}
public interface IAdminReportBAL : IManagerReportBAL
{
void GenerateProfitReport();
}
public class ReportBAL : IAdminReportBAL
{
public void GeneratePFReport()
{/*...............*/}
public void GenerateESICReport()
{/*...............*/}
public void GenerateResourcePerformanceReport()
{/*...............*/}
public void GenerateProjectSchedule()
{/*...............*/}
public void GenerateProfitReport()
{/*...............*/}
}
| public class EmployeeUI
{
public void DisplayUI()
{
IEmployeeReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
}
} |
| public class ManagerUI
{
public void DisplayUI()
{
IManagerReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
objBal.GenerateResourcePerformanceReport ();
objBal.GenerateProjectSchedule ();
}
} |
| public class AdminUI
{
public void DisplayUI()
{
IAdminReportBAL objBal = new ReportBAL();
objBal.GenerateESICReport();
objBal.GeneratePFReport();
objBal.GenerateResourcePerformanceReport();
objBal.GenerateProjectSchedule();
objBal.GenerateProfitReport();
}
} |
通过遵循 ISP,我们可以让客户看到他们需要看到的内容。
五) D – DIP– 依赖倒置原则
现实生活中的比较
让我们谈谈我们的台式电脑。RAM、硬盘、CD-ROM(等)等不同部件松散地连接到主板上。这意味着,如果将来任何部件停止工作,都可以轻松更换。想象一下,如果所有部件都紧密耦合在一起,那么任何部件都无法从主板上移除。那样的话,如果 RAM 停止工作,我们就必须购买新的主板,这将非常昂贵。
识别编程中的问题
看下面的代码。
public class CustomerBAL
{
public void Insert(Customer c)
{
try
{
//Insert logic
}
catch (Exception e)
{
FileLogger f = new FileLogger();
f.LogError(e);
}
}
}
public class FileLogger
{
public void LogError(Exception e)
{
//Log Error in a physical file
}
}
在上面的代码中,CustomerBAL
直接依赖于 FileLogger
类,该类会将异常记录在物理文件中。现在,假设明天管理层决定将异常记录在事件查看器中。那怎么办?更改现有代码。哦,天哪!这可能会导致新的错误!
什么是 DIP?
它说道:“高层模块不应依赖于低层模块。相反,两者都应依赖于抽象。”
使用 DIP 的解决方案
public interface ILogger
{
void LogError(Exception e);
}
public class FileLogger:ILogger
{
public void LogError(Exception e)
{
//Log Error in a physical file
}
}
public class EventViewerLogger : ILogger
{
public void LogError(Exception e)
{
//Log Error in a physical file
}
}
public class CustomerBAL
{
private ILogger _objLogger;
public CustomerBAL(ILogger objLogger)
{
_objLogger = objLogger;
}
public void Insert(Customer c)
{
try
{
//Insert logic
}
catch (Exception e)
{
_objLogger.LogError(e);
}
}
}
正如您所看到的,客户端依赖于抽象,即 ILogger
,它可以设置为任何派生类的实例。
现在我们已经涵盖了 SOLID 的所有五个原则。感谢 Bob 大叔。
结束了吗?
现在的问题是,除了 Bob 大叔分类的原则之外,还有其他原则吗?答案是肯定的,但我们暂时不会详细描述每一个。但它们是:
- 面向接口编程,而不是实现。
- 不要重复自己。
- 封装变化之处。
- 依赖于抽象,而不是具体类。
- 最少知识原则。
- 优先使用组合而非继承。
- 好莱坞原则。
- 尽可能应用设计模式。
- 努力构建松耦合系统。
- 保持简单明了/简单。
结论
我们无法避免变化。我们唯一能做的就是开发和设计软件,使其能够应对这些变化。
- 在创建任何类、方法或任何其他模块(甚至适用于 SQL 存储过程和函数)时,都应牢记 SRP。它使代码更具可读性、健壮性和可测试性。
- 根据我的经验,我们不能每次都遵循 DIP,有时我们不得不依赖具体类。我们唯一需要做的就是充分理解系统、需求和环境,并找出应该遵循 DIP 的领域。
- 遵循 DIP 和 SRP 将为实现 OCP 打开一扇门。
- 请确保创建特定的接口,以便将复杂性和混淆远离最终开发人员,从而不会违反 ISP。
- 使用继承时,请注意 LSP。
希望大家喜欢阅读这篇文章。感谢您的耐心。
如需有关 ASP.NET、设计模式、WCF 和 MVC 等各种主题的技术培训,请联系 SukeshMarla[at]Gmail.com 或访问 www.sukesh-marla.com
如需更多此类内容,请点击 此处。订阅 文章更新 或在 Twitter 上关注 @SukeshMarla
在 .NET, C#, ASP.NET, SQL, WCF, WPF, WWF, SharePoint, 设计模式, UML 等领域查看 400 多个常见问题解答。