【C++取经之路】继承

目录

继承的概念及定义

单继承的格式

继承方式和访问限定符

继承后子类访问基类成员的权限

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

切片

继承中的作用域

引申:重载和隐藏的区别

派生类的默认成员函数

继承与友元

继承与静态成员

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

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

虚拟继承

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

多继承中指针偏移问题

继承的总结和反思


继承的概念及定义

在C++中,继承是一种面向对象编程的重要特性,它允许一个类(称为派生类或子类)继承另一个类(称为基类或父类)的成员变量(通常称为属性)和成员函数(通常称为方法)。通过这种方式,派生类可以重用基类的代码,并且可以添加或覆盖基类中的方法。

根据继承的基类的数量,C++中的继承可以分为单继承和多继承。

单继承:指一个派生类只从一个基类派生的情况。

多继承:指一个派生类从多个基类派生的情况。

单继承:

多继承:

单继承的格式

class 子类名 :继承方式 父类 { };

这里借助一段简单的代码来帮助理解。

cpp 复制代码
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "Peter";
	int _age = 18;
};

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

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

先解释一些基本的概念,后面再通过调试感受继承。

继承方式和访问限定符

继承后子类访问基类成员的权限

1)如果子类通过public继承(公有继承)自父类,那么基类(父类)的public成员在子类中为public成员,在子类的外部可以直接访问。

2)如果子类通过public继承(公有继承)自父类,那么基类(父类)的protected成员在子类中为protected成员,在子类的外部不可以直接访问。

这里列举了这两个例子,剩下的可以从上表中看出。

总结:

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

2)基类的private成员在派生类中是不能被直接访问的。如果基类成员不想在派生类外直接被访问,但是需要在派生类中能直接被访问,那么可 以在基类中定义为protected。可见,保护成员限定符就是为继承而生的。

3)通过上述表格,可以发现,基类的私有成员在子类中都是不可见的。基类的其它成员在子类的访问方式 = min(该成员在基类的访问限定符, 继承方式),其中,public > protected > private。

4)如果不显式写继承方式,那么使用class时,默认为私有继承,使用struct时,默认为公有继承。但最好显式的写出继承方式。

5)实际应用中一般使用的都是public继承。因为protected/private继承下来的成员都只能在派生类里使用,实际中扩展维护性不强。

好了,基本的概念已经解释完毕,下面通过调试看看继承。

可以看到,通过派生类创建的对象s中,不仅有派生类自己的成员_stuid,还有继承自基类的成员_name和_age。至于如何去修改 _name和_age的值,后面再说吧~

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

只是文字描述很抽象,还是上代码吧~

cpp 复制代码
class Person
{
protected:
	string _name; //姓名
	string _sex;  //性别
	int _age;     //年龄
};

class Student : public Person
{
public:
	int _No;	 //专业课排名
};

void test()
{
	Student sobj;

	//1.子类对象可以赋值给父类的对象/指针/引用
	Person pobj = sobj;
	Person* pp = &sobj;
	Person& rp = sobj;

	//2.父类对象不能赋值给子类对象
	sobj = pobj;

	//3.父类的指针可以通过强制类型转换赋给派生类指针
	Student* ps1 = (Student*)pp;

	pp = &pobj;
	Student* ps2 = (Student*)pp;//这种情况也可以,但是存在越界访问的问题
}

int main()
{
	test();
	return 0;
}

将父类对象赋值给子类对象,编译器报错如下:

总结:

● 派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法,叫切片,下面会解释~

● 基类对象不能赋值给派生类对象。

● 基类的指针可以通过强制类型转换赋值给派生类的指针,但不一定安全。

切片

先来看一张图,就知道为什么叫做切片了~

把子类赋值给父类,就相当于把红色部分切过去赋给父类。所以说切片这个说法很形象~

上面说到,基类对象不可以赋值给派生类对象,通过上图,我们可以这么理解:基类有的派生类都有,因而派生类可以赋值给基类,但是,派生类有的基类未必有,所以基类不可以赋值给派生类。

继承中的作用域

1)在继承体系中,基类和派生类都有独立的作用域。

2)如果基类和派生类中有同名成员,那么派生类成员将屏蔽父类的同名成员,这种情况叫隐藏,也叫重定义。

3)基类和派生类中,对于成员函数,只要函数名相同,就构成隐藏。

4)在继承体系里,最好不要定义同名成员。

关于隐藏,这里还想再更详细的说一遍。

隐藏 :通常指在派生类中定义了与基类中某个成员同名的成员,从而导致基类中的那个成员在派生类的作用域内被隐藏,这并不意味着基类中的成员被删除或不可访问,而是说,在派生类的作用域内,直接访问将访问到的是派生类中的成员,除非使用基类 :: 基类成员 显式访问。

下面运行一段代码验证结论:

cpp 复制代码
class Person
{
protected:
	string _name = "小李子";
	int _num = 111;
};

class Student : public Person
{
public:
	void Print()
	{
		cout << "_name:" << _name << endl;
		cout << "直接访问_num:" << _num << endl;
		cout << "指定访问_num:" << Person::_num << endl;
	}
protected:
	int _num = 999;
};

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

引申:重载和隐藏的区别

派生类的默认成员函数

● 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数(不传参也能调),则必须在派生类构 造函数的初始化列表显式调用。

文字描述太抽象了,还是上代码吧~

基类中没有默认构造函数

cpp 复制代码
class Person
{
public:
	Person(const int& num) :_num(num) {}
protected:
	int _num;
};

class Student : public Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
		cout << "num" << _num << endl;
	}
protected:
	string _name = "李华";
	int _age = 18;
};

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

正确操作

cpp 复制代码
class Person
{
public:
	Person(const int& num) :_num(num) {}
protected:
	int _num;
};

class Student : public Person
{
public:
	//基类没有默认构造函数,需要在派生类的初始化列表显式调用基类的构造函数
	Student(): Person(1){}
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
		cout << "num:" << _num << endl;
	}
protected:
	string _name = "李华";
	int _age = 18;
};

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

上面没讲如何修改继承来的属性,其实这种做法就可以修改继承来的属性了~

● 派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类部分的拷贝。

● 派生类的operator=(赋值重载)必须调用基类的赋值重载来完成基类部分的赋值。

● 派生类的析构函数会在调用完成后自动调用基类的析构函数清理基类的资源。

● 派生类对象初始化先调用基类构造再调派生类构造。

● 派生类对象析构清理先调用派生类的析构再调用基类的析构。

以下是我在学习继承过程中做的一些笔记,大概是关于要不要写派生类默认构造的问题,希望对你有用~

1)如果基类有默认构造函数,那么派生类可以选择不提供构造函数,此时,在派生类中,编译器会自动生成一个默认构造,该构造函数会调用 基类的默认构造函数

2)如果需要初始化派生类的特有成员变量,那么应该在派生类中提供构造函数

3)派生类自动生成的拷贝构造函数调用基类的拷贝构造函数来完成基类部分的拷贝,对派生类特有的成员变量执行浅拷贝。

下面这张图是派生类对象和基类对象的构造函数、析构函数执行顺序:

上面已经演示了派生类的构造函数调用基类构造函数初始化基类部分的代码,下面将演示如何在派生类中调用基类的拷贝构造函数处理基类部分的拷贝。

cpp 复制代码
class Person
{
public:
	Person():_name(""), _age(0) {}
	Person(const Person& p) :_name(p._name), _age(p._age){}
protected:
	string _name;
	int _age;
};

class Student : public Person
{
public:
	Student():Person(),_id("") {}
	Student(const Student& s) : Person(s), _id(s._id) {}
protected:
	string _id;// 学号
};

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

直接将s传过去给Person的拷贝构造函数,因为父类的拷贝构造函数会通过切片来拿到父类的那部分。 赋值重载函数等也是同理。

继承与友元

友元关系不能继承,也就是说基类友元不能访问派生类的私有和保护成员。请看代码~

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

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

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

void DisPlay(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._No << endl;   //尝试访问派生类的保护成员
}

int main()
{
	Person p;
	Student s;
	DisPlay(p, s);
	return 0;
}

可以看到,Person类对象中的保护成员_name在友元声明后,是可以在类外访问的,但是继承自Person类的Student类对象中的保护成员_No并不能访问到。说明:基类友元不能访问派生类的私有和保护成员。

继承与静态成员

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

请看代码:

cpp 复制代码
class Person
{
public:
	Person() { ++_count; }
protected:
	string _name;
public:
	static int _count; //统计人数
};

int Person::_count = 0; //初始化

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

class Graduate : public Student
{
protected:
	string _seminarCourse; // 研究科目
};

void Test()
{
	Student s1;
	Student s2;
	Student s3;

	Graduate s4;

	cout << "人数:" << Person::_count << endl;
	Student::_count = 0;
	cout << "人数:" << Person::_count << endl;
}

int main()
{
	Test();
	return 0;
}

这说明了整个继承体系里只有一个_count,Student中的_count一改,Person中的_count也跟着改,因为它们就是同一个。

也可以通过监视窗口看看_count的地址。

_count从0变到3的过程中,_count的地址始终不变,说明只有一个_count。

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

可以通过将构造函数和析构函数声明为protected和private来阻止其他类继承该类,但是其他类仍然可以通过友元关系来继承它。用final关键字修饰类,可以彻底防止被继承,格式如下。

class A final

{

public:

int _a;

};

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

上面讲的全是单继承,从这部分开始,将讲到多继承中的一种特殊情况------菱形继承。

这张图描述的就是菱形继承。 直接上代码~

cpp 复制代码
class Person
{
public:
	string _name;
};

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

class Teacher : public Person
{
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};

void Test()
{
	Assistant a;
}

int main()
{
	Test();
	return 0;
}

监视窗口:

可以看到,一个a对象中,有两份_name,这就是菱形继承带来的问题------数据冗余和二义性

请看下面代码的运行结果:

cpp 复制代码
class Person
{
public:
	string _name;
};

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

class Teacher : public Person
{
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};

void Test()
{
	Assistant a;
	a._name = "Peter";
}

int main()
{
	Test();
	return 0;
}

二义性,就是不知道要访问哪一个,例如此处的_name,到底是访问继承自Student中的_name还是访问继承自Teacher中的_name不明确。

解决二义性的一个方法是:指定访问哪个父类的成员。

a.Student::_name = "Peter"; / /指定访问

虽然这种方式可以解决二义性问题,但是数据冗余问题并没有得到解决。

那么有没有可以根治菱形继承数据冗余和二义性的方法呢?有的,虚拟继承就是为了解决这一问题而生的。

虚拟继承

虚拟继承的格式:

class 子类名 :virtual 继承方式 父类

上代码验证一下虚拟继承是否可以解决数据冗余和二义性的问题~

cpp 复制代码
class Person
{
public:
	string _name = "李华";
};

class Student : virtual public Person
{
protected:
	int _No;
};

class Teacher : virtual public Person
{
protected:
	int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; //主修课程
};

void Test()
{
	Assistant a;
	a._name = "张三";
}

int main()
{
	Test();
	return 0;
}

监视窗口:

执行第76行后:

可以看到,执行完第36行后,_name全被改为了"张三",说明虽然监视窗口上显示3个_name,但是它们的地址是一样的,也就是说,对象a中只有一个_name。这样就解决了菱形继承带来的数据冗余和二义性问题。这里我没说明白, 如果还有疑问,请看原理部分~

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

为了了解虚拟继承的原理,这里给出一个简化的菱形继承体系,再借助内存窗口观察对象成员模型。

这里通过内存窗口再次看看数据冗余和二义性的问题~

cpp 复制代码
class A
{
public:
	int _a;
};

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

class C : 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;
}

内存窗口:

这一个整体为一个D的对象,该对象继承自B和C。通过这张图,可以看到该对象中既有一份来自B的_a,又有一份来自C的_a(观察数值就可以发现),这很好的展现了菱形继承的问题。

下面,将上述代码改为虚拟继承,再通过内存窗口看看是否还存在数据冗余和二义性的问题。

可以看到,_a只有一份了,并且放在了最后,所以数据冗余和二义性的问题不存在了。C中的_a一改B中的也跟着改了,说明B和C共用同一个_a,问题是,B和C是如何找到公共的_a呢?还有个疑问,上图未框起来的部分究竟是何物?这两部其实是都是地址,下面会通过内存窗口看看它们里面的存的内容是什么。

注意这里的计算:

0x0137F970 + 20(十进制) =0x0137F984(十六进制的计算)

0x0137F978 + 12(十进制) = 0x0137F984

对比加粗的结果和上图中用粉红色框起来的地址,发现计算结果和粉红色框起来的地址是一样的。这并不偶然,其实这就是虚拟继承解决数据冗余和二义性的原理了。上面只是一个引子,接下来总结原理~

接上面的问题------B和C是如何找到公共的_a的,其实是通过虚基表指针(vptr) ,也就是上图红色框起来的两个地址,虚基表指针指向同一张表,叫虚基表(vtable),虚基表里存的是偏移量,通过偏移量就可以找到_a。这里只是针对上面的测试代码进行说明,下面换种说法,尽量不针对某种情形,而是适用于广泛的场景~

什么是虚基表?

在讲适应性更广的说法之前,先了解一下虚基表,因为会用到它。

为了实现虚拟继承,C++引入了虚基表(vtable)的概念。虚基表用于记录虚基类(即被虚拟继承的基类)在派生类中的偏移量。

当一个类被声明为虚拟继承时(例如测试代码中的B和C),编译器会为该类生成一个虚基表指针(测试代码中B和C各有一个),当访问虚基类的成员(例如测试代码中的A)时,编译器会先通过vptr找到虚基表,然后根据虚基表中的偏移量定位虚基类在派生类中的实际位置,从而正确的访问虚基类成员。

原理部分的最后,根据测试代码画出一张图,来帮助理解虚拟继承的原理。

多继承中指针偏移问题

当一个基类指针指向一个派生类对象时,这个指针本身只包含该基类部分的地址。这句话很抽象,请看代码~

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;
}

下面来分析p1、p2、p3的指向。

p1和p3指向Base1的起始位置,但是范围却不相同,p1只包函基类Base1(红色部分),p3包函整个部分。p2指向Base2的起始位置,只包含基类Base2(橙色部分)。

继承的总结和反思

继承,它是一种"is-a"的关系,也就是说派生类是一个特殊的基类。优先使用组合而不是继承,还有,尽量不要写多继承,尤其是菱形继承,会坑自己~


完~

相关推荐
Eiceblue4 分钟前
使用Python获取PDF文本和图片的精确位置
开发语言·python·pdf
xianwu54312 分钟前
反向代理模块。开发
linux·开发语言·网络·c++·git
xiaocaibao77718 分钟前
Java语言的网络编程
开发语言·后端·golang
Bucai_不才34 分钟前
【C++】初识C++之C语言加入光荣的进化(上)
c语言·c++·面向对象
木向36 分钟前
leetcode22:括号问题
开发语言·c++·leetcode
comli_cn38 分钟前
使用清华源安装python包
开发语言·python
筑基.44 分钟前
basic_ios及其衍生库(附 GCC libstdc++源代码)
开发语言·c++
yuyanjingtao1 小时前
CCF-GESP 等级考试 2023年12月认证C++三级真题解析
c++·青少年编程·gesp·csp-j/s·编程等级考试
雨颜纸伞(hzs)1 小时前
C语言介绍
c语言·开发语言·软件工程
J总裁的小芒果1 小时前
THREE.js 入门(六) 纹理、uv坐标
开发语言·javascript·uv