ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++基础笔记(5):继承

C++基础笔记(5):继承 在前面的内容中我们已经学习了类与对象、成员函数、构造与析构、静态成员等基础知识”。然而仅仅能够定义独立的类还远远不够。在实际软件开发中各个对象之间往往存在明确的层次关系例如“学生属于人”“狗属于动物”“电动车属于交通工具”等。如果每个类都从零开始编写不仅效率低下还会造成大量重复代码。为了解决这一问题面向对象编程引入了继承Inheritance机制。继承允许我们在已有类的基础上创建新类新类自动拥有原有类的属性和行为并且可以根据需要进行扩展或修改。正如笔记中所强调的继承体现的是一种典型的“is-a” 关系它不仅实现了代码复用更重要的是建立了清晰的类型层次结构使程序设计更加符合现实世界的逻辑。1.继承的基本概念继承Inheritance允许我们在已有类的基础上创建新的类使新类自动获得原有类的数据成员和成员函数从而避免重复编写相同代码提高开发效率和程序的可维护性。1.1.基类和派生类在继承关系中存在两个基本角色基类Base Class和派生类Derived Class。基类被继承的类也称为父类Parent Class派生类通过继承得到的新类也称为子类Child Class当一个类继承另一个类时派生类会自动拥有基类中定义的成员变量和成员函数。开发者只需要在派生类中添加新的成员或修改部分行为就可以构建出新的功能类而不需要重新实现已有逻辑。例如可以定义一个表示动物的基类Animal其中包含动物共有的行为如“吃”和“睡”。随后再定义一个Dog类继承Animal这样Dog就天然拥有这些行为并可以额外添加“吠叫”等特有行为。1.2.继承表达的关系继承最重要的语义是表达一种“is-a” 关系也就是“某个对象属于某一类对象”。这种层次关系可以通过继承结构自然地表达出来。通过建立合理的继承体系程序中的类之间就可以形成类似树状的结构使系统设计更加直观。例如哺乳动物是动物狗是哺乳动物因此狗也是动物在实际的开发过程中如果两个类之间存在明确的is-a 关系通常就可以考虑使用继承来实现。如果只是“拥有关系”has-a则更适合使用组合Composition而不是继承。1.3.继承的基本语法在 C 中继承通过在类定义后使用冒号:来声明继承关系其基本语法如下class 派生类 : 继承方式 基类 { // 派生类新增成员 };我们来举例说明一下还是按照上文所述的哺乳动物和狗的关系我们可以编写父类是哺乳动物基类是狗那么我们需要从父类传递吃和睡两种关系到子类并且子类还需要加入叫的功能。// 基类 class Animal { public: void eat() {} void sleep() {} }; // 派生类 class Dog : public Animal { public: void bark() {} };在这个示例中Animal是基类Dog是派生类Dog继承了Animal的成员函数eat()和sleep()Dog同时新增了自己的成员函数bark()除了单继承之外C 还支持多继承即一个类可以同时继承多个基类。例如class Derived : public Base1, public Base2 { };这意味着派生类可以同时获得多个基类的成员变量和成员函数。不过在实际开发中多继承会增加系统复杂度因此通常需要谨慎使用。2.继承类型与访问权限接下来就需要进一步弄清楚一个非常关键的问题派生类究竟能继承到什么以及继承之后这些成员的访问权限会发生怎样的变化。在 C 中继承并不只是简单地“把基类成员拿过来”它还受到继承方式和成员访问权限的共同影响。2.1.三种继承类型在 C 中一个类在继承基类时可以使用三种不同的继承方式public、protected和private。它们写在派生类定义时基类名前面用来决定基类成员进入派生类后具有什么访问级别。如果没有显式写出继承方式那么对于class来说默认是private继承。但从实际开发来看最常用的是public继承因为它最符合面向对象中的 “is-a” 关系也就是“派生类本质上就是一种特殊的基类”。而protected和private继承在工程中相对少见通常只在一些需要限制接口暴露范围的特殊设计中使用。这三种继承方式的核心可以概括为:公有继承public基类的公有成员在派生类中仍然是公有成员基类的保护成员在派生类中仍然是保护成员。保护继承protected基类的公有成员和保护成员在派生类中都变成保护成员。私有继承private基类的公有成员和保护成员在派生类中都变成私有成员。一句话总结就是继承方式的本质就是控制“基类中原本可以访问的成员在派生类里会降到什么权限”。2.2. 继承中的访问权限规则除了继承方式本身我们还必须先明确 C 类成员原有的三种访问权限public、protected和private。public成员类内可以访问类外也可以通过对象访问。protected成员类内可以访问派生类也可以访问但类外不能直接访问。private成员只有当前类内部可以访问派生类和类外都不能直接访问。在继承关系中派生类只能直接访问基类中的非私有成员也就是public和protected成员而private成员不能被派生类直接访问。总结出继承后的变化规律总结继承方式只会影响基类中的public和protected成员在派生类中的可见级别而private成员无论如何都不能被派生类直接访问。本节的重点主要在以下两个结论继承方式决定的是基类非私有成员进入派生类后的权限变化而不是能不能继承到成员本身。基类中的private成员始终不能被派生类直接访问哪怕它们客观上仍然存在于派生类对象中。3. 继承中的内存继承不仅是一种语法机制更是一种明确的内存组织方式。理解这一点对于掌握类型转换、对象大小、构造析构顺序以及后续的多态机制都至关重要。因为派生类对象在内存中包含一份完整的基类子对象并在其后追加自己的成员。这意味着继承并不是共享同一块内存而是对原有结构的扩展。3. 1. 内存关系1内存结构当一个类继承另一个类时派生类对象在内存中的布局可以理解为[ 基类部分 ][ 派生类新增部分 ]也就是说基类成员位于对象的起始地址派生类成员紧随其后整体构成一个连续的内存块例如对下面这个实例代码分析他的内存结构对象A的内存布局为class Base { public: int index; int x; int y; }; class A : public Base { public: int a_index; };对象A的内存布局应该为[index][x][y][a_index]2基类子对象位于起始地址由于基类部分位于对象最前面因此派生类对象的起始地址 基类子对象地址。如读者有兴趣可以通过打印地址验证了这一点#include iostream using namespace std; class Base { public: int index{0}; }; class A : public Base { public: int a_index{0}; }; int main() { A a1; cout a1 : (long long)a1 endl; cout a1.index : (long long)a1.index endl; cout a1.a_index : (long long)a1.a_index endl; return 0; }这说明了a1的地址就是基类部分的地址基类永远在最前面派生类成员排在后面结论基类子对象始终位于派生类对象的起始地址派生类对象的地址等价于其基类部分的地址。这一特性并非实现细节而是 C 语言规范所保证的它直接支撑了向上转型Derived → Base能够零成本完成。3向上转型因为基类部分就在对象起始地址所以派生类对象可以无额外开销地当作基类对象使用这称为向上转型Upcasting。例如Base* p new A;这里无需额外转换步骤因为p 指向的地址本来就是 Base 子对象地址。4决定了构造与析构的顺序内存布局还直接决定了对象构造和销毁的顺序销毁顺序必须与构造顺序相反确保资源释放安全。构造顺序先构造基类 → 再构造派生类析构顺序先析构派生类 → 再析构基类3.2. 继承中的内存机制特征继承在内存层面并不是“共享”或“引用”而是一种严格定义的结构扩展机制可以概括为三个关键特征基类子对象位于派生类对象的起始地址派生类在基类之后追加新的成员对象大小等于基类大小加新增成员大小考虑对齐正因为采用这种“前缀扩展结构”C 才能够实现高效的类型转换、稳定的构造析构顺序以及零开销的向上转型。4.继承中同名成员变量与函数在继承体系中除了访问权限和内存布局之外还有一个非常容易被忽视但又十分重要的问题派生类与基类出现同名成员时会发生什么这很容易误以为派生类“覆盖”或“替换”了基类成员但实际上在 C 中默认发生的是名称隐藏name hiding而不是多态意义上的覆盖。也就是说基类成员并不会消失它仍然存在于对象中只是默认访问被派生类成员遮住了。4.1. 同名成员变量当派生类定义了与基类同名的成员变量时派生类成员会隐藏基类成员两个成员同时存在于对象中默认访问派生类版本可以通过一个简单示例理解#include iostream using namespace std; class Base { public: int value{100}; }; class A : public Base { public: int value{200}; // 与基类同名 }; int main() { A a; cout a.value endl; // 访问派生类成员 cout a.Base::value endl; // 显式访问基类成员 return 0; }输出结果200100这说明基类成员并没有被删除只是默认访问的是派生类版本可以通过作用域限定符Base::访问基类成员4. 2. 同名成员函数对于成员函数同样遵循名称隐藏规则。如果派生类定义了与基类同名的函数默认调用派生类版本基类函数仍然存在可通过Base::函数名调用示例代码#include iostream using namespace std; class Base { public: void Test() { cout Base Test endl; } }; class A : public Base { public: void Test() { cout A Test endl; } }; int main() { A a; a.Test(); // 调用派生类函数 a.Base::Test(); // 调用基类函数 return 0; }输出A TestBase Test需要特别注意 这不是虚函数机制。与运行时多态无关属于编译期名称解析规则。名称隐藏还有一个容易踩坑的地方一旦派生类定义了同名函数基类中所有同名重载版本都会被隐藏。例如class Base { public: void func(int) {} void func(double) {} }; class A : public Base { public: void func(int) {} // 隐藏 Base 中所有 func };此时若调用A a; a.func(3.14); // 编译错误虽然基类中存在func(double)但它已被隐藏。如果希望保留基类重载可以使用using Base::func;
返回列表