面向对象设计中,通常有七大原则被广泛认为是高质量软件开发的关键,它们帮助开发者创建出灵活、可维护和可扩展的代码。这些原则包括:
1. **单一职责原则(Single Responsibility Principle, SRP)**
- 一个类应该只有一个引起变化的原因,即一个类应该只负责一项职责。
2. **开闭原则(Open-Closed Principle, OCP)**
- 软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着设计时应使得软件的行为可以在不修改其源码的情况下进行扩展。
3. **里氏替换原则(Liskov Substitution Principle, LSP)**
- 子类型必须能够替换掉它们的基类型(父类)。换句话说,在软件中,如果使用某个基类的地方都可以用其子类来替换,而程序的行为没有变化,那么子类就可以被看作是其父类的合理替代品。
4. **接口隔离原则(Interface Segregation Principle, ISP)**
- 不应该强迫客户端依赖于它们不使用的接口。即应当将“胖”接口中的方法分离到更小和特定的接口中,让接口的实现者只需要关心他们真正需要的接口。
5. **依赖倒置原则(Dependency Inversion Principle, DIP)**
- 高层模块不应依赖低层模块,两者都应该依赖于抽象;抽象不应依赖于具体实现,具体实现应依赖于抽象。该原则鼓励使用接口和抽象类来达成解耦的目的。
6. **合成复用原则(Composite Reuse Principle, CRP)**
- 尽量使用对象组合(composition)/聚合(aggregation)而不是继承关系来达到复用的目的。合成复用原则就是在一个新的对象里通过包含一些已有的对象使之成为新对象的一部分,新对象通过委派调用已有对象的方法达到复用其功能的目的。
7. **迪米特法则(Law of Demeter, LoD)或最少知识原则(Principle of Least Knowledge)**
- 一个对象应该对其他对象有尽可能少的了解,不和陌生的类进行直接交流。迪米特法则指一个对象应该尽量少地与其他对象发生相互作用,这样当一个模块修改时,可以尽量少地影响其他的模块,达到高内聚、低耦合的效果。
以上这些设计原则共同构成了面向对象设计的核心思想,它们指导开发者如何组织和编写代码,以提升软件项目的可维护性和灵活性。
开闭原则
考虑一个支付系统的示例,它需要处理不同类型的支付方式。我们先看一个未遵循开闭原则的设计:
// 未遵循开闭原则的设计
public class PaymentProcessor
{
public void ProcessPayment(string paymentType, double amount)
{
if (paymentType == "CreditCard")
{
// 处理信用卡支付
Console.WriteLine("Processing credit card payment...");
}
else if (paymentType == "PayPal")
{
// 处理PayPal支付
Console.WriteLine("Processing PayPal payment...");
}
// 新增支付方法时,需要修改此处代码
}
}
在上面的代码中,ProcessPayment
方法基于 paymentType
参数执行不同的逻辑。如果要添加新的支付方式,比如“Apple Pay”,需要修改 ProcessPayment
方法并增加新的判断逻辑,这违反了开闭原则。
下面是一个遵循开闭原则的设计:
// 遵循开闭原则的设计
public interface IPaymentMethod
{
void ProcessPayment(double amount);
}
public class CreditCardPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
Console.WriteLine("Processing credit card payment...");
}
}
public class PayPalPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
Console.WriteLine("Processing PayPal payment...");
}
}
// 新增支付方法只需新增一个类实现IPaymentMethod接口
public class ApplePayPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
Console.WriteLine("Processing Apple Pay payment...");
}
}
public class PaymentProcessor
{
public void ProcessPayment(IPaymentMethod paymentMethod, double amount)
{
paymentMethod.ProcessPayment(amount);
}
}
在遵循开闭原则的版本中,我们定义了一个 IPaymentMethod
接口和多个实现该接口的具体支付方式类。PaymentProcessor
的 ProcessPayment
方法现在接受一个 IPaymentMethod
类型的参数,依赖抽象而不依赖具体实现。
当我们需要添加新的支付方式时,我们只需要新增一个类来实现 IPaymentMethod
接口,而不需修改已有的 PaymentProcessor
类或其他支付方式类。这使得系统更易于扩展,且减少了因为修改导致的潜在错误风险。
下面再列举一个例子,用来说明开闭原则。
想象一下,你正在为一个图形绘制库编写代码,这个库允许用户在屏幕上绘制不同类型的图形。我们将从未遵循开闭原则的设计开始,然后展示如何重构代码以遵守该原则。
// 未遵循开闭原则的设计
public class GraphicEditor
{
public void DrawShape(Shape shape)
{
if (shape.Type == ShapeType.Circle)
{
DrawCircle((Circle)shape);
}
else if (shape.Type == ShapeType.Rectangle)
{
DrawRectangle((Rectangle)shape);
}
// 当需要支持新的图形时,比如三角形,需要修改DrawShape方法,
// 添加新的判断逻辑
}
private void DrawCircle(Circle c)
{
Console.WriteLine("Circle is drawn.");
}
private void DrawRectangle(Rectangle r)
{
Console.WriteLine("Rectangle is drawn.");
}
}
public class Shape
{
public ShapeType Type { get; set; }
}
public class Circle : Shape
{
public Circle()
{
Type = ShapeType.Circle;
}
}
public class Rectangle : Shape
{
public Rectangle()
{
Type = ShapeType.Rectangle;
}
}
public enum ShapeType
{
Circle,
Rectangle
// 如果需要添加更多的图形类型,需要修改此枚举
}
在上述代码中,GraphicEditor
类的 DrawShape
方法根据传入的 Shape
对象的类型决定如何绘制。如果要支持新的图形类型,例如 Triangle
,我们需要改动 GraphicEditor
类和 ShapeType
枚举来添加新的条件分支,这违反了开闭原则。
下面是遵循开闭原则的设计。
// 遵循开闭原则的设计
public abstract class Shape
{
public abstract void Draw();
}
public class Circle : Shape
{
public override void Draw()
{
Console.WriteLine("Circle is drawn.");
}
}
public class Rectangle : Shape
{
public override void Draw()
{
Console.WriteLine("Rectangle is drawn.");
}
}
// 新增图形类三角形,不需要修改现有代码
public class Triangle : Shape
{
public override void Draw()
{
Console.WriteLine("Triangle is drawn.");
}
}
public class GraphicEditor
{
public void DrawShape(Shape shape)
{
shape.Draw(); // 调用具体形状的Draw方法
}
}
在遵循开闭原则的设计中,我们定义了一个抽象的 Shape
类,并提供一个抽象的 Draw
方法。每个具体的图形类(如 Circle
, Rectangle
)继承自 Shape
并覆盖 Draw
方法来实现自己的绘制逻辑。GraphicEditor
的 DrawShape
方法现在可以直接调用 shape.Draw()
,而无需知道具体的图形类型。
当我们需要添加新的图形类型时,我们只需创建一个新的类来继承自 Shape
并实现 Draw
方法。现有的代码无需做任何修改,即可处理新的图形类。这样的设计显著提高了代码的可扩展性和可维护性,并减少了修改引发错误的可能性。
开闭原则的重要性:
- 可扩展性:遵循开闭原则使得新增功能时,往往只需要添加新代码而不是修改既有的代码,这简化了系统的扩展。
- 可维护性:因为新增功能不会影响既有代码,所以降低了修改引发bug的风险,从而提高了代码的可维护性。
- 可测试性:每个类都有明确的职责,它们可以被独立地测试,而不需要担心其他模块的变化影响到当前模块的测试结果。
- 松耦合:系统各部分之间的依赖减少,提升了模块间的独立性。
上面的例子中,
可以将 Shape
类改为一个接口。在很多情况下,使用接口来定义行为是一种很好的做法,特别是当你想要规定一个实现类必须提供某些功能,但又不需要关心状态存储时。
以下是使用接口实现上述例子的方式:
// 使用接口实现上述例子的方式
public interface IDrawable
{
void Draw();
}
public class Circle : IDrawable
{
public void Draw()
{
Console.WriteLine("Circle is drawn.");
}
}
public class Rectangle : IDrawable
{
public void Draw()
{
Console.WriteLine("Rectangle is drawn.");
}
}
// 新增图形类三角形,继承自IDrawable接口
public class Triangle : IDrawable
{
public void Draw()
{
Console.WriteLine("Triangle is drawn.");
}
}
public class GraphicEditor
{
public void DrawShape(IDrawable shape)
{
shape.Draw(); // 调用具体形状的Draw方法
}
}
在这个版本中,IDrawable
接口定义了一个 Draw
方法。所有具体的图形类(如 Circle
, Rectangle
, Triangle
)都实现了这个接口,提供了各自的 Draw
实现。GraphicEditor
的 DrawShape
方法接受任何实现了 IDrawable
接口的对象,并调用其 Draw
方法。
通过定义一个接口,你明确了要实现的方法,同时保持了你的代码遵守开闭原则。当需要添加新的图形类型时,你只需创建一个新的类实现 IDrawable
接口。已有的代码(包括 GraphicEditor
类和其他实现了 IDrawable
接口的类)则无需修改,从而实现了对扩展开放、对修改封闭的目标。
相信你已经体会到了开闭原则的强大优势,那么,开闭原则有什么潜在劣势吗?
当然有。
开闭原则是面向对象设计原则中一个非常重要的原则,它鼓励设计灵活、可扩展且易于维护的代码。然而,没有任何设计原则是完美无缺的,开闭原则也有其潜在劣势:
-
过度抽象:为了遵循开闭原则,开发者可能会创建大量的接口和抽象类,这可能导致系统过度设计,增加了学习和理解代码的难度。
-
预测未来需求的难度:有效地使用开闭原则需要对系统将要如何变化有一定的预见性。如果对未来的需求预测不准确,可能会导致错误的抽象,反而增加了修改代码的成本。
-
初始开发成本增加:遵循开闭原则的代码通常需要花费更多的时间来设计和实现。对于某些小型或短期项目,这种额外成本可能并不合算。
-
复杂性和间接性增加:引入更多抽象层次会使系统的调用链变得更长,难以追踪和调试,提高了项目的复杂度。
-
性能问题:虽然不常见,但是在某些情况下,抽象特别是动态绑定可能会影响性能。在性能敏感的应用程序中,每个方法调用的额外开销都可能成为问题。
-
修改的需求可能被误解:某些情况下,遵循开闭原则实际上可能只是隐藏了代码的修改。例如,添加新功能可能不会修改现有的类文件,但仍然需要添加新类或修改配置,这也是一种“修改”。
-
YAGNI (You Aren't Gonna Need It) 原则冲突:按照 YAGNI 原则,你不应该去实现当前不需要的功能。但有时候为了遵循开闭原则,开发者可能会过早地引入抽象,做出一些基于预测的设计决策,这可能导致资源浪费。
总之,开闭原则提供了一种强大的模式来改善软件的可维护性和扩展性,但同时也需要开发者权衡以上劣势,并在实践中根据具体项目的需求和约束适度地应用这一原则。
依赖倒置原则
下面来说一说依赖倒置原则。可以举个小例子来对比一下遵循依赖倒置原则和未遵循依赖倒置原则的代码。
依赖倒置原则强调高层模块不应该依赖低层模块,它们都应该依赖抽象。接口或抽象类不应该依赖于细节,而细节应该依赖于接口或抽象类。
我将用一个简单的数据访问层(Data Access Layer, DAL)和业务逻辑层(Business Logic Layer, BLL)的例子来展示遵循与未遵循依赖倒置原则的情况。
// 未遵循依赖倒置原则的设计
// 低层模块
public class SqlDatabase
{
public void InsertData(string data)
{
// 将数据插入SQL数据库的逻辑...
Console.WriteLine("Data inserted into SQL database.");
}
}
// 高层模块
public class CustomerService
{
private SqlDatabase _database;
public CustomerService()
{
_database = new SqlDatabase();
}
public void AddCustomer(string data)
{
_database.InsertData(data);
// 其它添加客户的逻辑...
}
}
在上面的代码中,CustomerService
(高层模块)直接依赖于 SqlDatabase
(低层模块)。如果我们需要更改数据存储方式,比如改用 NoSQL 数据库,就必须修改 CustomerService
类。
下面来看看遵循依赖倒置原则的设计。
// 遵循依赖倒置原则的设计
// 抽象
public interface IDatabase
{
void InsertData(string data);
}
// 低层模块实现抽象
public class SqlDatabase : IDatabase
{
public void InsertData(string data)
{
// 将数据插入SQL数据库的逻辑...
Console.WriteLine("Data inserted into SQL database.");
}
}
// 另一个低层模块实现抽象
public class NoSqlDatabase : IDatabase
{
public void InsertData(string data)
{
// 将数据插入NoSQL数据库的逻辑...
Console.WriteLine("Data inserted into NoSQL database.");
}
}
// 高层模块依赖抽象
public class CustomerService
{
private IDatabase _database;
public CustomerService(IDatabase database)
{
_database = database;
}
public void AddCustomer(string data)
{
_database.InsertData(data);
// 其它添加客户的逻辑...
}
}
在遵循依赖倒置原则的代码中,创建了一个 IDatabase
接口作为抽象层,SqlDatabase
和 NoSqlDatabase
类都实现了这个接口。CustomerService
不再直接依赖于具体的 SqlDatabase
类,而是依赖于 IDatabase
接口。现在,切换数据库只需要在 CustomerService
的构造函数中传入不同的实现(比如 new NoSqlDatabase()
),无需修改 CustomerService
类的内部逻辑。
对于这个案例,遵循依赖倒置原则的好处有:
- 提高了代码的灵活性:可以轻松替换不同的数据库实现,只要它们遵从相同的接口。
- 降低了模块间的耦合度:高层模块和低层模块都依赖于抽象,减少了它们之间的直接依赖关系。
- 增强了系统的可维护性和可扩展性:在不破坏现有系统的情况下,支持新的数据库实现或其他功能变得更容易。
让我们再来看一个关于消息发送的案例。在这个例子中,我们将对比未遵循和遵循依赖倒置原则的代码设计。
// 未遵循依赖倒置原则的设计
// 低层模块
public class EmailService
{
public void SendEmail(string toAddress, string subject, string message)
{
// 邮件发送逻辑...
Console.WriteLine($"Sending email to {toAddress}");
}
}
// 高层模块
public class NotificationService
{
private EmailService _emailService;
public NotificationService()
{
_emailService = new EmailService();
}
public void NotifyCustomer(string toAddress, string subject, string message)
{
_emailService.SendEmail(toAddress, subject, message);
// 其他通知客户的逻辑...
}
}
在上面的代码中,NotificationService
类(高层模块)直接依赖于 EmailService
类(低层模块)。如果我们想要添加新的消息发送方式,例如短信或推送通知,就需要修改 NotificationService
类。
下面则是遵循依赖倒置原则的设计。
// 遵循依赖倒置原则的设计
// 抽象
public interface IMessageService
{
void SendMessage(string toAddress, string subject, string message);
}
// 低层模块实现抽象
public class EmailService : IMessageService
{
public void SendMessage(string toAddress, string subject, string message)
{
// 邮件发送逻辑...
Console.WriteLine($"Sending email to {toAddress}");
}
}
// 另一个低层模块实现抽象
public class SmsService : IMessageService
{
public void SendMessage(string toPhoneNumber, string subject, string message)
{
// 短信发送逻辑...
Console.WriteLine($"Sending SMS to {toPhoneNumber}");
}
}
// 高层模块依赖抽象
public class NotificationService
{
private readonly IMessageService _messageService;
public NotificationService(IMessageService messageService)
{
_messageService = messageService;
}
public void NotifyCustomer(string toAddress, string subject, string message)
{
_messageService.SendMessage(toAddress, subject, message);
// 其他通知客户的逻辑...
}
}
在遵循依赖倒置原则的代码中,我们引入了一个 IMessageService
接口作为抽象层,EmailService
和 SmsService
类都实现了这个接口。NotificationService
类不再依赖于具体的 EmailService
类,而是依赖于 IMessageService
接口。这样,引入新的消息服务(如推送通知服务)只需添加新的类实现 IMessageService
接口即可,而不必修改 NotificationService
的内部逻辑。
遵循依赖倒置原则后的设计提升了代码的灵活性和可维护性,因为高层模块 NotificationService
不再依赖于具体的邮件发送机制,而是依赖于更一般的消息发送机制。这种设计使得系统易于扩展,可以轻松地支持多种类型的消息服务。
里氏替换原则
里氏替换原则(Liskov Substitution Principle, LSP)是面向对象设计的基本原则之一,由芭芭拉·里斯科夫提出。其核心思想是:在程序中,如果用子类的实例去替换掉父类的实例,程序的行为没有变化。
让我们通过一个关于鸟类的例子来探讨未遵循和遵循里氏替换原则(LSP)的设计。
假设我们有一个基类 Bird
和一个派生类 Penguin
。按照常理,企鹅是一种鸟,所以它继承自 Bird
类。然而,企鹅不能飞行,如果 Bird
类有一个 Fly
方法,那么 Penguin
类就无法使用该方法,这违反了 LSP。
// 未遵循里氏替换原则的设计
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("This bird is flying.");
}
}
public class Penguin : Bird
{
// Penguin 类不应该实现 Fly 方法,因为企鹅不能飞
public override void Fly()
{
throw new NotImplementedException("Penguins do not fly.");
}
}
public class Program
{
public static void MakeBirdFly(Bird bird)
{
bird.Fly();
}
static void Main(string[] args)
{
var sparrow = new Bird();
var penguin = new Penguin();
MakeBirdFly(sparrow); // 正常工作
MakeBirdFly(penguin); // 运行时错误,因为企鹅不能飞
}
}
在这个设计中,传递 Penguin
对象到 MakeBirdFly
方法会导致运行时错误,因为企鹅不会飞,这显然违反了 LSP。
为了遵循 LSP,我们需要重构代码以确保基类和派生类之间的行为一致性。我们可以将 Bird
类分解成两个类:FlyingBird
和 NonFlyingBird
。
// 遵循里氏替换原则的设计
public abstract class Bird
{
// 所有鸟共有的属性和方法
}
public abstract class FlyingBird : Bird
{
public void Fly()
{
Console.WriteLine("This bird is flying.");
}
}
public abstract class NonFlyingBird : Bird
{
// 可能有与非飞行鸟相关的特殊属性或方法
}
public class Sparrow : FlyingBird
{
// 具体的飞行鸟类
}
public class Penguin : NonFlyingBird
{
// 企鹅类,企鹅无须实现飞行的方法
}
public class Program
{
public static void MakeBirdFly(FlyingBird bird)
{
bird.Fly();
}
static void Main(string[] args)
{
var sparrow = new Sparrow();
var penguin = new Penguin();
MakeBirdFly(sparrow); // 正常工作
// MakeBirdFly(penguin); // 编译错误,因为 Penguin 不是 FlyingBird
}
}
在这个重构后的设计中,FlyingBird
和 NonFlyingBird
抽象类分别代表了可以飞行和不能飞行的鸟。每个具体的鸟类都继承自适当的抽象类,这样我们就可以确保只有能够飞行的鸟被传递给 MakeBirdFly
方法。如果尝试将 Penguin
对象传递给 MakeBirdFly
,代码将无法编译,从而在编译时而不是运行时暴露问题。
通过采用这种方式,我们确保了派生类可以完全替代基类对象,遵循了里氏替换原则。
让我们再举一个例子:使用一个关于矩形和正方形的例子来对比未遵循和遵循里氏替换原则(LSP)的设计。
假设我们有一个 Rectangle
类,并且有一个派生类 Square
。一般认为正方形是一种特殊的矩形,因此它似乎应该继承自 Rectangle
类。但在实际应用中,如果 Rectangle
的设置宽度和高度是独立的,那么 Square
就不能正确地继承 Rectangle
,因为正方形的宽度和高度必须保持相等。
// 未遵循里氏替换原则的设计
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area()
{
return Width * Height;
}
}
public class Square : Rectangle
{
public override int Width
{
set { base.Width = base.Height = value; }
}
public override int Height
{
set { base.Height = base.Width = value; }
}
}
public static void SetAndPrintArea(Rectangle rect, int width, int height)
{
rect.Width = width;
rect.Height = height;
Console.WriteLine($"Expected area: {width * height}, Actual area: {rect.Area()}");
}
public class Program
{
static void Main(string[] args)
{
var rectangle = new Rectangle();
var square = new Square();
SetAndPrintArea(rectangle, 4, 5); // 这将输出 Expected area: 20, Actual area: 20
SetAndPrintArea(square, 4, 5); // 这将输出 Expected area: 20, Actual area: 16,违反 LSP
}
}
在这个设计中,尽管 Square
继承自 Rectangle
,但它的行为与 Rectangle
不同。尤其是当设置宽度和高度时,由于 Square
类重写了属性,使得无论怎样设置,宽度和高度总是相等,这显然违反了 LSP。
为了遵循 LSP,我们需要重新设计我们的类结构,以确保基类和派生类之间的行为一致性。考虑到正方形和矩形的行为差异,它们不应该通过继承来表示“是一个”关系。相反,我们可以定义它们实现共同接口或共享基类的方式。
// 遵循里氏替换原则的设计
// 定义一个图形接口,包含计算面积的方法。
public interface IShape
{
int Area();
}
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public int Area()
{
return Width * Height;
}
}
public class Square : IShape
{
public int SideLength { get; set; }
public Square(int sideLength)
{
SideLength = sideLength;
}
public int Area()
{
return SideLength * SideLength;
}
}
public static void PrintArea(IShape shape)
{
Console.WriteLine($"Area: {shape.Area()}");
}
public class Program
{
static void Main(string[] args)
{
var rectangle = new Rectangle(4, 5);
var square = new Square(4);
PrintArea(rectangle); // 输出 Area: 20
PrintArea(square); // 输出 Area: 16
}
}
在这个重构后的设计中,Rectangle
和 Square
类都实现了 IShape
接口,它们都必须实现 Area
方法。这样,任何期望一个 IShape
的地方都可以使用 Rectangle
或 Square
实例而不会违反 LSP。也就是说,任何使用了 IShape
类型的代码都能够无缝处理 Rectangle
和 Square
的实例。
通过采用这种方式,我们消除了由于错误的继承关系而引入的问题,从而遵循了里氏替换原则。
单一职责原则
单一职责原则(SRP)指出一个类应该只有一个引起变化的原因。这意味着一个类应该只承担一项责任,如果有多于一项责任,则这些责任就应该被分解到其他类中。
假如我们有一个 User
类,它不仅管理用户信息,还负责将用户信息保存到数据库和从数据库读取用户信息。这样的设计违反了单一职责原则,因为 User
类同时承担了两项不同的责任:用户信息管理和数据持久化。
// 未遵循单一职责原则的设计
public class User
{
public string Username { get; set; }
public string Password { get; set; }
// 用户管理的职责
public void ChangePassword(string newPassword)
{
Password = newPassword;
// ... 可能还有一些密码策略校验等操作
}
// 数据持久化的职责
public void SaveToDatabase()
{
Console.WriteLine("User saved to database.");
// ... 连接数据库并保存用户信息
}
public static User LoadFromDatabase(string username)
{
Console.WriteLine("User loaded from database.");
// ... 连接数据库并加载用户信息
return new User { Username = username };
}
}
public class Program
{
static void Main(string[] args)
{
var user = new User { Username = "JohnDoe", Password = "12345" };
user.ChangePassword("newPassword");
user.SaveToDatabase();
var loadedUser = User.LoadFromDatabase(user.Username);
}
}
在这段代码中,User
类既关心用户的业务逻辑(比如修改密码),又关心与数据库的交互(比如保存和加载用户信息)。如果用户信息的存储需求发生变化,比如需要改成保存到云服务,那么除了更改数据库相关的代码,可能还需要修改用户管理的代码,这使得类更难以维护且易于出错。
一个遵循单一职责原则的设计会将 User
类和数据访问逻辑分离开,通常会创建一个专门用于数据持久化的类(如 UserRepository
)。
// 遵循单一职责原则的设计
public class User
{
public string Username { get; set; }
public string Password { get; set; }
public void ChangePassword(string newPassword)
{
Password = newPassword;
// 其他密码策略校验等操作...
}
}
public class UserRepository
{
public void Save(User user)
{
Console.WriteLine($"User {user.Username} saved to database.");
// 实现保存到数据库的逻辑...
}
public User Load(string username)
{
Console.WriteLine($"User {username} loaded from database.");
// 实现加载用户信息的逻辑...
return new User { Username = username };
}
}
public class Program
{
static void Main(string[] args)
{
var user = new User { Username = "JohnDoe", Password = "12345" };
user.ChangePassword("newPassword");
var userRepository = new UserRepository();
userRepository.Save(user); // 现在保存操作由 UserRepository 类来处理
var loadedUser = userRepository.Load(user.Username); // 加载操作也是由 UserRepository 来处理
}
}
在遵循 SRP 的设计中,User
类专注于用户信息管理,而 UserRepository
类处理所有的数据持久化逻辑。现在,如果需求变化只影响数据存储方式,我们只需要修改 UserRepository
类,而不需要触碰 User
类。这提高了系统的可维护性,并降低了因需求变更导致的风险。
已上通过将不同的功能区分到专职的类中,我们的设计清晰地遵循了单一职责原则。
我们再举一个有关鸟儿的例子来说明单一职责原则(SRP)。
假设我们有一个 Bird
类,它负责处理鸟的各种行为,包括飞行和吃食。此外,这个类还负责记录鸟的飞行距离,显然,飞行距离是一种与鸟的日常行为(Action)不直接相关的功能(即存储数据的功能)。这样的设计违反了单一职责原则。
// 未遵循单一职责原则的设计
public class Bird
{
public string Name { get; set; }
public double FlightDistance { get; private set; }
public Bird(string name)
{
Name = name;
}
public void Eat()
{
Console.WriteLine($"{Name} is eating.");
}
public void Fly(double distance)
{
Console.WriteLine($"{Name} is flying.");
FlightDistance += distance; // 飞行行为同时管理飞行距离
}
// 这里同样也管理了距离记录的职责
public void ResetFlightDistance()
{
FlightDistance = 0;
}
}
public class Program
{
static void Main(string[] args)
{
var bird = new Bird("Sparrow");
bird.Eat();
bird.Fly(100);
bird.ResetFlightDistance();
}
}
在这段代码中,Bird
类负责了太多任务:处理食物、飞行,并且还要管理飞行历史记录。如果我们要修改飞行距离的记录方式或者要将飞行历史存储到数据库中,就需要修改 Bird
类,这会增加错误发生的可能性,并使得 Bird
类过于复杂。
为了遵循 SRP,我们应该创建独立的类来处理飞行日志。
// 遵循单一职责原则的设计
public class Bird
{
public string Name { get; set; }
public Bird(string name)
{
Name = name;
}
public void Eat()
{
Console.WriteLine($"{Name} is eating.");
}
// Fly 方法现在接受 FlightRecorder 作为参数
public void Fly(FlightRecorder recorder, double distance)
{
Console.WriteLine($"{Name} is flying.");
recorder.RecordFlight(this, distance); // 记录特定鸟的飞行距离
}
}
// 调整 FlightRecorder 类,使其能够记录特定鸟的飞行数据
public class FlightRecorder
{
private Dictionary<Bird, double> flightDistances = new Dictionary<Bird, double>();
public void RecordFlight(Bird bird, double distance)
{
if (flightDistances.ContainsKey(bird))
{
flightDistances[bird] += distance;
}
else
{
flightDistances[bird] = distance;
}
}
public double GetTotalDistance(Bird bird)
{
if (flightDistances.TryGetValue(bird, out double totalDistance))
{
return totalDistance;
}
return 0;
}
public void Reset(Bird bird)
{
if (flightDistances.ContainsKey(bird))
{
flightDistances[bird] = 0;
}
}
}
public class Program
{
static void Main(string[] args)
{
var bird = new Bird("Sparrow");
var flightRecorder = new FlightRecorder();
bird.Eat();
bird.Fly(flightRecorder, 100); // 飞行并记录
Console.WriteLine($"Total flight distance for {bird.Name}: {flightRecorder.GetTotalDistance(bird)}");
flightRecorder.Reset(bird); // 重置特定鸟的飞行记录
}
}
这里我们为 Bird
类的 Fly
方法添加了一个 FlightRecorder
参数,允许它将飞行距离记录到特定的 FlightRecorder
实例中。相应地,FlightRecorder
类使用一个字典来存储每个 Bird
对象与其飞行距离之间的映射。
现在当调用 Fly
方法时,bird
实例会将自己和飞行的距离一起传递给 flightRecorder
,这样就能保持每只鸟的飞行记录与 Bird
实例相关联。如果需要查询或重置特定鸟的飞行距离,也可以通过 FlightRecorder
的相应方法来实现。
这种设计遵循了单一职责原则:Bird
类专注于描述鸟的基本行为,而 FlightRecorder
类负责跟踪和记录飞行信息。同时,它们之间通过方法参数建立了联系,使得飞行数据与特定的鸟关联起来。
接口隔离原则
接口隔离原则(Interface Segregation Principle, ISP)建议客户端不应该被迫依赖于它们不使用的方法,一个类对另一个类的依赖应该仅限于其最小的接口。换句话说就是,将非常多的功能整合在同一个接口中是不理智的,若接口包含的功能太多(胖接口),则需要考虑拆分接口(小接口),毕竟一个类可以实现多个接口,每个类各取所需即可。
下面举例来认识接口隔离原则。假设我们有一个定义了多个方法的接口 IMultiFunctionPrinter
,这个接口包括打印、扫描和传真等功能。然而,并非所有的打印机都支持这些功能。如果某个只能打印的简单打印机类实现了这个接口,它就必须提供所有这些方法的实现,即使它不支持某些操作。
// 未遵循接口隔离原则的设计
// 胖接口:包含打印、扫描和传真等多种操作的接口
public interface IMultiFunctionPrinter
{
void Print();
void Scan();
void Fax();
}
// 只能打印的打印机类,根据 ISP 这样的设计是不合适的
public class SimplePrinter : IMultiFunctionPrinter
{
public void Print()
{
Console.WriteLine("Printing document.");
}
// SimplePrinter 类不支持扫描和传真功能,但它被迫实现这些方法。
public void Scan()
{
throw new NotImplementedException("Scan function is not supported.");
}
public void Fax()
{
throw new NotImplementedException("Fax function is not supported.");
}
}
在这段代码中,SimplePrinter
实现了 IMultiFunctionPrinter
接口,但它并不支持扫描和传真功能,因此它被迫提供了这些方法的空实现。这明显违反了接口隔离原则,因为 SimplePrinter
的客户端不应该看到它不需要的功能。
要遵循接口隔离原则,我们应该将 IMultiFunctionPrinter
接口分解为独立的接口,每个接口只包含一个特定的功能。这样,各个打印机类就可以只实现它们所需要的接口。
// 遵循接口隔离原则的设计
public interface IPrinter
{
void Print();
}
public interface IScanner
{
void Scan();
}
public interface IFax
{
void Fax();
}
// 现在 SimplePrinter 类只实现它支持的操作接口
public class SimplePrinter : IPrinter
{
public void Print()
{
Console.WriteLine("Printing document.");
}
}
// 如果存在支持所有功能的多功能打印机,它可以实现所有接口
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print()
{
Console.WriteLine("Printing document.");
}
public void Scan()
{
Console.WriteLine("Scanning document.");
}
public void Fax()
{
Console.WriteLine("Sending fax.");
}
}
在遵循 ISP 的设计中,我们创建了三个独立的接口:IPrinter
、IScanner
和 IFax
,每个接口分别对应一种设备功能。SimplePrinter
类只实现了 IPrinter
接口,因为它只需要打印功能。而 MultiFunctionPrinter
实现了所有三个接口,因为它是一个多功能设备。这样,每个类只关心它们真正需要实现的接口,提高了代码的内聚性和可维护性。
通过采用上面的设计,我们不仅满足了接口隔离原则,还使得系统更容易理解和扩展。每个接口都有一个清晰定义的职责,而且只有当打印机类真正需要某项功能时,它才会去实现相关的接口。
再来举个鸟类的例子。
假设我们定义了一个包含多种行为的 IBird
接口,这些行为包括飞行、游泳和鸣叫。然而,并不是所有的鸟类都具备这些功能。例如,企鹅可以游泳但不能飞行,而麻雀可以飞行和鸣叫但不能游泳。如果让企鹅和麻雀类都实现 IBird
接口,它们就会被迫提供所有这三个方法的实现,即使某些行为对它们来说并不适用。
// 未遵循接口隔离原则的设计
public interface IBird
{
void Fly();
void Swim();
void MakeSound();
}
public class Sparrow : IBird
{
public void Fly()
{
Console.WriteLine("Sparrow is flying.");
}
// Sparrow 可以鸣叫,因此实现 MakeSound 方法
public void MakeSound()
{
Console.WriteLine("Sparrow makes sound.");
}
// 麻雀不游泳,但依然需要实现 Swim 方法,即使它不做任何事情或抛出异常
public void Swim()
{
throw new NotImplementedException("Sparrows don't swim.");
}
}
public class Penguin : IBird
{
// 企鹅不会飞,所以这里的实现可能是空的或者抛出异常
public void Fly()
{
throw new NotImplementedException("Penguins can't fly.");
}
public void Swim()
{
Console.WriteLine("Penguin is swimming.");
}
// 企鹅会发出声音,因此实现 MakeSound 方法
public void MakeSound()
{
Console.WriteLine("Penguin makes sound.");
}
}
在上面的例子中,Sparrow
和 Penguin
类都被迫实现了它们不需要的方法,这违反了接口隔离原则。
按照接口隔离原则,我们应该将 IBird
接口分解成多个专门的接口,每个接口只包含与之相关的行为。
// 遵循接口隔离原则的设计
public interface IFlyable
{
void Fly();
}
public interface ISwimmable
{
void Swim();
}
public interface IMakeSound
{
void MakeSound();
}
// 麻雀可以飞行和鸣叫,所以它实现了 IFlyable 和 IMakeSound 接口
public class Sparrow : IFlyable, IMakeSound
{
public void Fly()
{
Console.WriteLine("Sparrow is flying.");
}
public void MakeSound()
{
Console.WriteLine("Sparrow makes sound.");
}
}
// 企鹅可以游泳和鸣叫,所以它实现了 ISwimmable 和 IMakeSound 接口
public class Penguin : ISwimmable, IMakeSound
{
public void Swim()
{
Console.WriteLine("Penguin is swimming.");
}
public void MakeSound()
{
Console.WriteLine("Penguin makes sound.");
}
}
在遵循 ISP 的设计中,我们创建了三个专门的接口:IFlyable
、ISwimmable
和 IMakeSound
。这样,每种鸟类只需实现它能够执行的行为对应的接口。Sparrow
类实现了 IFlyable
和 IMakeSound
接口,而 Penguin
类实现了 ISwimmable
和 IMakeSound
接口。
这样的设计避免了强制类实现它们不需要的方法,同时也保持了代码的灵活性和可扩展性。各个类只依赖于与它们行为相关的最小接口集,这正是接口隔离原则的核心思想。
合成复用原则
合成/复用原则(Composition Over Inheritance Principle, COP)是面向对象设计的一条重要原则,它建议在软件设计中应该优先使用组合(composition)或聚合(aggregation)来实现代码复用,而不是通过继承(inheritance)。
继承虽然强大,但它也可能导致类层次结构变得复杂和僵硬。子类与基类之间的紧密耦合有时会使得代码变更变得困难,因为基类的改动可能会影响到所有的派生类。此外,继承还可能引入不需要的功能,造成资源浪费。
相比之下,组合提供了更大的灵活性。通过将对象组合在一起,可以很容易地添加、修改或删除组件,而不会影响到其他部分。组合还允许动态地改变对象的行为,提供了更好的可维护性和扩展性。
举例:假设我们有一个 Vehicle
基类,其中包含了许多通用的方法和属性。如果我们想要创建一个 Car
类,可能会选择直接从 Vehicle
继承并添加一些特定的属性或行为。
// 未遵循合成复用原则的设计
public class Vehicle
{
public void StartEngine()
{
Console.WriteLine("Engine started.");
}
// 其他通用的方法和属性...
}
// 通过继承 Vehicle 来复用代码
public class Car : Vehicle
{
public void OpenTrunk()
{
Console.WriteLine("Trunk opened.");
}
// 再添加一些特定于汽车的方法和属性...
}
上面的设计使用继承来复用代码,但如果 Vehicle
类包含了一些对于 Car
类不适用的方法和属性,那么这种设计就不太合理了。
为了实践合成复用原则,我们可以重新考虑设计,使用组合而非继承。
// 遵循合成复用原则的设计
public class Engine
{
public void Start()
{
Console.WriteLine("Engine started.");
}
// 其他与引擎相关的方法...
}
public class Trunk
{
public void Open()
{
Console.WriteLine("Trunk opened.");
}
// 其他与行李箱相关的方法...
}
// 使用组合而非继承
public class Car
{
private Engine _engine;
private Trunk _trunk;
public Car(Engine engine, Trunk trunk)
{
_engine = engine;
_trunk = trunk;
}
public void StartEngine()
{
_engine.Start();
}
public void OpenTrunk()
{
_trunk.Open();
}
// 还可以添加一些特定于汽车的方法和属性...
}
在这个例子中,Car
类不再继承自 Vehicle
类。相反,它包含了 Engine
和 Trunk
对象的实例。通过组合这些对象,Car
类能够复用 Engine
和 Trunk
中的代码,并且只暴露它所需要的接口。这种方法不仅减少了类之间的耦合,还提供了更灵活的代码重用机制。
再来举一个有关鸟类的例子。
假设我们有一个基类 Bird
,其中包含了飞行和鸣叫等通用方法。如果我们想要创建一个具有附加特性的 Sparrow
类和 Penguin
类,我们可能会选择从 Bird
类继承,并添加一些特定的行为。
// 未遵循合成复用原则的设计
public class Bird
{
public void Fly()
{
Console.WriteLine("Flying high.");
}
public void MakeSound()
{
Console.WriteLine("Chirp chirp.");
}
// 其他通用的方法...
}
// 继承 Bird 类来表示麻雀
public class Sparrow : Bird
{
// 添加了麻雀特有的方法或属性
public void FluffUpFeathers()
{
Console.WriteLine("Sparrow fluffs up its feathers.");
}
}
// 继承 Bird 类来表示企鹅
public class Penguin : Bird
{
public override void Fly()
{
throw new InvalidOperationException("Penguins can't fly!");
}
// 企鹅特有的游泳方法
public void Swim()
{
Console.WriteLine("Penguin swims in the water.");
}
}
在上述设计中,Penguin
类不应该继承 Fly
方法,因为企鹅是不会飞的,但由于它继承自 Bird
类,我们被迫重写 Fly
方法并抛出异常。
而对于遵循合成复用原则,我们可以将行为(如飞行、游泳)分解成独立的组件,鸟类可以根据需要组合这些组件来获取特定行为。
// 遵循合成复用原则的设计
public interface IFlyBehavior
{
void Fly();
}
public class FlyingWithWings : IFlyBehavior
{
public void Fly()
{
Console.WriteLine("Flying high with wings.");
}
}
public interface ISwimBehavior
{
void Swim();
}
public class Swimming : ISwimBehavior
{
public void Swim()
{
Console.WriteLine("Swimming in the water.");
}
}
public class Bird
{
private readonly IFlyBehavior _flyBehavior;
private readonly ISwimBehavior _swimBehavior;
// 将飞行和游泳行为作为组件注入
public Bird(IFlyBehavior flyBehavior, ISwimBehavior swimBehavior)
{
_flyBehavior = flyBehavior;
_swimBehavior = swimBehavior;
}
public void PerformFly()
{
_flyBehavior?.Fly();
}
public void PerformSwim()
{
_swimBehavior?.Swim();
}
public void MakeSound()
{
Console.WriteLine("Chirp chirp.");
}
// 其他通用的方法...
}
// 使用组合来构造麻雀
public class Sparrow : Bird
{
public Sparrow() : base(new FlyingWithWings(), null) {}
// 麻雀特有的行为
public void FluffUpFeathers()
{
Console.WriteLine("Sparrow fluffs up its feathers.");
}
}
// 使用组合来构造企鹅
public class Penguin : Bird
{
public Penguin() : base(null, new Swimming()) {}
// 企鹅特有的行为
public void Waddle()
{
Console.WriteLine("Penguin waddles on the ice.");
}
}
在遵循 COP 的设计中,我们通过 IFlyBehavior
和 ISwimBehavior
接口定义了可插拔的飞行和游泳行为,并使用具体的实现类 FlyingWithWings
和 Swimming
来提供这些行为。每种鸟类都可以根据其特性选择合适的行为。
例如,Sparrow
类只注入了 FlyingWithWings
行为,而 Penguin
类只注入了 Swimming
行为,这样每个类都只拥有适合自己的功能。这种设计使得我们可以更灵活地重用代码,并且能够在不影响其他类的情况下修改特定行为。
通过这种方式,我们提高了代码的复用性和灵活性,同时减少了耦合度,更加符合合成复用原则。
迪米特法则
迪米特法则(Law of Demeter, LoD),也称为最少知识原则,它建议一个对象应当对其他对象有尽可能少的了解,并且只与直接的朋友通信。
直接的朋友定义为:在当前方法内部创建的对象、传入方法的参数、当前类中的实例变量。
迪米特法则主张:
- 每个单位应当尽可能地减少对其他单位的了解。
- 每个单位只和朋友交谈,不和陌生人说话。
- 只与直接的朋友通信。
按照这个原则,如果一个对象需要请求另一个对象的服务,那么这两个对象之间的交互应该通过公开的接口进行。此外,一个对象不应该暴露其内部组件或者自己依赖的其他对象的细节。
假设我们有一个 HouseOwner
类,它负责管理家庭中的各种事务。如果 HouseOwner
类直接与太多其他类发生交互,那么就会增加系统的复杂性和耦合度。
// 未遵循迪米特法则的设计
public class Wallet
{
public decimal Amount { get; set; }
public void Pay(decimal amount)
{
if (Amount < amount)
{
throw new InvalidOperationException("Not enough money.");
}
Amount -= amount;
}
}
public class HouseOwner
{
private Wallet myWallet;
public HouseOwner(Wallet wallet)
{
myWallet = wallet;
}
// 这里直接与 Wallet 对象交互,暴露了支付的细节
public void PayBill(decimal billAmount)
{
myWallet.Pay(billAmount);
}
}
在上面的例子中,HouseOwner
类直接调用 Wallet
类的 Pay
方法来支付账单。这样的设计违反了迪米特法则,因为 HouseOwner
类过多地了解了 Wallet
类的内部实现。
根据迪米特法则,我们可以重构代码,使得 HouseOwner
类不需要直接与 Wallet
类交互,而是提供一个更抽象的方法来处理支付。
// 遵循迪米特法则的设计
public class Wallet
{
private decimal amount;
public Wallet(decimal initialAmount)
{
amount = initialAmount;
}
public bool HasSufficientAmount(decimal amountToCheck)
{
return amount >= amountToCheck;
}
public void ReduceAmount(decimal amountToReduce)
{
if (!HasSufficientAmount(amountToReduce))
{
throw new InvalidOperationException("Not enough money.");
}
amount -= amountToReduce;
}
}
public class HouseOwner
{
private Wallet myWallet;
public HouseOwner(Wallet wallet)
{
myWallet = wallet;
}
// 现在 HouseOwner 不需要知道关于支付的具体细节
public void PayBill(decimal billAmount)
{
if (myWallet.HasSufficientAmount(billAmount))
{
myWallet.ReduceAmount(billAmount);
}
else
{
Console.WriteLine("Not enough money to pay the bill.");
}
}
}
这里,HouseOwner
类通过 HasSufficientAmount
方法来检查是否有足够的金额支付账单,然后通过 ReduceAmount
方法来减少钱包的金额。这样的设计减少了 HouseOwner
对 Wallet
内部情况的了解,符合迪米特法则。同时,也降低了类之间的耦合性,提高了代码的可维护性。
总结起来,遵循迪米特法则能够帮助我们设计出更松耦合、更易于维护和扩展的系统。
至于上面的例子为什么可以减少 HouseOwner
对 Wallet
内部情况的了解,降低了类之间的耦合性,下面展开讨论:
遵循了迪米特法则的设计减少了 HouseOwner
对 Wallet
内部情况的了解,并降低了类之间的耦合性,主要表现在以下几个方面:
-
抽象化交互:在遵循迪米特法则的设计中,
HouseOwner
通过两个高级方法HasSufficientAmount
和ReduceAmount
与Wallet
交互。这些方法隐藏了Wallet
的内部实现细节,如何检查余额和怎样减少金额都不需要HouseOwner
知道。因此,HouseOwner
只知道它可以检查钱包里是否有足够的资金,并能要求钱包减少相应的金额,而不必了解具体是怎么做到的。 -
降低依赖度:在原来的设计中,如果
Wallet
的Pay
方法签名或实现方式发生变化(例如添加新的参数、更改支付逻辑等),那么HouseOwner
类也必须相应地进行修改以适应这些变化。在遵循迪米特法则的设计中,只要HasSufficientAmount
和ReduceAmount
的接口保持不变,Wallet
内部的变化就不会影响到HouseOwner
,这样就减少了耦合性。 -
隔离变化:由于
HouseOwner
不直接操作Wallet
的内部状态,Wallet
的实现可以自由变化而不影响到HouseOwner
。比如,如果我们决定给Wallet
添加一个交易记录的功能,只需要在ReduceAmount
方法内部进行处理,而无需修改HouseOwner
类。这提供了更好的封装性,允许Wallet
独立于HouseOwner
进行改进和扩展。 -
提高代码的可读性和易维护性:遵循迪米特法则的设计简化了
HouseOwner
的职责,使其只关注是否可以支付账单和执行支付操作,而不用关心支付的具体逻辑。这样的代码结构更清晰,易于理解和维护。
总之,Wallet 只需将复杂的逻辑(可能包含多个方法)封装在简单的接口中一并实现,而无需 HouseOwner 知晓(调用)过多的 Wallet 方法,达到高内聚、低耦合的目的。
对于迪米特法则,再举一个关于鸟类的例子。
设想我们有一个鸟类园区管理系统,其中Bird
对象含有多个属性和行为,比如翅膀(Wing
)和心脏(Heart
)。如果BirdKeeper
(饲养员)直接与这些内部组成进行调用,这样的设计将违反迪米特法则。
// 未遵循迪米特法则的设计
public class Wing
{
public void Flap()
{
Console.WriteLine("Wing flaps.");
}
}
public class Heart
{
public void Beat()
{
Console.WriteLine("Heart beats.");
}
}
public class Bird
{
public Wing Wing { get; set; }
public Heart Heart { get; set; }
public Bird()
{
Wing = new Wing();
Heart = new Heart();
}
}
public class BirdKeeper
{
public void MakeBirdFly(Bird bird)
{
// 直接与鸟的内部组件交互
bird.Wing.Flap();
}
public void CheckBirdHeart(Bird bird)
{
// 直接与鸟的内部组件交互
bird.Heart.Beat();
}
}
在这个设计中,BirdKeeper
需要明确了解Bird
内部的细节,如它具有 Wing
和 Heart
属性,并且需要直接操作这些属性来使鸟飞行或检查鸟的心脏跳动。
按照迪米特法则,饲养员不应该直接与鸟的内部组件交互。相反,鸟应该提供方法来封装这些行为。
// 遵循迪米特法则的设计
public class Wing
{
internal void Flap()
{
Console.WriteLine("Wing flaps.");
}
}
public class Heart
{
internal void Beat()
{
Console.WriteLine("Heart beats.");
}
}
public class Bird
{
private Wing wing;
private Heart heart;
public Bird()
{
wing = new Wing();
heart = new Heart();
}
public void Fly()
{
wing.Flap(); // 封装翅膀拍动的行为
Console.WriteLine("Bird is flying.");
}
public void CheckHealth()
{
heart.Beat(); // 封装心脏跳动的行为
Console.WriteLine("Bird's health is checked.");
}
}
public class BirdKeeper
{
public void MakeBirdFly(Bird bird)
{
// 告诉鸟要飞,而不是告诉翅膀要拍动
bird.Fly();
}
public void CheckBirdHeart(Bird bird)
{
// 告诉鸟进行健康检查,而不是直接查看鸟的心脏
bird.CheckHealth();
}
}
在遵循迪米特法则的设计中,BirdKeeper
只需要调用 Bird
类公开的 Fly
和 CheckHealth
方法即可。Bird
类封装了内部逻辑,如何实现飞行和健康检查都隐藏起来。BirdKeeper
不需要知道这些行为是通过哪些具体的组件实现的。
这种设计符合迪米特法则,因为它减少了 BirdKeeper
对于 Bird
内部复杂结构和功能实现细节的依赖。这种黑箱设计降低了耦合性,并提升了代码的模块性和可维护性。当修改鸟类的内部工作机制时,只要对外提供的接口保持不变,饲养员的行为就无需改变,这大大简化了系统的维护和扩展。