C++ 继承从入门到踩坑:访问控制、对象切片、隐藏、构造析构、菱形与虚继承
继承 (inheritance)机制是面向对象程序设计使代码可以复用 的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展 ,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构 ,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
下面我们用一篇"通俗但不糊弄"的方式,把继承里最常用、最容易混淆、最容易面试翻车的点一次讲透。
1. 继承的基本写法:派生类、基类、继承方式
最常见写法:
cpp
class Person
{
public:
Person(){}
void Print()const
{
cout << _name << endl;
cout << _age << endl;
}
protected:
string _name="peter";
private:
size_t _age=18;
};
class Student:public Person
{
protected:
int _stuid;
};
class Teacher :public Person
{
protected:
int _teaid;
};
int main()
{
Person p;
Student s;
Teacher t;
s.Print();
t.Print();
}

Person:基类(父类)Student:派生类(子类): public Person:继承方式 (public/protected/private)

实际工程里几乎总是 public 继承,protected/private 继承会显著降低可扩展性与可维护性。
2. 访问控制:public / protected / private 到底怎么"传递"?


| 类成员/继承方式 | public继承 | protected继承 | private继承 |
|---|---|---|---|
| 基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
| 基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
| 基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
一个非常好记的总结:
派生类中成员的可访问级别 = min(基类成员访问限定符, 继承方式) ,并且 基类的 private 成员在派生类里始终不可直接访问(注意:不可访问 ≠ 不存在,它仍然占据对象内存)。
2.1 protected 的意义是什么?
private:类外不能访问,派生类内部也不能访问protected:类外不能访问,但派生类内部可以访问public:大家都能访问
因此:如果你不想让外部直接碰到成员,但希望派生类能用,就用 protected( protected 是"因继承而出现的"非常典型的需求)。
工程建议:数据成员尽量保持
private,即使要给派生类用,也更推荐"protected 的函数接口",而不是暴露 protected 数据(减少耦合)。
3. 基类与派生类对象的赋值转换:对象切片(切割)是啥?
- 派生类对象 可以赋值给 基类对象 / 基类指针 / 基类引用
这种把"派生类中属于基类的那部分"拷贝/绑定出去的行为叫:切片/切割(slicing) - 基类对象 不能赋值给 派生类对象
- 基类指针/引用强转成派生类指针/引用:只有当它实际指向派生类对象 时才安全;多态场景建议
dynamic_cast。

3.1 一个"看得见坑"的例子
cpp
class Person {
public:
std::string name{"peter"};
int age{18};
};
class Student : public Person {
public:
int no{0}; // 学号
};
void SlicingDemo() {
Student s;
s.name = "alice";
s.age = 20;
s.no = 1001;
Person p = s; // ✅ 发生切片:p 只保留 Person 那部分
// p.no = 123; // ❌ Person 没有 no
}
切片不是 bug,它是语义 :你明确写了 Person p = s;,那就表示"只需要 Person 视角"。
3.2 指针强转:能转 ≠ 安全
cpp
void CastDemo() {
Student s;
Person* pp = &s;
// ✅ pp 实际指向 Student,对应强转"恰好"安全(但仍不推荐 C 风格)
Student* ps1 = static_cast<Student*>(pp);
ps1->no = 10;
Person p;
pp = &p;
// ❌ pp 实际指向 Person,对应强转会产生越界/未定义行为风险
Student* ps2 = static_cast<Student*>(pp);
// ps2->no = 10; // 别这么干
}
工程建议(尤其面向多态时):
- 基类需要至少一个
virtual函数(成为多态类型)才能安全使用dynamic_cast - 优先使用
dynamic_cast<Derived*>(basePtr)并检查结果是否为空
4. 继承中的作用域:同名成员"隐藏"比你想得更阴险
核心点:基类和派生类有各自独立作用域。当派生类出现同名成员时,会**屏蔽(隐藏)**基类同名成员;函数隐藏只看"名字",不看参数列表,不看返回值。
4.1 数据成员隐藏
cpp
class Person {
protected:
int num_{111}; // 身份证号(示例)
};
class Student : public Person {
public:
void Print() const {
std::cout << "Person::num_ = " << Person::num_ << "\n";
std::cout << "Student::num_ = " << num_ << "\n";
}
private:
int num_{999}; // 学号(同名:隐藏基类 num_)
};

给出建议:继承体系里尽量不要写同名成员,可读性非常差。
4.2 成员函数隐藏(更常见的面试点)
cpp
class A {
public:
void fun() { std::cout << "A::fun()\n"; }
};
class B : public A {
public:
void fun(int x) {
A::fun(); // 显式调用基类版本
std::cout << "B::fun(int): " << x << "\n";
}
};
void Test()
{
B b;
b.fun(10);
//b.fun();//产生编译错误
};
很多人会误以为这是"重载"。但它们不在同一作用域里,所以不是重载,而是 隐藏。
工程化补救 :如果你确实想把基类同名函数引入派生类作用域,可以用 using:
cpp
class B : public A {
public:
using A::fun; // 把 A::fun() 引入 B 的作用域
void fun(int x) { /* ... */ }
};

5. 派生类的默认成员函数:构造、拷贝、赋值、析构的调用链


总结:派生类在生成"默认成员函数"(构造/拷贝构造/赋值/析构等)时,必须正确处理基类那部分,例如:
- 派生类构造函数:必须调用基类构造初始化基类子对象;基类没有默认构造时必须在初始化列表显式调用
- 派生类拷贝构造:必须调用基类拷贝构造 完成基类的拷贝初始化
- 派生类
operator=:必须调用基类operator=完成基类部分赋值 - 析构顺序:先派生类后基类 ;构造顺序相反 先基类后派生类
5.1 一份"规范写法"的示例
cpp
class Person
{
public:
explicit Person(std::string name = "peter")
: name_(std::move(name)) {}
Person(const Person&) = default;
Person& operator=(const Person&) = default;
virtual ~Person() = default; // ⭐ 多态基类:强烈建议虚析构
protected:
std::string name_;
};
class Student : public Person
{
public:
Student(std::string name, int num)
: Person(std::move(name)), num_(num) {}
Student(const Student& other)
: Person(other), num_(other.num_) {}
Student& operator=(const Student& other) {
if (this != &other) {
Person::operator=(other); // 基类部分赋值
num_ = other.num_;
}
return *this;
}
~Student() override = default;
private:
int num_{0};
};
提醒:析构函数在语法层面也会发生"隐藏",并且在多态场景如果基类析构不加
virtual,可能导致通过基类指针删除派生类对象时只调用基类析构,资源泄漏/未定义行为风险极高。工程里:只要一个类准备被继承且可能通过基类指针析构,就给它虚析构。
6. 继承与友元:友元关系不能继承
友元关系不能继承,基类的友元并不会自动获得访问派生类私有/保护成员的权限。
简言之:友元是"点对点授权",不是"血统继承"。
cpp
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;
cout << s._stuNum << endl;
}

7. 继承与静态成员:整个继承体系只有一份
如果基类定义了 static 成员,那么在整个继承体系里只有一个实例,不管派生多少层、多少个子类。
cpp
class Person
{
public:
Person() { ++_count; }
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
}

8. 菱形继承:数据冗余 + 二义性(以及虚继承怎么救)
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
-
菱形继承是多继承的一种特殊情况


-
问题:数据冗余 (基类子对象出现两份)+ 二义性(不知道访问哪一份)
-
解决:在中间层对共同基类使用 虚继承 (
virtual public),让最底层只保留一份共同基类子对象
8.1 菱形继承产生二义性的直观示例
cpp
class Person
{
public:
std::string name;
};
class Student : public Person {};
class Teacher : public Person {};
class Assistant : public Student, public Teacher {};
void AmbiguityDemo()
{
Assistant a;
// a.name = "peter"; // ❌ 二义性:到底是 Student::Person 还是 Teacher::Person?
}
你可以通过 a.Student::name / a.Teacher::name 消除二义性,但冗余依然存在。
8.2 用虚继承解决:只保留一份 Person
cpp
class Person
{
public:
std::string name;
};
class Student : virtual public Person {};
class Teacher : virtual public Person {};
class Assistant : public Student, public Teacher {};
void VirtualInheritanceDemo()
{
Assistant a;
a.name = "peter"; // ✅ 不再二义性,因为只有一份 Person
}
8.3虚拟继承解决数据冗余和二义性的原理
cpp
class A
{
public:
int _a;
};
// class B : public A
class B : virtual public A
{
public:
int _b;
};
// class C : public A
class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
- 菱形继承

- 下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。


9. 最重要的工程反思:能用组合就别用继承
- 多继承尤其菱形会让复杂度飙升、性能和维护性变差;一般不建议设计出多继承/菱形继承
public继承是 is-a ;组合是 has-a- 优先使用对象组合而不是类继承:组合更"黑箱复用",耦合更低,基类改动对外影响更小;继承更"白箱复用",对子类暴露内部细节,耦合更强
9.1 is-a vs has-a:一眼看懂
cpp
// is-a:BMW 是一种 Car
class Car
{
public:
void Run() {}
};
class BMW : public Car
{
public:
void DriveFun() {}
};
// has-a:Car 有一个 Tire
class Tire
{
public:
int size{17};
};
class CarWithTire
{
private:
Tire tire_; // 组合
};
一句话建议:
- 为了"多态"或"天然 is-a"关系:用继承
- 为了"复用实现"但又不想强耦合:优先组合
10. 小结:写继承代码的"安全清单"
-
默认使用
public继承;不要为了"复用代码"就随便继承 -
避免同名成员(数据/函数都尽量别重名);需要时用
Base::或using Base::func -
明确对象切片:
Base b = derived;只保留基类部分是正常语义 -
需要多态时:
- 基类析构函数加
virtual - 派生类重写加
override(可读性 + 防手滑)
- 基类析构函数加
-
菱形结构尽量不要设计;真碰上再用虚继承"定点拆弹"
-
设计层面:优先组合,其次继承

