里氏替换原则(LSP)要求子类必须严格遵守父类行为契约,而非仅语法合法;违反表现为抛未声明异常、改变返回值语义、破坏前置/后置条件等,此时应改用组合或接口隔离。

里氏替换原则(LSP)在 C++ 继承设计中不是“鼓励你多用继承”,而是给你划了一条行为红线:只要代码里写了 Bird&、std::unique_ptr<Bird> 或任何以基类类型为签名的接口,那传进去的 Penguin 就必须让这段代码继续跑通——不崩溃、不抛未声明异常、不静默改变逻辑。做不到?说明继承关系本身就有问题。
为什么 override 一个虚函数就可能违反 LSP
关键不在语法是否合法,而在语义是否守约。父类 fly() 的隐含契约可能是:“调用后对象进入空中状态,返回 void,不抛异常”。如果子类 Penguin::fly() 改成抛 std::logic_error,而调用方没做 try/catch,程序就崩了——这不是子类“功能少”,是它破坏了父类承诺的行为边界。
常见踩坑点:
-
override后返回值类型变窄(比如父类返回int,子类返回short),但调用方依赖返回值范围做计算,结果溢出 - 父类方法接受
const std::string&,子类重载成接受std::string_view—— 看似更轻量,但调用方传入临时std::string时生命周期可能提前结束 - 父类
save()保证幂等(多次调用等价于一次),子类实现成每次追加日志,导致外部逻辑重复保存被误判为“成功”
什么时候该用组合或接口,而不是 public 继承
当你发现子类不得不在 override 里写 assert(false)、throw、或空实现时,基本可以判定:这个“is-a”关系是假的。C++ 不强制你把所有共性塞进继承树。
立即学习“C++免费学习笔记(深入)”;
更务实的做法:
- 把行为拆成独立接口,比如
Flyable、Swimmable、LayEggs,让Sparrow和Duck分别继承需要的接口,而不是硬塞进一个大而全的Bird基类 - 用组合替代“伪继承”:比如
Car有Engine,而不是让Car继承Engine—— 后者明显违背常识 - 若必须复用数据结构,优先考虑
protected成员 + 非虚接口,而非暴露虚函数供随意重写
final 和纯虚函数不是银弹,但能帮你守住契约
父类中标记 virtual void fly() final,等于明示“此行为不可变”,后续子类连重写的权利都没有——这反而强化了 LSP。反过来,如果父类只留一个纯虚 virtual void fly() = 0,那就意味着:每个子类都得自己定义“飞”是什么,而调用方必须做好面对任意实现的心理准备(比如有的飞得快、有的飞得慢、有的根本不动——那它就不该叫 fly())。
真正难的从来不是语法,而是定义清楚“这个虚函数到底承诺了什么”。比如:
-
virtual size_t size() const必须是 O(1),不能在子类里变成遍历链表计数 -
virtual void clear()必须让empty()立即返回true,不能只清缓存、留脏数据 -
virtual T& at(size_t i)必须检查越界并抛std::out_of_range,不能静默返回首元素或 abort
LSP 最容易被忽略的点,是它不关心“子类多做了什么”,只盯住“子类有没有偷偷改掉父类已承诺的事”。哪怕你加了十个新接口、性能提升十倍,只要动了原有虚函数的语义边界,继承体系就已在悬崖边上。


















