面向对象设计原则

面向对象设计(Object-Oriented Design, OOD)的原则是软件工程中的一套指导思想,根据面向对象中的原则来设计代码,有助于构建结构良好、易于维护和拓展的代码。主要的设计原则有以下:

单一职责原则

单一职责原则(Simple Responsibility Pinciple,SRP)是最简单的面向对象设计原则,它用于控制类的粒度大小。

一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。即每个类应该专注于完成一项工作。

举例一个违反单一职责原则地例子,假设有一个员工管理系统,里面有一个EmployeeManager类,它负责员工的基本信息管理以及员工薪资计算:

public class EmployeeManager {
    public void addEmployee(Employee employee) {
        // 添加员工到数据库
    }

    public void removeEmployee(Employee employee) {
        // 从数据库删除员工
    }

    public void updateEmployee(Employee employee) {
        // 更新员工信息
    }

    public double calculateSalary(Employee employee) {
        // 根据员工的工作时间和工资率计算薪资
        return employee.getHoursWorked() * employee.getHourlyRate();
    }
}

在这个例子中,EmployeeManager类同时承担了员工信息管理和薪资计算两个职责,这违反了单一职责原则。如果公司政策变化,比如调整了薪资计算方式,那么EmployeeManager类就需要修改calculateSalary方法,而如果数据库结构发生变化,则需要修改与数据库交互的方法。这种情况下,一个类的变化可能会引起连锁反应,导致其他部分也需要修改

为了遵循单一职责原则,应该将员工管理和计算薪资地功能剔开分解为两个独立的类。

public class EmployeeInfoManager {
    public void addEmployee(Employee employee) {
        // 添加员工到数据库
    }

    public void removeEmployee(Employee employee) {
        // 从数据库删除员工
    }

    public void updateEmployee(Employee employee) {
        // 更新员工信息
    }
}

public class SalaryCalculator {
    public double calculateSalary(Employee employee) {
        // 根据员工的工作时间和工资率计算薪资
        return employee.getHoursWorked() * employee.getHourlyRate();
    }
}

现在,EmployeeInfoManager专门负责员工信息的管理,而SalaryCalculator专门负责薪资的计算。这样,如果薪资计算逻辑需要修改,只需要改动SalaryCalculator类,而不会影响到EmployeeInfoManager类。同样,如果员工信息管理的规则发生变化,也只需要修改EmployeeInfoManager类,而不会影响到薪资计算的部分。

开放封闭原则

开放封闭原则(Open/Closed Principle, OCP)是一种常用的设计原则,具体内容如下

软件实体(如类、模块、函数等)应该是可扩展的,但不可修改的。当需求发生变化时,应该通过添加新的代码来实现,而不是修改已有的代码。

这意味着,当需求发生变化时,我们应该能够通过添加新的代码来扩展系统功能,而不是修改现有代码。这样可以保持现有代码的稳定性和可维护性。

假设我们正在开发一个图形处理软件,目前支持处理两种类型的图形:圆形和正方形。我们定义了一个Shape接口和两个实现类CircleSquare

interface Shape {
    double area();
}

class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

class Square implements Shape {
    private double side;

    public Square(double side) {
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }
}

我们还有一个ShapeProcessor类,用于计算图形的面积:

public class ShapeProcessor {
    public double processArea(Shape shape) {
        return shape.area();
    }
}

现在,客户要求我们在软件中加入对三角形的支持。如果我们直接修改ShapeProcessor类,让它能够处理Triangle类,那么我们将违反开放封闭原则,因为我们修改了现有的代码。

正确的做法是,我们可以通过添加一个新的Triangle类来扩展系统,而不需要修改ShapeProcessor类:

class Triangle implements Shape {
    private double base;
    private double height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return 0.5 * base * height;
    }
}

Triangle实现了Shape接口,所以它可以直接被ShapeProcessor类所使用,而不需要对ShapeProcessor类进行任何修改。

里氏替换原则

里氏替换原则(Liskov Substitution Principle)是对子类型的特别定义。

子类必须能够替换其基类并保持程序的正确性。换句话说,任何可以使用父类的地方,都应该能够使用子类而不改变程序的期望行为。

简单的说就是,子类可以扩展父类的功能,但不能改变父类原有的功能:

  1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  2. 子类可以增加自己特有的方法。
  3. 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
  4. 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或与父类一样。

比如一个程序员工作的例子:

public abstract class Coder {

    public void coding() {
        System.out.println("我会打代码");
    }


    class JavaCoder extends Coder{

        /**
         * 子类除了会打代码之外,还会打游戏
         */
        public void game(){
            System.out.println("艾欧尼亚最强王者已上号");
        }
    }
}

可以看到JavaCoder虽然继承自Coder,但是并没有对父类方法进行重写,并且还在父类的基础上进行额外扩展,符合里氏替换原则。但是我们再来看下面的这个例子:

public abstract class Coder {

    public void coding() {
        System.out.println("我会打代码");
    }


    class JavaCoder extends Coder{
        public void game(){
            System.out.println("艾欧尼亚最强王者已上号");
        }

        /**
         * 这里我们对父类的行为进行了重写,现在它不再具备父类原本的能力了
         */
        public void coding() {
            System.out.println("我寒窗苦读十六年,到最后还不如培训班三个月出来的程序员");
            System.out.println("想来想去,房子车子结婚彩礼,为什么这辈子要活的这么累呢?");
            System.out.println("难道来到这世间走这一遭就为了花一辈子时间买个房子吗?一个人不是也能活的轻松快乐吗?");
            System.out.println("摆烂了,啊对对对");  
          //好了,emo结束,继续卷吧,人生因奋斗而美丽,这个世界虽然满目疮痍,但是还是有很多美好值得期待
        }
    }
}

可以看到,现在我们对父类的方法进行了重写,显然,父类的行为已经被我们给覆盖了,这个子类已经不具备父类的原本的行为,很显然违背了里氏替换原则。

要是程序员连敲代码都不会了,还能叫做程序员吗?

所以,对于这种情况,我们不需要再继承自Coder了,我们可以提升一下,将此行为定义到People中:

public abstract class People {

    public abstract void coding();   //这个行为还是定义出来,但是不实现
    
    class Coder extends People{
        @Override
        public void coding() {
            System.out.println("我会打代码");
        }
    }


    class JavaCoder extends People{
        public void game(){
            System.out.println("艾欧尼亚最强王者已上号");
        }

        public void coding() {
            System.out.println("摆烂了,啊对对对");
        }
    }
}

里氏替换也是实现开闭原则的重要方式之一。

依赖倒转原则

依赖倒转原则(Dependence Inversion Principle)最典型的有Spring的设计。Spring框架是依赖倒置原则的一个典型例子,它提供了依赖注入(Dependency Injection, DI)机制,使得组件间的依赖关系可以被外部管理,而不是由组件自己管理,这有助于解耦组件,提高系统的可测试性和可维护性。

高层次的模块不应该依赖于低层次的模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

以Spring框架为例,在实际开发中,我们通常会使用一系列的分层,来将业务分为ControllerMapperServiceServiceImpl各种层,在MapperService层定义接口,在ServiceImpl中实现

假设我们有一个UserService类,它需要使用UserMapper来操作数据库中的用户信息。在没有使用Spring的情况下,我们可能会这样编写代码:

public class UserService {
    private UserMapper userMapper;

    public UserService() {
        this.userMapper = new UserMapper(); // 紧耦合
    }

    public void saveUser(User user) {
        userMapper.save(user);
    }
}

在这个例子中,UserService类直接实例化了UserMapper,这违反了依赖倒置原则,因为UserService直接依赖于UserMapper的具体实现。

使用Spring框架,我们可以改写上面的代码如下:

@Service
public class UserService {
    private final UserMapper userMapper;

    @Autowired
    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public void saveUser(User user) {
        userMapper.save(user);
    }
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 定义一些数据操作方法
}

在这个版本中,UserService不再直接实例化UserMapper,而是通过构造器注入的方式,依赖于UserMapper接口。Spring框架会负责实例化UserMapper的具体实现,并将其注入到UserService中。这种做法使得UserService依赖于抽象的UserMapper接口,而不是具体的实现,符合依赖倒置原则。

这样做的好处是,如果将来需要更换UserMapper的实现,只需要更改配置,而无需修改UserService的代码。同时,这也使得单元测试更加容易,因为我们可以很容易地为UserService提供一个模拟的UserMapper实现。

接口隔离原则

接口隔离原则(Interface Segregation Principle, ISP)客户端不应该被迫依赖于它不使用的方法。如果一个接口太大,应该考虑将其拆分为更小、更具体的接口,这样客户端只需知道它们关心的部分。即

客户端不应依赖那些它不需要的接口。

假设我们有一个Machine接口,其中定义了一些机器可能具有的通用方法,比如打印、扫描和复印:

public interface Machine {
    void print(String document);
    void fax(String document);
    void scan(String document);
}

然后,我们有三种机器:打印机、扫描仪和多功能一体机。打印机和扫描仪分别只实现了其中的一部分功能,而多功能一体机实现了所有功能:

public class Printer implements Machine {
    @Override
    public void print(String document) {
        System.out.println("Printing " + document);
    }

    @Override
    public void fax(String document) {
        throw new UnsupportedOperationException("Fax not supported");
    }

    @Override
    public void scan(String document) {
        throw new UnsupportedOperationException("Scan not supported");
    }
}

public class Scanner implements Machine {
    @Override
    public void print(String document) {
        throw new UnsupportedOperationException("Print not supported");
    }

    @Override
    public void fax(String document) {
        throw new UnsupportedOperationException("Fax not supported");
    }

    @Override
    public void scan(String document) {
        System.out.println("Scanning " + document);
    }
}

public class MultiFunctionMachine implements Machine {
    @Override
    public void print(String document) {
        System.out.println("Printing " + document);
    }

    @Override
    public void fax(String document) {
        System.out.println("Faxing " + document);
    }

    @Override
    public void scan(String document) {
        System.out.println("Scanning " + document);
    }
}

这个例子问题在于,PrinterScanner类都不得不实现它们并不需要的方法,这违反了ISP。

为了遵守接口隔离原则,我们可以将Machine拆分成更具体地接口

public interface PrinterMachine {
    void print(String document);
}

public interface FaxMachine {
    void fax(String document);
}

public interface ScannerMachine {
    void scan(String document);
}

然后实现他们真正需要的接口即可

public class Printer implements PrinterMachine {
    @Override
    public void print(String document) {
        System.out.println("Printing " + document);
    }
}

public class Scanner implements ScannerMachine {
    @Override
    public void scan(String document) {
        System.out.println("Scanning " + document);
    }
}

public class MultiFunctionMachine implements PrinterMachine, FaxMachine, ScannerMachine {
    @Override
    public void print(String document) {
        System.out.println("Printing " + document);
    }

    @Override
    public void fax(String document) {
        System.out.println("Faxing " + document);
    }

    @Override
    public void scan(String document) {
        System.out.println("Scanning " + document);
    }
}

迪米特法则

迪米特法则(Law of Demeter, LoD),又称最少知识原则(Least Knowledge Principle, LKP),主张一个软件实体应当尽可能少地与其他实体发生相互作用。

一个对象应该对其他对象有最少的知识。一个对象不应与太多其他对象发生直接交互,以减少对象之间的耦合度。

简单来说就是:一个类应该对其他类有最少的了解,以降低类之间的耦合度,使系统更易于理解、维护和扩展。迪米特法则通过避免类之间的过度交互来实现。如A类需要与B类通信,而B类又需要与C类通信,那么通常A类不应该直接访问C类,除非确实有必要。如果A类需要C类的信息,它应该通过B类来获取,或者通过某种中介者模式来间接获取。

举例:一个学生管理系统,包含老师、学生、课程等实体。如果在学生类中能直接访问到教师类,就可能会违背迪米特法则

public class Student {
    private String name;
    private Teacher teacher;

    public Student(String name, Teacher teacher) {
        this.name = name;
        this.teacher = teacher;
    }

    public void printTeacherDetails() {
        System.out.println("My teacher's name is " + teacher.getName());
        System.out.println("He teaches at " + teacher.getDepartment().getName());
    }
}

public class Teacher {
    private String name;
    private Department department;

    public Teacher(String name, Department department) {
        this.name = name;
        this.department = department;
    }

    public String getName() {
        return name;
    }

    public Department getDepartment() {
        return department;
    }
}

public class Department {
    private String name;

    public Department(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

在这个例子中,Student类不仅知道它的Teacher,还知道了TeacherDepartment,这增加了类之间的耦合。

为了遵守迪米特法则,我们可以修改Student类,使其只知道自己的Teacher,而不知道TeacherDepartment。如果Student需要知道Department的信息,它可以请求Teacher提供这些信息,或者通过另一个实体(如UniversitySystem)来获取

public class UniversitySystem {
    public String getTeacherDepartmentName(Teacher teacher) {
        return teacher.getDepartment().getName();
    }
}

public class Student {
    private String name;
    private Teacher teacher;
    private UniversitySystem universitySystem;

    public Student(String name, Teacher teacher, UniversitySystem universitySystem) {
        this.name = name;
        this.teacher = teacher;
        this.universitySystem = universitySystem;
    }

    public void printTeacherDetails() {
        System.out.println("My teacher's name is " + teacher.getName());
        System.out.println("He teaches at " + universitySystem.getTeacherDepartmentName(teacher));
    }
}

public class Teacher {
    private String name;
    private Department department;

    public Teacher(String name, Department department) {
        this.name = name;
        this.department = department;
    }

    public String getName() {
        return name;
    }

    public Department getDepartment() {
        return department;
    }
}

public class Department {
    private String name;

    public Department(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

在这个修改后的例子中,Student类只与Teacher类和UniversitySystem类交互,而UniversitySystem类则负责处理TeacherDepartment之间的交互,这样就减少了类之间的直接依赖,遵循了迪米特法则。

合成/聚合复用原则

合成/聚合复用原则(Composite/Aggregate Reuse Principle, CARP)主张通过合成或聚合来重用代码,而不是通过继承。

在需要复用功能时,应优先使用对象组合或聚合而不是继承。

假设我们正在开发一个系统来管理不同类型的交通工具。我们有几种交通工具,比如汽车(Car)、自行车(Bicycle)和卡车(Truck)。每种交通工具都有一个引擎(Engine),但引擎的具体类型可能不同。

如果我们使用继承,可能会创建一个抽象的Vehicle类,然后让CarBicycleTruckVehicle继承。然而,Bicycle实际上并不需要一个引擎,这会引入不必要的复杂性。而且,如果我们想更改引擎的实现,那么所有依赖于Vehicle的子类都需要进行修改,这违反了开放封闭原则

相反,我们可以使用合成/聚合来解决这个问题。我们定义一个Engine接口或抽象类,并让具体的引擎实现这个接口。然后,在Vehicle类中,我们通过构造函数注入一个Engine对象。这样,Vehicle可以拥有一个引擎,而Bicycle可以选择不拥有一个引擎

interface Engine {
    void start();
    void stop();
}

class PetrolEngine implements Engine {
    @Override
    public void start() {
        System.out.println("Petrol engine started.");
    }

    @Override
    public void stop() {
        System.out.println("Petrol engine stopped.");
    }
}

class Vehicle {
    private Engine engine;
    
    public Vehicle(Engine engine) {
        this.engine = engine;
    }
    
    public void start() {
        if (engine != null) {
            engine.start();
        }
    }
    
    public void stop() {
        if (engine != null) {
            engine.stop();
        }
    }
}

class Car extends Vehicle {
    public Car(Engine engine) {
        super(engine);
    }
}

// 自行车类不需要引擎
class Bicycle extends Vehicle {
    public Bicycle() {
        super(null); // 自行车不需要引擎
    }
}

public class Main {
    public static void main(String[] args) {
        Engine petrolEngine = new PetrolEngine();
        Car car = new Car(petrolEngine);
        car.start();
        car.stop();
        
        Bicycle bicycle = new Bicycle(); // 自行车没有引擎
        bicycle.start(); // 没有引擎,所以啥都不做
    }
}

在这个例子中,Vehicle类通过构造函数接收一个Engine对象,这是合成的一个典型应用。Car类使用了一个具体的引擎实现(PetrolEngine),而Bicycle类则没有引擎,展示了聚合的灵活性。这种设计使得系统更易于扩展和维护,因为我们可以轻松地添加新的引擎类型或新的交通工具类型,而不必修改现有的代码

最后修改:2024 年 08 月 07 日
如果觉得我的文章对你有用,能不能v我50参加疯狂星期四