Effective C++ 条款32:确定你的 public 继承塑模出 is-a(是一种)关系
public 继承是 C++ 面向对象编程中最核心的机制之一,但也是最常被误用的特性。
本条款将揭示 public 继承的深层含义,帮助你设计出正确的继承体系。
一、问题的提出:继承真的用对了吗?
在 C++ 中,class Derived : public Base这样的代码随处可见。但你是否真正思考过:什么情况下应该使用 public 继承?
来看几个常见的错误示例:
// 错误示例1:企鹅是一种鸟,但企鹅会飞吗?classBird{public:virtualvoidfly(){/* 鸟的飞行实现 */}};classPenguin:publicBird{// 企鹅是一种鸟?// 企鹅不会飞!这里的设计有问题};// 错误示例2:正方形是一种矩形?classRectangle{public:virtualvoidsetWidth(intw){width=w;}virtualvoidsetHeight(inth){height=h;}protected:intwidth,height;};classSquare:publicRectangle{// 正方形是一种矩形?// 正方形的宽和高必须相等,但基类允许独立设置!};这些看似"理所当然"的继承关系,实际上隐藏着严重的设计缺陷。问题的根源在于:没有正确理解 public 继承的语义。
二、is-a 关系的本质
2.1 什么是 is-a 关系?
public 继承意味着 is-a。适用于 base classes 身上的每一件事情,一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。
这句话是理解 public 继承的关键。用更形式化的语言描述,这就是著名的里氏替换原则(Liskov Substitution Principle, LSP):
如果 S 是 T 的子类型,那么程序中所有使用 T 类型对象的地方,都可以无修改地替换为 S 类型对象,而程序的行为保持不变。
2.2 正确的 is-a 关系示例
// 正确的继承:学生是一种人classPerson{public:Person(conststd::string&name,intage):name_(name),age_(age){}virtual~Person()=default;std::stringgetName()const{returnname_;}intgetAge()const{returnage_;}virtualvoidintroduce()const{std::cout<<"我叫"<<name_<<",今年"<<age_<<"岁。\n";}protected:std::string name_;intage_;};classStudent:publicPerson{public:Student(conststd::string&name,intage,conststd::string&school):Person(name,age),school_(school){}voidintroduce()constoverride{std::cout<<"我叫"<<name_<<",今年"<<age_<<"岁,就读于"<<school_<<"。\n";}std::stringgetSchool()const{returnschool_;}private:std::string school_;};// 使用示例:里氏替换原则的完美体现voidgreet(constPerson&person){std::cout<<"欢迎!";person.introduce();}intmain(){Personperson("张三",30);Studentstudent("李四",20,"清华大学");greet(person);// 输出:欢迎!我叫张三,今年30岁。greet(student);// 输出:欢迎!我叫李四,今年20岁,就读于清华大学。// Student 可以完美替代 Person,这就是 is-a 关系}分析:学生(Student)是一种人(Person),所以学生拥有人的所有属性(姓名、年龄),可以在任何需要人的地方使用。这是 public 继承的正确用法。
三、错误继承关系的深度剖析
3.1 经典反例:正方形与矩形
这是面向对象设计中最著名的陷阱之一:
classRectangle{public:Rectangle(intw,inth):width_(w),height_(h){}virtualvoidsetWidth(intw){width_=w;}virtualvoidsetHeight(inth){height_=h;}intgetWidth()const{returnwidth_;}intgetHeight()const{returnheight_;}intarea()const{returnwidth_*height_;}protected:intwidth_,height_;};classSquare:publicRectangle{public:Square(intside):Rectangle(side,side){}// 正方形的宽和高必须相等!voidsetWidth(intw)override{width_=w;height_=w;// 强制保持相等}voidsetHeight(inth)override{width_=h;// 强制保持相等height_=h;}};问题分析:
voidprocessRectangle(Rectangle&rect){rect.setWidth(5);rect.setHeight(3);assert(rect.area()==15);// 对于矩形,这个断言成立}intmain(){Squaresq(4);processRectangle(sq);// 传入正方形// sq.setWidth(5) 后,height 也变成了 5// sq.area() == 15 的断言失败!}| 问题 | 说明 |
|---|---|
| 行为不一致 | Square 改变了 Rectangle 的行为契约 |
| 违反 LSP | 无法在所有使用 Rectangle 的地方替换为 Square |
| 设计缺陷 | 几何上"正方形是矩形",但程序行为上不是 |
正确的解决方案:使用组合而非继承,或者重新设计接口。
// 方案1:使用组合classShape{public:virtual~Shape()=default;virtualintarea()const=0;};classRectangle:publicShape{// ... 矩形特有的实现};classSquare:publicShape{// ... 正方形独立的实现,不继承 Rectangleprivate:intside_;};3.2 经典反例:企鹅与鸟
classBird{public:virtual~Bird()=default;virtualvoideat(){std::cout<<"鸟在吃东西\n";}};classFlyingBird:publicBird{public:virtualvoidfly(){std::cout<<"鸟在飞翔\n";}};classPenguin:publicBird{// 企鹅是一种鸟,但不会飞public:voidswim(){std::cout<<"企鹅在游泳\n";}};// 使用示例voidletBirdFly(Bird&bird){// 如果传入 Penguin,这里会出问题// bird.fly(); // 编译错误!Bird 没有 fly 方法}voidletFlyingBirdFly(FlyingBird&bird){bird.fly();// 安全,因为 FlyingBird 一定会飞}关键洞察:不是所有鸟都会飞,所以"会飞"不应该成为 Bird 类的接口。正确的做法是将"会飞"提取到 FlyingBird 子类中。
四、is-a 关系的实践检验法
在设计继承关系时,可以通过以下测试来验证 is-a 关系是否成立:
4.1 "是一个"测试
Derived 是一个 Base 吗? - 学生是一个人?是的。 -> public 继承合理 - 正方形是一个矩形?几何上是,但程序行为上不是。 -> 需要重新考虑 - 企鹅是一种鸟?是的。 -> 但"会飞"不是鸟的普遍属性4.2 替换测试
// 如果以下代码对所有 Derived 对象都应该正确工作,// 那么 Derived public 继承 Base 是合理的voidtestSubstitution(Base&base){// 调用 Base 的所有公有接口base.someMethod();// Derived 对象传入后,行为应该符合预期// 不能出现:// - 抛出意外异常// - 产生不一致的状态// - 违反 Base 的契约}4.3 需求分析表
| 关系 | is-a? | 建议 |
|---|---|---|
| Dog -> Animal | 是 | public 继承 |
| Cat -> Animal | 是 | public 继承 |
| Car -> Vehicle | 是 | public 继承 |
| Engine -> Car | 否(has-a) | 组合/成员变量 |
| Square -> Rectangle | 行为上否 | 重新设计或组合 |
| Penguin -> FlyingBird | 否 | 继承自更抽象的 Bird |
五、实际应用场景
场景1:GUI 框架中的控件继承
// Qt 风格的控件继承体系classQWidget{public:virtualvoidshow()=0;virtualvoidhide()=0;virtualvoidpaintEvent()=0;virtualQSizesizeHint()const=0;};classQAbstractButton:publicQWidget{public:virtualvoidclick()=0;virtualvoidsetText(constQString&text)=0;virtualQStringtext()const=0;};classQPushButton:publicQAbstractButton{// QPushButton 是一种 QAbstractButton// 所有按钮的属性和行为都适用于 QPushButtonpublic:voidclick()override;voidsetText(constQString&text)override;voidpaintEvent()override;};classQCheckBox:publicQAbstractButton{// QCheckBox 也是一种 QAbstractButton// 但它还有额外的状态:checked/uncheckedpublic:boolisChecked()const;voidsetChecked(boolchecked);voidclick()override;// 切换 checked 状态};分析:QPushButton is-a QAbstractButton,QCheckBox is-a QAbstractButton。所有按钮的通用行为(点击、设置文本)都适用于这两种具体按钮。
场景2:游戏开发中的角色体系
classGameEntity{public:virtual~GameEntity()=default;virtualvoidupdate(floatdeltaTime)=0;virtualvoidrender()=0;virtualvoidtakeDamage(intamount)=0;Vec3getPosition()const{returnposition_;}voidsetPosition(constVec3&pos){position_=pos;}protected:Vec3 position_;inthealth_=100;boolalive_=true;};classCharacter:publicGameEntity{public:virtualvoidmove(constVec3&direction)=0;virtualvoidattack(GameEntity&target)=0;voidtakeDamage(intamount)override{health_-=amount;if(health_<=0){alive_=false;onDeath();}}protected:virtualvoidonDeath(){}intlevel_=1;};classPlayer:publicCharacter{public:voidupdate(floatdeltaTime)override;voidrender()override;voidmove(constVec3&direction)override;voidattack(GameEntity&target)override;voidgainExperience(intexp);voidequipItem(Item&item);protected:voidonDeath()override{std::cout<<"玩家死亡!游戏结束。\n";}private:intexperience_=0;std::vector<Item>inventory_;};classNPC:publicCharacter{public:voidupdate(floatdeltaTime)override;voidrender()override;voidmove(constVec3&direction)override;voidattack(GameEntity&target)override;voidsetAIBehavior(AIBehavior*behavior);protected:voidonDeath()override{std::cout<<"NPC 死亡。\n";dropLoot();}private:AIBehavior*ai_=nullptr;std::vector<Item>lootTable_;};分析:
Player is-a Character:玩家是一种角色,可以移动、攻击、受到伤害。NPC is-a Character:NPC 也是一种角色,同样可以移动、攻击、受到伤害。- 所有对
Character的操作都适用于Player和NPC。
场景3:金融系统中的账户类型
classAccount{public:Account(conststd::string&id,doublebalance):accountId_(id),balance_(balance){}virtual~Account()=default;virtualvoiddeposit(doubleamount){balance_+=amount;}virtualboolwithdraw(doubleamount){if(balance_>=amount){balance_-=amount;returntrue;}returnfalse;}doublegetBalance()const{returnbalance_;}std::stringgetAccountId()const{returnaccountId_;}protected:std::string accountId_;doublebalance_;};classSavingsAccount:publicAccount{public:SavingsAccount(conststd::string&id,doublebalance,doublerate):Account(id,balance),interestRate_(rate){}voidapplyInterest(){doubleinterest=balance_*interestRate_;deposit(interest);}private:doubleinterestRate_;};classCheckingAccount:publicAccount{public:CheckingAccount(conststd::string&id,doublebalance,doubleoverdraftLimit):Account(id,balance),overdraftLimit_(overdraftLimit){}boolwithdraw(doubleamount)override{if(balance_+overdraftLimit_>=amount){balance_-=amount;returntrue;}returnfalse;}private:doubleoverdraftLimit_;};// 使用:所有账户都可以统一处理voidprocessMonthlyStatement(Account&account){std::cout<<"账户 "<<account.getAccountId()<<" 余额: "<<account.getBalance()<<"\n";}六、常见陷阱与最佳实践
6.1 不要混淆 is-a 和 has-a
// 错误:汽车是一种引擎?classCar:publicEngine{// 错误!};// 正确:汽车有一个引擎classCar{private:Engine engine_;// has-a 关系用组合};6.2 不要混淆 is-a 和 is-implemented-in-terms-of
// 错误:Set 是一个 List?template<typenameT>classSet:publicstd::list<T>{// 危险!// List 允许重复元素,Set 不允许// List 的接口不完全适用于 Set};// 正确:Set 根据 List 实现出来// 使用 private 继承(见条款39)template<typenameT>classSet:privatestd::list<T>{public:voidinsert(constT&item){if(std::find(this->begin(),this->end(),item)==this->end()){this->push_back(item);}}// ...};6.3 虚析构函数的重要性
classBase{public:// 如果类设计为多态基类,必须有虚析构函数virtual~Base()=default;};classDerived:publicBase{public:~Derived()override{// 清理 Derived 特有的资源}private:std::vector<int>data_;};// 安全的使用方式Base*ptr=newDerived();deleteptr;// 正确:先调用 ~Derived(),再调用 ~Base()七、总结
| 要点 | 说明 |
|---|---|
| public 继承 = is-a | 这是不可违背的语义契约 |
| Liskov 替换原则 | 子类必须能够替换父类而不改变程序行为 |
| 行为一致性 | 子类不能弱化父类的行为契约 |
| 接口继承 | 子类继承父类的所有公有接口 |
| 设计前思考 | 先问"Derived is-a Base?",再写继承代码 |
请记住:
- "public 继承"意味 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。
- 在设计继承体系之前,先用里氏替换原则检验:所有使用基类的地方,是否都能安全地使用派生类替代?
- 如果答案是否定的,那么 public 继承不是正确的选择,考虑组合或其他设计模式。
public 继承是 C++ 中最强大的代码复用机制,但也是最危险的。正确使用它,你的代码将优雅而强大;误用它,你将陷入维护的泥潭。始终牢记:is-a 不是语法规则,而是语义契约。
参考:《Effective C++》第三版,Scott Meyers 著
相关条款:条款33(避免遮掩继承而来的名字)、条款34(区分接口继承和实现继承)、条款38(通过复合塑模出 has-a)