一、模板的知识补充
在前面的博客中我们学习了模板的大部分语法,有需要的同学可以查找我一起写的博客。
下面我们要进一步的了解模板,什么是类型模板参数?什么是类的特化?什么是模板的分离编译?
1、非类型模板参数
对于模板参数我们可以分为类型参数于非类型参数:
类型形参即:出现在模板参数列表中,跟在class或者typename之类的参数类型名称
非类型形参:就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
举例:
//类型模板参数 template<class T1,class T2> class studnt { private: T1 _name; T1 _sex; T2 _age; }; //非类型参数模板 template<class T, size_t N> class array { private: T _a[N]; }; }
注意:
1. 浮点数、类对象以及字符串是不允许作为非类型模板参数的。
2. 非类型的模板参数必须在编译期就能确认结果。
2、模板的特化
2.1基本概念:
在一般情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结 果,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板:
// 函数模板 -- 参数匹配 template<class T> bool Less(T left, T right) { return left < right; } int main() { cout << Less(1, 2) << endl; // 可以比较,结果正确 Date d1(2022, 7, 7); Date d2(2022, 7, 8); cout << Less(d1, d2) << endl; // 可以比较,结果正确 Date* p1 = &d1; Date* p2 = &d2; cout << Less(p1, p2) << endl; // 可以比较,结果错误 return 0; }
在上面代码中,我们得出比较二个整数是可以的,比较日期类的大小也是没问题的,但是当我们分别取出d1和d2的地址让Less去比较日期类的时候,这时候我们就得不到我们想要的结果了,因为,这个时候模板推演的是指针,Less函数是按照地址的大小进行比较的,而没有按照日期类的大小,所以在这种场景下,我们就要考虑到进行模板的特化。
在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
2.2函数模板的特化:
1. 必须要先有一个基础的函数模板
2. 关键字template后面接一对空的尖括号<>
3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型
4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
//模板特化 // 函数模板 -- 参数匹配 template<class T> bool Less(T left, T right) { return left < right; } // 对Less函数模板进行特化 template<> bool Less<Date*>(Date* left, Date* right) { return *left < *right; }
这里我们也可以不用模板就能解决上面的问题,我们在针对上面的功能在写一个函数重载就可以了。
bool Less(Date* left, Date* right) { return *left < *right; }
这样写就显的代码非常明了,可读性非常高,因为对于一些参数类型的函数模板,特化就显示的非常复杂,所以函数模板不建议使用特化。
2.3类模板的特化
全特化
全特化即是将模板参数列表中所有的参数都确定化
举例:
template<class T1,class T2> class teacher { public: teacher() { cout << "teacher 这是一个测试" << endl; } private: T1 _name; T2 _age; }; //全特化 template<> class teacher<char ,int> { public: teacher() { cout << "teacher :char int" << endl; } private: char _name; int _age; };
偏特化
任何针对模版参数进一步进行条件限制设计的特化版本。比如我们对上面您个模板类:
部分特化:
//部分特化,第二个参数 template<class T> class teacher<T, int> { public: teacher() { cout << "teacher: T int" << endl; } private: T _name; int _age; };
参数更进一步的限制:
偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本
//限制性特化 template<class T1, class T2> class teacher<T1*, T2*> { public: teacher() { cout << "teacher: T1* T2*" << endl; } private: T1 _name; T2 _age; };
测试:
int main() { teacher<char, int> man1;// 调用全特化版本 teacher<string , int> man2;// 调用特化的int版本 teacher<char* ,int*> man3;//特化调用指针版本 return 0; }
3、模板的分离编译
什么是分离编译:
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
模板的分离编译:
这里其实是三个文件分别在a.h,a.cpp,main。cpp // a.h template<class T> T Add(const T& left, const T& right); // a.cpp template<class T> T Add(const T& left, const T& right) { return left + right; } // main.cpp #include"a.h" int main() { Add(1, 2); Add(1.0, 2.0); return 0; }
就是将模板在.h的文件声明,在.cpp中定义。但是我们这样做编译器会报错。
重定义问题:
这是因为:在C++中,函数模板的定义通常都放在头文件中,而头文件可能被多个源文件包含,当多个源文件包含相同的头文件时,其中的函数模板定义也会被多次包含,从而引发重定义问题。
编译器会对头文件add.h进行两次编译,并生成两个不同的目标文件。然后,编译器试图将两个目标文件链接到一起时,就会发现它们之间存在重复定义的符号,从而导致连接错误。重复定义的符号指的是在多个目标文件中都存在,名称相同但实体不同的符号。在C++中,符号通常是函数名,变量名,类名。
下面是模板分离编译遇到到链接问题:
解决方法:
1、将函数模板的定义放到头文件中,但这样也存在不足的地方:
- 将函数定义写在头文件中,暴露了函数的实现细节
- 不符合分离编译模式的规则
2、 模板定义的位置显式实例化
// 在使用模板的源文件中显式实例化模板 template int Add(const int& x,const int& y); template double Add(const double& x,const double& y);
但是这种方式一般不推荐
- 增加了代码量
使用显式实例化需要在每个需要使用的源文件中都显式地提供实例化类型,这会增加代码量并降低可维护性。
- 容易出现“遗漏”错误
如果某个源文件未显式地提供所需的实例化类型,则该文件中使用的模板将无法正确地实例化,从而导致链接错误。
- 可移植性差
由于不同的编译器和操作系统可能对显式实例化的支持程度不同,因此代码的可移植性可能会受到影响。
相比之下,将模板的声明和定义都放在头文件中,并使用 inline
关键字修饰模板的定义,可以有效地解决模板分离编译问题。这种方法不需要显式实例化,并且可以确保每个使用模板的源文件都能看到模板的定义。
模板总结:
【优点】
- 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库STL因此而产生。
- 增强了代码的灵活性。
【缺陷】
- 模板会导致代码膨胀问题,也会导致编译时间变长。
- 出现模板编译错误时,错误信息非常凌乱,不易定位
二、继承
1、继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象
程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继
承是类设计层次的复用。
下面的讨论都是基本此类展开:
//继承 class Person { public: void Print1() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; cout << "sex:" << _sex << endl; } string _sex = "man"; protected: string _name = "pjb";//姓名 int _age = 18;//年龄 }; class Student :public Person { public: void Print2() { _name = "张三"; _age = 20; _sex = "man"; _grand = 99; cout << "name:" << _name << endl; cout << "age:" <<_age<< endl; cout << "sex:" <<_sex << endl; cout << "_grand :" << _grand<< endl; } protected: int _grand = 100;//分数 }; class Teacher :public Person { void Print3() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; cout << "sex:" << _sex << endl; cout << "_number :" << _number << endl; } protected: int _number = 10086;//号码 }; int main() { Student s; Teacher t; s.Print(); t.Print(); return 0; }
这里我们可以看到类Teatcher和Student类都复用了类Person的成员变量
继承的定义:
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类
继承关系和访问限定符:
继承基类成员访问方式的变化
类成员/继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected 成员 |
派生类的private 成员 |
基类的protected 成员 |
派生类的protected 成员 |
派生类的protected 成员 |
派生类的private 成员 |
基类的private成 员 |
在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡
使用protetced/private继承因为protetced/private继承下来的成员都只能在派生类的类里
面使用,实际中扩展维护性不强。
2、基类和派生类对象赋值转换
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用,这里有个形象的说法叫切片或者切割寓,意把派生类中父类那部分切来赋值过去。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但是必须是基类的指针是指向派生类对象时才是安全的,这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast 来进行识别后进行安全转换。
简单的理解基类和派生对象的转换,其实就是指:子类可以被赋值给父类 ,而父类不能被赋值给子类,因为子类中有的成员而父类不一定有。这里要特别注意的是子类在给父类赋值不存在类型转换。
3、继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员
为了更好的理解继承中的作用域,来看下面代码:
下面二个fun函数什么关系
// 父类和子类的同名成员函数,函数名相同就构成隐藏 class A { public: void fun() { cout << "A::func()" << endl; } }; class B : public A { public: void fun(int i) { cout << "B::func(int i)->" << i << endl; } };
上面我在不特别提醒的情况下,有些同学可能会认为,二个函名相同,但是参数不同,这不就构成了重载吗?其实不然,因为函数重载的前提是二个函数在同一作用域中,上面我们说了子类和父类都是有自己的作用域的,所以这里应该构成的是隐藏(重定义)。
4、派生类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏
下面对继成派生类的默认成员函数的讨论都基于此类:
class Person { public: Person(const char* name) : _name(name) { cout << "Person()" << endl; } Person(const Person& p) : _name(p._name) { cout << "Person(const Person& p)" << endl; } Person& operator=(const Person& p) { cout << "Person operator=(const Person& p)" << endl; if (this != &p) _name = p._name; return *this; } ~Person() { cout << "~Person()" << endl; } protected: string _name; // 姓名 }; // 派生类中 // 1、构造函数,父类成员调用父类的构造函数完成初始化 class Student : public Person { public: Student(const char*name, int num) :Person(name) , _num(num) {} Student(const Student& s) :Person(s) , _num(s._num) {} Student& operator=(const Student& s) { if (this != &s) { Person::operator=(s); _num = s._num; } return *this; } protected: int _num; //学号 };
根据上面代码我们简单验证一下,上面的结论:
我们在建立对象s1需要调用默认构造,我们发现这里子类是先调用父类的默认构造,在去调用子类的默认构造的。
我们调试来看是先调用了父类的默认构造初始了_name,在去调用子类的默认构造初始化_num
我们在调用拷贝构造s2(s1),这时会直接调用父类的拷贝构造 。
在调用赋值重载时s1=s3,也会直接调用父类的赋值重载。
在最后调用析构函数,是先析构子类的成员,在析构父类的成员。
注意:
1、子类析构函数和父类析构函数构成的关系是隐藏。
2、子类先析构,父类在析构。子类析构函数不需要显示调用父类的析构函数。自己会调用。
5、继承的语法小知识
1、友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
2、基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例
6 、复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继
多承:一个子类有两个或以上直接父类时称这个继承关系为多继
菱形继承:菱形继承是多继承的一种特殊情况
这里我们重点讨论多继承中的菱形继承,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。
为了更好的理解菱形继承二异性的问题,下面我们简化一下代码,重新构建一个菱形继承
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; };
通过内存窗口观察对象成员的模型,菱形继承的内存情况:
可以看到,D 对象中存在着三部分成员 – 从 B 继承来的成员、从 C 继承来的成员以及 D 自身的成员;同时,由于 B 和 C 同时继承自 A,所以 D 对象中存在两份 A 的成员,从而造成数据冗余和二义性。
虚拟继承
为了解决菱形继承数据冗余和二义性的问题,C++11引入了虚拟继承 – 虚拟继承可以解决菱形继承的二义性和数据冗余的问题,如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
特别注意:虚拟继承是使用在产生菱形继承的地方,即菱形的腰部,而不是使用在最后出现问题的地方,即菱形的尾部
那我们知道了什么是虚拟继承,那我们进行来用内存窗口来观察,进行虚拟继承后,在内存上有什么变化
这里我们发现内存变的非常奇怪,原来存放B和C对象继承存变成了指针,由于d对象继承的a却放置在最下面,而且我们发现该窗口第二个整形的值恰好为 B/C 对象的起始地址与 A 对象的起始地址的偏移量。 (0x14 = 20B, 0x0c = 12B)
其中B 和 C 对象中多出来的那个指针指向的内容被称为虚基表。
那么为什么进行虚继承的类对象中要记录距离虚基类的偏移量呢?其实是为了满足切片的场景:
可以观察大盘,虽然 1 和 2 都是在 B 对象中去访问 A 成员变量 a,但是 A 的在内存中的位置是不同的,如果此时我们仍然到最下面去访问 _a,那么 2 访问的结果就会是 C 的虚基表指针;但是按照偏移量访问就则不会出现这种问题。
大家可能会认为,这样怎么会解决二义性的问题,我们这里不是占用了更多的空间吗?
其实不然,这是因为我们定义的类非常小,如果类占100个字节,我们存放一个虚基表只要4个字节,这里的节省空间是非常大的。
7、继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
优先使用对象组合,而不是类继承 。
为什么这么说?
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合