继承是C++里最核心,也最容易被误解的机制之一。很多人只把它当作复用代码、扩展类功能的语法工具,却没意识到它牵扯的远不止语法,对象的内存布局、类的对象模型、多态的底层实现,全都和继承深度绑定。本文将抛开表层用法,从底层原理切入,系统解析继承的真实运作机制。
一、初识继承
1.1 继承的概念与引入
继承(inheritance)是面向对象编程里实现代码复用最核心的手段。它能让我们在保留原有类全部特性的基础上做扩展,添上新的成员函数(方法)和成员变量(属性),由此产生的新类,就叫做派生类。
继承把面向对象的层级结构直观地展现了出来,也符合我们从简单到复杂的认知规律。我们之前熟悉的复用大多停留在函数层面,而继承,是站在类设计的高度做复用。
举个很实在的例子:现在有Student(学生)和Teacher(老师)两个类。两者都有姓名、地址、电话、年龄这些成员变量,也都有 identity 身份认证的成员函数,要是两个类里各写一遍,纯纯的代码冗余。当然二者也有各自独有的内容:老师专属的成员变量是职称,学生专属的是学号;学生有study(学习)的成员函数,老师有teaching(授课)的成员函数。
class Student : public Person{ public: void identity(){//...;} void study(){//...;} protected: int _stuid; }; class Teacher : public Person{ public: void identity(){//...;} void teaching(){//...;} protected: string title; };更省心的做法,是把这些公共成员都抽出来,统一放进一个Person(人)类里。后续Student和Teacher直接继承这个类,就能复用所有公共成员,不用再逐行重复定义,省了不少功夫。
class Person{ public: void identity(){ cout << "void identity()" << _name << endl; cout << _age << endl; } protected: string _name = "张三"; // 姓名 string _address; // 地址 string _tel; // 电话 private: int _age = 18; // 年龄 };<说了这么多,那继承到底要怎么写?>
1.2 继承的定义格式
先把叫法说清楚:像Person这种被继承的类,叫基类,也常叫父类;像Student这种继承后产生的新类,叫派生类,也叫子类。两组说法本质是一回事,只是翻译习惯不同,后面混着用也不用纠结。
继承的语法格式很简单:派生类名后面跟一个冒号,冒号右边先写继承方式,再跟上基类名。
相当于告诉编译器:我这个派生类,要以指定的方式继承基类的成员。
1.3继承的方式
访问限定符和继承方式采用的是同一套关键字。
先说结论,如下表,我们一部分一部分的拆开讲:
1.3.1基类private成员继承
基类的private成员,无论派生类采用哪种继承方式,在派生类中都是不可见的。
这里的“不可见”很容易产生误解:不是说这个成员没被继承下来,它实实在在地存在于派生类对象的内存空间里,只是语法上做了严格限制:派生类不管是在类内部还是类外部,都不能直接访问它。简单说就是:父类的私有成员,子类能继承到,但摸不着。
当然也不是完全没有办法访问,我们可以通过父类自己提供的public/protected成员函数去间接操作这些私有成员,相当于走父类预留的“官方通道”。
1.3.2 基类protected成员继承
基类的private成员派生类完全碰不到,但如果有这样的需求:某个成员不想在类外被直接访问,却希望派生类内部可以自由使用,这时候private就满足不了了,protected应运而生。
可以说,protected这个访问限定符,本质就是因继承机制才出现的,它专门用来给派生类开放访问权限,同时对类外保持封闭。
1.3.3 继承方式与访问权限总结
把前面的规则收拢一下,规律其实很清晰:
- 基类的private成员,在派生类中一律不可直接访问,这是铁则。
- 除此之外的成员,访问权限遵循 “就严原则”:继承方式的权限和基类成员本身的权限,两者取更严格的那个。权限从宽到严排序为:public > protected > private。
补充两个实用细节:
- 使用class关键字定义派生类时,默认继承方式是private;使用struct定义时,默认继承方式是public。但无论哪种,都建议显式写出继承方式,代码可读性会好很多。
- 实际开发中绝大多数场景都用public继承,protected/private继承非常少见,也不推荐使用。因为后两种继承下来的成员只能在派生类内部使用,扩展性和可维护性都很差。
二、模板类的继承
2.1 容器适配器的另一种实现思路
之前学习stack、queue这类容器适配器的时候,我们最常用的实现方式是组合:在适配器类内部定义一个底层容器的成员变量,再基于这个对象封装出适配器的对外接口,就像下图这样,在类里放一个vector<int> v作为底层存储。
但适配器也完全可以用继承来实现。比如要实现一个stack,我们可以直接继承vector模板类,直接复用它的底层能力:
template<class T> class stack : public std::vector<T>{ public: void push(const T& x){ /* 调用底层插入逻辑 */ } void pop(){ vector<T>::pop_back(); } const T& top(){ return vector<T>::back(); } bool empty(){ return vector<T>::empty(); } };2.2 模板继承的坑:按需实例化带来的问题
2.2.1 调用基类成员,必须显式指明依赖
模板有一个核心特性叫按需实例化:模板类的成员函数,不会在类实例化时就全部生成,只有真正被调用到的时候,才会被实例化。
而当子类继承一个模板基类时,这个特性就会引出问题: 比如我们写stack<int> s;,这一步只会实例化stack<int>的构造函数,连带触发vector<int>的构造函数,但vector里的其他成员函数都还没被实例化。
当我们调用s.push(5)时,编译器去实例化stack<int>::push函数。如果push里直接写pop_back(),编译器这时候不会自动去模板基类里查找这个函数,因为模板基类的成员属于“依赖名”,它是否存在、是什么形态都和模板参数T相关,编译器默认不会到依赖名的作用域里查找成员,直接就会报“标识符未定义”的错误。
所以模板类继承模板类时,要调用基类的成员,必须显式告诉编译器“这个成员来自基类”,有两种标准写法:
- 通过this->调用:this->pop_back();
- 显式指定基类作用域:vector<T>::pop_back();
这么设计的目的,本质还是为了配合按需实例化:避免不必要的模板展开,同时让编译器明确成员的来源,减少歧义。
2.2.2 按需实例化藏住的 “隐形错误”
按需实例化还有一个特点:没有被调用到的成员函数,编译器不会深入检查它内部的语法和逻辑正确性。
举个例子:
template<class T> class A{ public: A(){} void push(const T& x){ x.func(); } };如果我们只写A<int> a;构造一个对象,此时只会实例化构造函数,push函数根本不会被实例化。哪怕push里写了x.func(),int类型根本没有func这个成员函数,编译器这时候也不会报错。只有当你真正调用a.push(xxx)时,编译器实例化push函数,才会暴露出这个错误。
关于这点,不同版本的编译器还有差异:
- VS2013比较“宽松”:只要是没被调用的接口,一律不做深入检查,有问题也不会提前提示。
- VS2019及之后的版本做了优化:不依赖模板参数的代码,会提前做基础语法检查;但像x.func()这种依赖模板参数 T 的代码,依然要等到函数实例化时才会检查。
三、父子类对象的赋值兼容转换
3.1 赋值兼容转换到底是什么
先给前提:只有在public公有继承下,这条规则才成立,子类对象可以直接赋值给父类对象、父类指针,或是父类引用。这个操作有个很形象的俗称,叫切片/切割。
就像上图画的那样:子类对象的内存里,上半段是从父类继承来的成员(_name、_sex、_age),下半段是自己独有的成员(比如Student的_No)。赋值的时候,相当于把子类里属于父类的那部分“切”出来,单独赋值过去,子类自己的部分直接被截掉不用。反过来肯定不行,父类对象不能直接赋值给子类对象,编译就会报错,毕竟父类缺了子类独有的成员,补不上。
一个很容易踩的误区:这真不是类型转换
很多人第一眼看到“不同类型互相赋值”,下意识就觉得这是类型转换,其实完全是两码事。怎么证明?看这个图的对比就一目了然。
拿普通的int转double举例子:int i = 1; double d = i;这是正经的类型转换,中间会生成一个临时的double变量,再把值赋给d。临时变量自带常性,所以你写double& rd = i; 编译必报错。普通左值引用接不住临时对象。
但父子类的赋值完全不一样:Person& rp = sobj;这行代码能顺顺利利跑通。原因很简单:全程根本没生成临时变量。这个引用直接绑定了子类对象里、原本就存在的那一块父类内存,相当于给子类里的 “父类部分” 起了个别名。如下图也能实锤:通过rp修改_name,改的就是sobj本身的数据,没有拷贝、没有转换,就是同一块内存。
代码示例与内存对应
我们结合代码和内存图再顺一遍,逻辑就彻底通了:
int main() { Student sobj; // 1. 子类对象赋值给父类对象、父类指针、父类引用(反过来不行,编译报错) Person pobj = sobj; // 切片拷贝:只复制父类部分的成员 Person* pp = &sobj; // 父类指针:指向子类中父类部分的起始地址 Person& rp = sobj; // 父类引用:引用子类中父类的那一块区域 return 0; }下图也画得很清楚:父类指针的访问范围,刚好卡在子类对象里父类成员的边界上,碰不到子类自己的_No成员。
最后补个小知识点:反过来,父类的指针/引用也能通过强转转回子类的指针/引用,但这事儿有安全前提,只有当这个父类指针本来就指向一个子类对象时,转换才是安全的。如果是多态场景,更推荐用dynamic_cast做运行时类型识别,稳妥很多。
四、继承体系里的名称隐藏规则&实战拆解
4.1 隐藏规则详解+代码实例
继承关系中,基类(父类)和派生类(子类)有着互相独立的作用域。 如果父子类定义了同名成员,子类的同名成员会屏蔽对父类同名成员的直接访问,这个现象叫做隐藏(也叫名称遮蔽)。若想在子类内部访问被隐藏的父类成员,必须用基类::基类成员的格式显式标注作用域。
这里有个高频踩坑点:成员函数形成隐藏的门槛很低,只要函数名一致就会触发隐藏,哪怕参数列表不同也不例外,千万别和要求同一作用域才能成立的函数重载弄混!实际开发里非常不建议在继承体系设计同名成员,后续维护很容易出现难以排查的bug。
用一段可运行代码直观演示效果:
class Person{ protected : int _num = 111; string _name = "小李子"; // 此处_num存储身份证号 }; class Student : public Person{ public: void Print() { cout<<" 姓名:"<<_name<< endl; cout<<" 身份证号:"<<Person::_num<< endl; // 显式指定父类作用域访问被隐藏的成员 cout<<" 学号:"<<_num<<endl; // 无限定符会优先查找子类作用域的_num } protected: int _num = 999; // 学号,和父类同名形成隐藏关系 }; int main(){ Student s1; s1.Print(); return 0; }最终打印出的学号_num是999:子类的_num把父类的同名成员隐藏了,不加限定的情况下编译器会优先在子类作用域检索符号。 很多人会产生误解:隐藏不是父类成员消失了,只是调整了编译器的符号查找优先级,父类成员依旧存在于子类对象内存中,只是不能直接访问而已。
4.2 两道小测题检验掌握程度
- A类和B类的两个func函数属于什么关系?() A. 重载 B. 隐藏 C. 没关系
- 下面程序的编译运行结果是?() A. 编译报错 B. 运行报错 C. 正常运行
class A{ public: void fun(){cout << "func()" << endl;} }; class B : public A{ public: void fun(int i){cout << "func(int i)" << i << endl;} }; int main() { B b; b.fun(10); b.fun(); return 0; };逐题解析
第一题选B:重载的硬性要求是函数处于同一个作用域,父子类分属两个独立作用域,仅函数名一致就满足隐藏的判定条件。
第二题选 A:b.fun(10)可以正常调用子类带参版本,但b.fun()会编译失败,子类的fun(int i)隐藏了父类无参的fun(),子类作用域检索不到无参函数,想要调用只能写b.A::fun()指明父类作用域。
最后再敲个实用警钟:除非业务刚需,不然千万别在继承体系里定义同名成员!
五、派生类的6大默认成员函数深度解析
5.1 六个默认成员函数的继承适配规则
之前学类和对象时我们了解过,编译器会自动为类生成6个默认成员函数,这张思维导图清晰划分了它们的职能:
构造、析构负责初始化与资源清理;拷贝构造、赋值重载完成对象的拷贝复制;两组取地址重载分别适配普通对象和const对象,日常开发基本不用手动实现。
5.1.1 构造函数
子类的构造函数,必须调用父类构造函数,完成从父类继承来的那部分成员的初始化。 我们可以把子类对象拆成两部分理解:继承得到的父类专属成员,只能交给父类构造初始化;子类新增的独有成员,才由子类构造处理。如果父类有无参默认构造,编译器会自动帮你调用;要是父类没有默认构造,就必须在子类构造的初始化列表里显式调用父类构造:
public: Student(const char* name, int num, const char* addrss) : Person(name) // 显式调用父类构造初始化继承来的成员 , _num(num) , _addrss(addrss) {} protected: int _num = 1; // 学号 string _addrss = "湖州市吴兴区"; int main(){ Student s("张三", 1, "湖州市"); return 0; }5.1.2 拷贝构造函数
子类的拷贝构造,必须调用父类的拷贝构造,完成父类成员的拷贝初始化:
Student(const Student& s) : _num(s._num) , _addrss(s._addrss) , Person(s) // 调用父类拷贝构造,此处触发了子类向父类的赋值兼容转换 {}补充细节:初始化列表的初始化顺序严格遵循类内成员的声明顺序,父类成员声明更早、内存排布更靠前,因此永远先初始化父类部分;如果父类有编译器自动生成的默认拷贝构造,不用我们手动写调用语句,编译器会自动补齐逻辑。
5.1.3 赋值重载(operator=)
子类的赋值重载,必须调用父类的operator=完成父类成员的赋值拷贝。这里是新手高频翻车点:子类的operator=会隐藏父类的同名函数,不能直接裸写operator=(s)!错误写法(会触发无限递归):
Student& operator=(const Student& s) { if (this != &s) { operator=(s); // 调用的仍是子类自身的operator=,陷入递归死循环 _num = s._num; _addrss = s._addrss; } return *this; // 原文笔误*t已修正 }从调试堆栈截图能直观看到层层嵌套的递归调用栈:
正确写法是显式标注父类作用域:Person::operator=(s);,这样才能调用到父类的赋值重载逻辑。
5.1.4 析构函数
子类析构函数执行完自身的资源清理逻辑后,编译器会自动插入调用父类析构的代码,强制遵循「先清理子类独有成员,再清理父类继承成员」的顺序。 很多人会疑惑:父子析构函数函数名写法不同,怎么会形成隐藏?其实编译器会把所有类的析构函数统一编译为内部标识destructor(),所以父子析构天然满足隐藏的判定条件。
这里有个致命误区:绝对不要手动在子类析构里写Person::~Person();!
// destructor() ~Student() { Person::~Person(); }截图里仅创建了 3 个 Student 对象,却打印出 6 次父类析构调用:手动写了一次调用,编译器又自动补了一次,直接造成重复析构的未定义行为。反汇编代码也能实锤,子类析构的末尾会自动插入父类析构的调用指令:
5.1.5 构造&析构生命周期总结
铁律一定要记牢:
- 创建子类对象:先执行父类构造函数→再执行子类构造函数
- 销毁子类对象:先执行子类析构函数→再自动执行父类析构函数
编译器把析构调用逻辑强制自动化,就是为了避免开发者手动操作搞反析构顺序、引发内存崩溃;而构造阶段开放手动显式调用的权限,是为了适配父类自定义构造的多样化场景。
5.2 两种实现不可被继承类的方案
方案 1:C++98传统方案——私有化基类构造函数
子类想要实例化对象,就必须调用父类的构造函数;如果把基类构造设为私有,子类完全无法访问父类构造,就没法创建子类对象,自然实现了禁止继承的效果。
方案 2:C++11更推荐方案——final关键字
直接用final修饰基类,直白标记这个类为“最终类”,编译器会直接拦截后续所有继承行为,代码可读性也更强:
class Base final{ // final锁定,禁止任何类继承 public: void func5() { cout << "Base::func5" << endl; } protected: int a = 1; private: // C++98方案在这里私有化构造函数实现禁继承 Base(){} }; // 下行代码会直接编译报错,无法继承final修饰的类 class Derive : public Base{ public: void func4() { cout << "Derive::func4" << endl; } };六、继承与友元&继承与静态成员
6.1 继承与友元:交情不世袭
先记死一条结论:友元关系不能继承。说白了,你是我父类的朋友,不代表你自动就是我子类的朋友。父类的友元函数,只能访问父类的私有/保护成员,碰不到子类的私有和保护成员。
看个典型的错误写法:
class Student; // 前置声明,让Display的形参能识别Student类型 class Person{ public: friend void Display(const Person& p, const Student& s); protected: string _name; }; class Student : public Person { protected: int _stuNum; }; void Display(const Person& p, const Student& s) { cout << p._name << endl; // 没问题,Display是Person的友元 cout << s._stuNum << endl; // 直接报错,访问不了子类的保护成员 }很多人会想当然:子类都继承了父类,那父类的友元自然也能访问子类成员。其实完全不是这么回事,Display只拿到了Person的友元授权,Student没给它开权限,自然碰不到_stuNum。
解决办法也很直接:在 Student 类里也给 Display 补一个友元声明就行。
6.2 继承与静态成员:全族共享一份
如果父类里定义了static静态成员,那整个继承体系里,这个成员永远只有一份实例。不管你派生多少个子类、创建多少个对象,大家共用这同一个静态成员。
和普通成员对比一下就一目了然:
- 普通成员:父类有一份原生的,子类继承后自己也有一份独立拷贝,两者地址不同,互不干扰。
- 静态成员:父类和子类用的是完完全全同一份,地址一模一样。
上代码直观验证:
class Person { public: string _name; static int _count; }; int Person::_count = 0; // 静态成员必须类外初始化 class Student : public Person { protected: int _stuNum; }; int main() { Person p; Student s; // 普通成员_name:两个对象地址不同,各存各的 cout << &p._name << endl; cout << &s._name << endl; // 静态成员_count:地址完全相同,共用同一份 cout << &p._count << endl; cout << &s._count << endl; // 公有静态成员,通过类域也能直接访问 cout << Person::_count << endl; cout << Student::_count << endl; return 0; }上面的测试代码运行结果:
换个角度理解也很形象:父类里的静态变量,本质就是个套着父类类域外壳的全局变量,整个程序只有一份,继承体系自然也共享。
七、多继承原理与菱形继承的坑
7.1 三种继承模型先理清
先把基础概念掰明白:
- 单继承:一个派生类只继承一个直接基类,就是我们最常见的一对一继承。
- 多继承:一个派生类同时继承两个或以上的直接基类。多继承的对象内存布局有规律:先继承的基类排在内存最前面,后继承的跟在后面,最后放派生类自己的成员。
- 菱形继承:多继承的一种特殊坑人形态。两个子类继承同一个父类,又有一个类同时继承这两个子类,形状像个菱形,因此得名。
菱形继承天生带两个致命问题:数据冗余和二义性,最顶层的基类成员,会在最底层的派生类里存两份。也正因如此,很多语言直接就不支持多继承(比如Java),从根源上规避了这个问题。实际开发里也非常不建议写出菱形继承的结构。
7.1.1 单继承与多继承的内存模型
单继承的内存就是“父类成员在前,子类成员在后”,顺顺当当一块连续空间。 多继承就是按继承顺序,把多个父类的成员依次排开,最后接子类自己的成员,相当于把几个父类的内存拼在了一起。
7.2 菱形继承到底坑在哪?
举个最经典的场景:Person是父类,Student和Teacher都继承Person,最后Assistant(助教)同时继承Student和Teacher,这就是标准的菱形继承。 Assistant对象里会存两份Person的成员,一份来自Student路径,一份来自Teacher路径,所有问题都源自这两份重复的基类。
7.2.1 数据冗余:平白无故浪费空间
这个最好理解:Person里的姓名、年龄这些基础信息,Assistant里存了两份。但现实里一个助教只需要一份个人信息,多出来的那份完全是多余的,平白膨胀了对象空间,这就是数据冗余。
7.2.2 二义性:连程序都懵了
冗余只是浪费空间,二义性直接让对象的逻辑都站不住脚。 举个更极端的例子帮你感受:
Male继承Person后,sex代表男性;
Female继承 Person后,sex 代表女性。
那Child同时继承Male和Female,它就同时拥有了两个sex成员,一个是男一个是女,那这个孩子到底是什么性别?程序根本给不出唯一答案,这就是二义性。
这可不只是编译报错的小事,它直接让对象的语义崩了:现实里一个人只能有一个性别,菱形继承却硬生生造出两份Person子对象,搞出两个性别属性,完全违背了设计初衷。
落到代码里就是这样:
Assistant a; a._name = "peter"; // 编译报错:对"_name"的访问不明确编译器不知道你要访问的是Student路径的_name,还是Teacher路径的_name。 当然可以手动指定作用域a.Student::_name = "peter"来解决二义性,但数据冗余的问题还是消不掉。
7.2.3 标准库也有菱形继承?
菱形继承虽然坑,但C++标准库里还真有经典的菱形继承场景,就是我们后面会讲的IO流库,它正是靠虚继承解决的问题。
7.3 虚继承:菱形问题的官方解法
7.3.1 菱形虚拟继承怎么写
为了解决菱形继承的冗余和二义性,C++引入了虚继承。用法很简单:在中间层子类继承顶层基类时,加上virtual关键字。
class Person{ public: string _name; // 姓名 }; // 中间层继承时加virtual,虚继承Person class Student : virtual public Person{ protected: int _num; //学号 }; class Teacher : virtual public Person{ protected: int _id; // 职工编号 }; // 最底层子类正常继承即可 class Assistant : public Student, public Teacher{ protected: string _majorCourse; // 主修课程 }; int main(){ Assistant a; a._name = "peter"; // 不再报错,整个对象只有一份_name return 0; }虚继承解决问题的核心逻辑:让Student和Teacher共享同一份Person基类对象,而不是各自带一份。这样Assistant里最终也只会有一份Person成员,冗余和二义性就都解决了。
话虽这么说,但还是那句忠告:非必要别写菱形继承,更别随便用虚继承。这东西理解成本高,底层也有额外的性能开销,写出来维护的同事大概率要骂街。
7.3.2 关于虚继承的三个常见误区
这里纠正几个很容易踩的坑:
- 误区一:虚继承会影响中间类的正常使用。错。虚继承主要影响最底层的派生类(比如 Assistant),单独用Student或者Teacher的时候,和普通继承没什么区别。
- 误区二:只给一个中间类加virtual就行。错。必须所有中间子类(Student 和 Teacher)都加虚继承,才能保证最终只有一份基类。
- 误区三:virtual加在最底层就行。给你看个题
想让E继承后不出现A的冗余,virtual该加在哪? 答案是加在B和C继承 A 的时候。 记住原则:哪层开始产生重复的基类,就在哪层的继承上加virtual。冗余源自A,就从B、C继承A的时候开虚继承。
7.3.3 多继承与虚继承小结
很多人吐槽C++语法复杂,多继承就是典型的槽点之一。 有了多继承就会有菱形继承,有了菱形继承就得有虚继承,底层实现跟着变复杂,还带了性能损耗。所以业界普遍共识是:尽量别设计菱形继承,多继承本身都要慎用。不少后来的语言直接砍掉了多继承,也算是变相承认这是C++的一处设计瑕疵。
前面说的IO流库的菱形继承,就是用虚继承解决的,摘段源码给你看:
template<class CharT, class Traits = std::char_traits<CharT>> class basic_ostream : virtual public std::basic_ios<CharT, Traits> {}; template<class CharT, class Traits = std::char_traits<CharT>> class basic_istream : virtual public std::basic_ios<CharT, Traits> {};后面的iostream同时继承istream和ostream,靠虚继承保证只有一份basic_ios基类。
7.4 多继承里的指针偏移小细节
先做道小题热热身,下面代码里三个指针的值,说法正确的是?
class Base1 { public: int _b1; }; class Base2 { public: int _b2; }; class Derive : public Base1, public Base2 { public: int _d; }; int main() { Derive d; Base1* p1 = &d; Base2* p2 = &d; Derive* p3 = &d; }结论是:p1和p3地址相同,p2地址更高。
道理也简单,我们前面说过多继承的内存布局:先继承的排在最前面。Derive先继承Base1,再继承Base2,所以内存里Base1在最开头,Base2紧跟其后,最后是Derive自己的_d。
所以指向Base1的p1,刚好和Derive对象的起始地址重合;指向Base2的p2,会自动偏移到Base2 子对象的起始地址,数值上比p1、p3大。打开内存窗口和监视面板一看,就能实打实印证这个偏移效果。
八、继承与组合:两种复用的正确打开方式
8.1 什么是组合?和继承到底差在哪?
先看两种实现stack的写法,一眼就能看出区别:
组合写法:把list容器直接当成成员变量塞进类里
class stack{ private: list _lt; };继承写法:直接让stack继承list类
class stack : public list {};前者是在类内部“持有”一个基类对象,这就是组合;后者是通过继承“拥有”基类的全部成员,这就是继承。
落到设计原则上,两者有明确的语义区分:
- public继承是is-a(是一种)的关系:每个派生类对象,本质上都是一个基类对象。
- 组合是has-a(包含一个)的关系:如果B组合了A,说明每个B对象内部都持有一个A对象。
8.1.1 两句话搞懂 is-a 和 has-a
不用记复杂定义,套生活场景立刻明白:
- is-a:我是一名大学生。大学生属于人的一种,这就是继承关系。
- has-a:我有一颗心脏。人身上包含心脏这个部件,这就是组合关系。
8.1.2 本质差别:白箱复用vs黑箱复用
写法和语义的差别还是表层,两者真正的分水岭,在封装性和耦合度上。业内也给两种复用起了很形象的名字:白箱复用和黑箱复用。
继承 = 白箱复用(white-box reuse)
继承是基于基类的内部实现来扩展派生类,所谓“白箱”,就是基类的内部细节对子类是可见的。 代价也很直接:继承一定程度上破坏了基类的封装性。基类的protected成员子类能直接访问,基类的实现改动,很容易牵连到派生类。父子类绑定极深,耦合度非常高。 另外,继承不管是public还是protected级别的接口,子类都能直接使用,相当于把基类的半内部能力都开放给了子类。
组合 = 黑箱复用(black-box reuse)
组合是继承之外的另一种复用选择:不用继承,而是通过组装、组合对象来实现更复杂的功能。 被组合的对象只对外暴露定义清晰的public接口,内部实现完全不可见,就像一个打不开的黑盒子,所以叫“黑箱复用”。 组合的两个类只通过公开接口交互,彼此内部改动互不影响,依赖关系弱、耦合度低,还能保住各自的封装性。而且组合只能用到对方的public接口,开放的接口越少,耦合就越低。
补个大家好理解的类比:这和软件测试的思路完全对应,黑盒测试只测功能、不关心内部代码;
白盒测试要深入代码逻辑校验,两者的可视程度是一个道理。
8.1.3 最终结论:优先选组合
业界有一条公认的设计原则:优先使用对象组合,而不是类继承。 实际开发里能上组合就尽量用组合,耦合低、改动灵活、后续维护省心太多。
但也不用把继承一棍子打死:
- 如果类和类之间明确是is-a的关系,用继承更贴合语义;
- 要实现多态的话,继承是必选项,组合替代不了。 如果一个场景两种方式都能实现,拿不准选哪个,选组合基本不会踩坑。