面向对象设计原则

面向对象设计原则

1 开闭原则(Open-Closed Principle,OCP)

1.1 内容

软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。即当需要对软件进行功能扩展时,应该通过添加新的代码而不是修改已有代码来实现。

1.2 好处

  • 可扩展性强:软件实体对扩展开放,这使得系统能够轻松地添加新功能。例如在图形绘制系统中,通过定义抽象的Shape类和具体的图形子类(如CircleRectangle等),当需要添加新的图形(如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是父类,LionTurtle是子类。Animal类有move()方法,Lion类的move()方法可以实现奔跑的行为,Turtle类的move()方法可以实现缓慢爬行的行为。当在程序中使用Animal类型的变量来引用LionTurtle对象并调用move()方法时,程序能够按照子类的实际行为正确运行,保证了程序的健壮性。
  • 支持多态性的正确使用:里氏替换原则是多态性的基础,它使得多态的设计能够正常工作。通过允许子类替换父类,开发人员可以在不改变代码结构的情况下,根据实际对象的类型来执行不同的行为。例如在一个图形绘制系统中,Shape是父类,CircleRectangle是子类。可以定义一个方法drawShape(Shape shape),它可以根据传入的不同形状(CircleRectangle)对象,正确地调用相应子类的绘制方法,实现了多态性的正确应用。
  • 增强代码的可复用性:当子类能够正确替换父类时,父类的代码可以被更多的子类复用。例如在一个交通工具管理系统中,Vehicle是父类,CarTruck是子类。Vehicle类中的一些通用方法(如startEngine())可以被CarTruck子类复用,并且由于里氏替换原则的保证,这些子类在替换父类使用时能够正确运行,提高了代码的复用性。

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类是由CPUMemoryHardDisk等组件组合而成。如果要升级CPU的性能,只需要修改CPU类的相关代码和Computer类中与CPU组合的部分,而不会像继承那样可能影响到所有的子类。
  • 更符合现实世界的关系:现实世界中的很多对象之间是组合或聚合关系,在代码中使用这种关系更加直观。例如在一个汽车制造系统中,汽车是由发动机、车身、轮胎等部件组合而成的,这种组合关系在代码中的体现更符合实际情况,相比于继承关系,更容易理解和设计。

7.3 示例

假设我们有一个车辆管理系统,最初设计了一个Vehicle基类,有startEngine()stopEngine()方法。然后我们有CarTruck类继承自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");
    }
}

然后CarTruck类可以通过组合的方式使用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
{
    // 自行车特有的属性和方法可以放在这里
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Winemonk

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值