面向对象设计原则
文章目录
- 面向对象设计原则
1 开闭原则(Open-Closed Principle,OCP)
1.1 内容
软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。即当需要对软件进行功能扩展时,应该通过添加新的代码而不是修改已有代码来实现。
1.2 好处
- 可扩展性强:软件实体对扩展开放,这使得系统能够轻松地添加新功能。例如在图形绘制系统中,通过定义抽象的
Shape
类和具体的图形子类(如Circle
、Rectangle
等),当需要添加新的图形(如Triangle
)时,只需要创建一个新的子类并实现相应的绘制方法,而不需要修改已有的代码。这样可以快速响应业务需求的变化,如增加新的产品类型、新的报表格式等。 - 稳定性高:对修改关闭意味着已经经过测试和验证的现有代码不会轻易被修改,从而减少了引入新错误的风险。如果每次添加新功能都需要修改现有代码,可能会破坏原有的功能逻辑,而开闭原则可以保证系统在扩展功能的同时保持稳定。
- 易于维护:由于不需要频繁修改现有代码,维护成本降低。开发人员可以专注于新功能的扩展,而不用担心影响到其他部分的代码。例如在一个大型的企业级应用中,当需要添加新的业务规则或者功能模块时,遵循开闭原则可以使维护工作更加高效。
1.3 示例
假设我们有一个图形绘制程序,最初它只能绘制圆形。后来需求变更,需要能够绘制矩形和三角形等其他图形。
class CircleDrawer
{
public void Draw()
{
Console.WriteLine("Drawing a circle");
}
}
// 不规范的扩展方式,直接修改已有的类来添加新图形绘制功能
class CircleDrawerWithMoreShapes
{
private string shapeType;
public CircleDrawerWithMoreShapes(string shapeType)
{
this.shapeType = shapeType;
}
public void Draw()
{
if (shapeType == "circle")
{
Console.WriteLine("Drawing a circle");
}
else if (shapeType == "rectangle")
{
Console.WriteLine("Drawing a rectangle");
}
else if (shapeType == "triangle")
{
Console.WriteLine("Drawing a triangle");
}
}
}
问题:每次添加新的图形类型,都需要修改CircleDrawerWithMoreShapes
类中的Draw
方法,这违反了开闭原则,容易引入新的错误,而且代码的可维护性变差。
规范代码设计:
// 抽象图形绘制类
abstract class ShapeDrawer
{
public abstract void Draw();
}
// 圆形绘制类
class CircleDrawer : ShapeDrawer
{
public override void Draw()
{
Console.WriteLine("Drawing a circle");
}
}
// 矩形绘制类
class RectangleDrawer : ShapeDrawer
{
public override void Draw()
{
Console.WriteLine("Drawing a rectangle");
}
}
// 三角形绘制类
class TriangleDrawer : ShapeDrawer
{
public override void Draw()
{
Console.WriteLine("Drawing a triangle");
}
}
这样,当需要添加新的图形绘制功能时,只需创建一个新的继承自ShapeDrawer
的类,而无需修改已有的代码。
2 里氏替换原则(Liskov Substitution Principle,LSP)
2.1 内容
所有引用基类(父类)的地方必须能透明地使用其子类的对象。也就是说,子类对象能够替换父类对象,并且程序的行为不会发生改变。
2.2 好处
- 保证程序的正确性和健壮性:子类能够正确地替换父类,使得程序在运行过程中不会出现不符合预期的行为。例如在一个动物模拟系统中,
Animal
是父类,Lion
和Turtle
是子类。Animal
类有move()
方法,Lion
类的move()
方法可以实现奔跑的行为,Turtle
类的move()
方法可以实现缓慢爬行的行为。当在程序中使用Animal
类型的变量来引用Lion
或Turtle
对象并调用move()
方法时,程序能够按照子类的实际行为正确运行,保证了程序的健壮性。 - 支持多态性的正确使用:里氏替换原则是多态性的基础,它使得多态的设计能够正常工作。通过允许子类替换父类,开发人员可以在不改变代码结构的情况下,根据实际对象的类型来执行不同的行为。例如在一个图形绘制系统中,
Shape
是父类,Circle
和Rectangle
是子类。可以定义一个方法drawShape(Shape shape)
,它可以根据传入的不同形状(Circle
或Rectangle
)对象,正确地调用相应子类的绘制方法,实现了多态性的正确应用。 - 增强代码的可复用性:当子类能够正确替换父类时,父类的代码可以被更多的子类复用。例如在一个交通工具管理系统中,
Vehicle
是父类,Car
和Truck
是子类。Vehicle
类中的一些通用方法(如startEngine()
)可以被Car
和Truck
子类复用,并且由于里氏替换原则的保证,这些子类在替换父类使用时能够正确运行,提高了代码的复用性。
2.3 示例
假设有一个 Bird
类,它有一个 Fly
方法表示飞行。2.3 ```csharp
class Bird
{
public virtual void Fly()
{
Console.WriteLine(“Bird is flying”);
}
}
现在有一个 `Penguin` 类继承自 `Bird`,企鹅实际上不能飞,但我们在 `Penguin` 类中错误地继承了 `Fly` 方法而没有做特殊处理。
```csharp
class Penguin : Bird
{
// 这里没有对不能飞的情况做正确处理,继承了默认的飞行行为
}
问题:当我们在程序中使用 Penguin
类对象并调用 Fly
方法时,就会得到不符合实际情况的结果,这违反了里氏替换原则。
规范代码设计:
class Penguin : Bird
{
public override void Fly()
{
Console.WriteLine("Penguin can't fly");
// Or
// throw new InvalidOperationException("Penguin can't fly");
}
}
这样,当把 Penguin
类对象当作 Bird
类对象使用并调用相关方法时,程序的行为是符合预期的,遵循了里氏替换原则。
3 依赖倒置原则(Dependency Inversion Principle,DIP)
3.1 内容
高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
3.2 好处
- 降低耦合度:高层模块和低层模块都依赖抽象,而不是具体的实现,这样可以减少模块之间的直接依赖关系。例如在一个电商系统中,
OrderService
(高层模块)依赖于IRepository
接口,而不是具体的DatabaseRepository
(低层模块)。当需要更换数据库或者存储方式时,只需要修改DatabaseRepository
的实现,而不会影响到OrderService
,降低了OrderService
和数据库访问层之间的耦合度。 - 提高可维护性和可扩展性:由于模块之间的耦合度降低,维护和扩展变得更加容易。可以独立地开发和测试高层模块和低层模块,并且在需要修改或扩展功能时,只需要关注相关的抽象和具体实现,而不会影响到其他模块。例如在一个软件系统中,当需要添加新的数据源或者数据存储方式时,只需要创建一个新的实现类来实现抽象的存储接口,而不会对使用数据的高层模块产生影响。
- 便于代码的复用和替换:依赖抽象使得代码的复用性更高。例如在多个不同的业务模块中都需要使用数据访问功能,可以通过依赖同一个抽象的接口来实现复用。同时,当需要替换某个具体的实现时,只要新的实现符合抽象接口的定义,就可以很容易地进行替换,而不会影响到其他使用该接口的模块。
3.3 示例
考虑一个电商系统,有一个订单处理模块和一个数据库访问模块。订单处理模块不应该直接依赖于具体的数据库实现(如 MySQL 或 SQL Server),而是应该依赖于一个抽象的数据库访问接口。
class OrderProcessor
{
private MySQLDatabaseAccess dbAccess;
public OrderProcessor()
{
this.dbAccess = new MySQLDatabaseAccess();
}
public void ProcessOrder()
{
// 使用MySQL数据库访问对象进行订单处理相关的数据库操作
dbAccess.InsertOrder();
}
}
class MySQLDatabaseAccess
{
public void InsertOrder()
{
Console.WriteLine("Inserting order into MySQL database");
}
}
问题:OrderProcessor
类直接依赖于MySQLDatabaseAccess
这个具体的低层模块。如果以后要切换到其他数据库(如 SQL Server),就需要修改OrderProcessor
类的代码,违反了依赖倒置原则,可维护性差。
规范代码设计:
// 抽象数据库访问接口
interface IDatabaseAccess
{
void InsertOrder();
}
// MySQL数据库访问实现类
class MySQLDatabaseAccess : IDatabaseAccess
{
public void InsertOrder()
{
Console.WriteLine("Inserting order into MySQL database");
}
}
// SQL Server数据库访问实现类
class SQLServerDatabaseAccess : IDatabaseAccess
{
public void InsertOrder()
{
Console.WriteLine("Inserting order into SQL Server database");
}
}
class OrderProcessor
{
private IDatabaseAccess dbAccess;
public OrderProcessor(IDatabaseAccess dbAccess)
{
this.dbAccess = dbAccess;
}
public void ProcessOrder()
{
dbAccess.InsertOrder();
}
}
这样,OrderProcessor
类依赖于抽象的IDatabaseAccess
接口,具体的数据库实现类可以根据需要进行替换,而无需修改OrderProcessor
类的核心逻辑。
4 接口隔离原则(Interface Segregation Principle,ISP)
4.1 内容
客户端不应该被迫依赖于它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
4.2 好处
- 降低耦合度:客户端只依赖它需要的接口,避免了不必要的依赖。例如在一个办公自动化系统中,有
IDocumentPrinter
接口用于打印文档,IDocumentScanner
接口用于扫描文档。Printer
类实现IDocumentPrinter
接口,Scanner
类实现IDocumentScanner
接口。如果有一个DigitalDocument
类,它只需要打印功能,那么它只需要依赖IDocumentPrinter
接口,而不需要依赖IDocumentScanner
接口,这样就降低了DigitalDocument
类与扫描功能相关的耦合度。 - 提高系统的灵活性和可维护性:当接口被隔离成更小的、更具体的接口时,系统更容易适应变化。例如,如果需要修改扫描功能的接口定义,只有依赖
IDocumentScanner
接口的类会受到影响,而不依赖该接口的其他类(如只依赖IDocumentPrinter
接口的类)不受影响。这样可以方便地对系统的某个功能进行修改和扩展,而不会对整个系统造成较大的干扰。 - 避免接口的臃肿:防止接口包含过多的方法,使得接口更加简洁、明确。例如在一个多媒体处理系统中,有
IAudioPlayer
接口用于播放音频和IVideoPlayer
接口用于播放视频。如果将音频和视频播放的所有方法都放在一个大的IMediaPlayer
接口中,对于只需要播放音频的设备(如音频播放器)来说,就会依赖一些不需要的视频播放方法。通过接口隔离,可以使每个接口只包含与自身功能相关的方法,避免了接口的臃肿。
4.3 示例
假设我们有一个多功能打印机,它可以打印、扫描和复印。不同的用户可能只需要使用其中的部分功能,比如普通用户可能只需要打印功能,而办公室文员可能需要打印和复印功能。
// 一个包含所有功能的大接口
interface IMultiFunctionPrinter
{
void Print();
void Scan();
void Copy();
}
// 普通用户类,只需要打印功能,但被迫依赖了包含扫描和复印功能的大接口
class RegularUser
{
private IMultiFunctionPrinter printer;
public RegularUser(IMultiFunctionPrinter printer)
{
this.printer = printer;
}
public void DoPrint()
{
printer.Print();
}
}
问题:RegularUser
类只需要打印功能,但却被迫依赖了包含扫描和复印功能的整个IMultiFunctionPrinter
接口,这不符合接口隔离原则,增加了不必要的依赖,可能导致代码理解和维护的复杂性。
规范代码设计:
// 打印接口
interface IPrinter
{
void Print();
}
// 扫描接口
interface IScanner
{
void Scan();
}
// 复印接口
interface ICopier
{
void Copy();
}
// 普通用户类,只依赖打印接口
class RegularUser
{
private IPrinter printer;
public RegularUser(IPrinter printer)
{
this.printer = printer;
}
public void DoPrint()
{
printer.Print();
}
}
// 办公室文员类,依赖打印和复印接口
class OfficeClerk
{
private IPrinter printer;
private ICopier copier;
public OfficeClerk(IPrinter printer, ICopier copier)
{
this.printer = printer;
this.copier = copier;
}
public void DoPrintAndCopy()
{
printer.Print();
copier.Copy();
}
}
通过将大接口拆分成多个小接口,不同的客户端可以根据自己的需求依赖相应的最小接口,降低了依赖的复杂性。
5 单一职责原则(Single Responsibility Principle,SRP)
5.1 内容
一个类应该只有一个引起它变化的原因。也就是说,一个类应该只负责一项职责。
5.2 好处
- 降低复杂度:每个类只负责一项职责,使得类的内部逻辑更加清晰。例如在一个电商系统中,
Product
类只负责管理产品信息(名称、价格、库存等),而Order
类只负责处理订单相关的操作(下单、取消订单等)。这样开发人员在理解和维护代码时,不需要同时考虑多个不相关的功能,降低了代码的理解难度。 - 提高可维护性:当需求发生变化时,比如需要修改产品价格的计算方式,只需要在
Product
类中进行修改,而不会影响到其他类。如果一个类承担了过多职责,修改其中一个功能可能会导致其他功能出现错误。 - 增强可读性:代码的职责划分清晰,对于新加入项目的开发人员来说,更容易读懂代码的功能。例如,看到
ShippingService
类,就能明确它主要是用于处理商品运输相关的服务,而不是混杂着商品展示、用户注册等其他功能。 - 便于单元测试:单一职责的类更容易进行单元测试,因为每个类的功能明确。可以针对
Product
类的价格计算方法单独编写测试用例,而不用担心其他功能对测试结果的干扰。
5.3 示例
在一个学生管理系统中,有一个Student
类,它最初负责存储学生的基本信息(如姓名、年龄、学号等),后来又添加了功能,要负责计算学生的成绩平均值以及打印学生的详细信息。
class Student
{
private string name;
private int age;
private string studentId;
private List<int> scores;
public Student(string name, int age, string studentId, List<int> scores)
{
this.name = name;
this.age = age;
this.studentId = studentId;
this.scores = scores;
}
public void StoreStudentInfo()
{
// 存储学生基本信息的逻辑,比如保存到数据库等
Console.WriteLine("Storing student info: " + name + ", " + age + ", " + studentId);
}
public void CalculateAverageScore()
{
int sum = 0;
foreach (int score in scores)
{
sum += score;
}
double averageScore = (double)sum / scores.Count;
Console.WriteLine("Average score: " + averageScore);
}
public void PrintStudentDetails()
{
Console.WriteLine("Student details: " + name + ", " + age + ", " + studentId + ", Average score: " + CalculateAverageScore());
}
}
问题:Student
类承担了存储学生信息、计算平均成绩和打印学生详细信息等多项职责。如果以后存储学生信息的方式发生变化(比如从数据库存储改为文件存储),或者计算平均成绩的算法需要调整,都会影响到这个类的多个方法,导致代码的可维护性变差,违反了单一职责原则。
规范代码设计:
// 负责存储学生基本信息的类
class StudentInfoStorage
{
private string name;
private int age;
private string studentId;
public StudentInfoStorage(string name, int age, string studentId)
{
this.name = name;
this.age = age;
this.studentId = studentId;
}
public void StoreStudentInfo()
{
// 存储学生基本信息的逻辑,比如保存到数据库等
Console.WriteLine("Storing student info: " + name + ", " + age + ", " + studentId);
}
}
// 负责计算学生平均成绩的类
class StudentScoreCalculator
{
private List<int> scores;
public StudentScoreCalculator(List<int> scores)
{
this.scores = scores;
}
public double CalculateAverageScore()
{
int sum = 0;
foreach (int score in scores)
{
sum += score;
}
double averageScore = (double)sum / scores.Count;
return averageScore;
}
}
// 负责打印学生详细信息的类
class StudentDetailsPrinter
{
private StudentInfoStorage infoStorage;
private StudentScoreCalculator scoreCalculator;
public StudentDetailsPrinter(StudentInfoStorage infoStorage, StudentScoreCalculator scoreCalculator)
{
this.infoStorage = infoStorage;
this.scoreCalculator = scoreCalculator;
}
public void PrintStudentDetails()
{
double averageScore = scoreCalculator.CalculateAverageScore();
Console.WriteLine("Student details: " + infoStorage.name + ", " + infoStorage.age + ", " + infoStorage.studentId + ", Average score: " + averageScore);
}
}
将不同的职责拆分到不同的类中,这样当某一项职责发生变化时,只需要修改对应的类,而不会影响到其他类,提高了代码的可维护性。
6 迪米特法则(Law of Demeter,LoD)
6.1 内容
一个对象应该对其他对象有尽可能少的了解,也就是只和直接朋友通信,不和陌生人说话。
6.2 好处
- 降低耦合度:一个对象对其他对象保持最少的了解,减少了对象之间的直接交互。例如在一个学校管理系统中,
Student
类如果要获取课程信息,不应该直接和Course
类的所有细节进行交互,而是通过School
类或者Department
类来获取相关信息。这样Student
类和Course
类之间的耦合度降低,当Course
类的内部结构或者实现方式发生变化时,对Student
类的影响较小。 - 提高可维护性和可扩展性:由于对象之间的依赖关系简单,维护和扩展系统变得更加容易。例如在一个企业信息管理系统中,当需要添加新的部门或者业务流程时,只需要在相关的模块之间进行修改,而不会影响到其他模块。因为各个模块之间的交互是通过最少的中间对象进行的,所以修改的范围相对较小。
- 增强系统的封装性:迪米特法则促使更好的封装,每个对象只暴露必要的接口和信息给其他对象。例如在一个软件开发工具包(SDK)中,内部的类只向外部暴露必要的方法和属性,隐藏了内部的复杂实现细节。这样可以提高 SDK 的安全性和稳定性,同时也方便其他开发者使用,因为他们不需要了解 SDK 内部的复杂细节,只需要使用提供的接口即可。
6.3 示例
假设我们有一个 Employee
类和一个 Company
类,Employee
类想要获取公司的总人数。
class Company
{
private List<Employee> employees;
public Company()
{
this.employees = new List<Employee>();
}
public void AddEmployee(Employee employee)
{
employees.Add(employee);
}
public int GetTotalEmployees()
{
return employees.Count;
}
}
class Employee
{
private Company company;
public Employee(Company company)
{
this.company = company;
}
public int GetCompanyTotalEmployees()
{
// 直接调用公司的方法获取总人数,这违反了迪米特法则
return company.GetTotalEmployees();
}
}
问题:在这种设计下,Employee
类直接与 Company
类进行交互来获取总人数,这使得 Employee
类对 Company
类了解过多,违反了迪米特法则。
规范代码设计:
我们可以在 Company
类中增加一个方法,让它返回一个只包含总人数信息的对象,比如 EmployeeCountInfo
类。
class Company
{
private List<Employee> employees;
public Company()
{
this.employees = new List<Employee>();
}
public void AddEmployee(Employee employee)
{
employees.Add(employee);
}
public EmployeeCountInfo GetEmployeeCountInfo()
{
return new EmployeeCountInfo(employees.Count);
}
}
class EmployeeCountInfo
{
private int totalEmployees;
public EmployeeCountInfo(int totalEmployees)
{
this.totalEmployees = totalEmployees;
}
public int GetTotalEmployees()
{
return totalEmployees;
}
}
class Employee
{
private Company company;
public Employee(Company company)
{
this.company = company;
}
public int GetCompanyTotalEmployees()
{
// 通过获取专门的信息对象来获取总人数,遵循了迪米特法则
return company.GetEmployeeCountInfo().GetTotalEmployees();
}
}
这样,Employee
类通过一个中间对象(EmployeeCountInfo
)来获取公司总人数,对 Company
类的了解减少了,遵循了迪米特法则。
7 组合 / 聚合复用原则(Composite/Aggregate Reuse Principle,CARP)
7.1 内容
组合 / 聚合复用原则(CARP - Composition/Aggregation Reuse Principle)也叫合成复用原则。它强调尽量使用组合(Composition)和聚合(Aggregation)关系来实现复用,而不是使用继承关系。组合是一种强 “拥有” 关系,体现部分和整体的生命周期相同,例如鸟和它的翅膀;聚合是一种弱 “拥有” 关系,部分的生命周期可以独立于整体,例如雁群和大雁。
7.2 好处
- 灵活性更高:通过组合 / 聚合关系,可以在运行时动态地改变对象的组合方式。例如在一个游戏角色系统中,游戏角色的装备是通过组合的方式添加到角色身上的。玩家可以在游戏过程中根据自己的喜好和游戏场景,动态地为角色更换武器、盔甲等装备,这种灵活性是继承复用很难实现的。
- 可维护性更好:当需要修改某个组件的功能时,只需要修改该组件本身和与之相关的组合类。例如在一个电脑组装系统中,
Computer
类是由CPU
、Memory
、HardDisk
等组件组合而成。如果要升级CPU
的性能,只需要修改CPU
类的相关代码和Computer
类中与CPU
组合的部分,而不会像继承那样可能影响到所有的子类。 - 更符合现实世界的关系:现实世界中的很多对象之间是组合或聚合关系,在代码中使用这种关系更加直观。例如在一个汽车制造系统中,汽车是由发动机、车身、轮胎等部件组合而成的,这种组合关系在代码中的体现更符合实际情况,相比于继承关系,更容易理解和设计。
7.3 示例
假设我们有一个车辆管理系统,最初设计了一个Vehicle
基类,有startEngine()
和stopEngine()
方法。然后我们有Car
和Truck
类继承自Vehicle
类来复用这些方法。
class Vehicle
{
public void startEngine()
{
Console.WriteLine("Vehicle engine started");
}
public void stopEngine()
{
Console.WriteLine("Vehicle engine stopped");
}
}
class Car : Vehicle
{
// 汽车特有的属性和方法可以放在这里
}
class Truck : Vehicle
{
// 卡车特有的属性和方法可以放在这里
}
问题:现在如果要添加一个新的交通工具,比如Motorcycle
,继承Vehicle
类看起来是一种复用startEngine()
和stopEngine()
方法的好方式。但是,当有一些交通工具并不完全符合Vehicle
类的通用行为模式时,就会出现问题。例如,假设我们要添加一个Bicycle
,它没有发动机,所以startEngine()
和stopEngine()
方法对它来说是没有意义的,但由于继承关系,它还是会继承这些方法,这就导致了不适当的复用。
规范代码设计:
我们可以创建一个Engine
类来封装发动机相关的操作。
class Engine
{
public void start()
{
Console.WriteLine("Engine started");
}
public void stop()
{
Console.WriteLine("Engine stopped");
}
}
然后Car
和Truck
类可以通过组合的方式使用Engine
类来复用发动机相关的操作。
class Car
{
private Engine engine;
public Car()
{
this.engine = new Engine();
}
public void startEngine()
{
engine.start();
}
public void stopEngine()
{
engine.stop();
}
}
class Truck
{
private Engine engine;
public Truck()
{
this.engine = new Engine();
}
public void startEngine()
{
engine.start();
}
public void stopEngine()
{
engine.stop();
}
}
对于Bicycle
类,由于它没有发动机,就不需要组合Engine
类,这样代码更加合理。
class Bicycle
{
// 自行车特有的属性和方法可以放在这里
}