CPT304 SoftwareEngineeringII 软件工程 2 Pt.2 面向对象概念

文章目录

  • [1. OOP面向对象编程](#1. OOP面向对象编程)
    • [1.1 Abstraction](#1.1 Abstraction)
    • [1.2 Encapsulation](#1.2 Encapsulation)
    • [1.3 Inheritance](#1.3 Inheritance)
    • [1.4 Polymorphism](#1.4 Polymorphism)
  • [2. 好的设计](#2. 好的设计)
    • [2.1 Cohesion(内聚性)](#2.1 Cohesion(内聚性))
    • [2.2 Coupling(耦合性)](#2.2 Coupling(耦合性))
  • [3. SOLID 原则](#3. SOLID 原则)
    • [3.1 Single Responsibility Principle(单一职责原则)](#3.1 Single Responsibility Principle(单一职责原则))
    • [3.2 Open/Closed Principle(开闭原则)](#3.2 Open/Closed Principle(开闭原则))
    • [3.3 Liskov Substitution Principle(里氏替换原则)](#3.3 Liskov Substitution Principle(里氏替换原则))
      • [3.3.1 参数类型(Parameter Types)](#3.3.1 参数类型(Parameter Types))
      • [3.3.2 返回类型(Return Types)](#3.3.2 返回类型(Return Types))
      • [3.3.3 子类方法的异常类型](#3.3.3 子类方法的异常类型)
      • [3.3.4 子类不能强化前置条件(Pre-conditions)](#3.3.4 子类不能强化前置条件(Pre-conditions))
      • [3.3.5 子类不能削弱父类的后置条件(Post-conditions)](#3.3.5 子类不能削弱父类的后置条件(Post-conditions))
      • [3.3.6 父类的不变量必须被保持(Invariants of a superclass must be preserved)](#3.3.6 父类的不变量必须被保持(Invariants of a superclass must be preserved))
      • [3.3.7 子类不能改变父类的私有/受保护状态(private/protected state)](#3.3.7 子类不能改变父类的私有/受保护状态(private/protected state))
      • [3.3.8 LSP的重要性](#3.3.8 LSP的重要性)
    • [3.4 Interface Segregation Principle(接口隔离原则)](#3.4 Interface Segregation Principle(接口隔离原则))
    • [3.5 依赖倒置原则(Dependency Inversion Principle, DIP)](#3.5 依赖倒置原则(Dependency Inversion Principle, DIP))
    • [3.6 总结练习](#3.6 总结练习)
  • [4. 用组合去降低耦合](#4. 用组合去降低耦合)
    • [4.1 组合(Composition)和继承(Inheritance)](#4.1 组合(Composition)和继承(Inheritance))
    • [4.2 组合(Composition)的使用](#4.2 组合(Composition)的使用)

1. OOP面向对象编程

OOP有四个核心组成:

  1. Abstraction(抽象)
    含义:隐藏复杂的实现细节,只展示必要的功能给用户
    生活类比:开车时你只需要转动方向盘、踩油门,不需要了解发动机内部如何运作
    代码体现:抽象类(Abstract Class)、接口(Interface)
  2. Encapsulation(封装)
    含义:将数据(属性)和操作数据的方法绑定在一起,并隐藏内部数据,通过公开接口访问
    作用:保护数据不被外部随意修改,提高安全性
    代码体现:使用 private 修饰属性,通过 public 的 getter/setter 方法访问
  3. Inheritance(继承)
    含义:子类可以继承父类的属性和方法,实现代码复用,并可以扩展新功能
    关系:"is-a" 关系(如:狗是动物,学生是人)
    代码体现:class Dog extends Animal
  4. Polymorphism(多态)
    含义:同一个接口或方法,在不同对象上表现出不同的行为
    两种形式:
    编译时多态(重载/Overloading):同名方法,不同参数
    运行时多态(重写/Overriding):子类重写父类方法,调用时根据实际对象类型执行
    生活类比:"打开"这个动作,对门、对电脑、对手机的具体行为都不同

1.1 Abstraction

对象只在特定上下文中建模真实物体的属性和行为,忽略其余部分。

如图所示,这里左边是飞行控制系统角度,那么就要关注速度、高度等。而右边则是从机票预定系统考虑,那么考虑的就是作为。

抽象允许开发者在更高层次上与系统交互,而无需理解其复杂的实现细节。

生活类比:用智能手机拍照时,只需按下快门按钮,而不需要理解光学传感器、图像信号处理器、JPEG压缩算法的内部工作原理。

1.2 Encapsulation

封装是对象隐藏其部分状态和行为的能力,只向程序的其他部分暴露有限的接口。

生活类比:自动售货机,只能通过 投币口、按钮、取货口 与机器交互。

接口(Interface)允许定义对象之间的交互契约。

这里Interface在不同语境有不同的意思。

含义 解释 例子
1. 对象的公共部分 类中声明为 public 的方法和属性 getBalance(), deposit()
2. 编程语言关键字 Java/C# 中的 interface 语法 interface Drawable { void draw(); }
3. 人机交互界面 用户与计算机交互的媒介 图形界面(GUI)、命令行(CLI)

这里用的就是第一个意思。

我们再来区别一下抽象和封装。

抽象 (Abstraction) 封装 (Encapsulation)
关注点 "隐藏什么" --- 忽略无关细节 "怎么隐藏" --- 保护数据安全
目的 简化复杂度,聚焦核心 保护数据,控制访问
实现方式 抽象类、接口 访问修饰符 (private/public)
类比 地图只画主要道路 保险箱锁起来,只给钥匙

绑定到契约的类必须实现契约中定义的方法。换句话说,如果一个类承诺遵守某个接口(契约),它就必须具体实现接口里的所有方法,不能少。

当类 X 与绑定到契约的其他类一起工作时,类 X 可以完全确信它所合作的类已经实现了契约中定义的方法。

因此在 Java 中使用 interface 关键字创建契约。下面给出示例。

java 复制代码
// 创建契约(定义接口)
public interface Flyable {
    // 契约内容:所有实现者必须会飞
    void fly();           // 抽象方法,没有方法体
}

我们可以使用 implements 绑定到契约。

java 复制代码
// 类绑定契约:实现接口
public class Bird implements Flyable {
    
    @Override
    public void fly() {
        // 必须实现契约中定义的 fly() 方法
        System.out.println("Bird flaps wings to fly");
    }
}

// 另一个类也绑定同一契约
public class Plane implements Flyable {
    
    @Override
    public void fly() {
        // 各自实现自己的 fly()
        System.out.println("Plane uses jet engine to fly");
    }
}

1.3 Inheritance

继承是在现有类之上构建新类的能力。

在已有类的基础上创建新类,这样可以避免重复写相同代码,还可以通过扩展现有类来增加功能。

子类自动继承父类的属性和方法。

在大多数编程语言中,子类只能继承一个父类。

但任何类可以同时实现多个接口。

如果父类实现了接口,子类会自动继承该接口的实现。

我们将继承(extends)和接口(implements)进行对比。

特性 继承(extends) 接口(implements)
数量 只能 1 个 可以 多个
获得什么 属性和方法的具体实现 方法规范(契约)
关系 "is-a"(是一个) "can-do"(能做)
箭头 实线空心 虚线空心

1.4 Polymorphism

多态是同一个东西,多种形态。

允许一个类呈现不同的形态。

通过相同的接口访问不同类型的对象。

用父类引用指向子类对象。

下面给出一个例子。

java 复制代码
// 父类
public class Animal {
    public void makeSound() {
        System.out.println("Some sound");
    }
}

// 子类 1
public class Dog extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof! Woof!");
    }
}

// 子类 2
public class Cat extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow~");
    }
}

// ===== 多态的体现 =====
public class Main {
    public static void main(String[] args) {
        // 父类引用指向子类对象
        Animal animal1 = new Dog();   // Dog 形态
        Animal animal2 = new Cat();   // Cat 形态
        
        // 相同的方法调用,不同的结果
        animal1.makeSound();  // 输出: Woof! Woof!
        animal2.makeSound();  // 输出: Meow~
        
        // 更强大的用法:统一处理
        Animal[] animals = {new Dog(), new Cat(), new Dog()};
        for (Animal a : animals) {
            a.makeSound();  // 自动调用各自的重写方法
        }
    }
}

这里狗和猫都继承父类动物,它们各自实现叫声的方式不同,但是可以用相同的调用输出出来。

2. 好的设计

好的设计就是隐藏内在复杂性并消除附加复杂性的东西。

  1. 隐藏内在复杂性(inherent complexity)是问题本身不可避免的复杂性,抽象、封装和继承可以帮助我们把它"隐藏起来",让使用者不需要面对细节。
  2. 消除附加复杂性(accidental complexity)是实现过程中人为增加的复杂性,简化、提高可重用性、使用设计模式可以消除这种复杂性。

因此如果修改软件的成本最小,那么这个软件就是好的设计。

实现方式如下。

  1. Cohesion(内聚性)
    代码职责单一、专注;每段代码只做一件事,并且做好它。
  2. Coupling(耦合性)
    代码之间的依赖程度(耦合度低更好)。

所以好的设计需要高内聚性,低耦合性。

2.1 Cohesion(内聚性)

高内聚确保相关功能聚集在一起,这样随着软件演进,修改的频率会减少。因为也每个模块/类专注做一件事,相关功能都在同一个地方。

对于低内聚的代码,每次想做一个小改动(例如添加一种新的税类型),你必须进入多个不同的类,在每个类里都做小修改。因此低内聚就会导致修改成本高、容易出错。

我们尝试把下面的代码进行优化。

java 复制代码
public class Staff {
    public Speaker saveSpeaker(Speaker speaker);
    public boolean removeSpeaker(int speakerId);
    public Speaker findById(int speakerId);
    public void updateSpeakerPhoto(int speakerId, String photoUrl);

    public Product saveProduct(Product product);
    public void deleteProducts(List<Integer> productsId);
    public boolean deleteProduct(int productId);
}

目前的代码既要管理 Speaker 又要管理 Product,我们可以修改成两个类,如下所示。

java 复制代码
// 专注管理 Speaker 的操作
public class SpeakerService {

    public Speaker saveSpeaker(Speaker speaker) {
        // 保存演讲者逻辑
        return speaker;
    }

    public boolean removeSpeaker(int speakerId) {
        // 删除演讲者逻辑
        return true;
    }

    public Speaker findById(int speakerId) {
        // 查询演讲者逻辑
        return new Speaker();
    }

    public void updateSpeakerPhoto(int speakerId, String photoUrl) {
        // 更新演讲者照片逻辑
    }
}
// 专注管理 Product 的操作
public class ProductService {

    public Product saveProduct(Product product) {
        // 保存产品逻辑
        return product;
    }

    public void deleteProducts(List<Integer> productsId) {
        // 批量删除产品逻辑
    }

    public boolean deleteProduct(int productId) {
        // 删除单个产品逻辑
        return true;
    }
}

2.2 Coupling(耦合性)

低(松散)耦合可以防止一个类的修改迫使其他类连锁修改。因为模块之间依赖少,改动不会影响其他模块。

使用低耦合而不是"没有耦合",因为不可能完全没有依赖关系。如果依赖具体类,就是高耦合。如果依赖接口,就是松耦合。

下面的代码就是高耦合。

java 复制代码
public class Order {
    private Delivery del;

    public double calculateTotal() {
        return orderTotal + del.getExpressCharge();
    }
}

public class Delivery {
    public double getExpressCharge() { ... }
}

因为 Order 类直接依赖 Delivery 类的具体实现。如果 Delivery 类的实现改动(比如方法改名、返回类型改动、内部逻辑改动),Order 类可能也必须修改。

下面给出一种修改方案。

java 复制代码
public interface Delivery {
    double getExpressCharge();
}
public class ChinaDelivery implements Delivery { ... }
public class BritishDelivery implements Delivery { ... }
public class Order {
    private Delivery del;

    public Order(Delivery del){
        this.del = del;
    }

    public double calculateTotal(){
        return orderTotal + del.getExpressCharge();
    }
}

现在修改某个具体 Delivery 类不会影响 Order 类,而且增加新国家的快递计算,只需增加新类实现接口。

下面尝试优化一段代码。

java 复制代码
// Part-time staff 类
public class ParttimeStaff {
    public double getWorkedHours() {
        // 返回工作小时
        return 40.0;
    }
}

// Payroll 类直接依赖 ParttimeStaff
public class Payroll {
    private ParttimeStaff staff;

    public Payroll(ParttimeStaff ps) {
        this.staff = ps;
    }

    public double calculatePay() {
        return staff.getWorkedHours() * 100; // 假设每小时100元
    }
}

优化成以下结果。

java 复制代码
// 定义 Staff 接口
public interface IStaff {
    double getWorkedHours();
}

// Part-time staff 实现接口
public class ParttimeStaff implements IStaff {
    @Override
    public double getWorkedHours() {
        return 40.0;
    }
}

// Payroll 类依赖接口
public class Payroll {
    private IStaff staff;

    public Payroll(IStaff staff) {
        this.staff = staff;
    }

    public double calculatePay() {
        return staff.getWorkedHours() * 100;
    }
}

现在再练习一个。

java 复制代码
// Car 类
public class Car {
    public void move() {
        System.out.println("Car is moving");
    }
}
// Traveler 类直接依赖具体 Car 类
class Traveler {
    Car c = new Car();

    public void startJourney() {
        c.move();
    }
}

结果如下。

java 复制代码
// 定义 Vehicle 接口
public interface IVehicle {
    void move();
}
// Car 实现接口
public class Car implements IVehicle {
    @Override
    public void move() {
        System.out.println("Car is moving");
    }
}

// Traveler 依赖接口,不依赖具体 Car
public class Traveler {
    private IVehicle vehicle;

    public Traveler(IVehicle vehicle) {
        this.vehicle = vehicle;
    }

    public void startJourney() {
        vehicle.move();
    }
}

3. SOLID 原则

SOLID 是一个助记符(mnemonic),代表五个设计原则,目的是让软件设计更容易理解、更加灵活且易于维护。

应用这些原则到程序架构上可能会增加复杂性。

实践这些原则是好的,但要务实。

具体如下。

  1. S -- Single Responsibility Principle(单一职责原则)
    每个类或模块只负责一个职责(功能),并且该职责应当被完全封装在类中。
    好处:更易维护和扩展。
  2. O -- Open/Closed Principle(开闭原则)
    软件实体(类、模块、函数)应该对扩展开放,对修改关闭。
    也就是说,增加新功能时尽量通过扩展而不是修改已有代码。
  3. L -- Liskov Substitution Principle(里氏替换原则)
    子类对象应当可以替换父类对象而不破坏程序逻辑。
    也就是继承体系设计要合理,子类行为不能破坏父类契约。
  4. I -- Interface Segregation Principle(接口隔离原则)
    不应该强迫类依赖它不需要的接口。
    将大接口拆分成小接口,让类只依赖自己需要的接口。
  5. D -- Dependency Inversion Principle(依赖倒置原则)
    高层模块不依赖低层模块,两者都依赖抽象(接口或抽象类)。
    抽象不依赖细节,细节依赖抽象。

3.1 Single Responsibility Principle(单一职责原则)

具体如下。

  1. 一个类应该只有一个,且仅有一个修改的理由
    每个类只负责一个职责,避免职责混杂。
  2. 让每个类都有单一职责,并且将该职责完全封装在类中
    类内部的逻辑只完成这一件事,相关操作和数据都在类内封装。
  3. 该原则的主要目标是降低复杂性
    避免类过于庞大和功能混杂,使代码更容易理解和维护。
  4. 如果一个类有很多职责,会带来问题:
    难以理解:一个类负责的事情太多,别人很难快速理解它的功能。
    代码混乱:类里堆满不同功能的代码,逻辑不清晰。
    难以导航和查找特定代码:找一个方法或功能变得麻烦。
    修改风险大:修改一个功能可能意外破坏类里其他功能。

下图展示了一个不符合该原则的示例并给出了优化版本。

3.2 Open/Closed Principle(开闭原则)

软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。

也就是说,如果要增加新功能,尽量通过扩展已有代码(如继承、接口实现、策略模式),而不是直接修改原来的类或模块。

原则的核心目的:

  • 在实现新功能时,不破坏已有代码的行为。
  • 避免修改现有代码引入错误或不稳定性。

使用继承可以实现开闭原则,但要小心耦合。

继承可以让子类扩展父类功能,不直接修改父类代码。

但是,如果子类依赖父类的实现细节,就会产生 高耦合(tight coupling)。

也就是说,继承带来的扩展可能会让修改和维护变得危险。

使用接口而不是继承。

通过依赖接口而不是具体父类,可以实现不同实现的扩展,而无需修改使用它们的代码。

好处:多个实现可以互换,原有代码不用修改。

接口是"对修改关闭"的,同时可以提供新的实现来扩展功能。

接口定义好后不变,保持稳定

想增加新功能,只需要新增接口实现类即可

这样既能扩展功能,又不会破坏已有代码

总结一下:

继承实现 OCP → 容易高耦合,不够灵活

接口实现 OCP → 灵活、低耦合、易扩展

核心思想:扩展而不修改,保持已有代码稳定

下面给出一个实例。

原本 Order 类直接管理不同的运输方式(ground、air)。现在把运输逻辑抽取到 Shipping 接口,新增运输方式时,只需增加新的 Shipping 实现类,不需要修改 Order 类。Order 类使用 Shipping 接口,具体策略(Ground、Air)通过依赖注入提供。

下面给出一个不符合SRP和OCP的代码,我们尝试修改一下。

java 复制代码
public class NotificationService {

    public void sendEmail(String recipient, String subject, String body) {
        // Code to send an email
    }

    public void sendSMS(String phoneNumber, String message) {
        // Code to send an SMS
    }

    public void sendPushNotification(String deviceToken, String message) {
        // Code to send a push notification
    }

    public void logNotification(String type, String message) {
        // Code to log the notification details
    }
}

修改如下,把不同通知类型拆分成各自类。

java 复制代码
// 通知接口
public interface Notification {
    void send(String recipient, String message);
}

// Email 通知实现
public class EmailNotification implements Notification {
    @Override
    public void send(String recipient, String message) {
        // Code to send an email
    }
}

// SMS 通知实现
public class SMSNotification implements Notification {
    @Override
    public void send(String recipient, String message) {
        // Code to send an SMS
    }
}

// Push 通知实现
public class PushNotification implements Notification {
    @Override
    public void send(String recipient, String message) {
        // Code to send a push notification
    }
}

// 日志通知实现
public class LogNotification implements Notification {
    @Override
    public void send(String recipient, String message) {
        // Code to log the notification details
    }
}

// 使用通知的客户端
public class NotificationService {

    private Notification notification;

    public NotificationService(Notification notification) {
        this.notification = notification;
    }

    public void send(String recipient, String message) {
        notification.send(recipient, message);
    }
}

3.3 Liskov Substitution Principle(里氏替换原则)

具体如下。

  1. 继承应该仅用于可替代性(substitutability)
    如果一个对象 B 可以在任何使用 A 的地方替代 A,那么可以使用继承(B extends A)。
    如果 B 需要使用 A 的功能,但不是替代关系,那么应该使用 组合 而不是继承。
  2. 继承对开发者的要求更高
    子类提供的服务不应该比父类少,也不应该多出未承诺的行为。
    也就是说,子类必须遵守父类的契约(接口/方法行为)
    这保证了子类对象可以无缝替换父类对象。
  3. 为什么重要
    使用父类的用户应该能够使用子类实例而不需要知道它们之间的差别。
    也就是说,子类必须对父类透明 → 避免意外破坏已有功能。

子类对象应该可以替换父类对象而不破坏客户端代码。

当你继承一个类时,确保可以把子类对象传给原本使用父类对象的地方,程序仍能正常运行。

也就是说,子类必须保持父类对外行为的兼容性。

子类必须与父类行为兼容。

子类不能改变父类的契约(方法逻辑、返回值、异常行为等)

子类可以增强功能,但不能破坏已有行为。

因此在代码上,有以下几点。

3.3.1 参数类型(Parameter Types)

子类方法的参数类型应该 与父类方法相同或者更抽象(更通用)。子类不能要求比父类更严格的参数类型,否则无法在父类可用的地方替换子类。

java 复制代码
class Parent {
    void setValue(Number n) { ... }
}

class Child extends Parent {
    @Override
    void setValue(Integer n) { ... } // 错误!Integer 更具体
}

3.3.2 返回类型(Return Types)

子类方法的返回类型应该与父类相同或更具体(子类型)。

调用父类方法的代码可以安全使用子类方法返回的对象,不会破坏父类契约。

java 复制代码
abstract abstract class Foo {
    abstract Number generateNumber();
}

父类方法返回 Number 类型,那么子类方法可以返回 Integer 或 Double 等子类型。

java 复制代码
class Bar extends Foo {
    @Override
    public Integer generateNumber() {
        return new Integer(10);
    }
}

3.3.3 子类方法的异常类型

子类方法不应该抛出父类方法未预期的异常。子类可以抛出父类允许的异常或其子类型,不能抛出父类未声明或不兼容的异常。

java 复制代码
class Foo {
    // 父类允许抛出 IllegalArgumentException
    void doStuff(int num) throws IllegalArgumentException { ... }
}

class Bar extends Foo {
    // 子类也只能抛出 IllegalArgumentException 或其子类型
    @Override
    void doStuff(int num) throws IllegalArgumentException { ... }
}

3.3.4 子类不能强化前置条件(Pre-conditions)

子类不能让父类方法更严格。父类方法可能允许某些输入,子类覆盖时不能增加限制。也就是说,父类允许的范围,子类必须保持兼容。

这与第一点近似。

java 复制代码
// 父类
class Foo {
    void doStuff(int num) {
        if (num < 0 || num > 5) throw new IllegalArgumentException();
    }
}

// 子类
class Bar extends Foo {
    @Override
    void doStuff(int num) {
        if (num < 0 || num > 10) throw new IllegalArgumentException();
    }
}

父类允许 [0,5],子类扩展到 [0,10] → 合法

但是如果子类改为 [1,4] → 违反 LSP,因为父类允许的值不再被接受

3.3.5 子类不能削弱父类的后置条件(Post-conditions)

子类方法不能降低或破坏父类方法在执行完毕后应保证的结果或状态。父类方法可能规定某些操作完成后必须满足的条件(后置条件)。子类覆盖该方法时,必须保证这些条件仍然成立,不能减弱约束。

java 复制代码
// 父类
public abstract class Car {
    protected int speed;
    // 后置条件: 调用 brake() 后 speed 必须减少
    protected abstract void brake();
}

// 子类
public class HybridCar extends Car {
    @Override
    protected void brake() {
        // 实现逻辑
        // 后置条件仍然保证: speed 必须减少,同时 charge 必须增加
    }
}

父类要求:brake() 后速度减少

子类实现:保持速度减少,并增加新约束(charge 增加) → 合法

如果子类实现让速度不减 → 违反 LSP

3.3.6 父类的不变量必须被保持(Invariants of a superclass must be preserved)

这可能是所有规则中最不正式的。

不变量是关于对象属性的一个约束,它必须在对象的所有有效状态下成立。.

如果类很复杂,你可能会误解或没注意到父类定义了哪些不变量。

在扩展子类时,如果直接修改父类已有字段或逻辑,很容易破坏不变量。

因此安全的扩展方式是新增字段和方法而不要修改父类已有成员,这样能保证父类不变量仍然成立。

java 复制代码
public abstract class Car {
    protected int limit;

    // invariant: speed < limit
    protected int speed;

    // postcondition: speed < limit
    protected abstract void accelerate();

    // Other methods...
}

public class HybridCar extends Car {
    // invariant: charge >= 0
    private int charge;

    @Override
    // postcondition: speed < limit
    protected void accelerate() {
        // Accelerate HybridCar ensuring speed < limit
    }

    // Other methods...
}

speed < limit → 无论何时,Car 对象的速度不能超过限制。accelerate() 执行后必须保证 speed < limit。子类增加了新属性 charge(可以增强功能),子类仍保持父类不变量和后置条件 speed < limit。

3.3.7 子类不能改变父类的私有/受保护状态(private/protected state)

父类可能有一些状态(字段)在设计上有特定约束,例如只能在创建时设置、只能递增或不可重置。

子类如果直接修改这些状态,会破坏父类契约,也就是违反LSP。

java 复制代码
// 父类 Car
public abstract class Car {
    // Allowed to be set once at the time of creation.
    // Value can only increment thereafter.
    // Value cannot be reset.
    protected int mileage;

    public Car(int mileage) {
        this.mileage = mileage;
    }

    // Other properties and methods...
}

// 子类 ToyCar
public class ToyCar extends Car {
    public void reset() {
        mileage = 0; // 错误!直接修改父类受保护字段
    }
}

父类约束:mileage 只能在创建时设置,之后只能增加,不能重置

子类 ToyCar 重置 mileage → 破坏父类约束 → 不合法

3.3.8 LSP的重要性

  1. 提高灵活性和可复用性
    遵循 LSP 的代码是灵活的,可以安全地使用子类替换父类对象。
    这种设计可以让代码高度可复用。
  2. 避免高耦合和不必要的纠缠
    违反 LSP 的代码往往紧密耦合。
    子类和父类之间的依赖关系复杂,难以扩展或维护。
  3. 避免使用 instanceof 或特殊处理子类
    如果子类不能无缝替换父类,客户端代码就必须写很多判断。
  4. 条件判断代码分散到整个代码库
    破坏 LSP 会导致条件逻辑散布各处。
    程序难以理解、难以维护。
  5. 正确的抽象设计
    遵循 LSP 的代码帮助我们设计出合理的抽象。
    父类接口和子类实现的契约清晰。
    客户端代码可以依赖抽象,而不依赖具体实现。

3.4 Interface Segregation Principle(接口隔离原则)

  1. 客户端不应该依赖它不需要的接口
    如果一个接口定义了很多方法,而客户端只用其中一部分,那么客户端会被迫实现不需要的方法 → 违反 ISP
  2. 保持接口足够窄
    一个接口应该只包含客户端真正需要的方法。
    这样客户端只需实现它关心的行为,不必实现不需要的行为。
  3. 容易违反的情况
    软件不断发展、增加新功能时。
    原本的接口可能变得臃肿,迫使客户端实现不必要的方法 → 违反 ISP
  4. 目标
    像单一职责原则一样,ISP 的目的是降低复杂性。
    将软件拆分成多个独立的部分。
    减少副作用和修改频率,提高系统可维护性。

下面的例子的左边违反ISP。

大接口 CloudProvider,包含所有方法:storeFile(name)、getFile(name)、createServer(region)、listServers(region)、getCDNAddress()。

不同实现类(Amazon、Dropbox)只用到接口的一部分

比如 Dropbox 并不提供 createServer 或 getCDNAddress。

因此优化的结果在右边。

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

核心思想:

  1. 高层模块不应该依赖低层模块
    高层模块提供复杂逻辑,低层模块提供工具类或辅助功能。
    高层模块的逻辑不应该因为低层模块变化而被破坏。目的是提高可复用性和灵活性。
  2. 引入抽象来解耦
    使用接口或抽象类,让高层和低层模块都依赖抽象。高层模块不直接依赖低层模块的实现。

DIP 的两部分规则

  1. 高层模块不依赖低层模块,二者都依赖抽象
    高层模块只依赖接口或抽象类,不直接依赖具体实现。
    低层模块实现接口 → 可替换和扩展。
  2. 抽象不依赖实现,具体实现依赖抽象
    设计原则:细节(实现)依赖抽象,而不是抽象依赖细节。
    可以灵活更换低层模块实现而不影响高层模块。

实践指导如下:

  1. 低层类(Low-level classes)
    实现基础功能,例如:
    操作磁盘
    网络传输数据
    连接数据库
  2. 高层类(High-level classes)
    包含复杂的业务逻辑
    通过调用低层类来完成任务
  3. 依赖倒置的实现顺序:
    先设计高层类
    高层类依赖于低层类的抽象(接口或抽象类),而不是具体实现
    再实现低层类
    低层类实现接口或抽象类
    完成具体操作

左边高层类 BudgetReport 直接依赖低层类 MySQLDatabase。如果换数据库(比如 MongoDB),高层类必须修改 → 违反 DIP。

修改后高层类 BudgetReport 依赖抽象接口 Database(而不是具体实现),低层类 MySQL 和 MongoDB 实现接口 Database。高层类与低层实现解耦。可以自由替换不同数据库实现,而不影响高层类。

3.6 总结练习

我们通过几个问题总结这部分知识。

  1. 根据 SOLID 原则,什么时候最适合使用继承?
    答:当子类需要替代父类使用(符合 Liskov 替换原则)并且行为相似时,可以使用继承。
  2. 根据 SOLID 原则,什么时候最适合使用组合?
    答:当希望通过接口或对象组合功能,而不改变父类行为时,用组合更灵活。组合可以避免高耦合并且方便扩展。
  3. 解释当子类重写方法的返回类型是 long,而父类方法返回类型是 int 时可能出现的问题
    答:这违反了 Liskov 替换原则(LSP):
    客户端代码如果依赖父类返回 int,却得到子类的 long,可能导致溢出或类型错误
    破坏了子类可以替代父类的原则
    一般要求子类方法返回类型与父类一致,或者是父类返回类型的子类型(协变)
  4. 讨论单一职责原则(SRP),并举例说明如何在软件设计中应用
    单一职责原则:一个类只负责一个功能或职责
    目的:降低复杂性、提高可维护性
    举例:
    不要让 User 类同时处理用户数据和邮件发送
    可以拆成两个类:
    User 类只处理用户数据
    EmailService 类只负责发送邮件
  5. OCP 如何提升软件的可维护性和可扩展性?举一个现实例子说明。
    OCP:软件实体(类、模块、函数)应该对扩展开放,对修改关闭
    意思是:当新增功能时,不需要修改现有代码,只需扩展新的类或模块
    优点:
    维护现有代码时不会破坏原有功能
    增加新功能更容易,系统更灵活
  6. 解释接口隔离原则及其在软件设计中的作用
    ISP:客户端不应该依赖它不使用的接口
    意思是:不要让一个大接口让类实现大量不相关的方法
    优点:
    接口更小、更专一
    客户端实现简单、只关注自己需要的方法
    降低耦合,提高可维护性
    例子:
    大接口 Printer 包含 print(), scan(), fax()
    对于只需要打印的类,实现 Printable 小接口即可,不用依赖 scan() 和 fax()
  7. 依赖倒置原则如何帮助模块解耦,提高架构灵活性?
    高层模块不依赖低层模块,二者都依赖抽象(接口)
    低层模块实现接口,高层模块调用接口
    优点:
    高层逻辑与低层实现解耦
    可以自由替换低层实现(例如换数据库或外部服务)
    提高可复用性和系统可扩展性
    例子:
    高层模块 ReportService 依赖接口 Database
    低层模块 MySQLDatabase、MongoDB 实现 Database
    换数据库时不改高层逻辑

4. 用组合去降低耦合

4.1 组合(Composition)和继承(Inheritance)

ClassB 继承自 ClassA。

这表示 ClassB 是一个 ClassA("is a"关系)。

继承意味着子类拥有父类的所有属性和方法,并且可能会被强制耦合到父类的实现上。

ClassB 内部包含一个 ClassA 的实例 _pA。

这表示 ClassB 拥有一个 ClassA("has a"关系)。

组合让 ClassB 可以使用 ClassA 的功能,但不依赖于 ClassA 的实现细节,从而降低耦合。

理论上,你可以把"继承关系"替换为"组合关系",这通常会让代码更灵活、耦合更低。

4.2 组合(Composition)的使用

使用组合可以替代多继承。

多继承在一些语言里可能带来复杂性(如菱形继承问题)。

组合则通过在类中包含其他类的实例来复用功能,而不是直接继承多个类。

组合提供动态灵活性:

在运行时可以通过多态或接口改变对象行为。

也就是说,你可以在程序运行时替换对象的组成部分(比如不同策略或模块),实现功能变化,而无需修改类本身。

避免组合爆炸问题:

如果用继承,每增加一个特性就可能需要创建一个新的子类组合所有可能性。

组合允许通过不同的对象组合来实现不同功能,而不必为每个组合创建新的类。

4.2.1 使用组合可以替代多继承

左边是继承:

ClassB 继承自 ClassA 和 ClassC(多继承)。

这意味着 ClassB "是一个" ClassA,同时也是一个 ClassC。

缺点:

多继承会增加类之间的耦合。

父类的修改可能会直接影响子类。

增加复杂度,难以维护。

右边是组合:

ClassB 依赖于 ClassC,但不是继承关系,而是通过成员变量持有 ClassC 的实例。

ClassB "有一个" ClassC。

优点:

降低耦合:ClassB 不再依赖父类实现细节,只依赖它需要的接口/功能。

更灵活:可以在运行时替换不同的 ClassC 实例。

避免了多继承带来的复杂性和问题。

4.2.2 在运行时动态改变对象的行为

通过组合,你可以在运行时动态改变对象的行为。这通常通过多态(Polymorphism)实现。

例如,你可以在程序运行时替换对象中的某些组件(子对象),而不需要修改整体类。

多态让组合的对象可以以统一接口操作不同的子类对象,从而支持动态替换。

图片左边:ClassB 持有一个 ClassA 对象 (ClassA *_pA)。

ClassB 通过 set_pA(...) 可以动态替换 ClassA 对象。

图片右边:ClassA 有多个子类(ClassAChild1 到 ClassAChild5)。因为 ClassB 持有 ClassA 的引用,而不依赖具体的子类,所以可以在运行时替换成任意 ClassA 的子类对象。

这就是组合带来的灵活性:你不需要通过继承来固定行为,而是通过组合和多态动态控制对象的行为。

下面的图片是使用接口和依赖倒置来实现松耦合的例子。

Order 类需要发送消息(比如订单通知),但它不直接依赖具体的实现(Email 或 Facebook)。

IMessageService 是一个接口(抽象),Order 依赖这个接口而不是具体实现。

Email 和 Facebook 类实现了 IMessageService 接口。

这样一来Order 可以通过接口调用消息服务,而不管实际是 Email 还是 Facebook。

新增消息类型(比如 SMS)时,不需要修改 Order 类,只要实现 IMessageService 即可。

相关代码如下。

java 复制代码
// 定义接口
interface IMessageService {
    void Send(String text);
}

// Email 实现类
class Email implements IMessageService {
    @Override
    public void Send(String text) {
        System.out.println(text + "\nSending product info via Email");
    }
}

// Facebook 实现类
class Facebook implements IMessageService {
    @Override
    public void Send(String text) {
        System.out.println(text + "\nSending product info via Facebook");
    }
}

// 示例使用
class Order {
    private IMessageService messageService;

    public Order(IMessageService service) {
        this.messageService = service;
    }

    public void notifyCustomer(String message) {
        messageService.Send(message);
    }
}

4.2.3 组合爆炸问题

当你用继承来建模多个独立的特性(traits)时,每个特性都可能有多种实现。如果你要支持这些特性的所有组合,你就必须为每种组合创建一个子类。子类的数量会随着特性数量呈指数增长,这就是"组合爆炸"。

例如:如果有两个特性,每个特性有 2 个选项 → 你需要 2 × 2 = 4 个子类。如果有三个特性,每个特性有 3 个选项 → 你需要 3 × 3 × 3 = 27 个子类。特性越多,子类数量增长越快。

那么为什么继承会导致这个问题?

继承是一种 "is-a" 关系。每个组合必须用一个新的子类表示。

所以使用组合 (Composition) 可以避免,因为组合是一种 "has-a" 关系。可以将各个特性作为组件组合到对象中,而不是为每种组合创建子类。这样对象在运行时就可以动态地组合特性,而不需要为每种组合都创建新类。这就避免了子类数量指数增长的问题。

图中 cCritter 有"Fly / Cursor / Gravity / Evade"等不同能力,因此每种功能组合都可能需要一个新子类,随着功能增加,子类数量呈指数增长。

左边的 cCritter 类通过组合(has-a)持有多个组件对象(cSprite, cListener, cForce)的引用,而不是通过继承来扩展不同功能。

在运行时,你可以动态地更换 cSprite、cListener 或 cForce 的具体实现(多态),而不需要修改 cCritter 的类结构。这样即便新增功能,也只需新增组件类,而无需新增多种子类。

现在尝试一个练习。

一个系统里有多种"机器"(Machines),他们类型不同,有的可以飞,有的可以有以哦那个。无论机器如何移动,它们都可能拥有攻击敌人的能力,可以使用激光(laser)或步枪(rifle),但并不是所有的机器都有攻击能力,有些机器可能只是移动或执行其他功能,没有攻击功能。

顶层类是 Machine,表示"机器"的通用类型。

FlyMachine 和 SwimMachine 是两种机器类型,分别能飞和能游。

如果每种机器还有不同攻击方式(比如 Laser、Rifle),就需要为每种组合创建一个子类:FlyLaserMachine、FlyRifleMachine、SwimLaserMachine、SwimRifleMachine。

随着能力组合增加,子类数量会呈指数增长,这就是所谓的组合爆炸(Combinatorial Explosion)。

下面的代码用组合去解决这里的组合爆炸问题。

Machine 类通过组合Weapon(武器) 接口、Movement(移动方式) 抽象类,而不是通过继承。

java 复制代码
// Weapon 接口
public interface Weapon {
    public double shot();
}

// Movement 抽象类
public abstract class Movement {
    private int x, y;

    public void setPositionX(int x){
        this.x = x;
    }

    public void setPositionY(int y){
        this.y = y;
    }

    public int getPositionX(){
        return x;
    }

    public int getPositionY(){
        return y;
    }

    public abstract void move();
}

// Machine 类
public class Machine {
    private Weapon weapon;
    private Movement movement;

    public Machine(Weapon w, Movement m){
        this.weapon = w;
        this.movement = m;
    }

    public void attack() {
        weapon.shot();
    }

    public void moveToPosition(int x, int y){
        movement.setPositionX(x);
        movement.setPositionY(y);
        movement.move();
    }
}

// Rifle 类
public class Rifle implements Weapon {
    @Override
    public double shot() {
        // You could implement code that specific to shoting
        System.out.println("I am shoting using Rifle");
        return 1.5;
    }
}


// Jump 类
public class Jump extends Movement {
    @Override
    public void move() {
        // You could implement code that specific to jumping
        System.out.println("I am JUMPING to (" + super.getPositionX() + 
                           "," + super.getPositionY() + ")");
    }
}
相关推荐
_Evan_Yao1 小时前
RAG中的“Chunk”艺术:我试过10种切分策略后总结的结论
java·人工智能·后端·python·软件工程
skylijf1 小时前
2026 高项第 6 章 预测考点 + 练习题(共 12 题,做完稳拿分)
笔记·程序人生·其他·职场和发展·软件工程·团队开发·产品经理
今天你TLE了吗1 小时前
LLM到Agent&RAG——AI概念概述 第二章:提示词
人工智能·笔记·后端·学习
烤麻辣烫1 小时前
JS基础
开发语言·前端·javascript·学习
red_redemption1 小时前
自由学习记录(168)
学习·已经运行中世界-模与约束·闭环
2601_954971132 小时前
2026数学专业,靠什么技能能发挥数理优势转数据岗?
学习
九成宫2 小时前
IT项目管理期末复习——Chapter 9 项目人力资源管理
笔记·项目管理·软件工程
xuanwenchao3 小时前
ROS2学习笔记 - 2、类的继承及使用
服务器·笔记·学习
ILYT NCTR3 小时前
爬虫学习案例3
爬虫·python·学习
不灭锦鲤4 小时前
网络安全学习第59天
学习·安全·web安全