楼主: niah77
100 0

[作业] 里氏替换原则 C++ 实战指南:让子类替换不翻车 [推广有奖]

  • 0关注
  • 0粉丝

等待验证会员

小学生

14%

还不是VIP/贵宾

-

威望
0
论坛币
0 个
通用积分
0
学术水平
0 点
热心指数
0 点
信用等级
0 点
经验
40 点
帖子
3
精华
0
在线时间
0 小时
注册时间
2018-11-18
最后登录
2018-11-18

楼主
niah77 发表于 2025-11-18 17:22:30 |AI写论文

+2 论坛币
k人 参与回答

经管之家送您一份

应届毕业生专属福利!

求职就业群
赵安豆老师微信:zhaoandou666

经管之家联合CDA

送您一个全额奖学金名额~ !

感谢您参与论坛问题回答

经管之家送您两个论坛币!

+2 论坛币

目录

引言:为什么继承用着用着就出 BUG?

一、触目惊心的反例:违反 LSP 的代码有多坑?

1.1 反例 1:正方形继承长方形 —— 数学正确,代码错误

问题分析:

1.2 反例 2:企鹅继承鸟类 —— 生物正确,编程错误

问题分析:

1.3 反例带来的核心启示

二、里氏替换原则核心:4 个 "不能"+1 个 "必须"

2.1 核心要求 1:不能重写父类的非抽象方法

2.2 核心要求 2:不能强化前置条件

问题分析:

2.3 核心要求 3:不能弱化后置条件

问题分析:

2.4 核心要求 4:不能抛出额外的异常

2.5 核心要求 5:必须保持 "is-a" 关系

三、C++ 实战:如何正确实现里氏替换原则?

3.1 修复反例 1:正方形与长方形 —— 用组合替代继承

修复思路:

3.2 修复反例 2:企鹅与鸟类 —— 用接口分离行为

修复思路:

3.3 实战场景:支付系统设计 —— 遵循 LSP 的可扩展方案

设计思路:

代码亮点:

四、LSP 与其他设计原则的关联:构建完整的设计体系

4.1 LSP 是开闭原则的基础

4.2 LSP 与依赖倒置原则相辅相成

4.3 LSP 与接口隔离原则的互补

五、C++ 开发中遵循 LSP 的实用技巧

5.1 优先使用组合而非继承

优势:

5.2 用抽象基类和纯虚方法定义契约

5.3 编写单元测试验证 LSP 符合性

优势:

5.4 避免在子类中新增带约束的方法

六、总结:里氏替换原则的本质是 "契约精神"

核心要点回顾:

class 卑微码农:
    def __init__(self):
        self.技能 = ['能读懂十年前祖传代码', '擅长用Ctrl+C/V搭建世界', '信奉"能跑就别动"的玄学']
        self.发量 = 100  # 初始发量
        self.咖啡因耐受度 = '极限'
        
    def 修Bug(self, bug):
        try:
            # 试图用玄学解决问题
            if bug.严重程度 == '离谱':
                print("这一定是环境问题!")
            else:
                print("让我看看是谁又没写注释...哦,是我自己。")
        except Exception as e:
            # 如果try块都救不了,那就...
            print("重启一下试试?")
            self.发量 -= 1  # 每解决一个bug,头发-1
 
 
# 实例化一个我
我 = 卑微码农()

引言:为什么继承用着用着就出 BUG?

做 C++ 开发的同学大概率都遇到过这种场景:明明父类跑得好好的代码,换成子类后突然崩溃;或者新增一个子类扩展功能,却导致原有模块出现莫名其妙的异常。这不是你写的代码有语法错误,而是忽略了面向对象设计的核心准则 —— 里氏替换原则(Liskov Substitution Principle, LSP)。

里氏替换原则看似抽象,实则是解决继承乱象的 "定海神针"。它由图灵奖得主 Barbara Liskov 在 1987 年提出,本质是规范子类与父类的继承关系:

任何父类能出现的地方,子类都能无缝替换,且替换后程序行为不变、逻辑正确。简单说,子类可以给父类 "加分"(扩展功能),但不能给父类 "减分"(破坏原有功能)。

很多新手把继承当成 "代码复用工具",只要两个类有共性就盲目继承,却不知这种做法会埋下巨大隐患。本文将用通俗的语言、完整的 C++ 实战案例,从 "坑在哪"" 是什么 ""怎么用" 三个维度,带你彻底掌握里氏替换原则,写出健壮、可扩展的面向对象代码。

一、触目惊心的反例:违反 LSP 的代码有多坑?

在讲理论之前,先看两个真实开发中高频出现的反例。这些场景你可能似曾相识,而问题的根源正是违反了里氏替换原则。

1.1 反例 1:正方形继承长方形 —— 数学正确,代码错误

数学中正方形是特殊的长方形,但在编程中直接继承会导致逻辑崩溃。我们用 C++ 实现这个经典反例:

#include <iostream>
using namespace std;

// 父类:长方形
class Rectangle {
protected:
    int width;  // 宽度
    int height; // 高度
public:
    // 构造函数
    Rectangle(int w = 0, int h = 0) : width(w), height(h) {}
    
    // 设置宽度(核心方法:仅修改宽度,不影响高度)
    virtual void setWidth(int w) {
        width = w;
    }
    
    // 设置高度(核心方法:仅修改高度,不影响宽度)
    virtual void setHeight(int h) {
        height = h;
    }
    
    // 计算面积
    virtual int getArea() {
        return width * height;
    }
};

// 子类:正方形(错误继承长方形)
class Square : public Rectangle {
public:
    Square(int side = 0) : Rectangle(side, side) {}
    
    // 重写setWidth:正方形宽高必须相等,修改宽度时同时修改高度
    void setWidth(int w) override {
        width = w;
        height = w; // 破坏父类行为:父类setWidth不修改高度
    }
    
    // 重写setHeight:同理,修改高度时同时修改宽度
    void setHeight(int h) override {
        width = h;  // 破坏父类行为:父类setHeight不修改宽度
        height = h;
    }
};

// 客户端代码:计算长方形面积(依赖父类行为)
void testRectangleArea(Rectangle& rect) {
    rect.setWidth(5);  // 预期:宽度=5,高度不变
    rect.setHeight(4); // 预期:高度=4,宽度不变
    cout << "预期面积:20,实际面积:" << rect.getArea() << endl;
}

int main() {
    Rectangle rect;
    testRectangleArea(rect);  // 输出:预期面积:20,实际面积:20(正确)
    
    Square square;
    testRectangleArea(square); // 输出:预期面积:20,实际面积:16(错误!)
    return 0;
}

问题分析:

父类

Rectangle
的核心契约是 "宽高独立修改",但子类
Square
重写方法时破坏了这个契约。

客户端代码

testRectangleArea
基于父类契约开发,替换成子类后行为异常,这直接违反了里氏替换原则。

更隐蔽的是:如果后续有其他子类继承

Rectangle
,或客户端代码修改,这个 bug 可能会扩散到整个系统。

1.2 反例 2:企鹅继承鸟类 —— 生物正确,编程错误

另一个典型场景:父类

Bird
fly()
方法,子类
Penguin
(企鹅)继承后却无法实现飞行,导致程序崩溃。

#include <iostream>
#include <stdexcept>
using namespace std;

// 父类:鸟类
class Bird {
public:
    // 核心方法:飞行
    virtual void fly() {
        cout << "鸟类正在飞行" << endl;
    }
};

// 子类:企鹅(错误继承鸟类)
class Penguin : public Bird {
public:
    // 重写fly:企鹅不会飞,抛出异常
    void fly() override {
        throw runtime_error("企鹅不会飞!"); // 破坏父类无异常契约
    }
    
    // 企鹅特有方法:游泳
    void swim() {
        cout << "企鹅正在游泳" << endl;
    }
};

// 客户端代码:让鸟类飞行(依赖父类fly()无异常)
void letBirdFly(Bird& bird) {
    try {
        bird.fly(); // 预期:正常飞行,无异常
    } catch (const exception& e) {
        cout << "程序出错:" << e.what() << endl;
    }
}

int main() {
    Bird sparrow;
    letBirdFly(sparrow); // 输出:鸟类正在飞行(正确)
    
    Penguin penguin;
    letBirdFly(penguin); // 输出:程序出错:企鹅不会飞!(错误)
    penguin.swim();      // 企鹅特有功能正常,但飞行功能破坏父类契约
    return 0;
}

问题分析:

父类

Bird
fly()
方法隐含 "可以正常飞行" 的契约,但子类
Penguin
重写后抛出异常,违反了 "替换后程序行为不变" 的要求。

客户端代码被迫添加异常处理,打破了原有设计的简洁性。如果后续新增

Ostrich
(鸵鸟)等不会飞的鸟类,会导致更多重复的异常处理代码。

1.3 反例带来的核心启示

这两个反例暴露了违反里氏替换原则的三大危害:

  • 破坏代码稳定性:子类替换父类后,原有功能异常,导致 "牵一发而动全身"。
  • 增加维护成本:客户端代码需要针对子类特殊处理,违背 "开闭原则"。
  • 降低代码可读性:继承关系看似合理(正方形是长方形、企鹅是鸟),但实际行为不一致,让其他开发者困惑。

而解决这些问题的关键,就是理解里氏替换原则的核心要求,并在代码设计中严格遵循。

二、里氏替换原则核心:4 个 "不能"+1 个 "必须"

里氏替换原则的官方定义是:

如果 S 是 T 的子类型,那么所有使用 T 类型对象的地方,都可以用 S 类型对象替换,且不会改变程序的正确性

该定义可以分解成五个具体的要求,用 C++ 程序员易于理解的术语概括如下:

2.1 主要要求 1:不应覆盖父类的非虚拟方法

父类的非虚拟方法是已实现的具体操作,构成了父类协议的关键部分。子类覆盖这些方法,实际上破坏了父类的预定行为。

正确做法:子类可以增加新方法(例如企鹅的

swim()
),但不得更改父类已实现的方法。

C++ 示例:如果父类

Bird
fly()
是具体实现,子类
Eagle
可以添加
soar()
(飞翔)方法,但不能重写
fly()
来改变其核心逻辑。

2.2 主要要求 2:不得加强前提条件

前提条件是指方法执行前的输入限制(如参数范围、格式要求)。子类方法的前提条件不应比父类更为严格,否则可能会导致原本符合父类要求的输入,在子类中被拒绝。

#include <iostream>
using namespace std;

// 父类:文件读取器(前置条件:文件路径非空)
class FileReader {
public:
    virtual string read(const string& path) {
        if (path.empty()) {
            throw invalid_argument("文件路径不能为空");
        }
        return "读取文件内容:" + path;
    }
};

// 子类:加密文件读取器(错误:强化前置条件)
class EncryptedFileReader : public FileReader {
private:
    string key; // 新增密钥参数
public:
    EncryptedFileReader(const string& k) : key(k) {}
    
    // 重写read:新增"密钥不能为空"的前置条件(比父类严格)
    string read(const string& path) override {
        if (key.empty()) { // 父类没有这个约束
            throw invalid_argument("加密文件必须提供密钥");
        }
        if (path.empty()) {
            throw invalid_argument("文件路径不能为空");
        }
        return "解密并读取文件:" + path;
    }
};

// 客户端代码:使用父类读取普通文件
void processFile(FileReader& reader) {
    try {
        string content = reader.read("test.txt");
        cout << content << endl;
    } catch (const exception& e) {
        cout << "处理失败:" << e.what() << endl;
    }
}

int main() {
    FileReader normalReader;
    processFile(normalReader); // 输出:读取文件内容:test.txt(正确)
    
    EncryptedFileReader encryptedReader(""); // 密钥为空
    processFile(encryptedReader); // 输出:处理失败:加密文件必须提供密钥(错误)
    return 0;
}

问题分析: 父类

FileReader
read()
方法只需“路径非空”,但子类增加了“密钥非空”的限制。

客户端代码依据父类协议调用(提供合法路径),但由于子类的前提条件更严格而引发异常,违背了里氏替换原则。

2.3 主要要求 3:不得削弱后置条件

后置条件是指方法执行后的输出限制(如返回值范围、数据状态)。子类方法的后置条件不应比父类更宽松,否则可能导致客户端代码接收到不符合预期的结果。

#include <iostream>
#include <vector>
using namespace std;

// 父类:用户查询器(后置条件:返回非空用户列表)
class UserQuery {
public:
    virtual vector<string> getActiveUsers() {
        return {"张三", "李四", "王五"}; // 保证返回3个活跃用户
    }
};

// 子类:简化用户查询器(错误:弱化后置条件)
class SimpleUserQuery : public UserQuery {
public:
    // 重写getActiveUsers:返回空列表(违反父类非空契约)
    vector<string> getActiveUsers() override {
        return {}; // 后置条件比父类宽松
    }
};

// 客户端代码:依赖父类返回非空列表
void printActiveUsers(UserQuery& query) {
    vector<string> users = query.getActiveUsers();
    // 父类契约保证非空,未做空判断
    for (const auto& user : users) {
        cout << "活跃用户:" << user << endl;
    }
}

int main() {
    UserQuery normalQuery;
    printActiveUsers(normalQuery); // 输出3个用户(正确)
    
    SimpleUserQuery simpleQuery;
    printActiveUsers(simpleQuery); // 无输出(逻辑异常,若后续有users[0]会崩溃)
    return 0;
}

问题分析: 父类

UserQuery
getActiveUsers()
隐含“返回非空列表”的后置条件,客户端代码基于此设计,没有进行空判断。

子类返回空列表,削弱了后置条件,尽管语法正确,但会导致客户端代码逻辑异常(若后续访问列表元素会直接崩溃)。

2.4 主要要求 4:不得抛出额外的异常

父类方法声明的异常类型,规定了客户端需要处理的异常范围。子类方法抛出的异常不应超出父类的异常体系,否则客户端可能遇到未预料的异常。

C++ 中这一要求体现为:子类重写方法抛出的异常,必须是父类方法异常的子类(或相同类型),不得抛出更广泛的异常。

#include <iostream>
#include <stdexcept>
using namespace std;

// 父类:数据处理器(仅抛出runtime_error)
class DataProcessor {
public:
    virtual void process() {
        throw runtime_error("数据处理失败");
    }
};

// 子类:网络数据处理器(错误:抛出额外异常)
class NetworkDataProcessor : public DataProcessor {
public:
    // 重写process:抛出logic_error(父类未声明)
    void process() override {
        throw logic_error("网络连接超时"); // 额外异常
    }
};

// 客户端代码:仅处理父类声明的runtime_error
void handleData(DataProcessor& processor) {
    try {
        processor.process();
    } catch (const runtime_error& e) {
        cout << "处理已知异常:" << e.what() << endl;
    }
}

int main() {
    DataProcessor localProcessor;
    handleData(localProcessor); // 输出:处理已知异常:数据处理失败(正确)
    
    NetworkDataProcessor networkProcessor;
    handleData(networkProcessor); // 程序崩溃:未捕获logic_error(错误)
    return 0;
}

2.5 主要要求 5:必须维持 “is-a” 关系

继承的本质是 “is-a”(是一种)的关系,但这里的 “是” 并不是现实世界中的分类,而是编程中的行为协议。

正确的 “is-a”:麻雀是一种鸟(麻雀能飞,符合鸟的飞行协议)。

错误的 “is-a”:企鹅是一种鸟(企鹅不能飞,不符合鸟的飞行协议)。

C++ 设计启示:当子类无法完全遵守父类的行为协议时,表明继承关系设计不当,应放弃继承,采用其他策略(如组合、接口分离)。

三、C++ 实践:如何正确实现里氏替换原则?

理解了主要要求后,关键在于实际开发中的实施。以下通过 “修正反例” 和 “实战场景” 两个方面,提供完整的 C++ 实施方案。

3.1 修正反例 1:正方形与矩形 —— 使用组合代替继承

正方形继承矩形的问题,根本原因在于两者的行为协议不一致。正确的方法是:抽取公共基类,使用组合而非继承实现共性复用。

#include <iostream>
using namespace std;

// 抽象基类:图形(提取长方形和正方形的共性)
class Shape {
public:
    virtual int getArea() const = 0; // 纯虚方法:计算面积(统一契约)
    virtual ~Shape() {} // 虚析构函数:确保子类正确析构
};

// 子类:长方形(遵循Shape契约)
class Rectangle : public Shape {
private:
    int width;
    int height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int getArea() const override { return width * height; }
};

// 子类:正方形(遵循Shape契约)
class Square : public Shape {
private:
    int side;
public:
    Square(int s) : side(s) {}
    
    void setSide(int s) { side = s; }
    int getArea() const override { return side * side; }
};

// 客户端代码:依赖Shape抽象基类(符合LSP)
void testShapeArea(const Shape& shape) {
    cout << "图形面积:" << shape.getArea() << endl;
}

int main() {
    Rectangle rect(5, 4);
    testShapeArea(rect); // 输出:图形面积:20(正确)
    
    Square square(4);
    testShapeArea(square); // 输出:图形面积:16(正确)
    
    // 替换后程序行为一致,符合里氏替换原则
    rect.setWidth(6);
    testShapeArea(rect); // 输出:图形面积:24(正确)
    
    square.setSide(5);
    testShapeArea(square); // 输出:图形面积:25(正确)
    return 0;
}

修正思路: 抽取抽象基类

Shape
,定义统一的
getArea()
协议,防止父类包含子类无法遵守的方法(如
setWidth
)。

矩形和正方形各自继承

Shape
,实现符合自身特点的方法,不再互相继承。

客户端代码依赖抽象基类,替换任何子类均不会改变行为,完全符合 LSP。

3.2 修正反例 2:企鹅与鸟类 —— 使用接口分离行为

企鹅不会飞的问题,根本原因在于父类

Bird
包含了子类无法实现的行为。正确的方法是:将特定行为(飞行)分离为接口,子类根据需要实现。

#include <iostream>
using namespace std;

// 抽象基类:鸟类(包含所有鸟类的共性行为)
class Bird {
public:
    virtual void eat() const {
        cout << "鸟类正在进食" << endl;
    }
    virtual ~Bird() {}
};

// 接口:飞行能力(仅会飞的鸟类实现)
class Flyable {
public:
    virtual void fly() const = 0;
    virtual ~Flyable() {}
};

// 子类:麻雀(是鸟,且会飞)
class Sparrow : public Bird, public Flyable {
public:
    void eat() const override {
        cout << "麻雀吃虫子" << endl;
    }
    void fly() const override {
        cout << "麻雀正在低空飞行" << endl;
    }
};

// 子类:企鹅(是鸟,但不会飞)
class Penguin : public Bird {
public:
    void eat() const override {
        cout << "企鹅吃鱼" << endl;
    }
    void swim() const {
        cout << "企鹅正在南极游泳" << endl;
    }
};

// 子类:鹰(是鸟,且会飞)
class Eagle : public Bird, public Flyable {
public:
    void eat() const override {
        cout << "鹰吃肉类" << endl;
    }
    void fly() const override {
        cout << "鹰正在高空翱翔" << endl;
    }
};

// 客户端代码1:处理所有鸟类(调用共性行为eat)
void feedBird(const Bird& bird) {
    bird.eat();
}

// 客户端代码2:处理会飞的鸟(调用飞行行为)
void letFly(const Flyable& flyable) {
    flyable.fly();
}

int main() {
    Sparrow sparrow;
    Penguin penguin;
    Eagle eagle;
    
    // 所有鸟类都能被feedBird处理(符合LSP)
    feedBird(sparrow);  // 输出:麻雀吃虫子
    feedBird(penguin);  // 输出:企鹅吃鱼
    feedBird(eagle);    // 输出:鹰吃肉类
    
    // 只有会飞的鸟能被letFly处理(无异常)
    letFly(sparrow);    // 输出:麻雀正在低空飞行
    letFly(eagle);      // 输出:鹰正在高空翱翔
    
    // penguin没有实现Flyable,无法传入letFly(编译时检查,避免运行时错误)
    // letFly(penguin); // 编译报错:无法转换参数类型
    
    penguin.swim();     // 企鹅特有功能正常使用
    return 0;
}

修正思路: 拆分行为:将

Bird
类的
fly()
方法分离为
Flyable
接口,实现 “行为与基类分离”。

按需实现:会飞的鸟类(麻雀、鹰)同时继承

Bird
Flyable
,不会飞的鸟类(企鹅)仅继承
Bird

编译时检查:客户端代码调用飞行功能时,依赖

Flyable
接口而非
Bird
类,避免将不会飞的鸟传入,从根本上防止运行时异常。

3.3 实战场景:支付系统设计 —— 遵循 LSP 的可扩展方案

以下是电子商务支付系统的设计,展示了里氏替换原则在实际项目中的应用。需求是:支持多种支付方式(信用卡、微信、支付宝),并且在增加新的支付方式时不需要修改现有代码。

设计思路: 抽象基类

Payment
:定义统一的支付协议(
pay()
方法)。

子类实现:每种支付方式作为一个子类,实现自身的支付逻辑,但不改变父类协议。

客户端代码:依赖

Payment
基类,替换任何子类都能正常运行。

#include <iostream>
#include <string>
using namespace std;

// 支付结果结构体:统一返回格式(后置条件一致)
struct PaymentResult {
    bool success;      // 支付是否成功
    string orderId;    // 订单号
    string message;    // 提示信息
};

// 抽象基类:支付方式(定义统一契约)
class Payment {
protected:
    string orderId;
    double amount;
public:
    Payment(const string& oid, double amt) : orderId(oid), amount(amt) {}
    
    // 纯虚方法:支付(前置条件:订单号非空、金额>0;后置条件:返回PaymentResult)
    virtual PaymentResult pay() const = 0;
    
    virtual ~Payment() {}
};

// 子类1:信用卡支付
class CreditCardPayment : public Payment {
private:
    string cardNumber; // 卡号(子类特有属性)
public:
    CreditCardPayment(const string& oid, double amt, const string& cardNo)
        : Payment(oid, amt), cardNumber(cardNo) {}
    
    PaymentResult pay() const override {
        // 模拟信用卡支付逻辑(不改变父类契约)
        if (cardNumber.empty() || amount <= 0) {
            return {false, orderId, "支付失败:卡号为空或金额无效"};
        }
        // 模拟支付成功
        return {true, orderId, "信用卡支付成功:" + cardNumber.substr(cardNumber.size()-4)};
    }
};

// 子类2:微信支付
class WeChatPayment : public Payment {
private:
    string openId; // 微信openId(子类特有属性)
public:
    WeChatPayment(const string& oid, double amt, const string& oid)
        : Payment(oid, amt), openId(oid) {}
    
    PaymentResult pay() const override {
        // 模拟微信支付逻辑(不改变父类契约)
        if (openId.empty() || amount <= 0) {
            return {false, orderId, "支付失败:openId为空或金额无效"};
        }
        // 模拟支付成功
        return {true, orderId, "微信支付成功:" + openId.substr(0, 8) + "***"};
    }
};

// 子类3:支付宝支付(新增支付方式,不修改原有代码)
class AlipayPayment : public Payment {
private:
    string alipayAccount; // 支付宝账号(子类特有属性)
public:
    AlipayPayment(const string& oid, double amt, const string& account)
        : Payment(oid, amt), alipayAccount(account) {}
    
    PaymentResult pay() const override {
        // 模拟支付宝支付逻辑(遵循父类契约)
        if (alipayAccount.empty() || amount <= 0) {
            return {false, orderId, "支付失败:支付宝账号为空或金额无效"};
        }
        // 模拟支付成功
        return {true, orderId, "支付宝支付成功:" + alipayAccount};
    }
};

// 客户端代码:订单支付(依赖Payment基类,符合LSP)
void processOrderPayment(const Payment& payment) {
    cout << "正在处理订单:" << payment.getOrderId() << endl;
    PaymentResult result = payment.pay();
    cout << "支付结果:" << (result.success ? "成功" : "失败") << endl;
    cout << "提示信息:" << result.message << endl;
    cout << "------------------------" << endl;
}

// 扩展:获取订单号(父类新增方法,子类自动继承,不破坏LSP)
string Payment::getOrderId() const {
    return orderId;
}

int main() {
    // 信用卡支付
    CreditCardPayment creditPay("ORDER_20250101_001", 99.9, "622202********1234");
    processOrderPayment(creditPay);
    
    // 微信支付
    WeChatPayment wechatPay("ORDER_20250101_002", 199.0, "o6_bmjrPTlm6_2sgVt7hMZOPfL2M");
    processOrderPayment(wechatPay);
    
    // 支付宝支付(新增子类,客户端代码无需修改)
    AlipayPayment alipayPay("ORDER_20250101_003", 299.5, "zhangsan@163.com");
    processOrderPayment(alipayPay);
    
    return 0;
}

代码亮点: 协议一致性:所有子类都遵循

Payment
基类的协议,前提条件(订单号非空、金额 > 0)和后置条件(返回
PaymentResult
)保持一致。

可扩展性:新增

AlipayPayment
子类时,无需修改客户端代码
processOrderPayment
,符合 “开闭原则”。

兼容性:父类新增

getOrderId()
方法应用时,子类自然继承,不会破坏当前代码,展示了 LSP 的灵活性。
可读性:继承关系明了,每个子类的职责清晰,其他开发人员可以迅速理解并扩展。
四、LSP 与其他设计原则的关系:构建全面的设计体系
里氏替换原则并非孤立存在,它与 SOLID 的其他原则(特别是开闭原则、依赖倒置原则)紧密相连,共同构成了面向对象设计的核心框架。
4.1 LSP 是开闭原则的基础
开闭原则强调 “对扩展开放,对修改关闭”。而里氏替换原则通过确保子类替换的兼容性,使得扩展(增加新子类)不会影响原有代码,从而实现开闭原则。
反例:如果子类违背 LSP,增加新子类后需修改客户端代码(如添加异常处理、特定判断),这直接违反了开闭原则。
正例:支付系统中增加支付宝支付子类,无需修改客户端的订单处理代码,同时符合 LSP 和开闭原则。
4.2 LSP 与依赖倒置原则相互支持
依赖倒置原则强调 “高层模块应依赖抽象,而非具体实现”。而 LSP 确保了抽象基类的子类都能符合抽象契约,使高层模块可以安心依赖抽象。
关系:依赖倒置原则解决 “依赖什么” 的问题,里氏替换原则解决 “依赖后是否可靠” 的问题。
示例:支付系统的客户端代码
processOrderPayment
依赖抽象基类
Payment
(依赖倒置),而 LSP 确保任何
Payment
子类都能正常运作,两者结合使代码既灵活又稳健。
4.3 LSP 与接口隔离原则的互补作用
接口隔离原则要求 “客户端不应依赖不必要的接口”。里氏替换原则则要求 “子类必须实现接口的所有契约”,两者相辅相成:
接口隔离原则:分解冗余接口,避免子类被迫实现不需要的方法(例如将
fly()

Bird
类中分离出来)。
里氏替换原则:确保子类实现的接口方法符合契约(例如
Sparrow

fly()
方法正常工作)。
五、C++ 开发中遵循 LSP 的实用技巧
除了上述原则和示例外,在实际 C++ 开发中,还可以通过以下技巧高效遵循里氏替换原则:
5.1 优先考虑组合而非继承
当不确定子类是否能完全遵循父类契约时,组合是比继承更安全的选择。组合的核心在于 “拥有” 关系,而非 “是” 关系。
// 不推荐:继承可能违反LSP
class BadEncryptedFileReader : public FileReader {
    // 可能强化前置条件、修改父类行为
};

// 推荐:组合遵循LSP
class GoodEncryptedFileReader {
private:
    FileReader fileReader; // 组合FileReader,复用其功能
    string key;
public:
    string read(const string& path, const string& k) {
        key = k;
        if (key.empty()) throw invalid_argument("密钥不能为空");
        return decrypt(fileReader.read(path)); // 不修改FileReader行为
    }
};
优点:
组合不会破坏原有类的契约,避免违反 LSP。
更高的灵活性:可以选择调用原有类的方法,或添加额外逻辑,无需重写。
5.2 使用抽象基类和纯虚方法定义契约
在 C++ 中,通过抽象基类(包含纯虚方法)可以明确契约,强制子类实现必要的方法,同时避免父类包含子类无法遵循的具体行为。
技巧:将父类设计为抽象基类,仅包含纯虚方法和共性属性,具体实现放在子类中。
示例:支付系统的
Payment
类、图形系统的
Shape
类,都是通过纯虚方法定义契约。
5.3 编写单元测试验证 LSP 符合性
一个简单有效的验证方法:为父类编写单元测试,然后用子类对象替换父类对象,所有测试用例必须通过。
#include <gtest/gtest.h>
#include "Shape.h"

// 父类测试用例
TEST(ShapeTest, RectangleArea) {
    Rectangle rect(5, 4);
    EXPECT_EQ(rect.getArea(), 20);
    rect.setWidth(6);
    EXPECT_EQ(rect.getArea(), 24);
}

// 子类替换测试(验证LSP)
TEST(ShapeTest, SquareArea) {
    Square square(4);
    EXPECT_EQ(square.getArea(), 16); // 父类测试用例适配子类
    square.setSide(5);
    EXPECT_EQ(square.getArea(), 25);
}
优点:
自动化验证子类是否符合 LSP,提前发现潜在问题。
确保后续修改(如子类重构)不会破坏契约。
5.4 避免在子类中添加带有约束的方法
子类可以添加新方法,但新方法不能带有比父类更严格的约束,否则会让客户端代码在使用子类特有方法时产生混淆。
错误示例:子类新增方法要求参数必须为正数,而父类类似方法无此约束。
正确示例:子类新增方法的约束与父类一致,或更宽松。
六、总结:里氏替换原则的本质是 “契约精神”
里氏替换原则表面上是关于继承的规则,实际上是面向对象设计的 “契约精神”—— 父类定义契约,子类遵守契约,客户端依赖契约。
核心要点回顾:
一句话记住 LSP:父类能用,子类也能用,且表现同样出色。
四大禁忌:不重写父类非抽象方法、不加强前置条件、不削弱后置条件、不抛出额外异常。
两大方案:违反 LSP 时,优先考虑 “组合替代继承” 或 “接口分离行为”。
验证方法:子类替换父类后,原有代码无异常、单元测试全部通过。
遵循里氏替换原则,可能在设计初期需要更多时间思考继承关系和契约,但从长远来看,它可以大幅降低代码的维护成本、减少错误、提高扩展性。当你下次想编写
class A : public B
时,不妨先问问自己:子类能否完全遵守父类的契约?替换后程序的行为会改变吗?
设计模式的核心不是死记硬背规则,而是培养一种 “优雅解决问题” 的思维方式。里氏替换原则作为 SOLID 的重要组成部分,是你从 “能写出运行的代码” 到 “能写出易于维护的代码” 的关键一步。
欢迎在评论区分享你在项目中遇到的违反 LSP 的问题,以及你是如何解决的!
二维码

扫码加我 拉你入群

请注明:姓名-公司-职位

以便审核进群资格,未注明则拒绝

关键词:substitution rectangle exception principle virtual

您需要登录后才可以回帖 登录 | 我要注册

本版微信群
jg-xs1
拉您进交流群
GMT+8, 2026-1-7 01:33