前言

继承是C++语言和其他面向对象语言的三大特性之一

1、继承的概念及定义

1.1 继承的概念

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

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "fl"; // 姓名
	int _age = 21;
};

class Student :public Person
{
protected:
	int _stuid;//学号
};

class Teacher :public Person
{
protected:
	int _jobid;//工号
};

int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();
}

继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。

在这里插入图片描述

1.2 继承的定义

继承的定义格式:
在这里插入图片描述
我们把Person类叫做基类(或者父类),把Student叫做派生类(或者子类)

继承关系和访问限定符:
在这里插入图片描述
继承基类成员访问方式的变化:

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

关于private继承:

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
private:
	string _name = "fl"; // 姓名
	int _age = 21;
};

class Student :public Person
{
protected:
	void Print()
	{
		cout << _age << _name << endl;//错误
	}
	int _stuid;//学号
};

int main()
{
	Student s;
	s._age = 10;//错误
	s._name = "fll";//错误
}

假设我们把上述代码中基类成员的访问限定修饰符改为private,然后在子类内或者在子类外去访问这些private成员,再编译代码就会出下一下结果
在这里插入图片描述
出现该现象的原因是基类成员的访问限定修饰符为private,无论子类是哪种方式继承,无论在子类内还是子类外,都无法访问。如果我们不需要访问基类中private成员,通过调试看监视窗口
在这里插入图片描述
通过监视发现基类中的private成员依旧被继承下来了,但是不能访问。只要基类的成员是private,无论哪种方式继承,子类都不能访问

关于private和protected:private和protected在父类中没有区别,在子类中,private成员不可见,protected成员可见

1.3 小总结

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
  3. . 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
  5. 在实际运用中一般使用都是public继承几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强

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

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "fl"; // 姓名
	int _age = 21;
};

class Student :public Person
{
protected:
	int _stuid;//学号

};

int main()
{
	Person p;//创建Person对象
	Student s;//创建Student对象
	p = s;//将派生类赋值给基类
	Person* ptr1 = &s;//将派生类的地址赋给基类指针
	Person& pp = s;//将派生类引用赋给基类引用
}

在这里插入图片描述
注意:将派生类引用赋给基类引用不是类型转换,是语法天然支持的行为
为什么这么说呢?

int main()
{
	int i = 1;
	double d = 2.2;
	i = d;
	int& ri = d;
	return 0;
}

在这里插入图片描述
这里会报错,因为类型转换,中间会产生临时变量,而临时变量具有常属性,需要用const修饰。将 int& ri = d 改为 const int& ri = d 就可以了。因为将派生类引用赋给基类引用时,不需要加const修饰,,所以说不是类型转换,而是天然的语法支持行为

注意:只有public才能切割,而private和protected不能被切割

  • 基类对象不能赋值给派生类对象

在这里插入图片描述
即使强制类型转换也不行

  • 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的
int main()
{
	Person p;
	Student s;
	Student* ptr2 = (Student*)&p;
}

如果不加强制类型转换,则会报错
在这里插入图片描述
这种情况转换时虽然可以,但是会存在越界访问的问题

3、继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域(一个类就是一个作用域)
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用基类::基类成员显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  4. 注意在实际中在继承体系里面最好不要定义同名的成员

了解继承中的作用域之前,我们先看下列代码

int a = 10;
int main()
{
	int a = 0;
	cout << a << endl;//打印0
	cout << ::a << endl;//打印10
	return 0;
}

这里在全局中定义了一个a变量,在main函数里面又定义一个a,为什么能定义两次,因为作用域不同。第一个打印为0,因为在main函数局部范围内,会现在局部范围内找,所以为0,但是如果局部范围没有a,就会去全局范围找,如果全局范围找不到,就会报错。第二个打印为10,因为在打印a时,指定了作用域为全局范围,所以为10。总的来说找变量或者函数遵循就近原则——>先局部,再全局,如果都没有,就报错。

class Person
{

protected:
	string _name = "老佛爷"; // 姓名
	int _age = 21;
};

class Student :public Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
	}
protected:
	string _name = "小李子";
	int _stuid;//学号

};

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

毫无疑问,这里打印的结果是"小李子",是子类中的_name。但是如果我们想要打印父类中的"老佛爷"怎么办?此时就需要在_name前面指定 作用域:cout << “name:” << Person::_name << endl;

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
	}

protected:
	string _name = "老佛爷"; // 姓名
	int _age = 21;
};

class Student :public Person
{
public:
	void Print(int i)
	{
		cout << "name:" << _name << endl;
	}
protected:
	string _name = "小李子";
	int _stuid;//学号

};

Person中的Print()和Student中的Print()构成隐藏
父类的成员函数和子类的成员函数只要名字相同就构成隐藏,函数参数对其不构成影响(可以相同,也可以不同),注意:这里不是重载,因为重载的一个必要条件是要在相同的作用域

int main()
{
	Student s;
	s.Print(1);//可以调用子类的Print
	s.Print();//不可以调用父类的Print
}

通过上述方法调用父类的Print是不可以的,因为父类的Print被隐藏了,如果需要调用就必须指明作用域:s.Person::Print();

我们把上述代码稍作修改:

class Person
{
public:
	void Print(int i = 1)
	{
		cout << "name:" << _name << endl;
	}

protected:
	string _name = "老佛爷"; // 姓名
	int _age = 21;
};

class Student :public Person
{
public:
	void Print(int i = 1)
	{
		cout << "name:" << _name << endl;
	}
protected:
	string _name = "小李子";
	int _stuid;//学号

};

int main()
{
	Person p;
	p.Print(1);//可以调用子类的Print
}

运行代码:
在这里插入图片描述
可能会有人会疑惑:这里的Print()不是构成隐藏吗?又没有指明作用域,为什么还能调到父类的Print()?
其实原因很简单:子类和父类的Print()确实构成隐藏,但是隐藏只是对子类才有的,对父类没有,这里p的类型是父类,父类对象调用成员函数是没有隐藏这个概念的。

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

4.1 派生类的默认成员函数做了哪些事?

派生类中重点的4个默认成员函数,我们不写,编译器默认生成的会干些什么事情呢?

class Person
{
public:
	Person(const char* name = "fl")
		:_name(name)
	{
		cout << "Person" << endl;
	}
	~Person()
	{
		cout << "~Person" << endl;
	}
protected:
	string _name = "老佛爷"; // 姓名
	int _age = 21;

};

class Student :public Person
{
protected:
	int _stuid;//学号
};

int main()
{
	Student stu;
}

![在这里插入图片描述](https://img-blog.csdnimg.cn/22b2aff5838843e4baa039bb998d01a4.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5L2g5aW977yM5Yav5ZCM5a2m,size_18,color_FFFFFF,t_70,g_se,x_16

由上述可以知道子类会自动调用父类的构造函数去初始化父类继承下来的成员自动调用父类的析构函数去释放``父类继承下来的成员所占的资源`

但对于子类自身的成员呢?
a.成员是内置类型
b.成员是自定义类型

在这里插入图片描述
通过调试我们发现:对于子类成员,如果是内置类型,则不做处理,如果是自定义类型,则会调用自定义类型的构造函数。跟普通类一样
不仅构造和析构是这样,拷贝构造和operator=也是一样的
总结:继承下来调用父类去处理,自己的按照普通类基本规则去处理

4.2 什么情况下需要我们自己写默认成员函数?

class Person
{
public:
	Person(const char* name)//这里不给默认构造函数
		:_name(name)
	{
		cout << "Person" << endl;
	}
	~Person()
	{
		cout << "~Person" << endl;
	}
protected:
	string _name = "老佛爷"; // 姓名
	int _age = 21;

};

class Student :public Person
{
protected:
	int _stuid;//学号
	string _s;
};

int main()
{
	Student stu;
}

编译这段代码,结果报错
在这里插入图片描述
关于这里没有合适的默认构造函数可用,是子类(Student)中没有默认构造函数可用吗?不是,是父类(Person)没有默认构造函数,因为在子类中没写默认构造函数,编译器会自动生成一个,然后去调用父类的默认构造函数,父类没有,所以就报错。对于其它的也类似
所以:1.父类没有默认构造,需要我们自己显示写
2.如果子类有资源需要释放,就需要自己显示写析构
3.如果子类存在浅拷贝问题,就需要我们自己实现拷贝构造和赋值从而达到深拷贝

4.3 写默认成员函数需要做哪些事?

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 = "fl", int stuid = 2022 )
		:Person(name)/*这里不能写为  _name(name)  只能调用父类的构造函数*/
		, _stuid(stuid) 
	{}

	//stu2(stu1)
	Student(const Student& stu1)
		:Person(stu1)//会进行切片处理,将stu1中父类的那部分赋值给stu2中父类的那部分
		, _stuid(stu1._stuid)
	{}

	//stu2 = stu1;
	Student& operator=(const Student& stu1)
	{
		if (this != &stu1)
		{
			Person::operator=(stu1);//这里一定要指定作用域,否则类似自己调用自己,会导致Stack Overflow
			_stuid = stu1._stuid;
		}
		return *this;
	}

	~Student()
	{
		Person::~Person();
	}

protected:
	int _stuid;//学号
};

int main()
{
	Student stu1;
	Student stu2(stu1);
	Student stu3("ffff", 18);
	stu3 = stu2;
}

在这里插入图片描述

注意:
1.不能在子类构造函数的初始化列表中直接给父类继承的成员赋值,需要去显示调用父类的构造函数
2.析构函数名字会被统一处理成destructor(),那么子类的析构函数跟父类的析构函数就会构成隐藏,所以在调用时需要指明作用域
3.我们明明只有三个Student的对象,却调用了六次析构函数,相当于一个对象调用了两次析构函数。原因是子类的析构函数会自动调用父类的析构函数,然后又显示的调用了一次析构函数,所以加起来是两次

对于这里的第3点:

class Person
{
public:

	~Person()
	{
		delete[] _ptr;
	}
protected:
	string _name = "fff";
	int* _ptr = new int[10];
};

class Student:public Person
{
public:
	~Student()
	{
		Person::~Person();
	}
};

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

在这里插入图片描述
这里崩溃的原因就是对同一块空间进行了两次析构
所以子类的析构函数只需要析构自己的资源,对于继承下来的,默认的会去调用父类的析构,我们就不需要显示的去调用
重点:对于一个对象,先去调用父类的构造函数,将其父类继承下来的成员变量初始化,才会初始化自身的成员变量。对于析构来说刚刚相反,先去释放自己的资源,再去调用父类的析构函数,因为栈里面的变量复合后进先出

在这里插入图片描述

4.4 小总结

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类的析构

5、继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。也就相当于你爸爸的朋友不是你的朋友这个道理。

class Student;
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;//这里会报错
}
void main()
{
	Person p;
	Student s;
	Display(p, s);
}

Display函数中的第二个cout会报错,因为基类的友元访问了子类的protected
在这里插入图片描述

6、 继承与静态成员

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

class Person
{
public:
	Person(){ count++; }
	static int count;
};

int Person::count = 0;

class Student:public Person
{};

int main()
{
	Person p;
	Student stu;
	cout << "Person:" << p.count << endl;//打印2
	cout <<"Student:"<< stu.count << endl;//打印2
}

在这里插入图片描述
Student继承了Person,count也被继承了,但是因为count是静态成员变量,所以只会有一个count,因此Person创建对象p,count++,Student创建对象sty,count++,所以结果都为2

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

7.1 继承的种类

单继承:一个子类只有一个直接父类时称这个继承关系为单继承
在这里插入图片描述
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

在这里插入图片描述
菱形继承:菱形继承是多继承的一种特殊情况
在这里插入图片描述

7.2 菱形继承的二义性

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; // 主修课程
};
void Test()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a;
	a._name = "peter";

	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
}

在这里插入图片描述
Student和Teacher继承了Person,各有一份_name,Assistant 又继承了Student和Person,就有两份_name,如果在Assistant中访问_name,就会发生访问出现二义性的问题。这就相当于一位老师在教室说:请你起来回答问题。这个“你”到底是班上的哪一位同学,我们不得而知,必须指明姓名才行。对于菱形继承的二义性,我们只需要指定作用域即可。

7.3 菱形继承所造成的数据冗余

class test1
{
public:
	int a[10000];
};
class test2:public test1
{};
class test3 :public test1
{};
class test4 :public test2,public test3
{};

int main()
{

	test4 t;
	cout << sizeof(test4) << endl;
	return 0;
}

在这里插入图片描述

本来test4只想继承一份a[10000],但是由于菱形继承,结构导致了test4继承了两份,如果数据量小,所占空间小,还可以忽略不计,但是这里40000字节(4*10000),极大了造成了数据冗余和空间上的浪费 。

7.4 虚拟继承解决菱形继承所造成的数据冗余和二义性

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份
在这里插入图片描述

class Person
{
public:
	string _name; // 姓名
	int arr[10000];
};
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 = "fffl";
	cout << A._name << endl;
	cout << sizeof(A) << endl;
	return 0;
}

为了解决数据冗余和二义性,C++采用虚拟继承来解决它们。使用关键字virtual就可变为虚拟继承,只需要在菱形继承的中间层,也就是Student和Teacher,在它们继承方式前面加上virtual即可。
在这里插入图片描述
加上virtual之后,访问_name时就不需要指定作用域,Assistant创建的对象A所占空间的大小也减少了一半
注意:虚拟继承不要在其他地方去使用

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

为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型
代码如下(示例):

先看菱形继承

class A {
public:
	int _a;
};

class B : public A {
public:
	int _b;
};

class C : public A {
public:
	int _c;
};

//class D : public C, public B
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创建的对象d中,确实有两份_a,因为B和C都继承了A。此外还有就是在子类创建的对象中,先在内存的低地址为其先继承的类开辟空间,再在高地址为后继承的类开辟空间,最后再为自己开辟空间。先继承的在前面,后继承的在后面

再看虚拟继承

class A {
public:
	int _a;
};

class B : virtual public A {
public:
	int _b;
};

class C : virtual public A {
public:
	int _c;
};

class D : public B, public C{
public:
	int _d;
};

int main()
{
	D d;
	d._a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

在这里插入图片描述
通过内存窗口我们发现_a放在了最后面,对于_b(30 00 00 00)和_c(04 00 00 00 )之前多了我们不知道的一串数字,那它们到底是什么呢?难道说是是一个地址?
我们再次通过内存去观察它们:
在这里插入图片描述
通过验证我们确定这两个数字就是两个地址,通过这两个地址,我们找到了其中的内容,一个里面存放了14,另一个里面存放了0c,它们代表什么意思呢?
再次通过观察我们发现地址0x006FF7C0到0x006FF7D8相差了20个字节,转为16进制则为14。地址0x006FF7CC到0x006FF7D8相差了12个字节,转为16进制则为C。原来这里的14和C都是相对于_a的位置。现在我们就大致了解了这其中的细节。
这里我们将0x006FF7C0和0x006FF7CC成为虚基表指针,它们指向两张虚基表。

A一般叫虚基类。这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A

此时又有人会疑问,直接访问_a不好吗,为什么还要通过偏移量去找呢?
请看以下场景:

D d;
B b = d;
C c = d;

当我们需要将派生类对象赋值给基类对象的时候,此时就会发生切片处理。假设_a放在最前面,或者放在中间,又或者放在最后,怎么才能准确的找到_a呢?这就需要通过偏移量了

下面是上面的Person关系菱形虚拟继承的原理解释:
在这里插入图片描述

8、继承的总结和反思

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题
  2. 多继承可以认为是C++的缺陷之一,很多后来的语言都没有多继承,如Java
  3. 继承和组合
    3.1public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象
    3.2组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象
    3.3优先使用对象组合,而不是类继承
    3.4继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高
    3.5对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装
    3.6实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐