当前位置: 首页 > news >正文

Effective C++ 条款32:确定你的 public 继承塑模出 is-a(是一种)关系

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 -> Animalpublic 继承
Cat -> Animalpublic 继承
Car -> Vehiclepublic 继承
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 QAbstractButtonQCheckBox 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的操作都适用于PlayerNPC

场景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)

http://www.jsqmd.com/news/1015091/

相关文章:

  • 119、Sensor 驱动的 I2C 读写封装:Burst Read、连续写入与 Page 寄存器的处理
  • 2025-2026年湖南农村自建房市场观察:安徽乡村别墅建造品牌如何跨省服务? - 优质品牌商家
  • 如何一键获取九大网盘真实下载地址?LinkSwift全场景指南
  • python ide for linux Linux上Python IDE就选Wing Pro?轻量却强大到让你尖叫
  • Traymond:Windows任务栏拥挤的终极解决方案
  • 怎样轻松解锁Honey Select 2完整汉化与去码功能:超实用5步实战指南
  • 成都碳晶板工厂哪个靠谱 - 资讯速览
  • 一站式音乐聚合革命:如何用智能音源打通全平台壁垒
  • Java 迭代器(Iterator)完全指南:从入门到实战
  • 法考背诵资料pdf|背诵|资料已整理
  • 从模糊到清晰:Real-ESRGAN-GUI如何让AI图像修复变得轻松简单
  • 如何快速搭建个人云游戏平台:Sunshine游戏串流终极完整教程
  • 计算机Java毕设实战-基于 SpringBoot 技术栈的一体化宠物服务平台【完整源码+LW+部署说明+演示视频,全bao一条龙等】
  • 抖音下载器技术深度解析:从无水印下载到批量处理的完整解决方案
  • 学Java遇中文乱码?别慌,这里有解决秘籍
  • [Android] 贝格手机罗盘_2.8版本
  • 2026年成都货架厂家哪家靠谱?多维度实测对比与真实案例解析 - 优质品牌商家
  • 如何彻底解决Windows 11文件资源管理器窗口混乱问题:终极标签管理指南
  • 法考系统强化内部讲义2026|系统强化|资料已整理
  • 独立开发者如何用 Stripe 搭建按量计费与订阅系统
  • SD-PPP:如何在Photoshop中一键召唤AI绘画助手,让创意效率提升300%?
  • Sunshine终极指南:如何免费搭建你的个人云游戏服务器
  • 认准报喜鸟【2026街坊私藏】清远管道疏通六强诚信榜:不坐地起价、不暴力施工、30分钟上门、一口价明码 - 极速版本
  • Python PDF处理终极指南:5分钟掌握PyPDF核心功能
  • 计算机Java毕设实战-基于 Spring Boot+Vue 的智能调查问卷系统的设计与实现 基于前后端分离的在线调查问卷系统的设计与实现【完整源码+LW+部署说明+演示视频,全bao一条龙等】
  • 终极指南:3分钟快速实现Figma界面中文汉化,设计师必备工具
  • 告别手动操作:京东自动化脚本终极指南,解放双手轻松赚京豆
  • 开源阅读鸿蒙版:基于HarmonyOS的分布式数字阅读架构解析与技术实践
  • 图片去水印用什么工具,这6款我实测了一遍
  • Display Driver Uninstaller:彻底解决显卡驱动问题的5步终极方案