C++继承详解(菱形继承与虚拟继承)

继承的概念和定义

继承机制是面向对象程序设计中可以使得代码复用的重要手段,它允许程序员在保持原有类的特性上进行扩展,增加功能,这样产生的类,叫做派生类。而我们之前了解的重载只是函数功能的服用,继承则是类设计层次的复用。

举一个简单的列子帮助大家理解是继承的概念。

现在,假如我们需要做一个教务系统,我们这时候要实现一个学生类,还会实现一个老师类,这个时候我们会发现在这个学生类和老师类中会有一些成员变量是公共的,比如姓名,电话,年龄等信息,这些作为学生和老师是都会具有的,但也会有一些不一样的成员变量,比如学生会有学号,所属班级,选修课程等;而老师也会有不同于学生的成员变量,比如教师工号,所带课程等。

这样我们就会看到总有那么一些相同的成员变量需要在两个不同的类中分别定义以及初始化,其实感觉这部分内容就有点冗余了,那有没有一种办法就是把这些公共的成员变量直接提取出来进行定义和初始化呢?

答案就是继承,我们将它们两之间公共的部分提取出来放到一个新的类中,然后让这两个类分别继承这个类,这样就可以成功解决上面的问题。

那么应该如何实现继承呢?

现在我们就来通过一段代码,来见一见C++中的继承是什么样子的。

复制代码
#include<iostream>
#include<string>

class Person
{
public:
	void print()
	{
		std::cout << "name: " << _name << std::endl;
	}
protected:
	std::string _name = "buluo";
	int _age = 18;
};

class Student :public Person
{
protected:
	int student_id;
};

class Teacher :public Person
{
protected:
	int teacher_id;
};

int main()
{
	Student s;
	Teacher t;
	s.print();
	t.print();
	return 0;
}

可以看到即使我们在Student和Teacher类中并没有定义name和age,但是只要我们继承了Person类,我们就可以继承它的成员变量。

同时,不仅可以继承成员变量,还可以继承成员函数,我们可以看到我们并没有在Student和Teacher类中实现print函数,但是由于它实现了继承,所以它们两都可以使用print函数调用。

继承方式和访问限定符

为什么会有这三种继承方式呢?其实很好理解,子类继承父类的时候,总有一些东西是父类不想给子类使用的,就比如说,你爸和你的关系,你爸的房子你可以随便住,你爸买回来的东西你可以随便吃,但是总有一些东西是你爸不想让你用的(私房钱),你爸还准备用私房钱买鱼竿钓鱼,这些钱是你不可以使用的。

所以类似于这样的原因,C++在设计继承的时候就有了上面三种继承方式(public、protected、private)。

在访问限定符protected和private,其实没什么区别,都是在类内可以使用,但是在类外不可以使用。这两个真正的区别,只有在继承这里才能完美的体现。接下来,我们就来看看派生类继承基类成员时访问方式的变化会有什么情况发生。

  • 基类的private成员在派生类中无论以什么样的方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类的对象中,但是语法上限制派生类对象不管是在类内还是类外都不能访问它。我们将上面Person对象中的成员函数print改为private访问方式来看一看。

基类的private成员类外不能使用

复制代码
#include<iostream>
#include<string>

class Person
{
private:
	void print()
	{
		std::cout << "name: " << _name << std::endl;
	}
protected:
	std::string _name = "buluo";
	int _age = 18;
};

class Student :public Person
{
protected:
	int student_id;
};

class Teacher :public Person
{
protected:
	int teacher_id;
};

int main()
{
	Student s;
	Teacher t;
	s.print();
	t.print();
	return 0;
}

基类的private成员类内也不能使用

复制代码
#include<iostream>
#include<string>

class Person
{
private:
	void print()
	{
		std::cout << "name: " << _name << std::endl;
	}
protected:
	std::string _name = "buluo";
	int _age = 18;
};

class Student :public Person
{
public:
	void fun()
	{
		print();
	}
protected:
	int student_id;
};

class Teacher :public Person
{
protected:
	int teacher_id;
};

int main()
{
	Student s;
	Teacher t;
	return 0;
}
  • 基类private成员在派生类中是不能被访问的,如果基类成员不想在类外被访问,但是需要在派生类中访问,就定义为protected。这里就可以看出protected限定符是因为继承才出现的。

基类的protected成员类内可以使用

复制代码
#include<iostream>
#include<string>

class Person
{
protected:
	void print()
	{
		std::cout << "name: " << _name << std::endl;
	}
protected:
	std::string _name = "buluo";
	int _age = 18;
};

class Student :public Person
{
public:
	void fun()
	{
		print();
	}
protected:
	int student_id;
};

class Teacher :public Person
{
protected:
	int teacher_id;
};

int main()
{
	Student s;
	Teacher t;
	s.fun();
	return 0;
}

基类的protected成员类外不可以使用

复制代码
#include<iostream>
#include<string>

class Person
{
protected:
	void print()
	{
		std::cout << "name: " << _name << std::endl;
	}
protected:
	std::string _name = "buluo";
	int _age = 18;
};

class Student :public Person
{
public:
	void fun()
	{
		print();
	}
protected:
	int student_id;
};

class Teacher :public Person
{
protected:
	int teacher_id;
};

int main()
{
	Student s;
	Teacher t;
	s.print();
	t.print();
	return 0;
}
  • 基类的private在子类中是不可见的,基类的其它成员的访问方式=Min(成员在基类中的访问限定符,继承方式),public > protected > private。
  • 使用class默认是private继承,struct默认是public继承。

看到现在可能会有人好奇了继承中这个派生类的不可见和私有有什么区别?

不可见就代表类外类内都不可以使用。

私有代表类内可以使用,类外不可以使用。

基类和派生类对象赋值转换

这段代码其实很好地体现了 C++ 中"临时对象"的产生规则:当发生类型不匹配的转换并且用引用去接收时 ,往往会生成临时对象,比如 double d = 2.2; const int& r = d;,这里 double 不能直接绑定到 int&,编译器会先把 d 转换成一个 int(值为 2),再生成一个临时的 int 对象,最后让 r 绑定这个临时对象;同样地,const std::string& rs = "xxxx";"xxxx" 是字符串字面量(本质是 const char*),为了匹配 std::string,编译器会构造一个临时的 std::string 对象,再让 rs 绑定它。而在继承体系中则不同,例如 Student s; Person p = s; Person& rp = s;(假设 Student 公有继承自 Person),这里属于父子类之间的兼容转换,p = s 会发生对象切片 ,只拷贝子类中的父类部分来构造 p,但这个过程是直接构造目标对象,并不会额外产生一个临时对象;而 Person& rp = s; 则是让父类引用直接绑定到子类对象中的父类子对象,也同样不会产生临时对象。因此可以总结:普通类型转换或构造新类型对象时通常会产生临时对象,而继承体系下的向上转型(尤其是引用或指针)一般不会产生临时对象,只可能发生对象切片。

  • 派生类对象可以赋值给基类的对象,基类的指针,基类的引用,也有一个说话称这种方式叫说切片(切割),表示把派生类中父类的那部分切割出来赋值过去。
  • 基类对象不能赋值给派生类对象。
  • 基类的指针或者引用可以通过强制类型转化赋值给派生类的指针或引用,必须是基类的指针指向派生类对象时才是安全的

结合这张图,我们就可以明白,如果是对象赋值,就会将子类中的父类的那一部分赋值过去,如果是指针或者引用,就会将子类中父类的那部分内容切割出来交给它。这就是切割。注意:一般情况下都是子类对象赋值给父类对象,因为子类中有的,父类中不一定有;而父类中有的,子类一定有,所以只能是子类赋值给父类。

继承中的作用域

  • 在继承中父类和子类都有独立的作用域。

  • 子类和父类中有同名成员的话,子类成员会屏蔽掉父类中与自己同名成员的访问,这种情况叫做隐藏,也叫重定义。(但是在子类成员函数中,还是可以使用 父类::父类成员 直接访问)

  • 成员函数的隐藏,只需要函数名相同就构成隐藏。

    class Person
    {
    protected:
    std::string _name = "buluo";
    int _age = 18;
    };

    class Student :public Person
    {
    public:
    void print()
    {
    std::cout << "name : " << _name << std::endl;
    std::cout << "age : " << _age << std::endl;
    std::cout << "person_age : " << Person::_age << std::endl;
    }
    protected:
    int _age = 28;
    };

    int main()
    {
    Student s;
    s.print();
    }

派生类的默认成员函数

  • 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表中显示调用。

    class Person
    {
    public:
    Person(const std::string name = "buluo")
    : _name(name)
    {
    std::cout << "Person()" << std::endl;
    }

    复制代码
      Person(const Person& p)
      	: _name(p._name)
      {
      	std::cout << "Person(const Person& p)" << std::endl;
      }
    
      Person& operator=(const Person& p)
      {
      	std::cout << "Person operator=(const Person& p)" << std::endl;
      	if (this != &p)
      		_name = p._name;
    
      	return *this;
      }
    
      ~Person()
      {
      	std::cout << "~Person()" << std::endl;
      }

    protected:
    std::string _name;
    };

    class Student :public Person
    {
    protected:
    int _age;
    };

    int main()
    {
    Student s;
    return 0;
    }

可以看到,如果派生类自己没有实现构造函数,编译器就会自动生成一个去调用父类的构造函数。

现在我们来自己实现一下派生类的构造函数,相信大家一定会想如下的方式进行构造

复制代码
class Student :public Person
{
public:
	Student(const std::string name, int age)
		:_name(name)
		,_age(age)
	{}
protected:
	int _age;
};

通过这样的方式对完成对派生类的构造是不正确的,我们对派生类进行构造的时候,我们不能单个的进行初始化,要父类的成员当成一个整体去初始化。

复制代码
class Student :public Person
{
public:
	Student(const std::string name, int age)
		:Person(name)
		,_age(age)
	{}
protected:
	int _age;
};
  • 派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。

    复制代码
      Person(const Person& p)
      	: _name(p._name)
      {
      	std::cout << "Person(const Person& p)" << std::endl;
      }

这是父类的拷贝构造函数,需要我们传递一个父类引用,现在有一个问题就是我们如果要实现一个子类的拷贝构造时,我们如何在子类中,将子类对象的父类部分拿出来完成子类对象的拷贝构造函数呢?这里我们就可以直接使用切片的方法就可以实现,因为切片会直接将子类对象的那一部分直接切割出来,这样我们就可以完成派生类的拷贝构造函数。

复制代码
class Student :public Person
{
public:
	Student(const std::string name, int age)
		:Person(name)
		,_age(age)
	{
		std::cout << "Student(const std::string name, int age)" << std::endl;
	}
	Student(const Student& s)
		:Person(s)
		,_age(s._age)
	{
		std::cout << "Student(const Student& s)" << std::endl;
	}
protected:
	int _age;
};
  • 派生类的operator=必须调用基类的operator=完成基类的复制。

    class Student :public Person
    {
    public:
    Student(const std::string name, int age)
    :Person(name)
    ,_age(age)
    {
    std::cout << "Student(const std::string name, int age)" << std::endl;
    }
    Student(const Student& s)
    :Person(s)
    ,_age(s._age)
    {
    std::cout << "Student(const Student& s)" << std::endl;
    }
    Student& operator=(const Student& s)
    {
    Person::operator=(s);
    _age = s._age;
    std::cout << "Student& operator=(const Student& s)" << std::endl;
    return *this;
    }
    protected:
    int _age;
    };

  • 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员之后再清理基类成员。

    复制代码
      class Student :public Person
      {
      public:
      	Student(const std::string name, int age)
      		:Person(name)
      		,_age(age)
      	{
      		std::cout << "Student(const std::string name, int age)" << std::endl;
      	}
      	Student(const Student& s)
      		:Person(s)
      		,_age(s._age)
      	{
      		std::cout << "Student(const Student& s)" << std::endl;
      	}
      	Student& operator=(const Student& s)
      	{
      		Person::operator=(s);
      		_age = s._age;
      		std::cout << "Student& operator=(const Student& s)" << std::endl;
      		return *this;
      	}
      	~Student()
      	{
      		~Person();
      		std::cout << "~Student()" << std::endl;
      	}
      protected:
      	int _age;
      };

可以看到我们在完成派生类的析构函数时,发现无法运行,这其实是因为基类和派生类的析构函数构成隐藏关系,导致在派生类中无法调用基类的析构函数。

这里有人就好奇了,构成隐藏关系,不是需要同名吗?这里这两个看上去不是同名的,为什么会构成隐藏,这其实是因为多态的原因,析构函数会被统一处理称destructor,所以它们会构成隐藏,至于为什么时多态的原因,我们在下一篇博客中在了解。所以这里如果我们想要访问被隐藏的父类函数,需要增加父类作用域。

复制代码
	~Student()
	{
		Person::~Person();
		std::cout << "~Student()" << std::endl;
	}

这样看上去,好像是正确了,但是其实还有一个问题在其中,看图:

我们可以看到析构之后有调用了一次父类的析构函数,这是怎么回事呢?

其实我们可以先来想一个问题,就是我们在析构的时候,应该先析构父后析构子,还是先析构子后析构父?答案其实是我们应该先析构子后析构父,这是因为假如我们先析构了父,但是我们的子类是可以访问父类成员的,但是你已经将父类析构了,这个时候想要再访问父类成员,是会出错的,所以我们要先析构子后析构父。

所以为了保证析构函数,先子后父,父类的析构函数不需要显示调用,默认会在子类的析构函数之后自动调用父类的析构函数,这样就保证了先子后父。

继承和友元

友元关系是不能被继承的。

复制代码
class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	std::string _name; // 姓名
};
class Student : public Person
{
protected:
	int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
	std::cout << p._name << std::endl;
	std::cout << s._stuNum << std::endl;
}
void main()
{
	Person p;
	Student s;
	Display(p, s);
}

可以看到基类的友元是不能访问子类私有的和保护的成员的。这就好比你父亲的朋友不见得就是你的朋友。

继承和静态成员

基类中的static静态成员,在整个继承体系中只有一个这样的成员,无论有多少个子类,都只有一个static成员。

复制代码
class Person
{
protected:
	std::string _name;
public:
	static int count;
};

int Person::count = 1;

class Student :public Person
{
protected:
	int _id;
};

int main()
{
	std::cout << &Person::count << std::endl;
	std::cout << &Student::count << std::endl;
}

可以看到打印出来的count的地址是相同的,这就证明静态成员是独一份的,不会再创建一份新的。

复杂的菱形继承及菱形虚拟继承

单继承:一个子类只有一个父类时是单继承

多继承:一个子类有两个及以上的父类就是多继承

多继承其实也可以理解,当我们成功考上研之后,我们的导师可能就会安排他的一些本科课程交给你,这个时候你就是既是老师,也是学生。所以还是有多继承的情况发生。

菱形继承

但是多继承就会导致菱形继承的问题,对于最下面的Assistant这个类就会造成数据冗余以及二义性的问题,就是他会有两份关于Person的成员,这就会造成数据冗余的情况,并且一旦我们要进行修改他继承下来Persin类的成员,到底使用的是从哪边继承下来的,这个时候就会有二义性的情况。

复制代码
class Person
{
public:
	std::string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person
{
protected:
	int _id; // 工号
};
class Assistant : public Student, public Teacher
{
protected:
	std::string _majorCourse; // 主修课程
};
int main()
{
	Assistant a;
	a._name = "peter";
	return 0;
}

可以看到我们对_name这个成员变量的访问是不明确的,就会导致程序错误。所以我们必须显示的指定是哪个父类的成员,这样才能解决二义性的问题,但是数据冗余无法避免。

复制代码
int main()
{
	Assistant a;
	//a._name = "peter";
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
	return 0;
}

有人会这么想,我当老师的时候,学生们叫我张老师,我当学生的时候,导师叫我小张,很合理呀,貌似没有毛病的,但是我们其实可以仔细想想菱形继承还是不好的,一个人可能会有两个身份证号吗?一个人会有两个年龄吗?一个人会有两个家庭住址吗?所以菱形继承这种数据冗余的问题还是挺大的。

所以为了解决这个菱形继承的二义性和数据冗余,C++的祖师爷就引入了虚拟继承,这样就可以解决二义性和数据冗余的问题。

这样在Assistant这个类中只会有一份Person类的成员,不会再有数据冗余的情况,但是有人会说我看上面明明有三份,还是有数据冗余的情况啊?

这其实是VSCode的监视窗口的设计问题,在内存中其实只有一份,接下来我们就通过内存窗口来看看虚拟继承到底是如何解决数据冗余和二义性的。

虚拟继承解决数据冗余和二义性的原理

先来通过内存窗口看看菱形继承时的内存窗口是什么样子的。

复制代码
class A
{
public:
	int _a;
};
class B : public A
{
public:
	int _b1;
	int _b2;
};
class C : public A
{
public:
	int _c1;
	int _c2;
};
class D : public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b1 = 3;
	d._b2 = 4;
	d._c1 = 5;
	d._c2 = 6;
	d._d = 10;
	return 0;
}

先继承谁,谁就在前面,所以我们就可以看到d的内存分布如图所示,这样就会造成数据冗余,在B和C中就有两份A的成员。接下来我们来看看虚拟继承是如何解决这个问题的呢?

复制代码
class A
{
public:
	int _a;
};
class B : virtual public A
{
public:
	int _b1;
	int _b2;
};
class C : virtual public A
{
public:
	int _c1;
	int _c2;
};
class D : public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d._a = 1;
	d._b1 = 3;
	d._b2 = 4;
	d._c1 = 5;
	d._c2 = 6;
	d._d = 10;
	return 0;
}

我们可以明显的看到a被放到了最下面(高地址处),但是B和C中确实没有a了,但是怎么多了一些其它的东西呢?其实我们可以猜一猜,既然都将a放到了最下边,可能必须得让B和C可以找到这个独一份得a,不管它是怎么找到的,这个新增的东西肯定和找到a脱不了干系,现在我们就通过内存窗口来看看这个地址处到底是什么?

我们可以看到这两个地址处的第一个位置存放的都是0,而第二个位置分别是0000001c和00000010,看到这两个值这个时候就需要我们仔细进行观察,我们就可以发现这两个值就是偏移量。

0113FCCC+0000001C = 0113FCE8,0113FCD8+00000010 = 0113FCE8,而0113FCE8正好就是存放a的地址,这样通过这样的方式就解决了菱形继承的二义性和数据冗余的问题。这是其一。

我们其实看到虚拟继承改变了D的对象模型,那其它的是否也会被改变呢?

复制代码
int main()
{
	D d;
	d._a = 1;
	d._b1 = 3;
	d._b2 = 4;
	d._c1 = 5;
	d._c2 = 6;
	d._d = 10;

	B b;
	b._a = 1;
	b._b1 = 2;
	b._b2 = 3;
	return 0;
}

008FFD90 + 0000000C = 008FFD9C,可以看到B的对象模型也被改变了,不再是在B中存放a,b1,b2,而是和D的对象模型一样,在对象模型的第一个位置增加一个地址,在这个地址中会存放对应的距离a的偏移量。

总之:虚拟继承并不是"直接共享",而是通过间接访问实现的:

B 和 C 内部会多出一个"指针/表"(常称虚基表指针)

这个结构中存储:到 A 的偏移量

例如:

B地址 + 偏移量 = A地址

C地址 + 偏移量 = A地址

最终:不管从 B 还是 C 访问 _a,都会定位到同一块内存。

最后:

这次带大家简单了解了 C++ 继承的核心内容,包括继承方式、访问控制、对象切片,以及菱形继承和虚拟继承等重点知识。

继承不仅是代码复用的工具,更涉及到对象模型和内存结构,尤其是在多继承场景中,一定要理解其底层原理,避免踩坑。

📌 一句话总结:

继承解决复用,虚拟继承解决重复。

相关推荐
闻缺陷则喜何志丹2 小时前
【排序 离散化 二维前缀和】 P7149 [USACO20DEC] Rectangular Pasture S|普及+
c++·算法·排序·离散化·二维前缀和
君义_noip2 小时前
信息学奥赛一本通 4163:【GESP2512七级】城市规划 | 洛谷 P14921 [GESP202512 七级] 城市规划
c++·算法·图论·gesp·信息学奥赛
不想写代码的星星2 小时前
C++ 的花括号有多狂?std::initializer_list 那些不讲武德的事儿
c++
elseif1232 小时前
初学者必背【考点清单(大全)】【上篇】
开发语言·c++·笔记·学习·循环结构·分支结构·考纲
并不喜欢吃鱼2 小时前
从零开始C++----二.(下篇)模版进阶与编译全过程的复习
开发语言·c++
智者知已应修善业2 小时前
【51单片机按键控制流水灯+数码管显示按键次数】2023-6-15
c++·经验分享·笔记·算法·51单片机
汉克老师3 小时前
GESP2023年12月认证C++三级( 第三部分编程题(1、小猫分鱼))
c++·算法·模拟算法·枚举算法·gesp三级·gesp3级
不知名的老吴3 小时前
View的三大特性之一:迟绑定
开发语言·c++·算法
Huangjin007_3 小时前
【C++ STL篇(四)】一文拿捏vector常用接口!
开发语言·c++·学习