软件设计七大原则

94 阅读8分钟

单一职责原则(Single Responsibility Principle, SRP)

定义:一个类应该只有一个引起变化的原因。

解释:每个类应该仅仅负责一项职责或功能。这样可以使类更加简洁,并且在需求变更时,修改某一项职责的代码不会影响到其他职责的代码,从而提高系统的可维护性。

里氏替换原则(Liskov Substitution Principle, LSP)

定义:子类对象必须能够替换掉所有父类对象。

解释:子类应该能够替换其父类,并且子类的行为应与父类一致。即,使用基类的地方必须能够透明地使用其子类对象,而不会引发错误或行为变化。

示例

#include <iostream>

// 违反LSP,子类行为不符合父类预期
class Bird {
public:
    virtual void fly() {
        std::cout << "Bird is flying\n";
    }
};

// 麻雀类
class Sparrow : public Bird {
public:
    void fly() override {
        std::cout << "Sparrow is flying\n";
    }
};

// 鸵鸟类,鸵鸟不会飞
class Ostrich : public Bird {
public:
    void fly() override {
        throw std::logic_error("Ostriches can't fly");
    }
};

// 遵循LSP,设计更通用的接口
class Bird {
public:
    virtual void move() {
        std::cout << "Bird is moving\n";
    }
};

class Sparrow : public Bird {
public:
    void move() override {
        std::cout << "Sparrow is flying\n";
    }
};

class Ostrich : public Bird {
public:
    void move() override {
        std::cout << "Ostrich is running\n";
    }
};

int main() {
    Bird* bird = new Sparrow();
    bird->move();

    bird = new Ostrich();
    bird->move();

    delete bird;
    return 0;
}

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

具体解释

  1. 对扩展开放:软件实体应当可以通过新增功能来扩展其行为,而无需修改现有代码。这意味着当需求变化或增加时,我们应当能够通过增加新代码(如新类或新方法)来满足这些需求,而不是修改已有的代码。
  2. 对修改封闭:一旦软件实体被开发完成并投入使用,就不应再去修改它。修改现有代码可能会引入新的错误,并破坏现有系统的稳定性。因此,在设计时应尽量考虑到可能的扩展需求,确保以后只需要增加新代码即可满足新的需求。

实现方法

为了实现开放封闭原则,常用的方法有以下几种:

  1. 使用抽象和继承:通过定义抽象类或接口,并将具体实现延迟到子类中。这样可以在不修改抽象类的情况下,通过增加新的子类来实现功能的扩展。
  2. 使用多态:多态允许程序在运行时选择合适的实现,从而提高系统的灵活性和扩展性。例如,通过接口引用具体实现类的实例,客户端代码只需依赖接口而不依赖具体实现类。
  3. 使用装饰者模式(Decorator Pattern) :这种设计模式允许通过组合对象来动态地扩展对象的功能,而无需修改现有类的代码。

示例

#include <iostream>
#include <vector>
#include <memory>
#include <cmath>

// 抽象基类 Shape
class Shape {
public:
    virtual double area() const = 0; // 纯虚函数,计算面积
    virtual ~Shape() = default; // 虚析构函数
};

// 圆形类 Circle,实现了 Shape
class Circle : public Shape {
public:
    Circle(double radius) : radius(radius) {}
    double area() const override {
        return M_PI * radius * radius; // 计算圆形面积
    }
private:
    double radius;
};

// 矩形类 Rectangle,实现了 Shape
class Rectangle : public Shape {
public:
    Rectangle(double width, double height) : width(width), height(height) {}
    double area() const override {
        return width * height; // 计算矩形面积
    }
private:
    double width, height;
};

// 新增一个三角形类 Triangle,实现了 Shape
class Triangle : public Shape {
public:
    Triangle(double base, double height) : base(base), height(height) {}
    double area() const override {
        return 0.5 * base * height; // 计算三角形面积
    }
private:
    double base, height;
};

int main() {
    // 创建不同形状的对象
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(5.0));
    shapes.push_back(std::make_unique<Rectangle>(4.0, 6.0));
    shapes.push_back(std::make_unique<Triangle>(4.0, 7.0));

    // 计算并输出每个形状的面积
    for (const auto& shape : shapes) {
        std::cout << "Area: " << shape->area() << std::endl;
    }

    return 0;
}
  • 抽象基类 Shape

    • 定义了一个纯虚函数 area(),使得所有派生类必须实现这个方法。
  • 具体形状类 CircleRectangleTriangle

    • 每个类都实现了 Shape 类的 area() 方法来计算其面积。
    • 这样,当我们需要增加一个新形状时,只需创建一个新的类继承 Shape 并实现 area() 方法即可。

这种设计方式使得系统对扩展开放(可以轻松地添加新形状),对修改封闭(不需要修改现有的类和代码)。

依赖倒置原则(Dependency Inversion Principle, DIP)

定义:高层模块不应该依赖于低层模块,两者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。

解释:通过依赖于抽象(接口或抽象类)而不是具体实现,可以使高层模块和低层模块之间的依赖关系变得更加稳定,从而提高系统的可维护性和扩展性。

违反DIP的例子

#include <iostream>

// 具体实现类 LightBulb
class LightBulb {
public:
    void turnOn() {
        std::cout << "LightBulb turned on\n";
    }
};

// 高层模块 Switch 依赖于具体的 LightBulb 类
class Switch {
public:
    Switch(LightBulb* bulb) : bulb(bulb) {}

    void operate() {
        bulb->turnOn();
    }

private:
    LightBulb* bulb;
};

int main() {
    LightBulb bulb;
    Switch sw(&bulb);
    sw.operate(); // 输出: LightBulb turned on
    return 0;
}

在这个例子中,Switch 类直接依赖于 LightBulb 类,这违反了DIP,因为高层模块(Switch)依赖于低层模块(LightBulb)。

遵循DIP的例子

#include <iostream>

// 抽象接口 Switchable
class Switchable {
public:
    virtual void turnOn() = 0;
    virtual ~Switchable() = default;
};

// 具体实现类 LightBulb 实现了 Switchable 接口
class LightBulb : public Switchable {
public:
    void turnOn() override {
        std::cout << "LightBulb turned on\n";
    }
};

// 高层模块 Switch 依赖于抽象接口 Switchable
class Switch {
public:
    Switch(Switchable* device) : device(device) {}

    void operate() {
        device->turnOn();
    }

private:
    Switchable* device;
};

int main() {
    LightBulb bulb;
    Switch sw(&bulb);
    sw.operate(); // 输出: LightBulb turned on
    return 0;
}
  • 抽象接口 Switchable

    • 定义了一个纯虚函数 turnOn(),所有实现这个接口的类都必须提供这个方法的具体实现。
  • 具体实现类 LightBulb

    • 实现了 Switchable 接口,并提供了 turnOn() 方法的具体实现。
  • 高层模块 Switch

    • 依赖于 Switchable 接口,而不是具体的 LightBulb 类。
    • Switch 类的构造函数接受一个 Switchable 类型的指针,并调用 turnOn() 方法。

总结

依赖倒置原则通过引入抽象接口,使高层模块和低层模块之间的耦合度降低,提高了系统的灵活性和可维护性。通过这种方式,我们可以轻松地扩展系统,而无需修改现有的高层模块。

接口隔离原则(Interface Segregation Principle, ISP)

定义:客户端不应该被迫依赖于它们不使用的方法。换句话说,一个类对另一个类的依赖应该建立在最小的接口上。

解释:接口隔离原则旨在避免大而臃肿的接口,确保接口尽量小,只包含客户端真正需要的方法。这样可以提高系统的灵活性和可维护性。

违反ISP的例子

#include <iostream>

// 大而臃肿的接口 Worker
class Worker {
public:
    virtual void work() = 0;
    virtual void eat() = 0;
};

// HumanWorker 需要实现 work 和 eat 方法
class HumanWorker : public Worker {
public:
    void work() override {
        std::cout << "Human working\n";
    }

    void eat() override {
        std::cout << "Human eating\n";
    }
};

// RobotWorker 需要实现 work 方法,但不需要 eat 方法
class RobotWorker : public Worker {
public:
    void work() override {
        std::cout << "Robot working\n";
    }

    void eat() override {
        // 机器人不需要吃饭
        throw std::logic_error("Robots don't eat");
    }
};

int main() {
    HumanWorker human;
    RobotWorker robot;

    human.work();
    human.eat();

    robot.work();
    try {
        robot.eat();
    } catch (const std::logic_error& e) {
        std::cerr << e.what() << '\n';
    }

    return 0;
}

在这个例子中,RobotWorker类不需要实现eat方法,但由于Worker接口强制要求,它仍然需要提供一个实现。这违反了ISP。

遵循ISP的例子

#include <iostream>

// 单一职责接口 Workable
class Workable {
public:
    virtual void work() = 0;
    virtual ~Workable() = default;
};

// 单一职责接口 Eatable
class Eatable {
public:
    virtual void eat() = 0;
    virtual ~Eatable() = default;
};

// HumanWorker 需要实现 Workable 和 Eatable 接口
class HumanWorker : public Workable, public Eatable {
public:
    void work() override {
        std::cout << "Human working\n";
    }

    void eat() override {
        std::cout << "Human eating\n";
    }
};

// RobotWorker 只需要实现 Workable 接口
class RobotWorker : public Workable {
public:
    void work() override {
        std::cout << "Robot working\n";
    }
};

int main() {
    HumanWorker human;
    RobotWorker robot;

    human.work();
    human.eat();

    robot.work();

    return 0;
}
  • 单一职责接口 WorkableEatable

    • Workable 接口只包含 work 方法。
    • Eatable 接口只包含 eat 方法。
  • 具体实现类 HumanWorkerRobotWorker

    • HumanWorker 类实现了 WorkableEatable 接口,因为它需要同时工作和吃饭。
    • RobotWorker 类只实现了 Workable 接口,因为它只需要工作,不需要吃饭。

总结

通过将大接口拆分为多个小接口,每个接口只包含一个具体职责的方法,我们可以确保每个类只依赖于它实际需要的方法,从而遵循接口隔离原则。这种设计方式提高了系统的灵活性和可维护性,使得系统更容易扩展和修改。

迪米特法则(Law of Demeter,LoD)

迪米特法则(Law of Demeter,LoD),也称为最少知识原则(Principle of Least Knowledge),是一种软件设计原则。其核心思想是一个对象应当尽可能少地了解其他对象,以减少耦合度,提高系统的模块化和可维护性。

迪米特法则的优点

  1. 降低耦合度:通过减少对象之间的直接依赖,可以降低类与类之间的耦合度,提高系统的灵活性。
  2. 提高模块化:遵守迪米特法则可以使系统更加模块化,各个模块之间互不干涉,便于维护和扩展。
  3. 增强可读性:减少对象之间的交互,使代码更加清晰,容易理解。