继承全解——继承、默认成员函数、切片、隐藏与虚继承

文章目录

Ciallo(∠・ω< )⌒☆

C语言专栏

C语言博客_CSDN

数据结构专栏

数据结构博客_CSDN

C++专栏

C++博客_CSDN

作者仓库

代码仓库_gitee

引言

学完类和对象,我写类的时候常碰到一种情况:学生类里有姓名、地址、电话、学号,老师类里也有姓名、地址、电话,再多一个职称。同一批成员在两个类里各写一遍,又啰嗦又难看。继承就是干这个的,把公共的成员抽到一个基类里,其他类继承它,直接复用。

不过继承是我个人觉得 C++ 里最容易"以为会了、实际全是坑"的一块。切片、隐藏、菱形继承的二义性、默认成员函数在派生类里行为大变样,每一样都值得单独画图讲清楚。这篇把继承整个串一遍,重点放在三道选择题、派生类的默认成员函数和多继承三块。

注意:本篇文章需要 C++ 类和对象、默认成员函数的基础(可先看《类和对象全解》)。继承的坑比较多,文章若有误恳请读者见谅。

一、继承的概念与定义

1.1 继承的概念

先看这两个类,Student 和 Teacher 里有一大堆重复的公共成员:

复制代码
  Student                              Teacher
  +---------------------+              +---------------------+
  |  _name      姓名    |              |  _name      姓名    |
  |  _address   地址    |              |  _address   地址    |
  |  _tel       电话    |              |  _tel       电话    |
  |  _age       年龄    |              |  _age       年龄    |
  |---------------------|              |---------------------|
  |  _stuid     学号    |              |  _title     职称    |
  +---------------------+              +---------------------+

  Student 独有:学习 study()          Teacher 独有:授课 teaching()
  公共成员:姓名/地址/电话/年龄,还有 identity() 身份认证

继承的认知过程是从简单到复杂:公共成员放进 Person,Student 和 Teacher 都继承它,各自只需要写独有部分:

cpp 复制代码
class Person
{
public:
	void identity()          // 校园/图书馆/实验室刷二维码身份认证
	{
		// ...
	}
protected:
	string _name = "张三";   // 姓名
	string _address;        // 地址
	string _tel;            // 电话
	int _age = 18;          // 年龄
};

class Student : public Person
{
public:
	void study()            // 学习
	{
		// ...
	}
protected:
	int _stuid;             // 学号
};

class Teacher : public Person
{
public:
	void teaching()         // 授课
	{
		// ...
	}
protected:
	string title;           // 职称
};

继承是面向对象程序设计里代码复用的重要手段,体现的是类层次的复用。有了继承,就不用把 identity() 和那堆成员在两个类里各写一遍。

1.2 继承定义

语法就是 class 派生类 : 继承方式 基类

cpp 复制代码
class Student : public Person
{
	// ...
};

public 是继承方式。继承方式有三种:publicprotectedprivate,它决定基类成员在派生类里是什么访问属性。这张表值得背下来,考试也常考:

复制代码
  基类成员            public 继承       protected 继承    private 继承
  ---------------    ------------      -------------    ------------
  public  成员       派生类 public      派生类 protected   派生类 private
  protected 成员     派生类 protected   派生类 protected   派生类 private
  private  成员        不可见              不可见             不可见

学这块有四个点容易踩:

  1. 基类的 private 成员仍然被继承到了派生类对象里,只是语法上访问不到。 不管哪种继承方式,基类 private 成员在派生类里都不可见。这里的"不可见"是看都看不到、访问不了,不是"不存在"。
  2. 如果基类成员不想在类外被访问 ,但又想在派生类里能用,就把它定义为 protected(protected 就是为了继承出现的)。
  3. 实际开发里 public 继承用得最多,protected/private 继承很少用,因为继承下来的成员大多只能在派生类里用,扩展维护性不强。
  4. class 默认继承方式是 private,struct 默认是 public。不过最好显式写出来,别靠默认。

1.3 继承的类模板

继承也能用在模板类上。比如用 stack 继承 vector,堆栈的 push/pop 直接复用 vector 的 push_back/pop_back。这里有个坑:基类是模板时,调基类成员要指定作用域 ,否则编译报 error C3861: "push_back": 找不到标识符

cpp 复制代码
namespace xjw
{
	template<class T>
	class stack : public vector<T>
	{
	public:
		void push(const T& x)          { vector<T>::push_back(x); }
		void pop()                     { vector<T>::pop_back(); }
		const T& top()                 { return vector<T>::back(); }
		bool empty()                   { return vector<T>::empty(); }
	};
}

为什么一定要写 vector<T>::?因为 stack 是模板,只有它实例化(比如 stack<int>)时 vector<int> 才会真正实例化出来。在类定义里直接写 push_back,编译器还没实例化 vector<T>,根本找不到这个符号,所以必须显式指明作用域。

二、基类和派生类间的转换(切片)

public 继承下,派生类对象可以赋值给基类对象、基类指针、基类引用,这叫切片。形象点说,就是切出一块给基类用,只拿基类那部分。

复制代码
  Student sobj                        Person pobj = sobj
  +---------------------+             +---------------------+
  |  Person 部分        |             |  Person 部分        |
  |    _name            |             |    _name            |
  |    _sex             |             |    _sex             |
  |    _age             |             |    _age             |
  |---------------------|             +---------------------+
  |  Student 部分       |   只挪走基类那块,Student 独有部分不带走
  |    _No              |
  +---------------------+

  Person* pp = &sobj;   Person& rp = sobj;   // 都指向 sobj 里的 Person 部分
cpp 复制代码
Student sobj;
Person pobj = sobj;    // 1. 子类对象赋值给基类对象
Person* pp = &sobj;    // 2. 子类对象赋值给基类指针
Person& rp = sobj;     // 3. 子类对象赋值给基类引用

// 反过来,父类对象不能赋值给子类对象,这里会编译报错
// sobj = (Student)pobj;

反过来不行。这点和类型转换很像:int 可以赋给 double,但 double 不能直接赋给 int。子类对象更丰富,切给基类没问题;基类对象缺了子类那部分,硬塞给子类对象会编译报错。至于为什么基类指针指向派生类对象是安全的、反向需要 dynamic_cast 做运行期类型识别,这里先了解,后面多态章节会细讲。

三、继承中的作用域

3.1 隐藏规则

继承体系里,基类和派生类都有自己独立的作用域。如果派生类里出现了和基类同名的成员,默认访问到的都是派生类的,基类的那个会被隐藏。 这就是隐藏。

cpp 复制代码
class Person
{
protected:
	string _name = "小李子";   // 姓名
	int _num = 111;            // 身份证号
};

class Student : public Person
{
	void Print()
	{
		cout << _name << endl;          // 访问子类自己的 _name
		cout << _num << endl;           // 访问子类自己的 _num
		cout << Person::_num << endl;   // 显式访问基类的 _num
	}
protected:
	int _num = 999;            // 学号
};

这里 Student 里有一个 _numPerson 里也有一个 _num,二者构成隐藏关系。访问规则是按名字查找,从当前作用域往上找,找到哪个用哪个。所以 _num 默认拿到的是 Student 的 999,要拿 Person 的 111 必须 Person::_num。这也是为什么继承体系里最好别定义同名成员 ,容易把自己绕晕。注意成员函数隐藏只看函数名,同名即隐藏,可能问出大问题,正好看下面两题。

3.2 考察继承作用域相关选择题

下面这两道题几乎把隐藏的坑掏空了,面试经常变着形出。

第一题:A 和 B 里的两个 func 构成什么关系?

cpp 复制代码
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);
	return 0;
}

选项:A. 重载 B. 隐藏 C. 无关系

复制代码
  class A (基类)                 class B : public A (派生类)
  +----------------+            +----------------+
  |  void fun()    |            |  void fun(int) |
  +----------------+            +----------------+
        ^
        | 隐藏(B 里出现同名 fun,A 的 fun() 被整体隐藏)
        不是重载:重载要求同一作用域,而 fun() 和 fun(int) 分属 A、B 两个类域

答案是 B. 隐藏。 关键在"重载要求同一作用域"。A::fun()B::fun(int) 一个在 A 类域、一个在 B 类域,不在同一个作用域,所以根本不是重载。Bfun(int) 一出现,就用同一个名字把基类 A所有fun 的版本全屏蔽了,不管参数类型、不管重载。这就是隐藏和重载最本质的区别。

第二题:下面程序的编译运行结果是什么?

cpp 复制代码
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();    // 有问题
	b.fun(10);
	return 0;
}

选项:A. 编译报错 B. 编译运行 C. 正常运行

复制代码
  B b;  b.fun();       // 调用不了!
   因为 B 里只有一个 fun(int),A 的 fun() 被隐藏了
   名字查找:从 B 作用域找 fun -> 找到 fun(int) -> 参数不匹配 -> 编译报错(不会回 A 里找)

  b.A::fun();          // 必须显式指定基类作用域,才能调到 A::fun()
  b.fun(10);           // 这个正常

答案是 A. 编译报错。 b.fun() 找不到匹配的函数。因为 B 里的 fun(int) 已经把 Afun() 隐藏了,编译器按名字找到了 B::fun,可它需要一个 int 参数,你传了 0 个,于是报错。它不会好心回退到 A 里找 fun()。这也是隐藏最容易犯的错:子类只要定义了一个同名函数,父类所有同名函数就全用不了了 ,要调只能 b.A::fun() 显式指定作用域。

四、派生类的默认成员函数

4.1 六个默认成员函数

类和对象那篇讲过普通类的 6 个默认成员函数:构造、析构、拷贝构造、赋值重载、取地址重载(普通 + const)。关键是,在派生类里,这 6 个成员的默认生成行为变了一大截。这是《类和对象全解》的衔接补充,也是面试重灾区。

(1)构造函数

派生类构造函数必须调用基类构造函数来初始化基类那部分成员。如果基类没有默认构造,你必须在初始化列表里显式调用某个基类构造。

cpp 复制代码
class Student : public Person
{
public:
	Student(const char* name, int num, const char* addrss)
		: Person(name)      // 必须先初始化基类部分
		, _num(num)
		, _addrss(addrss)
	{}
protected:
	int _num = 1;
	string _addrss = "西安市高新区";
	int* _ptr = new int[10];
};

构造顺序是先基类、再派生类。C++ 保证先调用基类构造,再构造派生类自己的成员。内存布局里基类部分在低地址,派生类成员在高地址。

补充一个"默认构造"的细节:如果派生类不写构造函数,编译器会生成默认构造。这个默认构造的行为有三点:① 内置类型成员不初始化(不确定);② 自定义类型成员调用它的默认构造;③ 继承来的父类成员看成一个整体对象,要求调用父类的默认构造。

(2)拷贝构造

派生类拷贝构造必须调用基类的拷贝构造 来拷贝基类部分。写法上,把父类看成一个自定义类型,在初始化列表里用 Person(s) 调它:

cpp 复制代码
Student(const Student& s)
	: Person(s)            // 用基类的拷贝构造拷贝基类部分
	, _num(s._num)
	, _addrss(s._addrss)
{
	// 深拷贝(有指针等深拷贝资源才需要)
}

严格说,像上面这种 Studentint* _ptrnew int[10]),那就必须自己实现拷贝构造做深拷贝;如果没有这类动态资源,默认生成的拷贝构造其实就够了。判断标准和普通类一样,只是派生类多了个"别忘了先拷基类部分"的职责。

(3)赋值运算符重载

派生类 operator= 必须显式调用基类 operator= 。这里有个坑:父类和子类的 operator= 构成隐藏关系 ,直接写 Person::operator= 才行。

cpp 复制代码
Student& operator=(const Student& s)
{
	if (this != &s)
	{
		Person::operator=(s);   // 显式调用基类赋值,注意是隐藏关系
		_num = s._num;
		_addrss = s._addrss;
	}
	return *this;
}

如果漏掉 Person::operator=(s),基类部分就拷不到,对象里基类成员是旧的、甚至未初始化。这种 bug 很难查。

(4)析构函数

派生类析构函数不需要显式调用基类析构 ,编译器会自动在子类析构函数执行完之后再调用基类析构。这保证了先析构子类、再析构基类 (和构造顺序相反)。注意基类析构函数和子类析构函数也构成隐藏关系 ,但这是特例,编译器对析构函数做了特殊处理,统一转成 destructor() 来处理。

复制代码
  # 构造顺序(先基类后派生)          # 析构顺序(先派生后基类)
  +------------------+              +------------------+
  |  基类 Person()   |              |  ~Student()      |
  |  构造            |              |  析构            |
  +------------------+              +------------------+
           |                                   ^
           v                                   |
  +------------------+              +------------------+
  |  派生 Student()  |              |  ~Person()       |
  |  构造            |              |  析构            |
  +------------------+              +------------------+

为什么不能显式调用?因为"先子后父"这个顺序是编译器保证的,你显式调用 Person::~Person() 反而可能破坏顺序。显式调用是否可靠取决于具体实现,没人能保证,所以干脆交给编译器,它一定按"先子后父"来。

(5)取地址重载

跟普通类一样,默认生成的取地址重载(普通对象和 const 对象)就够了,很少自己实现。这 6 个里取地址几乎不用管,重点全在构造、拷贝、赋值、析构。

4.2 实现一个不能被继承的类

有时希望一个类不能被继承,两种方法:

  1. 构造函数设为私有:派生类构造必须调用基类构造,基类构造私有,派生类就无法实例化,也就继承不了。
  2. C++11 用 final 关键字 :直接 class Base final,编译器就不允许继承它了。
cpp 复制代码
class Base final
{
public:
	void func5() { cout << "Base::func5" << endl; }
};

class Derive : public Base   // 编译报错:无法从 final 类继承
{
	// ...
};

五、继承与友元

友元关系不能继承。 基类的友元函数不是派生类的友元,派生类里如果还需要,就得在派生类里也声明一次。

cpp 复制代码
class Student;                    // 前置声明

class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name;                 // 姓名
};

class Student : public Person
{
	friend void Display(const Person& p, const Student& s);   // 必须再声明
protected:
	int _stuNum;                  // 学号
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._stuNum << endl;    // 没有这行声明就会编译报错 error C2248
}

如果不在 Student 里也声明 friendDisplay 只能访问 Person_name,访问 Student_stuNum 就报 error C2248。我刚开始常忽略这点:友元是授出去的,不会自动传给子类 ,子类如果还要用,得自己再授一遍。另外提一句,编译器遇到不认识的类型只会向上查找,所以要提前前置声明 Student

六、继承与静态成员

基类定义了 static 成员,整个继承体系里只有这一个 static 成员,不管派生多少层,派生类和基类共用同一份。

cpp 复制代码
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;

	cout << &p._name << endl;     // 非静态成员:地址不一样
	cout << &s._name << endl;     // 说明子类继承下来了,父子类对象各有一份

	cout << &p._count << endl;    // 静态成员:地址一样
	cout << &s._count << endl;    // 说明子类和父类共用同一份静态成员

	Person::_count++;             // 公有的情况下,父子类指定类域都能访问
	cout << p._count << endl;     // 1
	cout << s._count << endl;     // 1
}

静态成员属于类,不属于具体哪个对象,所以不管 Person 还是 Student 访问的都是同一个 _count 实例。这也是静态成员和普通非静态成员最大的不同:普通成员是每个对象一份,静态成员是整条继承链一份

七、多继承及其菱形继承

7.1 多继承模型

单继承是一个派生类只有一个直接基类;多继承是一个派生类有两条或两条以上的直接基类。多继承对象在内存里的模型是:各个基类子对象按声明顺序排列,最后放派生类自己的成员。

复制代码
  class Derive : public Base1, public Base2
  +------------------+
  |  Base1 子对象    |  偏移 0
  |    _b1           |
  +------------------+
  |  Base2 子对象    |  偏移 8
  |    _b2           |
  +------------------+
  |  Derive 独有     |  偏移 16
  |    _d            |
  +------------------+

菱形继承是多继承的一种特殊情况,也是 C++ 里最麻烦的一种。看这个结构:

复制代码
        +----------+
        |  Person  |
        +----------+
           |      |
      +-----+      +-----+
      |                  |
  +--------+        +--------+
  | Student|        | Teacher|
  +--------+        +--------+
      |                  |
      +--------+--------+
               |
        +-----------+
        | Assistant |
        +-----------+

Assistant 既继承 Student 又继承 Teacher,而 StudentTeacher 都继承 Person。结果 Assistant 对象里会有两份 Person 成员

复制代码
  Assistant 对象(普通多继承)
  +---------------------+
  |  Student 的 Person  |   数据冗余:Person 出现两次
  |    _name            |
  +---------------------+
  |  Teacher 的 Person  |   第二份 _name
  |    _name            |
  +---------------------+
  |  Assistant 部分     |
  |    _majorCourse     |
  +---------------------+

  a._name = "peter";     // error C2385: 对 _name 的访问不明确
  a.Student::_name = "小李";   // 解决二义性,但数据冗余还在
  a.Teacher::_name = "老李";

这里两个坑,一个都跑不掉:数据冗余 (Person 在对象里出现两次,sizeof 会变大)和二义性_name 到底指哪个 Person,编译器报 error C2385)。显式指定 Student::_name/Teacher::_name 能解决二义,但数据冗余解决不了。所以老师课上反复说:别去玩菱形继承。Java 干脆不支持多继承,就是为了绕开这些。

7.2 虚继承

虚继承是解决菱形继承的手段。StudentTeacher 改为 virtual public Person 继承,PersonAssistant 对象里就只存一份了。

cpp 复制代码
class Person
{
public:
	Person(const char* name) : _name(name) {}
	string _name;
};

class Student : virtual public Person
{
public:
	Student(const char* name, int num = 0)
		: Person(name), _num(num) {}
protected:
	int _num;   // 学号
};

class Teacher : virtual public Person
{
public:
	Teacher(const char* name, int id = 1)
		: Person(name), _id(id) {}
protected:
	int _id;    // 职工编号
};

class Assistant : public Student, public Teacher
{
public:
	Assistant(const char* name1, const char* name2, const char* name3)
		: Student(name1), Teacher(name2), Person(name3) {}
protected:
	string _majorCourse;
};
复制代码
  Assistant 对象(虚继承)
  +---------------------+
  |  Student 部分       |
  |    _num (学号)      |
  |  [虚基类指针]       | --> 指向共享的 Person
  +---------------------+
  |  Teacher 部分       |
  |    _id  (职工编号)  |
  |  [虚基类指针]       | --> 指向共享的 Person
  +---------------------+
  |  Assistant 部分     |
  |    _majorCourse     |
  +---------------------+
  |  Person (唯一一份)  |
  |    _name            |
  +---------------------+

虚继承下 _name 不再有二义性,数据冗余也没了,a._name = "peter" 能直接写。代价是内存里多了几个虚基类指针 ,实现变复杂,还要靠虚基类指针去定位那份唯一的 Person

这里有个初始化陷阱Assistant("张三","李四","王五") 里,StudentTeacher 传给 Person 的张三、李四都无效,只有最派生类(Assistant)初始化列表里 Person(name3) 的王五真正生效。因为虚基类只构造一次,由最派生类负责初始化。这也是虚继承最容易踩的坑。

C++ 的多继承本身实现就很复杂,性能也有损失,虚继承更是如此。所以实际开发原则是:可设计出多继承,但不建议设计菱形继承,能用组合就用组合。

7.3 多继承中指针偏移问题

最后一道题,看这个程序。

cpp 复制代码
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;
	return 0;
}

p1p2p3 的关系,正确的是( ) A. p1==p2==p3 B. p1<p2<p3 C. p1==p3!=p2 D. p1!=p2!=p3

复制代码
  class Derive : public Base1, public Base2    // Base1 在前
  +--------------------+
  |  Base1 : _b1       |   偏移 0
  +--------------------+
  |  Base2 : _b2       |   偏移 4
  +--------------------+
  |  Derive : _d       |   偏移 8
  +--------------------+

  Derive d;   Base1* p1 = &d;   Base2* p2 = &d;   Derive* p3 = &d;

  p1 = 0x00   (对象首地址,Base1 在偏移 0)
  p3 = 0x00   (&d 本身就是这个首地址,所以 p3 == p1)
  p2 = 0x04   (Base2 在偏移 4,地址更大)

  所以 p1 == p3 != p2  ->  答案 C

答案是 C。 这里最值钱的不是背下"p1==p3",而是理解为什么 :多继承对象里,基类子对象按声明顺序排列,第一个写的基类位于偏移 0,指向它的指针和指向派生类对象本身的指针同址。后一个基类子对象排在后头,地址更大。

这道题真正常考的反例 :如果声明顺序反了,写成 class Derive : public Base2, public Base1(Base2 在前),那结果就变成 p2==p3!=p1。所以别死记"p1 等于 p3",要看哪个基类写在最前面。面试官换一换声明顺序,答案就翻过来,考验的是你有没有真懂对象布局。

7.4 IO 库里的菱形虚拟继承

标准库的 iostream 就是个典型的菱形虚拟继承。basic_ios 作为虚基类,basic_istreambasic_ostream 都虚继承它,iostream 再同时继承这两个,cout/cin 就是靠这套才只含一份 basic_ios,避免了冗余和二义性。这只是个成熟案例,理解菱形虚拟继承怎么解决冗余,比记住具体类名更有用。

八、继承和组合

学完继承,另一个经常被拿来比较的是组合。区别很清晰:

  • 继承是 is-a 关系Student 是一个 Person,学生是一种人。
  • 组合是 has-a 关系Car 有一个 Tire,车有一个轮胎。
cpp 复制代码
// 组合:Car 里有 Tire 成员
class Tire
{
protected:
	string brand = "米其林";
};

class Car
{
protected:
	Tire _t1;      // 车里有轮胎
	Tire _t2;
};

优先用组合,而不是继承。 继承会破坏封装:基类内部细节对派生类是可见的,基类一改,派生类受很大影响,耦合度高。组合的对象里有黑盒一样的成员,内部不可见,耦合度低,代码维护性好。那什么时候用继承?类之间确实符合 is-a 关系(比如轿车和车),或者需要多态 ,才用继承;否则优先组合。这也是为什么容器适配器(stackqueue)明明可以继承容器,STL 偏用组合。组合更稳、更黑盒。

相关推荐
by组态1 小时前
Ricon组态系统API参考手册
前端·后端·物联网
君顾11 小时前
外卖CPS系统开发实战指南:从架构设计到部署全流程解析
java·开发语言·外卖
前端逗比逗1 小时前
AI 前端落地实战:SSE 流式输出、断点续传、打字机渲染
前端·webassembly
晴天162 小时前
前端跨域方案解析:JSONP 的原理、实战与演进
前端·状态模式
web打印社区2 小时前
浏览器静默打印?先别装第三个库了
前端·vue.js·chrome·electron·pdf
艾伦野鸽ggg2 小时前
前端异步请求的状态竞争(请求竞态)问题
前端·javascript·axios
是立不是利2 小时前
前端交互基石:深入剖析JavaScript三级联动背后的设计哲学
开发语言·前端·javascript
奇牙coding1232 小时前
GPT-5.5 升级 GPT-5.6 接入指南:流式调用配置与常见问题
java·网络·gpt·ai
动恰客流统计2 小时前
传统红外对射客流统计为何逐步淡出主流?准确率与场景限制深度分析
大数据·前端·人工智能