C++ 继承入门
一。继承的概念与定义
继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法 (成员函数) 和属性 (成员变量),这样产生新的类,称派生类。
先想一个场景:Student 和 Teacher 都需要'姓名、地址、身份认证',但 Student 有学号、Teacher 有职称。如果各自写一遍,很多代码会变得冗余 —— 继承就是把'公共部分'抽成父类 (基类),子类 (派生类) 直接复用。
1. 继承的核心概念
- 父类(基类):存放公共成员的类,比如 person 类(包含姓名、地址、identity 身份认证函数)。
- 子类(派生类):继承父类并扩展专属成员的类,比如 Student (加学号)、Teacher (加职称)。
- 本质:子类是父类的'扩展',能直接用父类的公共/保护成员,不用重复定义。

2. 继承的定义格式
关键是'继承方式 + 父类名',比如 class Student : public Person。


有了上面的知识储备后,我们简单来看一段代码示例来加深理解(注意看注释):
#include<iostream>
using namespace std;
//基类/父类
class Person {
// 公共成员:子类和类外都能访问
public:
//进入校园等场合需要的身份认证
void identity() { cout << "void identity()" << _name << endl; }
void age() { cout << _age << endl; }
// 保护成员:子类能访问,类外不能访问(专门为继承设计)
protected:
string _name = "张三"; //姓名
string _address; //地址
string _tel = "123456"; //电话
// 私有成员:子类和类外都不能直接访问
private:
int _age = 18; //年龄
};
// 子类 Student:公有继承 Person
// class 的话不写默认是私有继承,struct 是公有继承
// class Student:Person (私有继承)
class Student : public Person {
public:
void study() { cout << "void study() " << _tel << endl; //通过父类公有函数能间接访问私有成员:age(); }
protected:
int _stuid; //学号
};
// 子类 Teacher:公有继承 Person
class Teacher : public Person {
public:
void teaching() { cout << "void teaching() " << _tel << endl; }
protected:
string title; //职称
};
int main() {
// 测试:子类能直接用父类的函数
Student s;
Teacher t;
s.identity();
t.identity();
s.study(); // 用子类的 study,调用父类的 age(),输出了 18
t.teaching();
return 0;
}

3. 继承方式与成员访问权限
父类成员在子类中的访问权限,取决于'父类的访问限定符'和'继承方式',核心规则是:访问权限 = 两者中更严格 (可以理解为 Min) 的那个 ( public > protected > private )。
我们用表格总结一下(重点记 public 继承,实际开发最常用):
| 父类成员类型 | public 继承(推荐) | protected 继承 | private 继承 |
| 父类 public 成员 | 子类中为 public | 子类中为 protected | 子类中为 private |
| 父类 protected 成员 | 子类中为 protected | 子类中为 protected | 子类中为 private |
| 父类 private 成员 | 不可见(不可访问) | 不可见(不可访问) | 不可见(不可访问) |
- 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类 private 成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
- 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
我们这里就拿我们之前的 Stack 来看,可以使用继承来实现 (注意看注释):
#include<iostream>
#include<vector>
#include<list>
#include<deque>
using namespace std;
#define CONTAINER vector
//#define CONTAINER list
//#define CONTAINER deque
namespace MyStack {
template<class T>
class stack : public std::CONTAINER<T> {
public:
void push(const T& x) {
//push_back(x); // 基类是类模板时,需要指定⼀下类域,
// 否则编译报错:error C3861: 'push_back':找不到标识符
// 因为 stack<int>实例化时,也实例化 vector<int>了
// 但是模版是按需实例化,调用哪个成员函数就实例化哪个,
// 由于 push_back 等成员函数未实例化,所以找不到 CONTAINER<T>::push_back(x);
}
void pop() { CONTAINER<T>::pop_back(); }
const T& top() { return CONTAINER<T>::back(); }
bool empty() { return CONTAINER<T>::empty(); }
};
}
void Test2() {
MyStack::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
while (!st.empty()) {
cout << st.top() << " ";
st.pop();
}
}
int main() {
Test2();
return 0;
}

二。基类与派生类的转换:子类对象能当父类用吗?
这是继承的核心特性之一,简单说:子类对象能隐式转换成父类对象 / 指针 / 引用,反之不行。
- public 继承的 派生类对象 可以赋值给 基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
- 基类对象不能赋值给派生类对象
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须基类的指针是指向派生类对象时才是安全的。这里如果基类的多态类型,可以使用 RTTI(Run-Time-Type Information) 的 dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面类型转换章节再单独专门讲解,这里先提一下)

class Person {
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person {
public:
int _No; // 学号
};
void Test3() {
//基类与派生类的转换
Student st;
// 1.派生类对象可以赋值给基类的指针/引用
// 子类对象 → 父类指针 / 引用(隐式转换,安全)
Person* ppe = &st; //父类指针 指向子类对象的'父类部分'
Person& rpe = st; //父类引用 引用子类对象的'父类部分'
//这里会有人误以为就是前面所学的简单的隐式类型转换
//但其实不是,从第三个引用来看就知道了:
int i = 1;
double d = i;
//double& rd = i;
const double& rd = i;
//如果是隐式类型转换的话会产生临时对象,临时对象显常性,如果不用 const 修饰就会报错
//但是上面的转换并没有 const 修饰也不会报错,
//所以和之前学习的隐式类型转换是有区别的,可以理解为是 C++规定的特例
// 2.子类对象 → 父类对象(调用父类拷贝构造,只拷贝父类部分)
// 生类对象可以赋值给基类的对象是通过调用后面会讲解的基类的拷贝构造完成的
Person pe = st;
// 3.基类对象不能赋值给派生类对象,这里会编译报错
// 父类对象 → 子类对象(编译报错,不安全)
//st = pe;
//st = (Student)pe;// 强制转换也不行
}
int main() {
Test3();
return 0;
}
为什么?
子类包含'父类部分 + 自己的部分',把子类当父类用,只会用到'父类部分',不会越界;但父类没有子类的成员,强行转换成子类会访问不存在的内容(比如学号),所以禁止。
三。继承中的作用域:同名成员会冲突吗?
父类和子类有独立的作用域,如果出现同名成员(变量或函数),子类会**'隐藏'父类的同名成员 ——— 这就是'隐藏规则'**,很容易踩坑。
- 在继承体系中基类和派生类都有独立的作用域
- 派生类和基类有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫做隐藏。(在派生类和成员函数中,可以使用基类::基类成员 显示访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏(参数什么的不重要)。
避坑提醒:继承体系中,尽量不要定义同名成员—— 如果必须同名,访问时一定要加父类作用域。
1. 变量隐藏
代码示例 (注意看注释):
//继承中的作用域
class Person {
protected:
string _name = "张三"; // 姓名
int _num = 123456; // 父类的_num:身份证号
};
class Student : public Person {
public:
void Print() {
// 同名变量:默认访问子类的_num(学号)
// 同名成员构成隐藏
//如果是成员函数的隐藏,只需要函数名相同就构成隐藏
cout << "子类的_num:" << _num << endl;//输出 111
// 想访问父类的_num:必须加'父类::',即'基类::基类成员'显式访问
cout << "父类的_num:" << Person::_num << endl;//输出 123456
}
protected:
int _num = 111; // 学号
};
int main() {
Student s;
s.Print();
}

规则:不管变量类型、参数,只要同名,子类就隐藏父类的 —— 想访问父类的,必须用 父类名::成员名。
2. 函数隐藏
比变量隐藏更坑:只要函数名相同,不管参数列表,子类就隐藏父类的函数。
我们看一下继承作用域的相关选择题:

答案:B,A
具体分析可以看下面代码注释 (附如何修改):
//继承作用域相关选择题
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); // 调用子类的 fun(int),输出'func(int i)10'
// b.fun(); // 编译报错:父类的 fun() 被隐藏了,不能直接调用
b.A::fun(); // 想调用父类的 fun():加'父类::',输出'func()'
return 0;
}
四。派生类的默认成员函数:构造、拷贝、析构怎么写?
子类和普通类一样,有 6 个默认成员函数(构造、拷贝构造、赋值重载、析构等),但子类的默认成员函数必须先处理父类的部分。

核心规则:子类的成员函数 = 父类成员的处理 + 子类成员的处理。
1. 构造函数:先调用父类构造,再初始化子类成员
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
基类有默认构造函数的情况:
class Person {
public:
// 父类全缺省构造 (默认构造)
Person(const char* name = "张三") :_name(name) // 初始化父类的_name
{ cout << "Person()" << endl; }
protected:
string _name;// 姓名
};
class Student :public Person {
public:
//默认生成的构造函数行为:
//1、内置类型——>不确定
//2、自定义类型——>调用对应的默认构造
//3、继承父类成员看作一个整体对象,要求调用父亲的默认构造
protected:
int _num; // 学号
string _address; // 地址
};
int main() {
Student s1;
return 0;
}

基类没有默认构造函数的情况:(则必须在子类构造的初始化列表中显式调用父类构造)
class Person {
public:
// 父类带参构造 (无默认构造)
Person(const char* name) :_name(name) // 初始化父类的_name
{ cout << "Person()" << endl; }
protected:
string _name;// 姓名
//int _age; //年龄
//string _gender; //性别
//string _title; //职业
};
class Student :public Person {
public:
// 子类构造:必须在初始化列表显式调用父类构造
Student(const char* name = "张三", int num = 18, const char* address = "北京")
//这里可以显式写一下 (不能直接用_name),其实就把他当成一个自定义类型成员变量就可以了
//: _name(name)
//error C2614: 'Student':非法的成员初始化:'_name'不是基或成员
:Person(name) // 先初始化父类(必须写在前面)
//这样要求也是变相的为我们节省构造的实现,如果父类的成员变量非常多,
//难道子类为了实现构造要把每个父类的成员变量都初始化一遍吗?显然太麻烦了
//所以初始化列表通过调用父类构造就可以直接对父类把部分的成员变量全部进行初始化 ,
_num(num) // 再初始化子类自己的成员
, _address(address)
{ cout << "Student()" << _name << endl; }
protected:
int _num; // 学号
string _address; // 地址
};
int main() {
// 构造顺序:先调用 Person(name),再调用 Student()
Student s1("李四", 20, "北京");
return 0;
}

关键顺序:构造时**'先父后子'** —— 父类先初始化,子类才能用父类的成员。
2. 拷贝构造:先拷贝父类,再拷贝子类
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
class Person {
public:
// 父类拷贝构造
Person(const Person& p)//传的是派生类的话也可以转换,前面讲过
:_name(p._name)
{ cout << "Person(const Person& p)" << endl; }
protected:
string _name;// 姓名
};
class Student :public Person {
public:
//子类拷贝构造
Student(const Student& s) :Person(s) //调用父类拷贝构造,拷贝父类部分
//(s 是子类,能隐式转父类:通过切割把子类中父类那部分成员变量切出来进行拷贝)
,_num(s._num) // 拷贝子类自己的学号
,_address(s._address) // 拷贝子类自己的地址
{
// 如果有深拷贝资源(比如 int*),这里要手动处理
}
protected:
int _num; // 学号
string _address; // 地址
};
int main() {
// 构造顺序:先调用 Person(name),再调用 Student()
Student s1("李四", 20, "北京");
//拷贝构造
Student s2(s1);
return 0;
}

3. 赋值重载:先赋值父类,再赋值子类
派生类的 operator= 必须调用基类的 operator= 完成基类的复制。需要注意的是派生类的 operator= 隐藏了基类的 operator=(由于是同名函数),所以显示调用基类的 operator=,需要指定基类作用域。
class Person {
public:
// 父类的赋值重载
Person& operator=(const Person& p) {
cout << "Person& operator=(const Person& p)" << endl;
if (this != &p) {
_name = p._name;
}
return *this;
}
protected:
string _name;// 姓名
};
class Student :public Person {
public:
//子类的赋值重载
Student& operator=(const Student& s)//一定要注意子类的同名函数 operator= 与父类构成隐藏
{
if (this != &s) {
//operator=(s); //不能直接调用 operator=否则就是调用子类本身的赋值重载导致死循环而栈溢出
Person::operator=(s);
_num = s._num;
_address = s._address;
}
return *this;
}
protected:
int _num; // 学号
string _address; // 地址
};
int main() {
// 构造顺序:先调用 Person(name),再调用 Student()
Student s1("李四", 20, "北京");
//拷贝构造
Student s2(s1);
//赋值重载
Student s3("王五", 18, "上海");
s1 = s3;
return 0;
}

4. 析构函数:先析构子类,再自动析构父类
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
因为在多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同 (这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destructor(),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系。不过我们这里不显示调用,只是补充一下这个知识点。
class Person {
public:
//父类的析构
~Person() { cout << "~Person()" << endl; }
protected:
string _name;// 姓名
};
class Student :public Person {
public:
//子类的析构
~Student() {
//~Person(); //error
//由于析构函数都会被特殊处理成 destructor()
//所以子类的析构和父类的析构也是会构成隐藏关系 (虽然表面上不是同名的)
//规定:不需要显式调用父类析构,子类析构结束后,编译器会自动调用父类析构
//Person::~Person(); //这样写如果存在有动态开辟的空间反而会导致对同一块空间析构两次而程序崩溃
cout << "~Student()" << endl;
}
protected:
int _num; // 学号
string _address; // 地址
};
int main() {
// 构造顺序:先调用 Person(name),再调用 Student()
Student s1("李四", 20, "北京");
// 构造顺序:先 Person(),再 Student()
// 先父后子:我们联想一下之前初始化列表按声明顺序来的原理
// 析构顺序:先~Student(),再~Person()
// 先子后父:我们可以想一下如果先析构父类,那么子类的成员如果需要访问父类就出问题了
return 0;
}

5. 实现一个不能被继承的类
方法一:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类不可访问也就不能调用了,那么派生类就无法实例化出对象。
//方法一:基类的构造函数私有 (C++98)
class Base {
public:
void func() { cout << "Base::func" << endl; }
protected:
int a = 1;
private:
//C++98 的方法:构造函数私有的类不能被继承
Base() { }
};
class Derive : Base { };
int main() {
Derive d;
return 0;
}

方法二:C++11 新增了⼀个 final 关键字,final 修改基类,派生类就不能继承了。
//方法二:添加 final 关键字 (C++11)
class Base final {
public:
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : Base { };
int main() {
Derive d;
return 0;
}
结束语
继承的核心是'复用'与'扩展',吃透单继承的权限规则、对象转换、同名隐藏及默认函数逻辑,就能避开多数基础陷阱。
总结为以下五点:
(1) 用 public 继承实现代码复用,区分 public/protected/private 的访问权限;
(2) 记住**'子类能转父类,父类不能转子类',避免不安全转换;
(3) 同名成员会隐藏,访问时加父类作用域;
(4) 子类默认成员函数要先处理父类部分**,尤其是构造和赋值重载。
(5) 构造顺序:先父后子,析构顺序:先子后父。
C++参考文档:
https://legacy.cplusplus.com/reference/
https://zh.cppreference.com/w/cpp
https://en.cppreference.com/w/


