c++类在继承中的一些细节

        继承是c++面向对象程序设计使代码可以复用的最重要的手段,它允许用户在保持原有类特性的基础上进行扩展,增加功能这样产生新的类,称为派生类。

1、继承的定义

        继承的格式如下。很显然,之前创建的类为基类,根据基类派生的类称为派生类,那么继承方式是什么?继承方式决定了基类成员在派生类中的访问方式。

        继承中基类成员在派生类中的访问方式具体如下。

        注意,使用class关键字时默认继承方式是private,而使用struct关键字时默认继承方式是public,一般情况下还是显式的加继承方式好一点。

2、子类和父类之间的赋值兼容规则

        子类对象可以赋值给父类对象/引用,子类的地址可以赋值给父类指针。如以下代码的写法是合法的,我们默认student是person的一个派生类。

person p;
student s;
p = s;
person* ptr = &s;
person& ref = s;

当我们用一个子类对象对父类对象或者引用赋值时,只会赋值子类对象从父类对象中继承下来的成员变量的部分,同理,用一个子类对象的地址对一个父类对象指针进行赋值时,这个父类指针解引用获取的空间大小也是只有父类的大小。

        反过来,不能用父类的对象对子类对象赋值。但是用父类对象指针有可能可以对子类对象指针进行赋值,这个父类指针如果指向一个子类对象就可以,记得赋值时进行强制类型转换。

3、继承中的作用域(重定义)

        在继承体系中父类和子类都有独立的作用域,子类和父类中有同名成员,子类将屏蔽对父类成员的直接访问,这一现象叫重定义,也称隐藏。如果时成员函数的隐藏,只要函数名相同就构成隐藏。如以下的父子类,同名的func函数构成重定义。

class person
{
public:
	void func()
	{
		cout << gender << endl;
	}
protected:
	int gender = 0;
};

class student : public person
{
public:
	void func(int a)
	{
		cout << std_id << endl;
	}
protected:
	int std_id;
};
int main()
{
	student st1;
	st1.person::func();
	return 0;
}

        有小伙伴可能疑惑为什么这两个同名func函数,参数不同,为什么不构成重载?注意构成重载的条件是在同一作用域下的同名函数,参数不同。而person类和student类都具有独立的作用域,因此不构成重载。

        那么怎么访问父类中的同名成员变量或者调用父类中的同名成员函数?在成员名前加类名,确定访问类域即可,如上。

4、派生类的构造、析构、拷贝构造、operator=函数

        (1)构造函数,派生类中包含了父类的成员变量,在构造时,父类的部分的构造和子类新定义的成员变量是分开构造的。如果用户不自己设置子类的构造函数,或者在子类的构造函数中不显式的调用父类的构造函数,那么会自动调用父类的默认构造函数(即不需要传参的构造函数),如果没有可用的默认构造函数,编译器会报错。如果用户在子类的构造函数中显式地调用父类地构造函数,需要严格调用父类中已有的构造函数。

        (2)拷贝构造,依旧保持父类成员和子类新成员构造分离的规则,即父类成员初始化必须调用父类的相匹配的构造函数,可以是默认构造函数、普通构造函数、拷贝构造函数,如果调用父类拷贝构造函数,传参时传入的是子类拷贝构造函数传入的参数,下面代码有例子。

        (3)析构函数,需要在子类的析构函数中手动地调用父类的析构函数吗?不用,子类的析构函数会自动调用父类的析构函数。如果用户想要手动调用父类的析构函数怎么办,要在父类析构函数前加上父类名称,访问父类类域,下面代码有例子,为什么要这么加,因为父类和子类的析构函数,经过编译器处理后,函数名都被替换成destructor,两个析构函数构成重定义,换句话说,在子类中,子类的析构函数隐藏了父类的析构函数

        (4)operator=函数,依旧保持父类成员和子类新成员构造分离的规则,如果需要对父类的成员变量进行写入,依旧需要通过父类提供的接口。可以选择调用父类提供的operator=函数,由于在子类中构成重定义,调用时需要在函数前加上类名访问类域,下面有例子。

        以下代码是一段典型的子类地四个重要函数的写法。

class person
{
public:
	person(int &age)
		:_age(age)
	{}
	person(person& ps)
		:_age(ps._age)
	{}
	person& operator=(person ps)
	{
		_age = ps._age;
		return *this;
	}
	~person()
	{}
protected:
	int _age;
};

class student :public person
{
public:
	student(int& age, int& id)
		:person(age),_id(id)
	{}
	student(student& sdt)
		:person(sdt), _id(sdt._id)
	{}
	student& operator=(student st)
	{
		person::operator=(st);
		_id = st._id;
	}
	~student()
	{
		person::~person();
	}
protected:
	int _id;
};

        一个小问题:如何设计一个无法被继承的类?根据上面的讲解,我们需要意识到,当我们对子类中地父类成员变量进行改写时,必须通过父类提供的接口(构造函数和拷贝构造等),如果我们将父类的构造函数或者析构函数通过private访问限定符限制,那么子类不能调用这些接口,这个父类自然就是一个无法被继承的类。

5、继承与友元关系及静态成员变量

        友元关系不能继承,也就是说父类的友元不能访问子类的private和protected成员。

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

6、菱形继承及菱形虚继承

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

        多继承:一个子类又两个或以上直接父类时称这个继承关系为多继承。其中菱形继承是多继承的一种特殊情况。

        

        可以看出多继承有数据冗余和二义性的问题。最下方的类中有两份person类中的成员。以下 有一段典型的菱形继承的代码。

class person
{
public:
	string name;
};

class student :public person
{
protected:
	int id;
};

class teacher :public person
{
protected:
	int num;
};

class assistant :public student, public teacher
{
protected:
	int mj;
};

int main()
{
	assistant a1;
	a1.student::name = "jack";
	a1.teacher::name = "peter";
	return 0;
}

这是a1内存分布情况。

        可以看到,对象a1中student和teacher有各自的空间,并且有两份基类person中的name成员。总结一下,在这种写法下,有二义性问题,可以通过显式制定访问哪个父类的成员解决。但是数据冗余的问题无法解决,如果person类中的成员很多,那么assistant类中会有两份,数据相当冗余。

        通过虚继承解决菱形继承中的数据冗余和二义性,在继承方式前加virtual关键字,注意虚继承不要再其他地方使用。先从对象模型的角度来分析一下。观察以下代码。注意环境为x86环境(指针的大小为4字节)。 

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()
{
	cout << sizeof(D) << endl;
	return 0;
}

        执行结果如下。

        这很好理解,_a*2 + _b + _c + _d,五个int类型成员大小为20字节。接下来看看利用虚继承之后的现象是什么,代码和执行结果如下。

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()
{
	cout << sizeof(D) << endl;
	return 0;
}

       

        可以看到使用了虚继承之后,对象的大小反而变大了,这是为什么?看看下面这个代码的内存是怎么表现的。

        d对象的内存,保存了_b,_c_d, _a变量,注意_a变量既没有在B部分中,也没有再C部分中,而是在最下方的公共位置。B部分和C部分中除了保存_b和_c变量之外,上方还保存了两个地址,我们查看地址所指向的空间存储了偏移量,这个偏移量是d内存中,_a分别相对两个地址的偏移量。

        上述地址所指向的空间被称为虚基表,虚基表中保存会造成冗余的变量相对虚基表的偏移量,在赋值时,比方有一个C对象c,在执行c = d时(子类对象可以对父类对象赋值),会根据续集表中存储的_a偏移量寻找a的位置再进行赋值。

        需要注意一点这个虚基表并不是由于多继承产生的,而是由于virtual关键字产生的,就算是单继承关系,如果加上virtual关键字,在子类中也会包含一个指针指向虚基表,这张虚基表保存父类成员关于虚基表指针的偏移量。所以对于上述代码,D对象的两张虚基表本质上不是它自己生成的,而是B和C自身生成的,D是从B和C中继承了两张虚基表。如果你观察B和C类型的大小,会发现除去成员变量之外也多了4个字节,用来保存虚基表地址。

7、继承和组合

        先看一段代码。

class A
{};

class B:public A
{};

class C
{};

class D
{
	C c;
};

        A和B的关系是继承,C和D的关系是组合,本质上都是完成类层次的复用。

        继承是一种白箱复用,一定程度上破坏了类的封装性。组合是一种黑箱复用,保持了C的封装性。组合这种关系,类之间的耦合度更低。

        什么时机选用哪种关系呢?当类之间的关系是 is_a的时候,比如,宝马是汽车是,那么宝马就是汽车的继承。当类之间的关系是has_a的时候,比如汽车上有轮胎,那么汽车就是轮胎的组合。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值