【C++】继承


🚀 作者简介:一名在后端领域学习,并渴望能够学有所成的追梦人。

🚁 个人主页:不 良

🔥 系列专栏:🛸C++ 🛹Linux

📕 学习格言:博观而约取,厚积而薄发

🌹 欢迎进来的小伙伴,如果小伙伴们在学习的过程中,发现有需要纠正的地方,烦请指正,希望能够与诸君一同成长! 🌹


文章目录

继承的概念和定义

继承的概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继承便是类设计层次的复用。

例如当我们想实现一个学校人员的管理系统,其中每个对象类都具有一些相同的属性(校长、辅导员、学生都具有姓名,年龄等这些相同的的属性),有些类型具有共性,我们把这些公有的属性提取出来都放到一个类中,让其他的类去继承,继承的本质就是复用。

继承是面向对象的三大特性之一。面向对象的三大特性:封装、继承和多态。封装主要在类和对象有较好的体现。

继承是类层次的复用;把公共特征专门提取出来,放到一个类里,那个类就叫做基类或者父类。

下面的代码中Person类中有Student类和Teacher类共有的属性,所以Person类可以作为它们的父类/基类,而Strudent和Teacher类可以作为Person的子类/派生类:

c 复制代码
//父类/基类
class Person {
protected:
	string _name = "张三";
	int _age = 18;
};

//派生类/子类
class Teacher : public Person
{
protected:
	string _id = "204080234";//工号
};
class Student : public Person
{
protected:
	string _num = "1234566";//学号
};

继承后,父类Person的成员,包括成员函数和成员变量,都会变成子类的一部分,也就是说,子类Student和Teacher复用了父类Person的成员。

继承的定义

继承的定义格式如下:

在继承当中,父类也称为基类,子类是由基类派生而来的,所以子类又称为派生类。

继承方式有三种,继承方式有三种:公有继承、保护继承和私有继承。

我们先试一下下面的代码,观察打印结果:

c 复制代码
//父类/基类
class Person {
public:
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << "年龄:" << _age << endl;
	}
protected:
	string _name = "张三";
	int _age = 18;
};
//派生类/子类
class Teacher : public Person
{
protected:
	string _id = "204080234";
};
class Student : public Person
{
protected:
	string _num = "1234566";
};

int main()
{
	//实例化一个Person类对象
	Person p;
	p.Print();//调用自己类中的成员函数

	//实例化一个Teacher类对象
	Teacher t;
	t.Print();//调用父类中的成员函数
}

打印结果:

第一次打印是对象p调用Print函数打印的,第二次打印是派生类对象调用基类对象的成员函数打印的,Teacher类本身并没有定义_name_age变量,以及打印函数,但是因为继承了父类,所以可以使用。

继承方式和访问限定符

继承基类成员访问方式的变化

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

上面的表中可以这样理解:

采用public继承的话,基类中的public成员变为派生类中的public成员,基类的protected成员变为派生类的protected成员,基类的private成员在派生类中不可见;

采用protected继承的话,基类中的public成员变为派生类的protected成员,基类中的protected成员变为派生类的protected成员,基类的private成员在派生类中不可见;

采用private继承的话,基类中的public成员变为派生类的private成员,基类中的protected成员变为派生类的private成员,基类的private成员在派生类中不可见;

总结:

1、基类private成员在派生类中无论以什么方式继承都是不可见的,这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

2、基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。

3、可以认为三种访问限定符的权限大小为:public > protected > private,基类成员访问方式的变化规则如下:

  • 在基类当中的访问方式为public或protected的成员,在派生类当中的访问方式 == Min(成员在基类的访问方式,继承方式)
  • 在基类当中的访问方式为private的成员,在派生类当中都是不可见的

4、使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5、在实际运用中一般都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

所以当父类中的成员不想被子类继承的时候就可以使用私有。

访问限定符什么时候有区别呢?在继承的时候,私有和保护在自己的类里面是没有区别的,但是在继承之后(不考虑继承方式)私有在子类中不可见,保护在子类中依旧可以使用。

c 复制代码
//继承方式
protected;//在子类中可见,只能防外面不能防止子类
private;//在子类中不可见,不仅能防外面还能防子类

派生类对象可以通过调用父类的函数去访问父类的私有成员,不可见指的仅仅只是在派生类里面不可见,不可见是子类想直接访问不能访问。

继承方式也可以像访问限定符一样,是不需要写的class Student : Person,不写的话有一个默认的访问限定符:使用class时默认的是私有;使用struct时默认的是公有。如果改成private继承,子类对象不能调用父类的public成员。

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

  • 派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
  • 基类对象不能赋值给派生类对象
  • 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast来进行识别后进行安全转换。

下面的代码中d并不是直接赋值给i,i = d中间会产生一个临时变量,临时变量具有常性,这里进行的是隐式类型转换。

c 复制代码
double d = 2.2;
int i = d;

那么一个子类对象可不可以给一个父类对象呢?可以,下面的规则只适用于公有继承。

c 复制代码
Student s;
Person p = s;//天然支持的,不存在类型转换的发生

在公有继承下(其他继承不可以)编译器认为子类对象就是一个特殊的父类对象,是纯天然的,会把子类对象的一部分切割或者切片给父类对象。会调用拷贝构造把父类对象的一部分找出来拷过去。

int& ri = d;不能给ri,ri引用的是临时变量,因为临时变量具有常性,所以我们可以加const:const int& ri = d。但是当我们使用Person& rp = s是可以的,因为这是天然的,中间没有产生临时变量。

引用Person& rp = s;是子类对象当中父类那一部分的别名,切出来了但是还在那,变成了rp的别名,可以通过rp进行修改。

指针也可以这样使用:

指针看到的是子类中父类的那一部分(只看父类的那一部分):

上面的就叫子类和父类的对象赋值兼容规则。

子可以给父,但是父能不能给子呢?现阶段认为不可以,但是后来有特殊的情况认为可以。

c 复制代码
//父类/基类
class Person {
public:
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << "年龄:" << _age << endl;
	}
protected:
	string _name = "张三";
	int _age = 18;
};
//派生类/子类
class Teacher : private Person
{
protected:
	string _id = "204080234";
};
class Student : public Person
{

protected:
	string _num = "1234566";
};

int main()
{
	//实例化子类对象
	Student s;
	//1、派生类对象可以向上赋值给基类对象/基类指针/基类引用
	Person p = s;//true
	Person& rp = s;//true
	Person* pp = &s;//true

	//2、基类对象不能向下赋值给派生类
	//s = p;//false

	//3、基类的指针可以通过强制类型转换赋值给派生类的指针
	pp = &s;//基类的指针指向派生类对象
	Student* ps = (Student*)pp;//这种情况转换是安全的

	pp = &p;//基类的指针指向基类对象
	Student* ps1 = (Student*)pp;//这种情况虽然可以,但是会存在越界访问的问题
}

继承中的作用域

  • 在继承体系中,基类和派生类都有独立的作用域。
  • 如果出现子类和父类中有同名成员,子类成员将屏蔽父类对成员的直接访问,这种情况叫做隐藏,也叫重定义。如果需要访问基类成员,可以采用基类::基类成员的方式显示访问。
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  • 注意在实际应用中在继承体系里面最好不要定义同名的成员。

基类和派生类中能不能有同名成员呢?他们两个是独立的作用域可以定义同名成员。

c 复制代码
//父类/基类
class Person {
protected:
	string _name = "张三";
	int _age = 18;
};
//派生类/子类
class Student : public Person
{
public:
    void Print()
    {
    	cout << "姓名:" << _name << endl;
    }
protected:
	string _name = "李四";
};

那上面的程序中访问的成员变量_name是是子类的还是父类的呢?访问的是子类的成员:

**遵循的是就近原则。**那当我们想访问父类中的成员时应该怎么办?在函数Print中指定作用域就可以访问到父类中的成员。

c 复制代码
void Print()
{
    cout << "姓名:" << Person::_name << endl;
}

子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用基类::基类成员显示访问)。

练习题:

c 复制代码
class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		A::fun();
		cout << "func(int i)->" << i << endl;
	}
};
int main()
{
	B b;
	b.fun(10);
};

针对上面的程序判断下列选项哪个是正确的?

A:两个func构成函数重载 B:两个func构成隐藏 C:编译报错 D:以上说法都不对

B正确。

函数重载必须在同一个作用域里面;

如果是成员函数的隐藏,只需要函数名相同就构成隐藏;

上面程序的打印结果:

但是将主函数中更换为B b;b.fun();时选BC,构成隐藏同时编译报错。我们可以指定作用域访问B b;b.A::fun();

但是最好不要定义同名成员。

派生类的默认成员

在前面类和对象那一节我们学习了6个默认成员函数:

我们知道即使我们不写,编译器也会自动生成。我们也来学习一下派生类的默认成员函数。

在下面的代码中Person是基类,Student是Person的派生类:

c 复制代码
class Person
{
public:
    //构造函数
	Person(const char* name = "张三")
		: _name(name)
	{
		cout << "Person()" << endl;
	}
    //拷贝构造函数
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
    //赋值重载函数
	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}
    //析构函数
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num;//学号
};
int main()
{
	Student s;
	return 0;
}

当我们在子类中不做任何定义的时候,会调用父类的构造函数和析构函数,上述代码打印结果如下:

我们发现子类不做定义,子类会自动的调用父类的成员(构造函数和析构函数)。

构造函数

派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。

我们可以像下面这样在子类的构造函数中对父类成员变量初始化吗?

c 复制代码
class Student : public Person
{
public:
    Student(const char* name, int num)
        :_name(name) //或者Person::_name(name) //都不可以
        ,_num(num)
     {}
protected:
	int _num;//学号
};

不可以,父类的成员必须调用父类的构造函数进行成员初始化,所以我们想要在子类中的初始化列表写可以用显示的调用父类的构造函数,调用父类的构造函数去初始化父类的成员:

c 复制代码
class Student : public Person
{
public:
	Student(const char* name, int num)
		:Person(name)
		,_num(num)
	{}
protected:
	int _num;//学号
};
int main()
{
	Student s("王五",8);
	return 0;
}

先去调用父类的构造函数再去调用子类的构造函数。那当我们在子类中不写:Person(name)时也是可以编译通过的,无论我们写不写,在初始化列表都会去调用父类的构造函数。

默认构造函数是指无参的构造函数、全缺省的构造函数、编译器自动生成的构造函数,如果父类没有默认构造函数,子类必须在构造函数的初始化列表显示的调用父类的构造函数。

c 复制代码
 //父类构造函数
Person(const char* name)
    : _name(name)
{
    cout << "Person()" << endl;
}

//子类构造函数
class Student : public Person
{
public:
	Student(const char* name, int num)
        //子类构造函数显示调用父类的构造函数
		:Person(name)
		,_num(num)
	{}

如上父类不是默认构造函数,子类中必须显示的去调用。

拷贝构造函数

当子类中并没有实现拷贝构造时,子类进行拷贝构造时也会自动调用父类的拷贝构造,完成父类部分的拷贝:

c 复制代码
class Person
{
public:
	//构造函数
	Person(const char* name = "张三")
		: _name(name)
	{
		cout << "Person()" << endl;
	}
	//拷贝构造函数
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	//赋值重载函数
	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}
	//析构函数
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name = ""; // 姓名
};
class Student : public Person
{
public:
	Student(const char* name, int num)
		:Person(name)
		,_num(num)
	{
	}
protected:
	int _num;//学号
};
int main()
{
	Student s("王五",8);
	Student s1(s);
	return 0;
}

打印结果:

对于派生类的拷贝构造来说必须调用基类的拷贝构造函数完成基类成员的拷贝构造,但是父类的拷贝构造参数是父类对象,那如何将子类中父类的那一部分拿出来呢?切片就可以。

父类的引用可以引用子类对象,引用的是子类对象中切出来的父类的那一部分。所以我们可以将子类传过去,让父类切就可以了。

c 复制代码
Student(const Student& s)
		:Person(s)
		,_num(s._num)
	{}

如果我是一个子类对象给父类对象,Person p = s;,是怎么拷贝过去的呢?调用父类的拷贝构造完成的,父类拷贝构造参数是引用,拿到子类中父类的那一部分,然后通过拷贝构造完成拷贝。

赋值重载函数

派生类的operator=必须要调用基类的operator=完成基类部分的赋值。所以赋值的实现先调用父类的赋值函数,再完成子类的赋值。

c 复制代码
Student& operator=(const Student& s)
{
    if (this != &s)
    {
        //先调用父类的赋值完成基类部分的赋值
        operator=(s);
        _name = s._name;
    }
    return *this;
}

如果采用上面的代码将会程序崩溃,因为子类的operator=和父类的operator=构成重命名(隐藏),所以我们在if条件里默认访问到的是子类的赋值重载操作,造成死递归。所以我们需要指明一下作用域。

c 复制代码
Student& operator=(const Student& s)
{
    if (this != &s)
    {
        //先调用父类的赋值完成基类部分的赋值
        Person::operator=(s);
        _name = s._name;
    }
    return *this;
}

我们实现了构造函数、拷贝构造函数、赋值重载函数,代码如下:

c 复制代码
class Person
{
public:
	//构造函数
	Person(const char* name = "张三")
		: _name(name)
	{
		cout << "Person()" << endl;
	}
	//拷贝构造函数
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	//赋值重载函数
	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}
	//析构函数
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name = ""; // 姓名
};
class Student : public Person
{
public:
	Student(const char* name, int num)
		:Person(name)
		,_num(num)
	{
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		:Person(s)
		, _num(s._num)
	{
		cout << "Student(const Student& s)" << endl;
 	}
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			//先调用父类的赋值完成基类部分的赋值
			Person::operator=(s);
			_name = s._name;
		}
		cout << "Student& operator=(const Student& s)" << endl;
		return *this;
	}
	void Print()
	{
		cout << Person::_name << _num << endl;
	}
protected:
	int _num;//学号
};
int main()
{
	Student s("王五",8);
	Student s1(s);
	s1 = s;
	//s1.Print();
	return 0;
}

打印结果:

通过打印结果我们可以看出,无论是构造函数、拷贝构造还是赋值重载都是父类先运行其次是子类,下面我们通过实现析构函数来观察默认成员函数是否都是这样。

析构函数

c 复制代码
~Student()
{
    ~Person();//调用不了析构函数
    cout << "~Student()" << endl;
}

**析构函数这里比较特殊,不需要子类去调用父类的析构函数,因为编译器会对析构函数名进行特殊处理,都会被处理成destructor(),所以这里子类和父类的析构函数就构成了隐藏。**因此无法调用父类的析构函数。所以要指明类域。

c 复制代码
~Student()
{
    Person::~Person();//调用不了析构函数
    cout << "~Student()" << endl;
}

但是运行结果Person的析构:

唯独析构函数不让我们显示去调用,其他几个都可以显示去调用。构造的时候是先构造父类,再构造子类;析构的时候先析构子类,再析构父类。如果是自己显示的写不能够保证先子后父。

子类析构函数完成时,会自动调用父类析构函数,保证先析构子类再析构父类。也可以理解为栈结构。

继承和友元

友元关系不能够被继承,基类的友元可以访问基类的私有和保护成员,但是不能访问派生类的私有和保护成员。

c 复制代码
#include <iostream>
#include <string>
using namespace std;
class Student;//因为友元函数中使用了这个类所以要在这里声明
class Person
{
	//友元函数
	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;
}
int main()
{
	Person p;
	Student s;
	Display(p, s);
}

上面程序中友元函数可以访问父类中的成员,但是不能访问子类中的成员,因为友元函数不会被继承。当要想访问子类中成员的话我们可以在子类中再定义一个友元函数。

c 复制代码
class Student : public Person
{
	//友元函数
	friend void Display(const Person& p, const Student& s);//在父类中再定义一个友元函数就可以访问子类中的成员了。
protected:
	int _stuNum; // 学号
};

继承和静态成员

静态成员概念:声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。

static静态成员特性:

  • 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
  • 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明,静态成员变量一定要在类外进行初始
  • 类静态成员即可用 类名::静态成员 或者 对象静态成员 来访问
  • 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
  • 静态成员也是类的成员,受public、protected、private 访问限定符的限制

思考:当父类中有一个静态的count成员,那子类中会不会有呢?

不会,不能继承下来,但是从域的角度可以访问他。

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。

看下面的代码,代码中有三个类:父类Person,里面有一个静态成员_count,Student继承Person,Graduate继承Student。

c 复制代码
#include <iostream>
#include <string>
using namespace std;
//基类
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 Person
{
protected:
	string _seminarCourse; //研究科目
};

Student继承了Person,那他们的_name成员是一个吗?我们可以通过将_name修改访问权限修改为public,打印结果来观察一下:

通过打印结果,我们可以看到_name并不是同一个。虽然Student继承了Person,但是也有独属于自己的_name属性。

那么我们再看下静态成员_count是不是同一个:

根据打印结果它们的地址相同,可以看出是同一个变量。

思考:统计一下一共创建了多少个对象?

因为创建对象就会调用父类,所以我们若是在基类Person的构造函数和拷贝构造函数当中设置静态成员_count进行自增,那么我们就可以随时通过_count来获取该时刻已经实例化的Person、Student以及Graduate对象的总个数。

c 复制代码
#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
	Person() 
	{ 
		_count++; 
	}
	Person(const Person& p) 
	{
		_count++;
	}
protected:
	string _name; //姓名
public:
	static int _count; //统计人的个数。
};
int Person::_count = 0; //静态成员变量在类外进行初始化
//派生类
class Student : public Person
{
protected:
	int _stuNum; //学号
};
//派生类
class Graduate : public Person
{
protected:
	string _seminarCourse; //研究科目
};
int main()
{
	Student s1;
	Student s2(s1);
	Student s3;
	Graduate s4;
	cout << Person::_count << endl; //4
	cout << Student::_count << endl; //4
	return 0;
}

如何实现一个不能被继承的类?

可以将构造函数和析构函数私有化。因为子类必须调用父类的构造函数去创建对象,当父类的构造函数私有化调不动就无法创建对象。

c 复制代码
class A {
private:
	A()
	{}
	~A()
	{}
};
class B :public A {
};
int main()
{
	B bb;
}

我们要创建A对象时,类A在类外面不能调用,但是可以在类里面调用,但是类B无论无何也调用不了A。但是创建A对象时如果使用非静态成员函数,在类外面调用需要使用A对象,但是没对象,所以我们可以把该函数定义成静态成员函数。

创建对象时定义友元函数也可以但是能不用就不用。

c 复制代码
class A {
public :
	static A CreateOBJ()
	{
		return A();
	}
private:
	A()
	{}
};
class B :public A {
};
int main()
{
	//我要先创建对象,才能调用,但是创建对象又要调用函数,所以可以使用静态的
	A::CreateOBJ();
}

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

继承方式

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

菱形继承:菱形继承是多继承的一种特殊情况。

菱形继承有数据冗余和二义性的问题。

代码:

c 复制代码
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
	string _name; //姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person
{
protected:
	int _id; //职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};
int main()
{
	Assistant a;
	a._name = "peter"; //这样会有二义性无法明确知道要访问哪一个_name
	return 0;
}

上述代码中,实例化出的Assistant对象继承了Student和Teacher,而Student和Teacher都继承了Person,因此Student和Teacher当中都有_name成员,若是直接访问Assistant对象的_name成员会出现访问不明确的报错。

通过监视窗口Assistant对象中有两个Person成员:

因此如果要访问我们需要指定访问Assistant对象哪个父类的_name成员:

c 复制代码
 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
//给_name赋值
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
//打印观察
cout << a.Student::_name << endl;//xxx
cout << a.Teacher::_name << endl;//yyy

增加上面的代码之后监视窗口如下:在同一个Assistant对象中Person成员具有不同的值。

虽然该方法可以解决二义性的问题,但是数据冗余问题无法解决(在Assistant的对象中Person成员会有两份)。

菱形虚拟继承

C++中引入了虚拟继承可以解决菱形继承的二义性和数据冗余的问题

**在继承关系中第二层的类前增加一个关键字virtual。**此时上面的代码如下:

c 复制代码
class Person
{
public:
	string _name; //姓名
};
class Student : virtual public Person
{
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
protected:
	int _id; //职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};
int main()
{
	Assistant a;
	a._name = "peter"; //虚拟继承之后不会再发生二义性,这里可以直接访问
	return 0;
}

此时我们可以直接访问Assistant对象的_name成员,并且当我们访问指定的Student父类和Teacher父类的_name成员,访问到的也是同一个_name成员,解决了二义性的问题。

c 复制代码
cout << a.Student::_name << endl;//Peter
cout << a.Teacher::_name << endl;//Peter

当我们更改_name属性之后,打印结果也都一致。

c 复制代码
//给_name赋值,下面几种修改方式之后,打印结果都一样
//a.Teacher::_name = "xxx";
//a._name = "xxx";
a.Student::_name = "xxx";
//打印观察
cout << a._name << endl;//xxx
cout << a.Student::_name << endl;//xxx
cout << a.Teacher::_name << endl;//xxx

使用虚拟继承之后我们可以看到监视窗口:

_name属性内容相同,虽然在调试窗口看到的是多个Person成员,但是其实它们是同一个,我们可以打印它们的地址来观察。

当打印Assistant的Student父类和Teacher父类的_name成员的地址时,显示的也是同一个地址,解决了数据冗余的问题。

c 复制代码
cout << &(a._name) << endl;//0101F79C
cout << &(a.Student::_name) << endl;//0101F79C
cout << &(a.Teacher::_name) << endl;//0101F79C

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。

如上面的继承关系,在Student和Teacher继承Person时使用虚拟继承就可以解决。但是需要注意的是,虚拟继承不要在其他地方去使用。

菱形虚拟继承原理

我们通过下面的代码去观察:

c 复制代码
class A
{
public:
	int _a;
};
//B继承A
class B :public  A
//虚拟继承
//class B : virtual public A
{
public:
	int _b;
};
//C继承A
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;
}

不考虑继承时,我们通过内存窗口去看:(VS下内存窗口是小端存储)

D对象里面B、C各自有一个_a_b_a_c,D对象自己类里的成员_d

我们再通过同样的代码来看一下虚继承之后的内存:

其中D类对象当中的_a成员被放到了最后,在内存中B对象和C对象原来存放_a成员的位置现在存放的是两个指针,这两个指针叫虚基表指针,它们分别指向一个虚基表两个指针指向的数据存的是两个指针在D对象中和_a的地址的偏移量或者相对距离,虚继承要解决数据冗余和二义性为什么不直接存A的地址呢?

指针其实指向一张表,其实它指向的并不是一个位置,而是一块区域,这块区域里面可能存了多个有用的值,一般这种我们把它叫做表(这里的表称为虚基表),另外我们其实也发现这个偏移量并没有存在指向的第一个位置,偏移量没有存在第一个位置,存到第二个位置,第一个位置空出来为以后的多态做铺垫。

c 复制代码
D d;
B b = d;
B* ptrb = &d;
C* ptrc = &d;

代码中的三个是赋值兼容也就是切片,把一个D对象给B对象,把D对象中的B给切出来,如果直接切父类,少了一份_a,那么_a在哪呢?所以切的时候要先找到偏移量,再找到对应的a。如果直接存放A的地址,切片就变得复杂了。让ptrb指向D对象中的B对象,可以通过偏移量找到_a。然后把B对象切出来。

ptrc也是同样的道理:指向D对象中C对象,可以通过偏移量访问到C中的_a

可以通过ptrb->_a;ptrc->_a;的方式去访问:

以前可以直接访问,但是这里的访问要通过偏移量找到_a

那么B对象的对象模型是怎样的?虚继承之后和D对象模型保持一致,也是通过偏移量找到a。

所以在虚继承中,除了A对象本身,无论是哪个对象访问_a都要通过偏移量。

不用区分出来子类对象和父类对象,动作相同。我们可以通过汇编代码来观察:

c 复制代码
D d;
B b;
B* ptrb = &d;
ptrb->_a++;

ptrb = &b;
ptrb->_a++;

不是要解决数据冗余吗?但是为什么会变大了呢?这是因为A类对象中的成员少,解决数据冗余和二义性是有成本的,要增加两个指针(32位平台下多8个字节)。但是当A对象中的成员大小是8个字节或者较大时就可以了。

为什么指向的空间不需要考虑?因为当有多个相同的对象时,他们的偏移量是一样的,只需要同一个空间,不需要独立的空间,他们存的是同一个指针。

那如果A对象中有多个对象需不需要存多个指针?只需要存一个,通过一个偏移量找到A的位置,然后自己按顺序来算。不需要考虑内存对齐,因为编译器知道内存对齐的规则。

库中的ostream和istream中都使用了菱形继承。

学到这里我们看下面的代码:

c 复制代码
class A
{
public:
	A(const char* s)
	{
		cout << s << endl;
	}
	~A(){}
};
//B继承A
class B : virtual public  A
{
public:
	B(const char* s1, const char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
//C继承A
class C : virtual public A
{
public:
	C(const char* s1, const char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
class D : public B,public C
{
public:
	D(const char* s1, const char* s2, const char* s3, const char* s4)
		:B(s1,s2)	
		,C(s1,s3)
		,A(s1)
	{
		cout << s4 << endl;
	}
};
int main()
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

刚开始分析应该打印三次classA,但是因为是虚拟继承所以在D对象中只有一份A,所以看起来调用了3次,但是一定只调用了一次。

但是调用的是哪个呢?肯定调用单独的A最好,最后的打印结果是class A class B class C class D。先调用的是A(s1),因为初始化列表初始化的顺序跟出现的顺序无关,跟声明的顺序有关。

就算将初始化顺序D() : C(s1,s3),B(s1,s2),A(s1),打印结果也依然是这样。当我们将继承顺序更改之后,才会改变打印结果class A class C class B class D。

继承总结

很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承,否则在复杂度及性能上都有问题。

多继承可以认为是C++的缺陷之一,很多后来的OO语言(OO是面向对象的意思)都没有多继承,如Java。

继承和组合

public继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象,基类的内部细节对子类可见。

比如学生和人:学生是一个人、奔驰和车:奔驰是车的一个品牌。

c 复制代码
//Preson类
class Person
{
};
//Student类
class Student : public Person
{
};

组合是一种has-a的关系,假设B组合了A,每个B对象中都有一个A对象

如车和轮胎的关系 :车有轮胎。

c 复制代码
class A{
    
};
class B{
public:
    A _a;
};

若是两个类之间既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。

继承允许你根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为白箱复用(White-boxreuse)。白箱是相对可视性而言:在继承方式中,基类的内部细节对于派生类可见,继承一定程度破坏了基类的封装,基类的改变对派生类有很大的影响,派生类和基类间的依赖性关系很强,耦合度高。

组合是类继承之外的另一种复用选择,新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称之为黑箱复用(Black-box reuse),因为对象的内部细节是不可见的,对象只以黑箱的形式出现,组合类之间没有很强的依赖关系,耦合度低,优先使用对象组合有助于你保持每个类被封装。

设计中尽量做到高内聚低耦合,所以实际中尽量多使用组合,组合的耦合度低,代码维护性好。在实际中有些关系适合用继承,另外要实现多态也必须要继承。若是类之间的关系既可以用继承,又可以用组合,则优先使用组合。

继承相关题目

1、什么是菱形继承?菱形继承的问题是什么?

菱形继承是一种特殊的继承关系,是多继承的一种特殊情况,它指的是一个派生类同时继承了两个或更多个基类,而这些基类又直接或间接地继承自同一个基类。这样就形成了一个菱形的继承结构。

菱形继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。当派生类调用从不同基类继承而来的同名成员时,编译器会产生歧义,因为它无法确定应该使用哪个基类的成员。这种二义性称为菱形继承问题。另外,菱形继承还可能导致内存空间的浪费,因为派生类中会存在多个基类的数据成员的副本。

2、什么是菱形虚拟继承?如何解决数据冗余和二义性?

菱形虚拟继承是通过在继承关系中使用虚继承来解决菱形继承问题的一种方式。在菱形虚拟继承中,派生类使用虚继承来继承共同的基类,从而确保在派生类中只有一个共享的基类子对象。通过虚继承,派生类只保留一个基类子对象,这样可以避免数据冗余,节省内存空间。虚继承还可以解决二义性的问题:当派生类通过虚继承继承两个具有相同成员的基类时,编译器会将这些相同成员合并为一个,采用虚基表指针和虚基表的方式,通过偏移量去访问,从而消除了调用时的二义性。派生类可以直接访问这个合并后的成员,而不需要指定具体使用哪个基类。

3、继承和组合的区别?什么时候用继承?什么时候用组合?

继承是一种is-a的关系,而组合是一种has-a的关系。如果两个类之间是is-a的关系,使用继承;如果两个类之间是has-a的关系,则使用组合;如果两个类之间的关系既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合。

相关推荐
乐悠小码4 分钟前
数据结构------队列(Java语言描述)
java·开发语言·数据结构·链表·队列
史努比.6 分钟前
Pod控制器
java·开发语言
程序猿麦小七14 分钟前
基于springboot的景区网页设计与实现
java·spring boot·后端·旅游·景区
敲敲敲-敲代码14 分钟前
游戏设计:推箱子【easyx图形界面/c语言】
c语言·开发语言·游戏
蓝田~22 分钟前
SpringBoot-自定义注解,拦截器
java·spring boot·后端
theLuckyLong23 分钟前
SpringBoot后端解决跨域问题
spring boot·后端·python
ROC_bird..23 分钟前
STL - vector的使用和模拟实现
开发语言·c++
机器视觉知识推荐、就业指导23 分钟前
C++中的栈(Stack)和堆(Heap)
c++
.生产的驴24 分钟前
SpringCloud Gateway网关路由配置 接口统一 登录验证 权限校验 路由属性
java·spring boot·后端·spring·spring cloud·gateway·rabbitmq
小扳28 分钟前
Docker 篇-Docker 详细安装、了解和使用 Docker 核心功能(数据卷、自定义镜像 Dockerfile、网络)
运维·spring boot·后端·mysql·spring cloud·docker·容器