C++继承(2)

1. 继承中的作用域

1.1 隐藏

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

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。 就是说参数也不影响,可以相同也可以不相同。
  3. 注意在实际中在继承体系里面最好不要定义同名的成员。

对于同名的行为,编译器都是采用就近原则,也就是先查找自身类内部的成员,想要查找对应类里面的成员,可以指定作用域访问。

对于成员类型来说:

// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑
// 但是非常容易混淆
class Person
{
protected :
	string _name = "小李子"; // 姓名
	int _num = 111;  // 身份证号
};
class Student : public Person
{
protected:
	int _num = 999; // 学号
};

对于成员函数类型来说:

// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
	void fun()
	{}
};
class B : public A
{
public:
	void fun(int i)
	{}
};

这时候想要调用带参的fun是可以直接调用的,但是想要调用无参的是不允许的,因为它被隐藏了,必须要指定作用域。

B b;
b.fun(1);//正确,调用带参fun
b.fun();//错误
b.A::fun();//正确,调用A类不带参fun

2. 派生类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数生成了以后会做什么事情?什么时候需要自己写?如果我们要写,要做些什么事呢?

2.1 实现原理

  • 实现

补充知识点:默认构造函数是指不传参数即可调用的构造函数,例如全缺省、无参;并不是说系统默认生成的就是默认构造函数。

  • 调用

调用的规则大致就是:继承下来的就使用父类的,原来自己的就按照类的基本规则处理。

2.2 关于默认成员函数的需要与不需要

2.2.1 不需要

先来看不需要自己生成默认构造函数的情况:

//对于继承的父类成员,调用父类的默认构造(并未实现所以系统生成)
class Person
{
protected:
    string _name = "小李子"; // 姓名
};
class Student : public Person
{
protected:
    int _num = 999; // 学号
};
int main()
{
    Student s1;
}

不使用系统提供的话,也可以在父类实现一个默认构造:

//对于继承的父类成员,调用父类的全缺省默认构造
class Person
{
public:
    Person(const char* name = "张三")
        : _name(name)
    {}
protected:
    string _name = "小李子"; // 姓名
};
2.2.2 需要

再看需要自己在子类中实现函数的情况:

  1. 父类没有默认构造,需要我们自己显示写构造,这个过程用初始化列表实现:
//父类实现的是未缺省的构造函数,则子类需要显示实现构造函数
//当父类有了这个函数以后,系统就不会生成默认构造函数,
//所以如果这时候定义对象的话,就会编译失败
//这个显示调用的过程在子类的默认构造的初始化列表里完成
class Person
{
public :
//这个不是默认构造,这是未缺省的构造函数
Person(const char* name)
	: _name(name )
{} 
protected :
	string _name = "小李子"; // 姓名
};
class Student : public Person
{
public:
	//默认构造 
	//在子类中显示实现构造函数,用初始化列表初始化继承的父类成员
 	Student(const char* name = "张三",int num = 1)
 		:Person(name)//会去调用父类的构造函数
 		//,_name(name)这种方式不允许,因为这是父类的成员
 		,_num(num)
 	{}
protected:
    int _num = 999; // 学号
};

  1. 子类有资源需要释放,就需要自己显示写析构:
//若子类申请了资源,是需要自己实现析构的
class Person
{
public :
//默认构造省略
protected :
	string _name = "小李子"; // 姓名
};
class Student : public Person
{
public:
//默认构造省略
	~Student()
	{
		//Person::~Person();//析构函数也需要指定类域
		// delete[] _ptr;
	}
protected:
    int _num = 999; // 学号
};

对于析构函数,有两个问题需要说清楚:

  • 析构函数名字会被统一处理成destructor()

这就导致了父子类的析构函数构成隐藏,在调用的时候需要指定类域,至于为什么这样处理,这里不做解释。

  • 子类析构函数结束时,会自动调用父类的析构函数

就比如说假设我们不知道有这个行为,那么如果父类有资源需要清理,我们就会自己去实现析构函数进行资源的清理,并且在子类的析构函数里显示调用父类的析构函数(就像子类析构函数里第一句注释的那样,显示调用),那么子类先析构,然后析构一次父类,然后子类析构结束后又会调用一次父类析构,就造成两次析构,程序崩溃。

所以我们自己实现子类析构函数时,不需要显示调用父类析构函数;这样才能保证先析构子类成员,再析构父类成员。因为父类先定义,子类后定义,根据栈帧的后进先出,子类的空间是必须先被清理的。

  1. 子类存在浅拷贝问题,就需要自己实现拷贝构造和赋值解决浅拷贝:
class Person
{
public :
//默认构造省略
	//拷贝构造
	Person(const Person& p)
		:_name(p._name)
	{}
	protected :
	string _name = "小李子"; // 姓名
};
class Student : public Person
{
public:
//默认构造省略
	Student(const Student& s)
		:Person(s)//切片
		,_num(s._num)
	{}
protected:
    int _num = 999; // 学号
    int* ptr = new int[10];//子类申请了空间
};

这里有一个细节,就是在把s传给Person的拷贝构造的时候,只传递继承下来的父类成员,而不传递子类的成员,要实现这个过程就需要用到切片;在传参的过程会自动发生切片,以达到这个目的。

有了这个理解,对于赋值重载也是一样的做法:

class Person
{
public :
//默认构造省略
//拷贝构造省略

//赋值重载
Person& operator=(const Person& p )
{
	if (this != &p)
	_name = p ._name;
}
protected :
	string _name = "小李子"; // 姓名
};
class Student : public Person
{
public:
//默认构造省略
//拷贝构造省略

//赋值重载
Student& operator = (const Student& s )
{
	if (this != &s)//防止自己给自己赋值
	{
		Person::operator =(s);//指定类域
		_num = s ._num;
	}
	return *this ;
}
protected:
    int _num = 999; // 学号
};

这里同样有个细节,就是调用operator=()的时候必须指定父类类域,否则会引发无穷递归导致栈溢出;这是因为父类的赋值构造和子类的赋值构造同名了,构成了隐藏,不指定类域的话优先调用本类里的赋值构造,也就是子类的。

3. 默认成员函数总结

继承下来的成员必须严格按照父类的方法处理,而不是使用子类的方法去处理。

  • 构造与析构
  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。 如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。而对于派生类的内置类型和自定义类型成员,就跟普通类一样处理。
  2. 派生类对象析构清理先调用派生类析构再调基类的析构。 当派生类的析构函数被调用完成后,会自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 拷贝构造与赋值
  1. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

  2. 派生类的operator=必须要调用基类的operator=完成基类的复制。

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

  4. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

4. 菱形继承及菱形虚拟继承

4.1 基本概念

  • 单继承

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

  • 多继承

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

在这里插入图片描述

  • 菱形继承

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

在这里插入图片描述

菱形继承的问题:从上面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。由于Teacher与Student都被Assistant继承了下来,所以在Assistant的对象中Person成员会有两份。

菱形继承其实是一个坑,那么必定会有填坑的过程。

对于二义性,我们可以指定类域访问,但是代码冗余问题如何解决?或者说有没有更好的方法可以一次性解决这两个问题?

  • 解决方法是虚拟继承:

增加virtual关键字的用法:

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

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

通俗的理解就是将相同的成员放在一个“公共”的位置,通过某种方式让它们自己去找到那个成员。

用ABCD四个类来举例:A是虚基类,BC是中间类,D是子类。值得注意的是,在没有虚拟继承的时候,A继承下来的成员是分别存在B和C里的,各占一份。而虚拟继承以后A就存放在D成员里了。

也就是说虚拟继承之前D成员里只能看到B和C继承的成员(里面各占一个A)和D自己的成员,虚拟继承之后就能直接看到多了A的成员,也就是那份公共的成员。

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

4.2 测试实例

下面借助内存窗口来说明虚继承的原理:

首先演示的是没有虚继承的情况下:

class A
{
public:
	int _a;
};
// class B : public A
class B : public A
{
public:
	int _b;
};
// class C : public A
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;
}

在这里插入图片描述

可以看到,在没有虚继承的情况下,由于B先继承A,所以B的成员包括继承下来的_a(赋值为1)和自己的成员_b(赋值为3)排在前面;而C后继承所以它的成员分别是02(_a)、04(_c)在中间;最后_d是05在最后面。

那么此时也可以看得出来,_a这个成员明显产生了数据冗余 ,因为只要是被继承了,它会存在每个子类的内存里。

接下来再演示虚继承的情况下,继承的内存情况:

class A
{
public:
	int _a;
};
// class B : public A
class B : virtual public A
{
public:
	int _b;
};
// 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;
}

查看d的地址内存情况,可以看见在这个位置B类的_a被初始化为1:
在这里插入图片描述

此时再运行一步:
在这里插入图片描述

可以看见同样是在0x001AFD2C这个位置,里面的内容被初始化成了2。

这里基本就可以看出“公共”的意思了。

然后把程序运行完毕,再观察&d的内存情况:

(由于是多次测试,显示出来的地址会和上面不一样,意思理解就行了)

在这里插入图片描述

最后得出的现象:B类的成员被放在了当中最低的地址,C类第二,其次是D类,最后_a被放在了最下面(不同编译器实现方法不同)。

由这些测试结果可以得出结论:虚继承的情况下,D对象中将A放到的了对象组成的最下面,这个A同时属于B和C。

那么那么B和C如何去找到公共的A呢?

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

这个所谓的偏移量,就是刚才运行结果中的那段看不懂的地址。

再打开内存2窗口,将刚才运行的偏移量输入查找:

在这里插入图片描述

可以看到结果的第一列是00 00 00 00,第二列是14 00 00 00,这个14是十六进制,所以代表的不是14,而是20。

所以呢?那这个20有什么意义吗?

观察程序运行完成后的结果,可以看到_a相对于D类的偏移量刚好就是0x14,也就是20。

在这里插入图片描述

这说明了:一开始说的各个子类通过某种方法来找到这个“公共”的成员,这个“某种方法”指的就是地址的偏移量。而这个偏移量是存放在虚基表中的。

而对于C类也是如此:

在这里插入图片描述

  • 补充

找到虚基类的表叫做虚基表。

为什么需要这个表?

A一般叫虚基类。在D里面,A放到一个公共位置,那么有时候B需要找A,C需要找A,就要通过虚基表中的偏移量进行计算。

看下列这种情况:

B b = d;
C c = d;

或者这种情况:

B* ptr = &d;
ptr->_a = 10;

很明显这个赋值的过程需要切片,那么就需要d去找到B/C对应的成员;而对于ptr,如果它不知道_a的地址,是不可能访问到_a的。那么这个过程就依赖于虚基表。

为什么偏移量不是直接存在d内存里,而是要通过存放的地址再次寻址找到偏移量?

因为偏移量不是只有一个,就比如偏移量最开始的位置存放的是00 00 00 00 这个位置是为其他偏移量准备的,而这也体现了“表”的概念。

5. 组合

继承是一种复用行为,除此之外组合也是一种复用行为。

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

就比如:A类是叶子,B类是树,那么可以说树包含叶子;而对于继承来说,就像一开始的例子,A类是人,B类是学生/老师等。

对于组合来说,实现方式:

Class A
{
	A _a;
}
Class B
{
	A _obj;//组合复用行为
	B _b;
}

如果两种情况都完全符合:优先使用对象组合,而不是类继承 。

继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 ,这就意味着白盒测试的要求会更高。

继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。

可以这样对比:对于组合来说,B类只能访问A类中的公有成员,A类修改其私有及保护成员不会影响到B类;而对于继承,B类可以访问A类的保护和公有成员。那么继承的耦合度会明显高很多,基类修改以后派生类可能会有很大影响。

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

实际尽量多去用组合。组合的耦合度低,代码维护性好。 不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

对于继承来说:

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

久菜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值