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

继承不是“拿来用“:is-a 关系与组合

文章目录

  • 引言
  • 一、C 的"继承":结构体嵌套
  • 二、C++ 的继承基础语法
    • 2.1 最简单的继承
    • 2.2 `public` 继承意味着什么
  • 三、is-a 的铁律:何时才能用继承
    • 3.1 简单的判断标准
    • 3.2 反例:Square 应该继承 Rectangle 吗?
  • 四、继承最常见的误用:为了复用代码
    • 4.1 一个典型的错误
    • 4.2 正确的做法:组合
  • 五、继承和组合的实战对比
    • 5.1 场景:设计一个"带颜色的矩形"
    • 5.2 决策速查表
  • 六、继承的其他细节
    • 6.1 派生类的构造与析构顺序
    • 6.2 派生类中调用基类方法
    • 6.3 三种继承方式速查
  • 总结

本系列为《C++深度修炼:基础、STL源码与多线程实战》第5篇
前置条件:理解 class 封装(第2篇)、构造函数(第3篇)、成员函数(第4篇)

引言

很多 C 程序员学会 C++ 的继承语法之后,第一反应是:“太好了!我有一个现成的类,里面大部分代码我都要用,继承它改几个方法就行了。”

这是个危险的直觉。把继承当成"代码复用工具",是 C++ 中最常见的面向对象设计错误。

继承的真正含义只有一个:is-a(是一个)Dog继承Animal,是因为 Dogis anAnimal——狗就是动物。不是因为Animal里面有一堆好用的函数,省得你自己写。

本文用 C 程序员的视角,从 struct 嵌套开始,解释继承的本质、误用场景,以及什么时候应该用**组合(composition)**替代继承。


一、C 的"继承":结构体嵌套

在 C 中,"复用"一个结构体的方式只有一种:嵌套

// embed.c#include<stdio.h>structPerson{charname[32];intage;};structEmployee{structPersonperson;// 嵌套 Personintemployee_id;doublesalary;};intmain(){structEmployeeemp;snprintf(emp.person.name,32,"张三");emp.person.age=30;emp.employee_id=1001;emp.salary=50000.0;printf("%s, %d岁, 工号%d, 月薪%.0f\n",emp.person.name,emp.person.age,emp.employee_id,emp.salary);}

这就是组合(Composition)——Employeehas aPerson(员工有一个人信息),不是"员工是一个人"。

在 C++ 中,你有两种方式表达这个关系:继承(is-a)和组合(has-a)。用对还是用错,是区分设计水平的分水岭。


二、C++ 的继承基础语法

2.1 最简单的继承

#include<iostream>#include<string>classAnimal{public:Animal(conststd::string&name):name_(name){}voideat(){std::cout<<name_<<" 吃东西\n";}voidsleep(){std::cout<<name_<<" 睡觉\n";}std::stringname()const{returnname_;}private:std::string name_;};// Dog is-a AnimalclassDog:publicAnimal{public:Dog(conststd::string&name):Animal(name){}// 委托基类构造voidbark(){std::cout<<name()<<" 汪汪叫\n";}};intmain(){Dogd("旺财");d.eat();// 继承自 Animald.sleep();// 继承自 Animald.bark();// Dog 自己的方法}
$ g++ -std=c++17 dog.cpp && ./a.out 旺财 吃东西 旺财 睡觉 旺财 汪汪叫

2.2public继承意味着什么

classDog:publicAnimal{/* ... */};// ^^^^^^

public继承的含义是:“Dog is an Animal” 这个事实对全世界公开。具体表现为:

基类的访问权限在 public 继承后,派生类中外部代码中
public成员仍然是public可通过派生类对象访问
protected成员仍然是protected不可访问
private成员不可访问(但存在于对象中)不可访问
classBase{public:intpub_;protected:intprot_;private:intpriv_;// 派生类也看不见};classDerived:publicBase{public:voidtest(){pub_=1;// ✅prot_=2;// ✅ 派生类可以访问 protected// priv_ = 3; // ❌ 派生类也不能访问基类的 private}};intmain(){Derived d;d.pub_=10;// ✅ public 继承后仍然是 public// d.prot_ = 20; // ❌ protected 对外部始终不可见// d.priv_ = 30; // ❌ private 永远不可见}

三、is-a 的铁律:何时才能用继承

3.1 简单的判断标准

用一句话问自己:“派生类是否在任何场景下都可以替换基类?”

如果答案是"是"——用继承。如果答案是"大部分可以,但有些场景不太对"——不能用继承。

// ✅ 好的继承:Rectangle is a ShapeclassShape{public:virtualdoublearea()const=0;virtual~Shape()=default;};classRectangle:publicShape{public:Rectangle(doublew,doubleh):w_(w),h_(h){}doublearea()constoverride{returnw_*h_;}private:doublew_,h_;};// Rectangle 在任何需要 Shape 的场景下都能用——is-a 成立voidprint_area(constShape&s){std::cout<<"面积: "<<s.area()<<'\n';}

3.2 反例:Square 应该继承 Rectangle 吗?

这是最经典的继承陷阱。直觉上"正方形是特殊的矩形",但:

classRectangle{public:Rectangle(doublew,doubleh):w_(w),h_(h){}virtualvoidset_width(doublew){w_=w;}virtualvoidset_height(doubleh){h_=h;}doublearea()const{returnw_*h_;}protected:doublew_,h_;};classSquare:publicRectangle{public:Square(doubleside):Rectangle(side,side){}voidset_width(doublew)override{w_=w;h_=w;// 保持正方形:宽高同步}voidset_height(doubleh)override{w_=h;h_=h;// 保持正方形:宽高同步}};// 问题来了:voidstretch(Rectangle&r){r.set_width(r.area()/r.w_*2);// 假设 w_ 可访问// 调用者以为 r 是个普通矩形,单独调宽没问题// 但如果 r 实际是 Square,set_width 会连带改高度——调用者不知道!}

Square is NOT a Rectangle——在可变的设定下,正方形不能自由改变宽高,因此不能在需要Rectangle的地方无差别地替换。让Square继承Rectangle是对 is-a 的违反。

正确的做法:两者各自继承Shape,互不依赖。


四、继承最常见的误用:为了复用代码

4.1 一个典型的错误

// ❌ 错误示范:为了复用而继承classCsvWriter{public:voidopen(conststd::string&path){/* ... */}voidwrite_row(conststd::vector<std::string>&row){/* ... */}voidclose(){/* ... */}};// 我想要一个写 JSON 的功能,加上 CSV 里 open/close —— 继承吧?classJsonWriter:publicCsvWriter{// 错!JsonWriter is NOT a CsvWriterpublic:voidwrite_json(conststd::string&json){// 调用 write_row 来输出?完全用不上……}};

JsonWriter不是CsvWriter——它们本质上是两个独立的写器。把CsvWriter当"工具包"继承过来,就是典型的"为了复用而继承"。

4.2 正确的做法:组合

// ✅ 正确:组合classFileHandle{// 提取共享的"文件打开关闭"职责public:voidopen(conststd::string&path){/* ... */}voidclose(){/* ... */}};classCsvWriter{public:CsvWriter():file_(newFileHandle()){}voidwrite_row(conststd::vector<std::string>&row){/* 用 file_->write() */}private:std::shared_ptr<FileHandle>file_;// has-a,不是 is-a};classJsonWriter{public:JsonWriter():file_(newFileHandle()){}voidwrite_json(conststd::string&json){/* 用 file_->write() */}private:std::shared_ptr<FileHandle>file_;// has-a,不是 is-a};

从"继承以复用"切换到"组合以复用"——这条原则在 GoF 的《设计模式》中被总结为:

“优先使用对象组合而不是类继承。”(Favor object composition over class inheritance.)


五、继承和组合的实战对比

5.1 场景:设计一个"带颜色的矩形"

方案 A:继承

// ❌ 仅仅为了加个 color 就继承classColoredRectangle:publicRectangle{std::string color_;public:ColoredRectangle(doublew,doubleh,conststd::string&c):Rectangle(w,h),color_(c){}};

方案 B:组合

// ✅ Rectangle 干 Rectangle 的事,Color 是额外的属性classColoredRectangle{Rectangle rect_;std::string color_;public:ColoredRectangle(doublew,doubleh,conststd::string&c):rect_(w,h),color_(c){}doublearea()const{returnrect_.area();}std::stringcolor()const{returncolor_;}};

判断标准:"带颜色的矩形"是不是一种特殊的矩形?还是说,它的"矩形性"只是它的一个属性?

如果在你的设计中,矩形的所有操作对"带颜色的矩形"都完全适用,那继承也可以。但如果只是"需要一个矩形 + 一个颜色",组合更简单直接。

5.2 决策速查表

场景用继承用组合
新类"是"旧类的特化
新类"有"旧类作为组件
需要基类的所有接口原样传递
只需要基类的部分功能
运行时要切换多种实现✅(多态)
基类的实现细节频繁变化
不确定、说不清楚"是不是"✅(安全默认值)

💡经验法则:当你犹豫该用继承还是组合时,先写组合。如果后来发现到处都在手动转发同样的接口(“组合疲劳”),再考虑提取为继承。从组合到继承容易,从继承到组合难。


六、继承的其他细节

6.1 派生类的构造与析构顺序

#include<iostream>structBase{Base(){std::cout<<"Base()\n";}~Base(){std::cout<<"~Base()\n";}};structMember{Member(){std::cout<<" Member()\n";}~Member(){std::cout<<" ~Member()\n";}};classDerived:publicBase{public:Derived(){std::cout<<" Derived()\n";}~Derived(){std::cout<<" ~Derived()\n";}private:Member m_;};intmain(){Derived d;}
$ g++ -std=c++17 order.cpp && ./a.out Base() Member() Derived() ~Derived() ~Member() ~Base()

构造顺序:基类 → 成员(按声明顺序) → 派生类自身
析构顺序:完全逆序(派生类自身 → 成员 → 基类)

这和"先打地基再盖房子,拆房先拆顶再拆地基"是一个道理。

6.2 派生类中调用基类方法

classBase{public:voidfoo(){std::cout<<"Base::foo\n";}};classDerived:publicBase{public:voidfoo(){std::cout<<"Derived::foo\n";Base::foo();// 显式调用基类版本}};

不加Base::的话,foo()会递归调用自己——因为派生类的名字遮蔽(hide)了基类的同名函数。

6.3 三种继承方式速查

继承方式基类 public → 派生类基类 protected → 派生类典型用途
publicpublicprotected“is-a” 关系(最常用)
protectedprotectedprotected“对子类公开,对外保密”
privateprivateprivate“用基类来实现”(本质上就是组合的替代语法)
// private 继承:等于"用基类的代码,但不承认 is-a 关系"classMyStack:privatestd::vector<int>{public:voidpush(intv){push_back(v);}voidpop(){pop_back();}inttop()const{returnback();}usingstd::vector<int>::empty;usingstd::vector<int>::size;};// MyStack 不是 vector<int>——它只是用 vector 来实现 stack// 外部代码不能写: MyStack *p = new std::vector<int>; // ❌

⚠️private继承在大多数场景下可以用一个私有成员变量替代。优先考虑组合;只有当需要访问基类的protected成员或覆盖虚函数时才用private继承。


总结

继承是 C++ 中最容易被滥用的机制,因为它看起来太方便了——在类名后面加个: public Base就能"免费"获得大量代码。

记住这条核心原则:继承表达的是 is-a(是一个),不是 has-a(有一个),更不是"我想要你里面的函数"

  1. public 继承表达严格的 is-a 关系——派生类必须能在任何场景下替换基类
  2. 组合(成员变量)表达 has-a 关系——这是默认的安全选择
  3. 有疑虑时,先写组合——从组合迁移到继承比重构错误的继承体系容易得多
  4. 构造析构顺序是基类→成员→派生类(析构逆序),这决定了对象从"毛坯"到"精装"的搭建过程

第1章到此结束。下一篇开始,我们进入第2章——命名空间、头文件和 C++ 的基础设施升级,让代码从"能编译"进化到"有组织"。


📝动手练习

  1. 设计一个"鸟"的继承体系——Bird基类,SparrowPenguinEagle作为派生类。试着给Bird加一个fly()方法,然后发现企鹅不会飞——重新思考设计
  2. 把第2篇的BankAccount扩展为SavingsAccount(有利率)和CheckingAccount(有透支额度),验证 is-a 是否成立
  3. 找一段自己以前用继承写的代码,审视是否误用了"为了复用而继承"——如果是,用组合改写
http://www.jsqmd.com/news/825066/

相关文章:

  • 2026年文心一言GEO推广服务商TOP3权威测评:谁能让品牌在百度AI搜索中实现增长突破? - 博客湾
  • claw-kits:开源开发者工具箱的设计理念与实战应用
  • 嵌入式设备自定义字体转换:从TTF到优化位图字体实战
  • 【Oracle数据库指南】第47篇:Oracle 11g在Linux下的安装详解
  • 2×2mm LGA封装+14位分辨率:SMA131在紧凑汽车钥匙中的集成方案
  • 手把手复现IDEA加密:用Python从零理解128位密钥的轮运算
  • 成员函数与 this 指针:函数属于数据
  • 2026年竹盐厂商综合实力深度解析与选择指南 - 2026年企业推荐榜
  • 基于Rust与Hyper构建高性能MCP协议服务器框架
  • 【仅限前500名设计师获取】Midjourney未来主义风格私藏资源包:含87组版权可商用材质贴图+动态光效LORA模型+失效预警提示库
  • 构建智能监控防护系统:从Prometheus到自动化运维闭环
  • 【Oracle数据库指南】第48篇:Oracle 11g在Windows下的安装与配置
  • Python 数据库优化:查询与索引优化
  • 从 ConcurrentLinkedDeque 与 LinkedBlockingDeque 透视 Synchronized 与 CAS 的底层原理
  • 嵌入式Python高效数据处理:迭代器与生成器实战指南
  • 深度探索网易游戏NPK解包:从入门到精通的完整指南
  • SpringBoot集成BouncyCastle实现AES/CBC/PKCS7Padding加解密实战
  • HTML怎么创建话题标签自动联想_HTML输入#触发建议列表【技巧】
  • Chrome for Testing 终极指南:5个实战技巧让自动化测试更稳定高效
  • 智能负载共享电源模块设计:从DC-DC升压到不间断供电的工程实践
  • 终极免费文档下载工具指南:一键下载30+平台文档资源
  • Taotoken用量看板与账单功能如何帮助清晰掌握项目AI支出
  • Java开发者如何高效集成Dify AI能力:dify-java-client实战指南
  • 智能代码助手SmarterCL/copaw:基于Agent架构的开发者效率革命
  • GitHub PR全流程实战:从自动化检查到代码审查的协作艺术
  • 从碎片化到生态化:Zotero插件市场的技术演进之路
  • 从AD9288到STM32H750:手把手拆解开源示波器osc_fun的硬件设计(附原理图分析)
  • 保姆级教程:用Docker部署Jenkins时,如何搞定Agent节点的50000端口映射(附避坑点)
  • 品牌联盟营销:如何创建一个可追踪的Affiliate联盟链接?
  • zcuda项目解析:用纯Rust实现CUDA Runtime API兼容层