模板(补充)+ 继承

目录

1 模板

1.1非类型模板参数

2 模板的特化

3 模板的分离编译

2 继承

2.1 继承的概念及定义

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

2.3 继承的作用域

2.4 派生类的默认成员函数

2.5 继承与友元

2.6 继承与静态成员

2.7 单继承和多继承

2.8 菱形继承和菱形虚拟继承


1 模板

1.1非类型模板参数

模板参数分为类型形参和非类型形参。类型形参就是我们之前一直所使用的,使用typename和class,用一个模板参数来接收类型的,在模板中可以当作类型来使用,以此来实现泛型编程。而非类型模板参数则是模板中用来接收常量的参数,在模板中可以当作常量来使用。

具体的应用场景也很简单,比如我们需要定义一个静态数组的类模板,那么我们如何决定静态数组的长度呢?似乎只能通过宏来定义 

#define NUM 10

template<typename T>
class Array
{
public:
	// ... ...
	// ... ...

private:
	T _a[NUM];
};

但是用宏来定义并不能够满足大多数的要求,因为我们一般使用这种静态数组都是希望自己在定义的时候来指定数组的大小的,而不是一个死板的固定无法改变的值,那么这时候就需要用到非类型模板参数来实现了,怎么用呢?其实也很简单


template<typename T ,size_t  NUM =10 >
class Array
{
public:
	// ... ...
	// ... ...

private:
	T _a[NUM];
};

非类型模板参数则不适用typename 和class来声明,而是使用合适的具体的类型来接收传过来的参数。这样一来,如果我们对数组长度没有要求,像上面一样也只需要传一个类型参数就行了,因为我们给了数组长度的缺省值。而如果我们对数组的长度有要求,则可以通过模板的第二个参数指定数组的长度。

	Array<int> a;
	Array<int, 100> a2;

那么实现这样一个静态数组有什么意义呢?这样的数组其实C++11就已经有了,也就是我们的array,array就是一个固定大小的顺序容器。

array就是一个静态数组,是一个固定大小的容器,他的接口不支持头插尾插等操作,因为它本身就是可以直接通过下标来完成所有操作的,就跟我们在C语言中使用的数组一样。 array 与 vector 的区别就是,array不需要将数据连续存放,他是可以间隔存放的,只要不越界,都能够通过下标进行数据的存储与覆盖,同时,它的空间是在栈区的,而不是vector所在的堆区。 

更严格来说,array本身对标的就不是vector,所以那他们两个来比较有些不恰当。array对标的其实是C语言中的静态数组,从底层看,array他就是一个静态数组,只不过用类进行了一系列的封装。那么array的封装的意义是什么呢?相比于C语言数组,array的最大的优势优势就是对于越界的检查。array对于越界的检查是十分的严格的,因为array是一个自定义类型,他的下标访问是通过重载 [ ] 来实现的,而在[ ] 的重载中,就有一系列的越界检查 assert ,所以我们的下标一旦越界,不管是越界读还是越界写,都是会直接报错的。但是C语言的数组则不一样,数组对越界的检查是抽查,主要是在数组的前后几个位置的检查,对于越界的读基本检查不出来,而越界的写也是有几率检查出来。

但是目前来说使用array的还是很少,因为它相对于数组就是多了这一个越界检查的优势,其他的方面跟数组差不多,尤其是它的定义相比于数组的定义,我们早就已经习惯了数组的定义形式,同时,数组的越界检查也不是一个很痛点的问题,一般来说只要检查的仔细都能够避免。

函数模板也是可以使用非类型模板参数的

只不过这时候,编译器就无法对其进行推演实例化了,我们只能够显式实例化出来才能够调用。

template <typename T, size_t num>
int add(const T& x, const T& y)
{
    int count = num;
    int sum = x;
    while (count--)
    {
        sum += y;
    }
    return sum;
}

	int a = 10;
	int b = 10;
	//const int n = 5;
	//int ret=add<int,n>(a,b);
	int ret=add<int,10>(a,b);

要注意:

1.浮点数、类对象以及字符串是不允许作为非类型模板参数的,只能够是整型家族比如int,short,char等类型

2.非类型的模板参数必须是在编译期就能确认结果。也就是必须是常量或者常量表达式,而不是局部变量或者全局变量等要程序运行起来才起作用的。

2 模板的特化

通常情况下,我们使用模板来实现一些与类型无关的代码,但是对于一些特殊类型,匹配模板可能会得到不是我们所期望的结果,这时候我们就需要对其进行特殊处理。

举个简单的例子,我们使用一个函数模板来实现两个自定义类型对象的比较。

class Date
{
public:
	Date(int month,int day)
		:_month(month)
		,_day(day)
	{}

	bool operator<(const Date&d1)const
	{
		if (_month < d1._month)
			return true;
		else if(_month==d1._month&&_day<d1._day)
		{
			return true;
		}
		return false;
	}

private:
	int _month;
	int _day;
};


template<typename T>
bool myless(const T& x, const T& y)
{
	return x < y;
}

如果我们是这样调用

	Date d1(5, 20);
	Date d2(5,21);
	cout << myless(d1,d2) << endl;

传的是两个对象,那还好说,结果肯定是对的。

但是如果是下面这种情况呢?

	Date* p1 = new Date(5, 21);
	Date* p2 = new Date(5, 20);

如果我们要比较两个对象的大小,那么能不能直接传 p1 和 p2 呢?不能,如果传的是p1和p2,那么编译器推演出 T 就是Date* 了,那么最终比较的就是两个指针的大小,而不是指针所指向的对象的大小。 那么这时候是不是就只能这样传参了呢?

    cout << myless(*p1,*p2) << endl;

这样做是能够达到预期效果,但是用户这样用起来舒服吗?用户肯定是希望可以直接传指针,然后自动去对比指针所指向的对象的大小的。

    cout << myless(p1,p2) << endl;

那么这要怎么实现呢?

最简单的办法就是我们直接针对 Date* 写一个具体的函数,这种方法其实是函数重载,函数模板和普通函数之间是支持重载的

bool myless(const Date* x, const Date* y)
{
	return *x < *y;
}

另一种方法就是我们可以对模板进行特化,特化的意思就是针对某一种特殊情况进行特殊的处理,他的语法如下

template<>
bool myless<const Date*>(const Date* x,const Date* y)
{
	return *x < *y;
}

这就是函数模板的特化。

目前可能还看不出来模板的特化有什么很大的意义,这是因为函数模板与普通函数是支持重载的,我们能够对函数模板进行特化,相同,我们也可以使用函数重载来解决这个问题。 

但问题是,函数模板支持重载,但是类模板呢?类可不支持重载啊,我们可无法再定义一个同名的普通类来应对特殊情况,这时候就只能时候类模板的特化处理特殊情况了。

template<typename T>
class A
{
public:
	A() { cout << "A--T" << endl; }
};

比如这样一个类,当我们想对 T 为 char 时做特殊处理,可能类里面的成员函数和成员变量都不符合我们的要求,我们想要另外一套函数和变量来使用

template<>
class A<char>
{
public:
	A() { cout << "A--char" << endl; }
	//... ...
};

    A<int> a;
    A<char> ac;

于是我们也能够针对类模板进行特殊处理了,这就是模板特化带来的意义。

但是我们可能会想,如果一个类很大的话,对他进行特化,特化出来的类其中可能会有很多代码会与模板重复,因为我们进行特殊化处理可能只是针对某一个或者几个方法进行处理,那么是不是会造成代码的冗余了? 的确是。但是类模板的特化不是用于类很大的场景的,而是用于一些比较小的特殊的类,比如我们的仿函数类。对于一些比较大的类如果我们想要特殊化处理,当然我们一般不会设计这样的很大的类还需要特殊处理的,一般都会将其属性拆分,写成继承的形式然后进行虚函数的重写实现多态调用来处理特殊情况。所以一般函数模板是不建议特化的

我们上面对类模板的特化都是对全部的模板参数都进行限制,也就是全特化,类模板的特化还能够实现半特化或者偏特化,全特化其实就是相当于将模板中的参数全部确定下来了,相当于实现了一个单独的类,它只有在所有参数都能匹配特化的版本时才会走全特化。

//类模板
template<typename T1,typename T2>
class A
{
public:
	A() { cout << "A--T1--T2" << endl; }
	//...
};

//全特化
template<>
class A<char,char>
{
public:
	A() { cout << "A--char--char" << endl; }
	//... ...
};

但是如果我们只想限制其中一个参数呢?比如我们想要 只要第二个参数类型是 char 就进行特殊化处理,我们就可以使用半特化来实现

//偏特化
template<typename T1>
class A<T1, char>
{
public:
	A() { cout << "A--T1--char" << endl; }
	//... ...
};

这时候,我们发现上面的全特化和偏特化的某些情况是有些重叠的,我们可以看一下当同时满足全特化和偏特化时会调用哪一个

	A<int,int> a1;
	A<char,char> ac;
	A<int, char>a2;

我们发现,当同时能够匹配全特化和偏特化的时候,编译器会使用全特化。为什么呢?我们可以看出,全特化其实以及相当于针对一个特殊情况写了一个单独的现成的类出来了,而半特化其实相当于是一个半成品,编译器则还需要去实例化,有现成的可以用为什么还要去使用半成品呢?所以编译器会匹配全特化的版本。

3 模板的分离编译

关于模板的分离编译其实我们已经在初阶时就讲过了,这里再稍微提一嘴。

模板的分离编译是指,模板的声明与定义分离,声明放在.h文件中,实现放在.cpp文件中,这其实就是我们编写代码一直建议做的事,但是声明与定义分离在模板的应用上则不适合。

比如我们的函数模板

//test.h
template<typename T>
T myadd(const T& x, const T& y);


//test.cpp

template<typename t>
t myadd (const t& x, const t& y)
{
	return x + y;
}

如果我们把声明和定义分离,这时候我们在 main.cpp 中使用 myadd

	int d1 = myadd<int>(10,20);
	cout << d1 << endl;

这时候编译的时候就会报错

报的错是无法解析的外部符号,其实也就是函数未定义,或者说函数模板没有实例化出我们想要的函数出来,这就导致了编译器在编译的时候无法在函数表中找到调用的函数的地址,所以在编译期间就报错了。

但是为什么会这样呢? 

还是与我们的编译的过程有关。编译的过程要经过  预处理 、编译 、汇编 、链接这四个步骤,头文件在预处理时就已经被展开了,预处理之后就没有头文件的概念了,头文件的内容都被复制到了各个包含他的源文件中,而对于源文件来说,在链接之前他们都是分开单独编译的,也就是在链接之前都是没有任何交互的,只有链接时才会合并符号表。

也就这里,问题就出现了,函数模。板的实例化是编译期间进行实例化的,但是编译期间 test.cpp  和main.cpp 都会单独编译,而 tes.cpp中我们对函数模板声明进行了实现,但是我们却没有任何调用模板函数或者进行实例化的代码,所以在 test.cpp 中是显而易见是不会进行实例化的。而在 main.cpp中,虽然调用了模板函数,但是由于我们只包含了函数模板的声明,而没有函数模板的定义,所以这时候编译器会将调用的函数放进该源文件符号表中,但是由于只有声明而没有定义,所以在 main.cpp 的符号表中,调用的函数的地址是一个无效的地址,正常来说他会在链接合并符号表的时候,在别的源文件的符号表中找到对应的函数有效的地址,然后将地址填到调用函数的地方,但是由于 test. cpp 中并不会实例化函数模板,所以就导致了 test.cpp 的符号表中其实没有myadd(int,int)的地址,所以最终链接之后 myadd(int,int)关联的还是一个无效的地址,所以编译器就无法在函数调用的地方填入地址,这就说明函数未定义,或者说并没有实例化,所以就报错了。

两种解决方法: 1 模板的声明和定义不要分离,写在头文件里,我们可以把文件的后缀写成. hpp来与普通的头文件进行区别.

2 就是在的定义模板的文件中进行模板的显式实例化。但是这样太墨迹,不够实用。

2 继承

2.1 继承的概念及定义

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

说到服用,我们以前接触过的复用大多数都是函数层面的复用,比如容器的接口的设计中,尾插尾删头插头删等接口我们直接复用 insert和erase 的代码来完成。而对于类层面的复用,我们前面讲的适配器设计模式这种在内部定义一个其他类的对象进行转换得到想要的东西,可以算是对类进行了复用,此外我们基本没有接触过其他的了。

现实生活中有很多事务并不是非此即彼或者简单经过转换就能得到对方的,最常见的就是 人这个物种,他的属性是所有的人都具有的属性的集合,以及具体的某些角色比如学生老师工人等,学生老师这些角色都是人,但是我们可以说人是老师是学生吗?不能,因为不管何种角色都具有人的所有的属性,但是人却不能包含每一种身份的独有的一些属性和方法。也就是说,学生和老师都是具有人的所有的属性和方法的,但是在此基础上,他们还多了一些其他的属性和方法,比如学生还多了学号班级等属性,这并不是所有的人都具有的属性,又比如老师增加了管理班级、任课科目等属性,这也是老师相对于人的属性而独有的属性。 

那么如果我们要用C++来描述人、老师和学生,我们是不是可以用三个类来描述。

class Person
{
public:
	//... ...
private:
	string _name;
	string _id;
	string _sex;
	string _address;
};

class Student
{
public:
	//... ...
private:
	string _name;
	string _id;
	string _sex;
	string _address;
	int _class;
	string _stu_id;
};

class Teacher
{
public:
	//... ...
private:
	string _name;
	string _id;
	string _sex;
	string _address;
	vector<vector<int>> _grade_and_class;
	string _subject;
};

但是单纯这样定义我们会发现代码的冗余问题很严重,而且,Person类的所有的属性及方法其实 Student和Teacher这两个类中也都要有,虽然可能有的方法的具体实现在具体的生活中会有所区别,但是不管怎样Person类中的所有的东西都是Student 和 Teacher 中的基础属性,Student 与Teacher都是在Person的基础上加了一些自己的属性。如果Student和Teacher能够把Person复用起来,是不是就能减少很多重复的代码和工作了呢?

那么我们可以这样写来达成复用吗?

class Student
{
public:
	// ... ...
private:
	Person _per;
	int _class;
	string _stu_id;
};

这样做虽然可以让Student复用Person的代码,但是这里的关系是不是有问题呢?如果我们这样写,那么Student 和Person的关系就变成了 "学生里面有一个人",但是我们实际上的关系是 "学生是一个人" ,这样写是不是有一点不恰当呢?而且,我们说过,Student 方法并不是一定完全就是Person的方法,对于同一个方法,Student 可能有自己独特的行为,他可能会对Person中的一些东西进行一些调整。所以这样写在这个场景下是不恰当的。

那么这里我们就需要用到继承,首先我们要知道继承的语法或者说写法

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

这里的父类我们也称之为基类,子类也称为派生类。

继承从字面上也很好理解,就是在子类中继承了父类的那一套东西,包括成员变量和成员方法。

class Student : public Person  //class定义的子类不写集成方法默认是 private,struct 默认是 public ,与访问限定符的默认一致
{
public:
	// 子类自己的方法,不需要写父类已经有的方法,除非需要重定义该方法
private:
	//子类增加的属性
	int _class;
	string _stu_id;
};

继承方法有三种,public公有继承,protect 保护继承,private私有继承,那么这三个继承方式有什么区别呢?区别就在于父类的某些成员在子类中是否课件,或者说是否能够直接被子类访问。

具体的继承方式和成员的访问权限我们可以用下面这张表来表示

总结一下就是 :

1 父类的私有成员,在派生类中都不可见,不管以何种方式继承

2 父类的非私有成员在子类中的权限就是 继承方式 和访问限定符 中权限较小的。

比如父类的 public 成员,子类以 private 继承,那么他在子类中就是私有的,也就是不能在类外直接被访问。

同时在这里我们就能知道 protect 和 private 的成员有什么区别了,父类的private不管怎么继承在子类中都不可见,只能通过调用父类的方法才能访问到。而父类的protected成员在子类中就相对于子类的 protected 或者 private成员,在子类中是能够看到的。其实如果单纯看一个类不考虑继承的话,protected和private是没有区别的,只有在继承的场景下才有区别,private成员就是不管怎么样,只要在类外都不可见,不管你是不是我的子类。而protected则是如果你是我的子类,那么在子类的类域中能够直接访问,但是在类外当然还是不能访问。

如果基类的成员不想在类外被访问,但是又想让基类能够访问,就可以设置为保护。

class A
{
public:
	A(int prot=1,int pri=2)
		:_prot(prot),_pri(pri)
	{}
	int Agetprot()
	{
		return _prot;
	}
protected:
	int _prot;
private:
	int _pri;
};

class B : public A
{
public:
	int Bgetprot() // B可以直接访问 A 的protected 成员
	{
		return _prot;
	}
private:
	int _b=0;
};
	cout<<b.Agetprot()<<endl;
	cout << b.Bgetprot() << endl;

如果是公有继承,我们可以这样玩,但是如果是保护继承protected,那么我们还是可以在类外调用 B自己的Bgetprot()函数,但是无法调用到 Agetprot() ,因为Agetprot被B继承之后,对于B的权限就是 protected了,而protected成员能够在类里面访问,在类外无法直接访问。而如果是私有继承,Bgetprot()还是一样能在类外通过对象调用,Agetprot()还是无法在类外调用,因为相对于是B的私有成员了。

而对于 A 的私有成员,在B中是不可见的,但是我们可以调用 A 的方法来间接访问 A 的私有成员,当然前提还是这个方法能够调得动,如果这个方法在A中也是私有的,那还是没办法 。

当然实际应用中我们绝大多数用的都是公有继承,几乎很少使用私有或者保护继承,同时也不提倡私有和保护继承,不好维护与使用,比如在基类中一些共有的方法,被保护继承或者私有继承之后就不能再类外直接通过子类对象来调用了。

我们后面说的继承如果没有特殊指明也都是公有继承。

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

在存储上我们可以理解为在子类对象中有一个父类对象,这个父类对象是一个整体,通过监视窗口的对象模型我们也能看出来,父类在子类中不可能是分散分布的,如果这样设计那可就真是恶心人又恶心自己了。父类的那一部分在子类中是一个整体,

正是因为父类的那一部分在子类中是一个整体,那么是不是意味着,我们可以将其单独提取出来,提取出来之后是不是就是一个完整的父类对象呢?

比如,我们要定义一个父类对象,能不能用一个子类对象来对其初始化呢?是可以的。

C++中,派生类对象可以赋值给基类的对象\基类的指针\基类的引用,这里有个形象的说法叫做切片或者切割,意思就是把子类对象中父类的那一部分切割出来,用来给父类的对象指针或者引用赋值,当然这只是针对公有继承的情况,其他的继承方式在子类中的父类成员的访问权限是和父类不一样的,不能直接切割。

虽然父类和子类是不同的两种类型,但是在这个切片的过程中是没有发生类型转换的,而是天然就能这样赋值。不存在类型转换意味着什么呢?意味着中间不产生临时变量,我们以前的不同类型之间的赋值,比如用一个double类型的变量 b 去给 int 类型的变量 a进行赋值的话,其实是拿 a 进行类型转换之后产生的临时变量去给 b 赋值,而不是直接拿 a 取赋值给 b ,这就是类型转换产生的临时变量的作用,最直接的表现就是,引用的赋值,必须用一个const 的引用进行定义,因为这里的引用是类型转换所产生的临时变量的别名,临时变量具有长兴,所以必须是const 引用才能够进行赋值。

    int a = 0;
    const double& rd = a;

而在这里的父类和子类的复制转换中,中间是不会产生临时变量的,而是一种直接的赋值。比如要用一个子类对象来给一个父类对象赋值,是直接将子类对象中的父类那一部分拷贝给父类对象,而不会产生一个临时变量。

而如果是使用一个父类对象对子类的指针进行赋值的话,首先指针类型肯定是父类类型,但是指针所指向的确实子类对象中的父类那一部分的起始地址,同时,这个指针的权限也只有父类的那一部分,看不到子类所独有的成员。

虽然 pa 是一个 父类 A* 类型的指针,但是物理上它指向的确是一个子类的对象,只不过在这个指针看来,他就是一个子类的对象的指针,因为他就只能够看到父类的那一部分,而父类的那一部分我们是可以当成一个父类对象来看的。

在多数编译器上,父类对象的存储就如同我们上面的对象模型一样,父类的那一部分在低地址处,然后才是子类所独有的成员,也就是说,子类所继承的第一个父类,在子类的对象模型中,他的起始地址就是子类的起始地址。也就是我们上面所看到的,pa 的值其实就是 子类对象b的起始地址,因为编译器将父类的部分放在了子类的成员的的前面。

而引用的赋值也是跟指针一样的,虽然他是一个弗雷德引用,但是实际上他确实一个子类对象中父类那一部分的别名。

父类和子类对象的赋值转换是一种特殊的不需要类型转换的情况,它允许向上转换,但是显而易见不允许向下转换,换句话说,子类对象可以类父类的对象、引用、指针来赋值,但是父类对象无法转换为子类的对象、指针和引用。这是一种单向的转换关系。

当然这只针对公有继承的情况,因为公有继承下来的子类,父类部分的访问权限是和父类一致的,父类的那一部分能够被父类的对象访问到,那一部分可以理解为是父类和子类所公有的,子类允许父类来访问。而如果是保护继承或者私有继承,我们可以理解为子类中的父类那一部分被子类所保护起来了或者是子类的私有,他不对父类放开权限,不允许父类进行访问。

2.3 继承的作用域

在继承体系中,基类和派生类都有独立的作用域。最简单的证明方法就是,我们在子类和父类中定义一个同名变量,看编译器会不会报错,我们说过在同一个作用域是不能存在同名变量的,

class A
{
private:
	int _a=0;
};

class B :public A
{
private:
	int _a=1;
	int _b = 2;
};

int main()
{
	B v;

	return 0;
}

在监视窗口中很明显的给我们提示了,子类和父类是都有自己的作用域的。如果这时候我们把 A和B的成员都设置为共有的,那么我们创建一个B 类型对象,直接访问 _a,访问到的是子类的_a还是父类的_a呢?

我们发现,如果直接访问这个同名变量时,默认访问的是子类自己独有的成员变量,这是作用于的就近原则,我们以前在讲变量就讲过,如果全局域和局部域有同名变量,那么在局部域中访问这个同名变量优先访问到的就是局部的同名变量。如果我们想要访问父类的同名变量,那么我们就需要指定作用域,子类自己有一个作用域,而子类中有一部分相当于父类对象,而这父类的部分在子类的类域中右自成一个作用域,就是作用域中的作用域。那么我们要找到子类中父类作用域的同名变量也很简单,我们只需要指明我们要访问子类类域中的父类部分的作用域的同名变量就行了。

当子类和父类中有同名成员时,子类成员将屏蔽父类同名成员的直接访问,这种情况叫做隐藏,也叫做重定义。

我们上面讲了同名成员变量的作用域就近原则,这其实就是一种隐藏关系。那么如果由同名成员函数呢?

如果成员函数的函数名和参数列表都一样,那么肯定构成隐藏\重定义,这一点我们能够确定,但是如果只是函数名相同,而参数列表不同呢?

我们在子类的同名成员函数中加上一个参数

这时候我们就发现,如果我们调用该函数不传参,编译器直接就报错了,而不会转而去调用父类的那个同名函数,这说明父类的同名函数还是被隐藏了。

我们还是只能这样指定类域来调用子类的同名成员函数。

在这里我们一定要区分好重载和隐藏的区别。

函数重载是发生在同一作用域的两个同名函数,且他们的参数列表不同,调用时不构成二义性,这就叫函数重载。而对于子类中继承下来的父类,在两个类域中如果存在同名的成员,不管是成员函数还是成员变量,父类的同名成员会被隐藏,构成重定义关系。函数的重载必须要发生在同一个作用域的。

但是话虽然这么说,我们在实际的使用过程中还是尽量避免同名成员的存在,这降低了代码的可读性以及可维护性。

2.4 派生类的默认成员函数

对于一个普通类而言,我们将它的成员分为内置类型和自定义类型,我们的构造和析构等函数都会针对这两种情况来处理,但是在继承关系的子类中,则比上面的情况更加复杂,因为他的成员中还多了一个基类,我们把基类当成一个整体,看作是一个基类对象,但是这个基类对象的构造和析构该怎么进行呢?

构造函数

首先看一下构造函数,如果我们只写父类的构造函数,不写子类的构造函数,让编译器自动生成,这时候编译器会怎么做呢?

我们发现,如果我们没有写子类的构造函数,那么编译器自动生成的构造函数就会自动调用父类的默认构造函数。但是如果没有默认构造函数呢?我们可以验证一下:

这时候我们发现,编译器直接报错了,报错的原因是无法引用B的默认构造函数,也就是说编译器并没有给B生成默认构造,为什么呢?这是因为编译器生成的默认构造函数会去调用A的默认构造函数,但是由于我们写了一个A的单参构造函数,所以编译器没有为A自动生成默认构造,所以编译器无法去调用A的默认构造,为了防止用户不知道,所以干脆就不给B自动生成构造函数了,而是必须由用户自己实现一个子类的构造函数,因为编译器这时候不管怎么生成可能都会不符合用户的本意,干脆就交给用户自己去实现了。

那么我们该如何去实现子类的构造函数呢?

按我们以前的做法, 就是对每一个成员变量进行初始化,也就是下面这种做法

	B(int a=0,int b=0)
		:_a(a),_b(b)
	{
	}

但是如果你这样写,编译器就会报错了,为什么呢?

首先我们看报错信息,这里的报错大致是两类,一种就是 _a 是父类 A 的私有成员,所以在子类中是不可访问的,无法直接初始化 ; 第二种就是父类 A 没有构造函数。但是这两个报错好像并没有什么关系啊,那到底是哪种报错呢? 

第一眼看,我们可能会觉得是第一类,也就是B无法直接访问 A 的私有成员,那么是这样吗?我们将A的成员变量设为公有就能验证了。  这时候报错就明显了,是因为 A 没有默认构造。

报错是明确了,但是我们却更懵逼了,这里我们根本就没有去调用A的默认构造啊,为什么会报这样的错误呢?

C++规定了,不能直接在初始化列表对父类的成员变量进行初始化,而是必须要调用父类的构造函数来对父类的那一部分进行初始化。至于为什么不让我们直接在初始化列表对父类成员进行初始化,可能是怕我们漏掉某些成员导致随机值,程序不可控?原因我们就不追究了,那么该如何显式调用父类构造函数呢?我们只需要在初始化列表中进行父类的构造函数的调用即可,不用指定类域,因为父类构造函数名就是父类的类名,没有人会显得蛋疼在子类中定义一个与父类类名同名的成员函数。

只需要这样,我们就完成了子类的构造函数。

那么总结一下,对于子类的构造函数,父类的那一部分的初始化必须在初始化列表去调用的父类的构造函数进行初始化,而对于其他的成员变量,我们则还是按照以前的方法进行初始化。在这里相比于普通类的构造只是多了一个父类的初始化而已。

拷贝构造

子类的拷贝构造如果我们不写,编译器也会自动生成,自动生成的拷贝构造会自动调用父类的拷贝构造对子类中的父类部分进行拷贝构造,对自定义类型会去调用对应的拷贝构造,然后对内置类型成员完成值拷贝也就是浅拷贝。

无论怎么样,我们参考拷贝函数,对于父类的那一部分必须要调用父类的方法进行拷贝。

如果我们子类的成员需要深拷贝,这时候我们就不得不自己写一份拷贝构造了,这时候父类的那一部分还是需要显式调用父类的拷贝构造来完成拷贝,但是父类的拷贝构造需要传的参数是一个父类的引用,我们怎么得到子类中的父类呢?我们上面所说的切片在这里不久排上用场了吗?我们只需要写父类对象,传参的时候就会赋值转换为一个父类的引用,也就是父类的拷贝构造所需要的参数,那么剩下的就和普通类的拷贝构造一样了,该深拷贝的深拷贝,该值拷贝的值拷贝。

当然这里还是需要在初始化列表进行现实调用拷贝构造,因为初始化的过程是在初始化列表完成的,而不是在函数体内完成的,拷贝构造也是一种初始化。

赋值重载

学完前面的构造和拷贝构造,相比我们已经对这几个默认成员轻车熟路了,还是一样的,子类不写编译器会自动生成,自动生成的赋值重载会去调用父类的赋值重载来对父类的那一部分进行赋值,而对于自定义类型会调用对应的赋值重载,对于内置类型完成字节为单位的值拷贝,

而如果我们需要自己实现赋值重载,那么也需要调用父类的赋值重载来对父类的那一部分进行赋值,那么按照之前的写法,我们是不是这样就行了?赋值重载是不需要在初始化列表调用的,因为赋值重载的两个操作对象都是已经初始化好的对象。

	B operator=(const B&b)
	{
		operator=(b);
		_b = b._b;
		return *this;
	}

那么这样是不是就可以了?那只能说你想的还是太简单了

结果就是程序陷入死循环挂了。

我们上面才讲了,父类中与子类同名的成员会被隐藏,我们如果不指定作用域,那么调用就是子类的同名成员,也就是说,我们这里会调用子类的赋值重载,于是就乱套了,陷入了套娃中。正确的做法是指定作用域去调用父类的赋值重载,编译器会自动识别,然后对父类的那一部分进行赋值

	B operator=(const B&b)
	{
		A::operator=(b);
		_b = b._b;
		return *this;
	}

这里为什么会多义词拷贝构造呢?这是因为我们并没有写子类的赋值重载,那么子类中就是用的编译器自动生成的赋值重载,而编译器自动生成的赋值重载可能用的是我们以前讲过的现代写法,也就是利用传值传参,然后直接交换两个对象的内容来完成赋值,所以在传参的过程中由于是传值传参所以会调用到 A 的拷贝构造。

析构函数

编译器自动生成的析构函数肯定还是会调用自定义类型和父类的析构函数,对内置类型不做处理,我们看一下,便于观察我们需要写一个A的析构函数来打印一串信息

	~A()
	{
		cout << "A:析构函数" << endl;
	}

确实调用了父类的析构函数来完成对父类那部分的析构。

那我们自己首先析构函数呢?也需要显式调用父类的析构函数吗?

当我们写上去之后就会发现,编译器并不会识别到我们显式写的析构函数,而是把它当作 ~ 运算符和一个匿名的 A 对象来处理,这是为什么呢?同时,当我们指定作用域之后,又发现没有报错了,

这说明了什么?有两个点,

1 析构函数的函数名一定被处理了,底层的函数名绝对不是~+类名,不然编译器不可能识别不出来这是A的析构函数,而是把它识别为一个匿名对象

2 指明类域就能够调用A的析构函数,这说明A的析构函数被隐藏了,那么就只有一个可能,B的析构函数和A的析构函数的函数名被处理成一样的了。同时我们也能发现一个小的点,就是在自己的类域中,还是能够识别出 ~+类名 是析构函数的,但是出了类域,编译器就不会把他当作析构函数了。

其实,由于后面我们讲的多态的需要,编译器会把所有的析构函数都做特殊处理,把函数名处理成destructor。

那么这样就完了吗?

我们运行代码来验证一下刚刚写的析构函数

奇怪的是,他怎么调用了两次A的析构函数呢?这不是又出错了吗,如果我们的类中有资源需要释放,那么岂不是会释放两次,程序不就崩了吗,所以我们到目前还是没有把子类的析构函数搞明白。

这时候我们就只能看一下汇编代码来看一下编译器到底做了什么了。

我们发现,除了我们显式调用的A的析构函数,编译器在B的析构函数返回之前,还去调用了依次A的析构函数,这一次是编译器自动去调用的,与我们无关。所以才会导致A的析构函数被调用了两次。

那么编译器为什么要怎么做呢?为什么要在B的析构调用返回之前自动调用A的析构呢?这里就涉及到了一个顺序的问题,我们在构造的时候是先初始化的父类还是先初始化的子类呢?当然是先构造的父类的部分,最简单的来说就是父类是子类的一个部分,所以必须先初始化完父类,子类才能够完全初始化,因为子类成员是可能会被对类的成员有依赖关系的。而在资源的释放的过程中,比如栈空间的销毁释放,我们都遵循着一个后进先出的原则(LIFO),而编译器为了保证子类先析构,父类后析构,就会在子类的析构函数的代码执行完之后,在函数返回之前,再来调用一次父类的析构函数。如果编译器把这个工作交给我们来做的话,不能保证用户一定会先析构子类再析构父类,这样一来可能会导致一些其他的问题。所以干脆编译器就自动在子类的析构函数之后调用父类的析构函数了,如此一来,用户写的子类的析构函数就不需要显式调用父类的析构函数了。

这一部分内容就是为了告诉一件事,对父类那一部分的处理,一定要调用的父类的方法来完成,而不能自行处理。

2.5 继承与友元

友元关系能不能够通过继承传递给子类呢?

class A
{
	friend class C;
public:
private:
	int _a = 1;
};

class C
{
public:
	int getb(const B& b)
	{
		return b._b;
	}
	int geta(const A& a)
	{
		return a._a;
	}
private:
	int _c = 2;
};



class B :public A
{
public:
private:
	int _b = 1;
};

int main()
{
	B b;
	A a;
	C c;
	cout << c.geta(a) << endl;
	cout << c.getb(b) << endl;

	return 0;
}

我们发现,C是访问不了B的私有成员的,也就是说,父类的友元并不能够直接访问子类的私有和保护成员。如果想要它能够访问,那么也需要在子类中声明友元关系。

但是我们在实际的编程中是不推荐使用友元的方案的,因为这会破坏封装。

2.6 继承与静态成员

我们知道,普通类中的静态成员变量是整个类所共享的,静态成员函数则有一个特点就是不需要this指针。

那么对于继承关系中父类的静态成员变量能否被子类继承下去呢?直接说结论。

基类定义了static成员,则整个继承体系中只有一个这样的成员的实例,不管派生出多少子类,都只有一个static成员实例,所有的子类对象和父类对象共用这一个成员实例。

同时,基类的静态成员只能在积累类域初始化,不能同过派生类初始化。

静态成员特殊在它不属于某一个对象,而是属于整个类及其所有对象,同时,也属于所有的派生类及其所有对象。

最主要的原因还是因为静态成员不是存储在对象内,而是存在静态区的。同时也就决定了我们下面的用法不会报错。

int A::cnt = 0;

int main()
{
	
	A* pa = nullptr;
	B* pb = nullptr;
	pa->cnt++;
	pb->cnt++;
	(*pa).cnt++;
	(*pb).cnt++;
	A::cnt++;
	B::cnt++;
	cout << A::cnt << endl;
	cout << B::cnt << endl;
		
	return 0;
}

对于 pa 是空指针的情况下,使用 pa->cnt 不会报错,这里我们以前就讲过,不难理解,

但是对于(*pa).cnt 这样的操作竟然也不会报错,这是为什么呢?

首先,我们上面的两个操作的性质是不是一样的?不管是 pa->cnt 还是(*pa).cnt ,这两行代码都不会真正发生解引用,这个指针的唯一的作用就是指定类域,那么是不是 (*pa).cnt  其实就是等价于pa->cnt 的,那你觉得编译器会真的去对 pa 解引用吗,编译器对于 pa->cnt 这样的操作都根本不会去 pa 所指向的对象中去找 cnt ,而是去它所对应的类域中找,那么对于 *pa ,编译器其实也是直接去类域中找cnt的,而不会说真的对 pa 解引用然后在 对象中找。其实这两种写法都相当于后面的 A::cnt ,本质上没有区别,编译器的对这三个指令的处理也是一样的,所以不会报错。

2.7 单继承和多继承

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

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

是单继承还是多继承主要取决于直接父类的个数,直接父类就是你在声明这个类时,冒号后面跟的父类的个数。

多继承在有的场景下确实有他的优势,但是多继承其实不好维护,同时他需要注意的问题也有很多,有很多的坑。在我们实际的使用中,尽量使用单继承,避开多继承这个坑。当然,不管是单继承还是多继承,对于派生类的默认成员函数还是需要按照前面讲的规则来,该显式调用的都得调用。

2.8 菱形继承和菱形虚拟继承

我们说多继承存在很多坑。其实多继承本身是没有问题的,只是C++存在多继承的化,那么就有可能会存在菱形继承,而菱形继承是有问题的。

菱形继承是多继承的一种特殊情况,,继承关系如图:

为什么说菱形继承有问题呢?我们从上面的继承关系图就能看出来,在D的对象中会存在一份对象B和一份对象C,而B和C中都有一份A对象,那么D中就有两份A,如此一来,就有可能会出现二义性和数据冗余的问题。

class A
{
public:int _a=0;
};
class B:public A
{
public:
	int _b = 1;
};

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

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

这个就是二义性的问题,指定类域才能够访问到指定的_a,而数据冗余我们很容易就能看出来,存在两份相同的A,当然如果两份A不相同的话还好说,但是从逻辑上来讲,B和C都是D的部分属性,而A又是B和C的部分属性,同理,A肯定也是D的部分属性,同一个类的同一个属性怎么会不一样呢?所以我们还是认为他是数据冗余的。

二义性的问题还好解决,但是数据冗余的问题在这里是没有办法解决的。

于是就出现了虚拟继承的概念,虚拟继承就是在继承的时候加上 virtual ,那么在哪里加上虚拟继承呢?菱形继承的问题就出在了两个父类继承了同样的一个类,也就是C和B都继承了A ,那么解决问题也应该在这里解决,也就是B和C对于A的继承要设置为虚拟继承。

虚拟继承之恩那个用于解决菱形集成的问题,不能用在其他地方。

这时候就没有问题了。那么虚拟继承是如何解决二义性和数据冗余的问题的呢?我们可以看一下监视窗口的对象模型。

初看我们可能会觉得很奇怪,怎么会有三份A呢?这不是更加冗余了吗

其实并不是有三分A,而是B和C都共享一份A,这份公共的A存储在了D的所有成员的后面,也就是存在最后。

那为什么监视窗口的对象模型中会显示三份A呢?这其实编译器的一种自以为的优化,编译器经过了处理,为了让我们更直观的看到B和C中都有一份A,只不过没有很好的表现出来这两份A其实是共享的,而且并不存储在B和C的内部,而是单独存储在了D中。

虚拟继承使得这两个直接父类的虚基类对象只存在一份,而且不存储在这两个父类对象中,而是存储在子类对象中,父类对象中存储了能够找到该虚基类的方法。

父类中具体是存了什么呢?我们可以打开内存窗口看一下,将B类的指针切片切片出来,然后看一下对应的位置存了什么

我们看到在B对象中,在对象模型中是显示有一份A对象和一个_b,但是其实他并没有存A对象,至少我们没有看到_a的值存在着里面,那这八个字节存的是什么呢?这其实是一个指针,只不过这张图中我们用的是64位平台,我们不太好看出来,我把它换成32位平台来更方便我们理解。

换成32位我们就好看出来了,在B对象中就是存了一个指针和一个_b,并没有存A对象,那么这个指针指向了什么东西呢?我们直接把 D中的B和C的指针指向的内存都看一下

首先我没找到指针所指向的内容,我们还是看不出来这是什么东西,只知道这两块内存都是12个字节,后四个字节是 0 ,用来当作结尾标志,而前四个字节也都是 0,但是我们不知道这四个字节是用来干嘛的,而5-8这四个字节则是存储了一个值,对于 pb ,存储的是0x14,换算过来就是20,而对于pc而言,则是0x0c,也就是12,他们中间差了 8 ,这是干嘛用的?

我们可以画一下d的对象模型,下图是内存对齐之后各部分的存储

这时候我们大概就知道是什么意思了,为什么他们两差了8,就是因为B对象的大小就是8个字节,B和C中存的指针指向的内存块中,中间四个字节表示的就是子类中的父类切片指针到虚基类的偏移量,也就是共享的虚基类对象 A 举例 pb 和 pc 的字节数,这个偏移量就是为了能够通过父类切片对象B或者C来找到这个虚基类A,因为这时候A对象并不是存储在B和C内部的,而是单独拿出来存储了,所以必须要让子类的切片 B和C能够找到A,也就是这个偏移量,B或者C只要拿着这个偏移量B和C的其实地址再加上这个偏移量就能找到共享的虚基类对象了。

我们把B和C中存储的这两个指针所指向的内容叫做虚基表,从它的名字就可以看出来他存的是用来找到虚基类对象的方法。

但是我们还是没有了解前四个字节是用来存储什么的,这四个字节都是0,还没有填充数据,这是为后面我们要学的多态所预留的四个字节。

虚继承和我们正常的继承的差别就很大了,正常的继承是将积累存在派生类对象中的,是连续存储的,根据声明顺序就能够知道基类的基类对象的位置,但是有了虚继承之后,就不能简单地根据声明顺序去计算直接父类的父类的位置了,而是需要拿到偏移量和直接父类的起始地址来计算。

这样一来,当我们拿着父类的切片的指针或引用来访问他们的基类对象时,就会比正常的解引用所需要的时间长,因为中间存在找到偏移量和计算的过程,所以虚继承访问最终基类的效率是比不上普通继承的。虚拟继承其实是牺牲了一些效率来节省空间,一般来说还是不建议,但是当父类的共同基类很大时,虚继承还是能够节省出可观的空间的。

但是,即使虚继承能够解决菱形继承的问题,我们还是不建议用,因为它比普通的单继承和多继承更加复杂。在实际的设计中,我们最推荐的还是单继承,能用单继承解决的问题就千万不要用多继承。

回到最开始,我们说继承是为了解决 “ A 是 B 的问题”,而对于 “A 有一个 B”的问题我们是使用组合来解决的,也就是在 A 中定义一个 B 对象。那么这两种方式的优缺点是什么呢?

首先组合和继承其实都是对类的一种复用,但是复用的方式不一样,作用域和使用的方式上会有一些区别,对于(公有)继承而言,它是能够在子类中去访问基类的公有和保护成员的,而组合则只能访问到内部对象的公有成员 ,继承相比组合具有更大的权限。

但是如果有一个场景组合和继承都能用,我们还是更推荐组合,为什么呢?组合的耦合度更低,为什么这么说呢? 对于继承而言,父类的保护或者公有成员任何一个做出调整,比如成员名发生变化,那么都会影响子类,子类中可能用到了父类的成员,这时候就需要对子类的依赖于父类的那一部分成员进行相应的调整。而对于组合而言,由于他的权限更小,只能用到内部类对象的公有成员,所以只有在内部类对象修改了公有成员的时候才需要做出调整。

补充

还有一个我们上面没有讲出来的点,就是多继承场景下的初始化的顺序,初始化的顺序其实都是按照声明的顺序来的,继承的基类会比派生类自己的成员先进行初始化,而多个直接父类之间,也是谁先被声明就先初始化谁,比如下面的这个场景

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

D对象的初始化的顺序也一定是先初始化内部的B对象,再初始化内部的C对象,最后也是按照声明的顺序来初始化自己的成员变量。

成员的初始化顺序不取决于他在初始化列表中的顺序,而是取决于声明的顺序。

  • 7
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值