C++知识——继承

本文详细介绍了C++中的继承机制,包括继承的三种方式(公有、保护、私有),基类和派生类对象的赋值转换,继承中的作用域、访问声明,派生类的默认成员函数,以及静态成员和菱形继承与虚拟继承的概念。通过对实例的分析,解释了继承如何影响成员的访问权限和对象的内存布局。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

继承概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
继承有三种方式:公有继承(public)、保护继承(protected)、私有继承(private)。
采用不同的继承方式,派生类对基类的成员有不同的访问限定。

总结:

1.  基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。

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

1. 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。
2. 基类对象不能赋值给派生类对象/指针/引用。
3. 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。

#include<iostream>
using namespace std;
class A
{
public:
	A(int _a1, int _a2) :a1(_a1), a2(_a2)
	{
	}
	void print_A()
	{
		cout << a1 << " " << a2 << endl;
	}
protected:
	int a1;
	int a2;
};
class B :public A
{
public:
	B(int _a1,int _a2,int _b) :A(_a1,_a2),b(_b)
	{}
	void print_B()
	{
		cout << a1 << " " << a2 << " " << b << endl;
	}
private:
	int b;
};

int main()
{
	A obj_a(1, 2);       
	B obj_b(4, 5, 6);
	obj_a.print_A();
	obj_b.print_B();

	obj_a = obj_b;             //派生类对象给基类的对象赋值
	A *pobj_a = &obj_b;        //派生类对象给基类的指针赋值
	A &qobj_a = obj_b;         //派生类对象给基类的引用赋值

	obj_a.print_A();
	pobj_a->print_A();
	qobj_a.print_A();

    //将基类指针强转成派生类指针,并赋值给派生类指针。
	B *pobj_b = (B*)pobj_a;     //情况一:基类指针指向的是派生类对象
	pobj_b->print_B();

	pobj_a = &obj_a;            
	pobj_b = (B*)pobj_a;        //情况二:基类指针指向的是基类对象 该情况会存在访问越界问题。
	pobj_b->print_B();

	return 0;
}

程序运行结果如下:

我们来分析一下上述过程。
将派生类对象赋值给基类的对象、指针、引用,就是将派生类对象切片过去,我们可以通过下面这幅图来形象的理解。

基类的指针也可以赋值给派生类的指针,怎么理解呢? 其实我们也可以用上面的方法理解。
首先第一种情况,基类的指针指向派生类的对象。

第二种情况:基类指针指向基类对象。

我们可以看到,基类指针所指的对象中只有 a1 和 a2,但是派生类指针指向的对象中会有成员 b,所以就将基类指针后面的地址空间的内容赋值给 b 所以我们从上面中的结果可以看到,b是一个随机值。

继承中的作用域(同名函数)

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

注意区别隐藏(重定义)和重载,重载是针对同一作用域下的同名函数,而且需要参数列表不同才构成重载。隐藏是在基类和派生类中体现的,不在同一作用域中,而且同名函数和变量都可以是隐藏。

class A
{
public:
	void print()
	{
		cout << a << endl;
	}
protected:
	int a = 10;
};
class B :public A
{
public:
	void print()
	{
		cout << a << endl;
		cout << A::a << endl;
	}
protected:
	int a = 20;
};
int main()
{
	B obj_b;
	obj_b.print();
	return 0;
}

程序运行结果为
20
10

访问声明

我们已经知道,对于公有继承,基类的公有成员也是派生类的公有成员。这说明外界可以通过派生类对象来调用基类的公有成员。但是对于私有和保护继承。基类的公有成员在派生类中变成了私有和保护成员。这时外界无法通过派生类对象来调用基类的公有成员,而只能通过调用派生类的成员函数间接的调用基类成员函数。

C++提供了访问声明的特殊机制,可以调整基类的某些成员,使其在派生类中的访问属性不变。
访问声明就是把基类的保护成员或公有成员直接写到派生类中,给成员名前面加上基类名并加上作用域标识符:: 这样,该成员就成为派生类的保护成员或公有成员了。


#include<iostream>
using namespace std;
class A
{
public:
	void print()
	{
		cout << "基类成员函数" << endl;
	}
	int x = 10;
};
class B :private A
{
public:
	A::print;   //访问声明
	A::x;       //访问声明
};
int main()
{
	B obj;
	obj.print();
	cout << obj.x << endl;
	return 0;
}

我们可以看到,类B 私有继承类A,原本基类中的公有成员在派生类中访问属性变为私有,不能在外界通过派生类对象访问,但是通过访问声明,我们看到基类中的公有成员在私有继承的派生类中保持了原来的访问属性,可以通过派生类对象访问。
程序运行结果为:

1. 访问声明只包含不带类型和参数的函数名或变量名。
2. 访问声明不能改变成员在基类中的访问属性,也就是说,访问声明只能把原基类的保护成员声明为派生类的保护成员,把原基类的公有成员声明为派生类的公有成员。基类的私有成员不能使用访问声明。
3. 对于基类中的重载函数,访问声明将对基类的中所有同名函数起作用,所以对重载函数使用访问声明要慎重。

派生类的默认成员函数

1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构
7. 如果有对象成员的派生类,派生类对象初始化时,先调用基类的构造函数,再调用对象成员的构造函数,最后调用派生类的构造函数。 析构函数的执行顺序相反。
8. 当基类构造函数不带参数时,派生类可以不定义构造函数。当基类构造函数有参数时,派生类必须定义构造函数,并且将基类的构造函数写在初始化列表中。若派生类中有对象成员的话,还需要将对象成员名和参数列表写在派生类构造函数的初始化列表中。

#include<iostream>
using namespace std;

class A
{
public:
	A(int a1)
	{
		a = a1;
		cout << "基类构造函数" << endl;
	}
	A(const A& obj)
	{
		a = obj.a;
		cout << "基类拷贝构造" << endl;
	}
	A& operator=(const A& obj)
	{
		if (this != &obj)
		{
			a = obj.a;
		}
		cout << "基类赋值运算符重载" << endl;
		return *this;
	}
	void print1()
	{
		cout << a << endl;
	}
	~A()
	{
		cout << "基类析构函数" << endl;
	}
private:
	int a;
};
class B: public A
{
public:
	B(int a1, int b1) :A(a1), _a(a1)
	{
		b = b1;
		cout << "派生类构造函数" << endl;
	}
	B(const B& obj) :A(obj), _a(obj), b(obj.b)
	{
		cout << "派生类拷贝构造" << endl;
	}
	B& operator=(const B& obj)
	{
		if (this != &obj)
		{
			this->A::operator=(obj);
			_a = obj._a;
			b = obj.b;
		}
		cout << "派生类赋值运算符重载" << endl;
		return *this;
	}
	void print2()
	{
		A::print1();
		_a.print1();
		cout << b << endl;
	}
	~B()
	{
		cout << "派生类析构函数" << endl;
	}
private:
	int b;
	A _a;
};
int main()
{
	A aa1(10);           //基类构造函数
	B bb1(20, 30);       //基类构造函数,对象成员构造函数,派生类构造函数

	B bb2 = bb1;         //基类拷贝构造,对象成员拷贝构造,派生类拷贝构造
	bb1 = bb2;           //基类赋值运算符重载,对象成员赋值运算符重载,派生类赋值运算符重载

	aa1.print1();
	bb1.print2();
	bb2.print2();

	return 0;            //bb2析构 bb1析构 aa1析构
}

程序运行结果:

 

继承中的静态成员

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

class A
{
public:
	A()
	{
	}
	void show1()
	{
		x++;
		y++;
		cout << x << endl;
		cout << y << endl;
	}
protected:
	static int x;
	int y = 10;
};

class B :public A
{
public:
	B()
	{
	}
	void show2()
	{
		x++;
		y++;
		cout << x << endl;
		cout << y << endl;
	}
};
int A::x = 10;
int main()
{
	A a;
	a.show1();
	B b;
	b.show2();
	return 0;
}

我们看上述代码,基类中定义了静态成员变量x和普通变量y。
在主函数中调用show1(),我们都清楚x和y的值都加一,变为11。
在调用show2()时,x 和 y同样加一,但是这个 x还是之前的x 也就是说是在 11的基础上加1,变为12,对于y,派生类会生成一个新的变量y,所以派生类中的y为10+1=11。

菱形继承和菱形虚拟继承

假设现在有一个类A,类B是它的派生类,类C也是它的派生类,类D是类B和类C的派生类。我们都知道,派生类会继承基类中的成员。那么,在派生类D中,会有两份类A的成员,这就出现了数据冗余的问题,而且还会产生二义性。

class A
{
public:
	int a = 100;
};
class B :public A
{
public:
	int b = 200;
};
class C :public A
{
public:
	int c = 300;
};
class D :public B, public C
{
public:
	int d = 400;
};
int main()
{
	D obj;
	//obj.a=1;  这样访问成员a会产生二义性,编译器不知道是通过类B还是类C继承来的a
    //可以通过加上类名和作用域运算符来访问
	obj.B::a = 2;   
	obj.C::a = 3;

	return 0;
}

我们可以通过监视窗口来查看类D的实例对象内有哪些成员。

我们可以看出来,该实例对象中确实存在两个成员变量a,分别是从类B和类C中继承来的,而且我们可以通过类名加作用域运算符来访问它(可以看到a分别被改变为2和3)。我们通过一张图来理解菱形继承中类D实例对象中的成员。

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在类B和类C继承类A时,可以用虚拟继承,这样就会解决上面的问题。
使用虚拟继承时,派生类的实例对象会有一个虚基表指针(vbptr),这个指针指向一个虚基表。而且,使用虚拟继承之后从类A中继承来的成员会在类D的实例对象组成的最下面。我们通过一张图来理解上述继承关系中,使用虚拟继承后,虚基表指针和成员在对象中的组成。

虚基表指针指向虚基表,虚基表中第一项为0,第二项为该虚表指针在实例对象中与类A中成员中的偏移量。
以上面为例:B的虚基表指针指向的虚基表中的第一项为0,第二项为4+4+4+4+4=20。
接下来我们证明一下上面的论述。

class A
{
public:
	int a = 100;
};
class B :virtual public A
{
public:
	int b = 200;
};
class C :virtual public A
{
public:
	int c = 300;
};
class D :public B, public C
{
public:
	int d = 400;
};
int main()
{
	A obj_a;
	B obj_b;
	C obj_c;
	D obj_d;
	cout << sizeof(obj_a) << endl;
	cout << sizeof(obj_b) << endl;
	cout << sizeof(obj_c) << endl;
	cout << sizeof(obj_d) << endl;

	cout <<"B的虚基表指针地址:"<< &obj_d << endl;
	cout << "成员b的地址:" << &obj_d.b << endl;
	cout << "C的虚基表指针地址:" << (int*)&obj_d +2<< endl;
	cout << "成员c的地址:" << &obj_d.c << endl;
	cout << "成员d的地址:" << &obj_d.d << endl;
	cout << "成员a的地址:" << &obj_d.a << endl;

	cout << "B的虚基表的第一项:" << *(int*)(*(int*)(&obj_d)) << endl;
	cout << "B的虚基表的第二项:" << *((int*)(*(int*)(&obj_d)) + 1) << endl;
	cout << "b的值为:" << *((int*)(&obj_d) + 1) << endl;

	cout <<"C的虚基表的第一项:"<< *(int*)(*((int*)(&obj_d) + 2)) << endl;
	cout <<"C的虚基表的第二项:"<< *((int*)(*((int*)(&obj_d) + 2)) + 1) << endl;
	cout << "c的值为:" << *((int*)(&obj_d) + 3) << endl;

	cout << "d的值为:" << *((int*)(&obj_d) + 4) << endl;
	cout << "a的值为:" << *((int*)(&obj_d) + 5) << endl;


	return 0;
}

程序运行结果为:


第四行中我们打印了类D的对象看到大小为24,证明了不仅存在a,b,c,d四个变量,还有两个虚基表指针vbptr。
通过打印虚基表指针和成员的地址我们可以证明他们在对象组成中的顺序以及位置(和上图一致)。
而且通过打印虚基表中的内容可以看到,表项中存放的就是虚基表指针与类A成员在对象组成中的偏移量。

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值