C++语法复习
1. C++入门基础
缺省参数
- 半缺省参数必须从右往左依次来给出,不能间隔着给
- 缺省参数不能在函数声明和定义中同时出现
- 缺省值必须是常量或者全局变量
- C语言不支持(编译器不支持)
函数重载
函数重载是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这 些同名函数的形参列表**(参数个数 或 类型 或 类型顺序)不同**,常用来处理实现功能类似数据类型不同的问题。
原理:函数名的修饰规则 链接时需要通过函数名找到函数实现
gcc的函数修饰后名字不变。而g++的函数修饰后变成【_Z+函数长度 +函数名+类型首字母】
通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修 饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载
引用和指针
引用概念:引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空 间,它和它引用的变量共用同一块内存空间。
引用和指针的区别:
从语法的角度来说:引用是给已有的变量取别名,不开空间;指针是开空间存储对象的地址,指向对象
从使用角度来说:
- 引用定义的时候需要初始化,指针可以不初始化
- 上面推论:没有NULL引用,但有NULL指针, 引用比指针使用起来相对更安全
- 指针可以改变指向,引用不可以改变
- 有多级指针,但是没有多级引用
- sizeof指针和引用含义不一样
- 引用++和指针++表达的含义不一样
- 访问实体的方式不一样,指针需要显示解引用,引用由编译器处理
从底层实现的角度来说(最后):引用在底层实现上实际是有空间的,因为引用是按照指针方式来实现的
引用的使用场景:
1.做参数
2.做返回值(注意使用安全,不要返回栈上的临时对象)
内联inline
C++建议使用 const enum inline 代替 宏, 内联/宏 和 函数对应
再来说说宏的缺点:
- 容易出错,符号优先级问题
- 不能调试,预处理阶段已经替换,看不到宏的具体处理逻辑
- 不够严谨,与类型无关,缺少类型检查
宏的优点:对于各种类型都适配,可以增加代码的复用性,当处理小型计算的任务时宏比函数调用的消耗更小
内联:以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会 用函数体替换函数调用,缺陷:可能会使目标文件变大(这一点和宏一样),优势:少了调用开销,提高程序运 行效率
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址 了,链接就会找不到
nullptr
因为NULL在C++中可能是0也可能是((void*)0)
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
- 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入 的。
- 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
- 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。
2. 类和对象
面向对象和面向过程的区别?
面向对象更加注重:类和类之间的关系(如:栈的实现,容器适配器、迭代器统一(反向迭代器),算法通过迭代器获取容器的数据)
面向对象更加注重实现过程
**面向对象(Object-Oriented Programming, OOP)**是一种编程范式,它基于“对象”的概念,将数据和操作数据的方法组织在一起。在面向对象编程中,对象是类的实例,类定义了对象的属性(数据成员)和行为(方法)。对象可以互相通信,通过调用彼此的方法来完成任务。面向对象的四个核心原则是封装、继承、多态和抽象。
面向对象的主要特点:
- 封装:隐藏对象的内部细节,只对外提供接口进行交互,保护数据的安全性。
- 继承:允许创建一个新类(子类)作为现有类(父类)的扩展,继承其属性和方法。
- 多态:同一方法可以根据调用它的对象类型表现出不同的行为。
- 抽象:通过抽象类或接口来定义通用行为,实现代码的重用和模块化。
面向过程(Procedural Programming): 面向过程编程更侧重于步骤和函数的组合来解决问题。程序被设计为一系列有序的步骤,每个步骤对应一个函数或子程序,这些函数直接操作数据。面向过程编程不强调对象的概念,而是以数据为中心,通过函数来处理数据。
面向对象与面向过程的区别:
- 编程思路:面向对象是基于类和对象,通过对象之间的交互实现功能;面向过程是通过函数调用来完成任务序列。
- 封装性:面向对象封装的是数据和操作数据的方法,而面向过程主要封装的是功能逻辑。
- 结构与复用:面向对象支持继承和多态,使得代码更容易复用和扩展;面向过程的复用主要依赖函数和模块。
- 复杂性管理:面向对象更适合处理复杂的系统,因为它能更好地模拟现实世界中的实体和关系;面向过程则适用于简单的、线性的任务。
类大小的计算
内存对齐
和结构体的内存对齐规则一样
- 首元素位于0偏移量地址
- 对齐数 = 编译器的默认对齐数 和 对象大小 (取小)
- 整体大小等于最大对齐数的整数倍
空类大小(占位)
注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。
成员函数代码位于代码区,成员变量在实例化位置定义
struct 和 class 的区别
访问限定符、继承关系、兼容C语言
struct访问限定符默认public,class访问限定符默认private
struct默认public继承,class默认private继承
struct兼容C语言,在C语言中是结构体,在C++中可以定义成员函数方法,class不兼容C语言
this指针
C++编译器给每个“非静态的成员函数“增加了一个隐藏 的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量” 的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编 译器自动完成
特性:
- this指针的类型:类类型 const*,即成员函数中,不能给this指针赋值
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给 this形参。所以对象中不存储this指针
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
this指针储存位置(寄存器ecx或调用位置的栈空间)
从概念上讲,当你调用一个非静态成员函数时,编译器会自动将调用该函数的对象的地址作为第一个隐式参数传递给该函数,这个地址就是this指针的值。在函数内部,你可以通过this指针来访问或修改对象的成员。
然而,this指针并不是存储在对象的内存布局中的一部分。它更像是函数调用的一个“附加”参数,由编译器在函数调用时自动处理。
在大多数实现中,当你调用一个成员函数时,编译器会生成一些额外的代码来设置this指针。这个指针通常会被存放在一个特殊的寄存器中,或者如果寄存器不可用,它可能会被压入调用栈中。但是,这些都是实现细节,并且在不同的编译器和平台上可能会有所不同
this指针可以为空吗,当你调用的成员函数内部不访问成员变量(就不会解引用报错)- 使用方式类似于静态成员函数
八个默认成员函数
- 构造和析构
- 拷贝构造和赋值
- 移动构造和移动赋值
- 取地址重载和const取地址重载(基本不用)
一般情况下,构造都是要自己实现的
深拷贝的类,需要提供拷贝构造、赋值重载和析构
移动构造和移动赋值,当上面三个是编译器默认生成的,编译器才会自己在生成
也就是说一般深拷贝的类需要自己提供移动构造和移动赋值
默认生成的,会对内置类型浅拷贝,对自定义类型调用其拷贝构造或赋值
对于默认移动构造和移动赋值,会对内置类型拷贝,自定义类型调用其对应的(自己实现的yes)移动构造,如果没有调用拷贝构造
初始化列表
特性:所有的构造初始化都会经过初始化列表
哪些成员必须要在初始化列表初始化?
- 引用成员变量
- const成员变量
- 没有默认构造的自定义成员变量
无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数
初始化列表的初始化顺序是成员对象的声明顺序
运算符重载
目的是让自定义类型用运算符,增强可读性
哪些运算符不能重载 (.* .)
class Person
{
public:Person(double name = 1.1, int age = 0):_name(name), _age(age){}void* operator new(size_t size) // new重载{}void operator delete(void* p) // delete重载{}
private:Test2 _name;int _age;
};
友元(了解)
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以 友元不宜多用
友元函数
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在 类的内部声明,声明时需要加friend关键字
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元函数的调用与普通函数的调用原理相同
友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员
- 友元关系是单向的,不具有交换性
- 友元关系不能传递
- 友元关系不能继承
explicit关键字
构造函数对于单个参数或者除第一个参数无默认值其余均有默认值 的构造函数,还具有类型转换的作用。
使用用explicit修饰构造函数,将会禁止构造函数的隐式转换
static成员
不属于类的成员大小
可以当作静态全局变量,只是受类域的限制
需要在类外定义
所有类实例化的对象共用一个静态成员变量
- 静态成员函数可以调用非静态成员函数吗?(参数传了this就可以)
- 非静态成员函数可以调用类的静态成员函数吗?可以
3. 内存管理¥
内存分布
malloc/calloc/realloc 区别
。。。
new/delete 和 malloc/free 区别(重点)
- 性质 操作符运算符 库函数
- 使用 需不需要大小/返回值类型强转与否/出错返回空或抛异常
- 功能 对于类型的初始化,和对自定义类型的析构
new和new[]的底层实现原理
new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过 operator delete全局函数来释放空间。
operator new (全局)+ 构造函数 operator new封装malloc
为什么?解决抛异常的问题,operator new失败内部抛异常
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间失败,尝试执行空 间不足应对措施,如果改应对措施用户设置了,则继续申请,否则抛异常
定位new/显示调用构造函数 - new(地址)类型
显示调用析构 - 地址->~类型()
内存泄漏
堆内存泄漏(Heap leak) 和 系统资源泄漏(套接字、文件描述符、管道)
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内 存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对 该段内存的控制,因而造成了内存的浪费。 内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现 内存泄漏会导致响应越来越慢,最终卡死。
如何避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps: 这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智 能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵
4. 模板
非类型模板参数,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用(整形)
使用模板可以实现一些与类型无关的代码
语法格式
template<class T>bool Less(T left, T right){return left < right;}
在模板函数使用的时候一般是传参,然后编译器推导
对类模板使用使用时,一般是显示指定类型—类类型<模板参数类型> 对象名
但对于一些特殊类型的可能会得到一些错误的结果
例如:对比指针类型,比较的是指针的地址,而不是指向的对象大小关系
此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
模板特化
函数模板特化(函数模板特化必须要指定特定的类型,没有偏特化的说法)
- 必须要先有一个基础的函数模板
- 关键字template后面接一对空的尖括号<>
- 函数名后跟一对尖括号,尖括号中指定需要特化的类型
- 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
namespace kele
{template<class T> // 基础的函数模板bool less(T a, T b){return a < b;}template<> // 特化的函数模板bool less<int*>(int* a, int* b){return *a < *b;}bool less(int* left, int* right) // 函数 优先级最高{return *left < *right;}
}
注意:一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出
类模板特化
全特化:全特化即是将模板参数列表中所有的参数都确定化。
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。
- 部分特化
- 参数更进一步的限制(指针/引用)
要注意:参数限制还有const限制要考虑
namespace kele // 反向迭代器利用萃取类(模板偏特化)
{ template <class Iterator>struct iterator_traits {typedef typename Iterator::value_type value_type;typedef typename Iterator::pointer pointer;typedef typename Iterator::reference reference;};template <class T>struct iterator_traits<T*> {typedef T value_type;typedef T* pointer;typedef T& reference;};template <class T>struct iterator_traits<const T*> {typedef T value_type;typedef const T* pointer;typedef const T& reference;};template<class Iteartor>struct Reverse_Iterator{typedef typename iterator_traits<Iteartor>::value_type value_type;typedef typename iterator_traits<Iteartor>::reference reference;typedef typename iterator_traits<Iteartor>::pointer pointer;typedef Reverse_Iterator<Iteartor> self;Iteartor rit;Reverse_Iterator(Iteartor x):rit(x){}template<class iter>Reverse_Iterator(const Reverse_Iterator<iter>& x):rit(x.rit){}reference operator*(){Iteartor tmp = rit;return *(--tmp);}pointer operator->(){return &(operator*());}self operator++(){--rit;return *this;}self operator++(int){self tmp = *this;--rit;return tmp;}self operator--(){++rit;return *this;}self operator--(int){self tmp = *this;++rit;return tmp;}bool operator!=(const self& x) const{return rit != x.rit;}bool operator==(const self& x) const{return rit == x.rit;}};
}
模板分离编译
模板不能分离编译:模板在没有实例化的时候是不会被编译的,在两个文件中,一个有实例化的声明,一个没有函数,就会链接报错,解决方法:
- 将声明和定义放到一个文件 “xxx.hpp” 里面或者xxx.h其实也是可以的。推荐使用这种。
- 模板定义的位置显式实例化。这种方法不实用,不推荐使用。
模板优点和缺点
优点:
- 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
- 增强了代码的灵活性
缺点:
- 模板会导致代码膨胀问题,也会导致编译时间变长
- 出现模板编译错误时,错误信息非常凌乱,不易定位错误
5. 继承(重点)
什么是继承?继承的意义
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的protected 成员 | 派生类的protected成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public
在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承
赋值兼容 - 切片
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
基类对象不能赋值给派生类对象
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的
隐藏(重定义)
- 在继承体系中基类和派生类都有独立的作用域
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
- 注意在实际中在继承体系里面最好不要定义同名的成员
类继承
class Person
{
public:void print(){cout << _name << _age << endl;}
protected:string _name = "kele";int _age = 21;
};class Student :public Person
{
public:void print(){cout << _name << _age << _stdid << endl;}
protected:string _name = "lihua";int _stdid = 1111;
};int main()
{Student s;s.print();s.Person::print();//显示访问基类print函数return 0;
}
结果:
lihua211111
kele21
说明理解:成员也可以隐藏,这种隐藏更加像是在不同类域的显示优先级,例如同名的局部变量和全局变量
派生类的默认成员函数行为
- 构造函数:派生类的构造函数必须调用基类的构造函数 初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
- 拷贝构造:派生类的拷贝构造函数必须调用基类的拷贝构造 完成基类的拷贝初始化
- 赋值重载:派生类的operator=必须要调用基类的operator=完成基类的复制
- 析构函数:派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序
- 派生类对象初始化先调用基类构造再调派生类构造。派生类对象析构清理先调用派生类析构再调基类的析构
class Person
{
public:Person(string name, int age):_name(name),_age(age){cout << "Person(string name, int age) 基类构造" << endl;}Person(const Person& t): _name(t._name),_age(t._age){cout << "Person(const Person & t) 基类拷贝构造" << endl;}Person& operator==(const Person& t){if (this != &t){_name = t._name;_age = t._age;}cout << "Person& operator==(const Person& t) 基类赋值" << endl;return *this;}Person(Person&& t):_name(std::forward<std::string>(t._name)),_age(t._age){cout << "Person(Person&& t) 基类移动构造" << endl;}Person& operator==(Person&& t){if (this != &t){_name = std::forward<std::string>(t._name);_age = t._age;}cout << "Person& operator==(const Person& t) 基类移动赋值" << endl;return *this;}~Person(){cout << "~Person() 基类析构" << endl;}void print(){cout << _name << endl << _age << endl;}
protected:string _name;int _age;
};class Student :public Person
{
public:Student(string name, int age, int stdid):Person(name, age),_stdid(stdid){cout << "Student(string name, int age, int stdid) 派生类构造" << endl;}Student(const Student& s):Person(s), // 利用&引用切片_stdid(s._stdid){cout << "Student(const Student & s) 派生类拷贝构造" << endl;}Student& operator==(const Student& s){if (this != &s){Person::operator==(s); // 利用&引用切片_stdid = s._stdid;}cout << "Student& operator==(const Student& s) 派生类赋值" << endl;return *this;}Student(Student&& s):Person(std::forward<Person>(s)), _stdid(s._stdid){cout << "Student(Student&& s) 派生类移动构造" << endl;}Student& operator==(Student&& s){if (this != &s){Person::operator==(std::forward<Person>(s)); // 利用&引用切片_stdid = s._stdid;}cout << "Student& operator==(Student&& s) 派生类移动赋值" << endl;return *this;}~Student(){cout << "~Student() 派生类析构" << endl;}void print(){cout << _name << _age << _stdid << endl;}
protected:int _stdid;
};int main()
{Student s("coke", 22, 21);cout << "-------------------------------------" << endl;Student s1 = s; // 拷贝构造cout << "-------------------------------------" << endl;s == s1; // 赋值cout << "-------------------------------------" << endl;Student s2(move(s));cout << "-------------------------------------" << endl;s1 == move(s2);cout << "-------------------------------------" << endl;return 0;
}
结果:
Person(string name, int age) 基类构造
Student(string name, int age, int stdid) 派生类构造
Person(const Person & t) 基类拷贝构造
Student(const Student & s) 派生类拷贝构造
Person& operator==(const Person& t) 基类赋值
Student& operator==(const Student& s) 派生类赋值
Person(Person&& t) 基类移动构造
Student(Student&& s) 派生类移动构造
Person& operator==(const Person& t) 基类移动赋值
Student& operator==(Student&& s) 派生类移动赋值
~Student() 派生类析构
~Person() 基类析构
~Student() 派生类析构
~Person() 基类析构
~Student() 派生类析构
~Person() 基类析构
总结:只有析构是先析构派生类再析构基类,其他默认成员函数的调用顺序都一样相反
原因:对于构造类,规则是初始化顺序由声明的顺序决定,继承的声明在类最开始的时候;对于析构,由于派生类的成员变量使用了基类的成员(例如指针引用),可能造成析构错误,或者二次析构
网上的解答:
在面向对象编程中,一个对象可能拥有需要手动释放的资源,如动态分配的内存、文件句柄、网络连接等。派生类可能在其构造函数中分配这些资源,并在其析构函数中释放它们。如果基类的析构函数在派生类的析构函数之前被调用,那么派生类释放资源的代码将无法访问这些资源,因为它们可能已经被基类析构函数中的代码释放或改变了。因此,先调用派生类的析构函数可以确保派生类在基类之前释放其拥有的资源。
继承与友元和静态成员
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例
菱形继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
_name在Assistant的对象中Person成员会有两份
class Person
{
public:string _name; // 姓名
};
class Student : public Person
{
protected:int _num; //学号
};
class Teacher : public Person
{
protected:int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};
void Test()
{// 这样会有二义性无法明确知道访问的是哪一个Assistant a;//a._name = "peter";// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}int main()
{Test();return 0;
}
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地 方去使用。
在基础重复的基类时 virtual 继承
虚基表包含:
- 偏移量(Offset):虚基表中存放的是偏移量,这些偏移量用于确定从派生类对象的起始地址到虚基类成员的实际内存地址的偏移量。在菱形继承中
- 以NULL结尾(编译器不同情况下不一样,适用于VS):与虚函数表(virtual function table,简称虚表或V-Table)类似,虚基表也是以NULL结尾的。这标识了表的结束,方便程序在遍历或访问表时知道何时停止。
在g++编译器下,一般采用在虚函数表中放置虚基类的偏移量的方式
继承和组合
- public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称 为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很 大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象 来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复 用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被 封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
6. 多态(重点)
什么是多态?
概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态
静态的多态(编译时):
- 函数重载,利用参数匹配和函数名修饰规则
- 模板,利用模板根据参数实例化
动态的多态(运行时)
和指向对象有关
构成条件
-
父子类,继承关系
-
虚函数重写
虚函数:父类虚函数,子类的虚函数可以不加virtual
重写:三同,特例协变和析构,具体看下面
-
父类指针或者引用调用虚函数
结果:指向父类调用父类虚函数,指向子类调用子类虚函数
重写(覆盖)
首先,必须要是虚函数,重写叫做虚函数的重写
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:void BuyTicket() { cout << "买票-半价" << endl; } // 可以不加virtual但是不建议
};void Func(Person& p) // 切片
{p.BuyTicket();
}int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
重写的特例:
- 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变
常见的使用场景:operator==重写,返回值必须是该对象的引用,所以存在使用场景,规则做出的妥协
- 析构函数的重写(基类与派生类析构函数的名字不同)
编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,这样函数名就相同了
析构函数建议是虚函数
纯虚函数
在虚函数的后面写上 =0 ,则这个函数为纯虚函数
包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
抽象类的作用:
-
定义接口
纯虚函数常用于定义接口类。接口类是一种只包含纯虚函数的类,它定义了一组操作,但具体的实现由派生类提供。这样,不同的派生类可以根据需要实现不同的行为,而接口类则提供了一个统一的访问方式
-
实现多态(可以使用抽象类指针类型)
-
间接强制派生类的重写实现(不重写就还是抽象类)
class Car
{
public:virtual void Drive() = 0;
};
class Benz :public Car
{
public:virtual void Drive() {cout << "Benz-舒适" << endl;}
};
class BMW :public Car
{
public:virtual void Drive() {cout << "BMW-操控" << endl;}
};void func(Car* car)
{car->Drive();
}void Test()
{Benz* pBenz = new Benz;BMW* pBMW = new BMW;func(pBenz);func(pBMW);
}int main()
{Test();return 0;
}
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口
重载、覆盖(重写)、隐藏(重定义)的对比
override/final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写
1. final:修饰虚函数,表示该虚函数不能再被重写,若重写编译报错(在基类虚函数修饰)
class Person {
public:virtual ~Person() final { cout << "~Person()" << endl; }
};class Student : public Person {
public:virtual ~Student() { cout << "~Student()" << endl; }
};
报错:“Person::~Person”: 声明为 “final” 的函数不能由 “Student::~Student” 重写
2. override(重写覆盖) 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。(在派生类虚函数修饰)
class Person {
public:~Person() { cout << "~Person()" << endl; }
};class Student : public Person {
public:virtual ~Student() override { cout << "~Student()" << endl; }
};
报错:“Student::~Student”: 包含重写说明符“override”的方法没有重写任何基类方法
多态原理
虚函数表
// 在32位平台下,sizeof(Base)等于多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main()
{Base b;cout << sizeof(Base) << endl;return 0;
}
通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代 表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表
在多继承的情况下,如果多个基类都包含虚函数指针,那么这个派生类就需要多个虚函数指针
虚函数表本质是一个存虚函数指针的指针数组,在vs下这个数组最后面放了一个nullptr
总结一下派生类的虚表生成(编译阶段):
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
虚函数指针生成在构造函数的初始化列表(编译器自动)
虚函数存在代码段,虚表存在代码段(常量区)
// 虚函数表打印
class Person
{
public:virtual void BuyTicket() {cout << "买票全价" << endl;}virtual void Func() {cout << _id << endl;BuyTicket();}
protected:int _id = 1;
};class Student :public Person
{
public:void BuyTicket() {cout << "买票半价" << endl;}protected:int _id = 2;
};typedef void(*VFPtr) ();void PrintVFTable(VFPtr* p)
{for (int i = 0; p[i] != nullptr; ++i){printf("[%d] -> %p\n", i, p[i]);}cout << endl;
}int main()
{Person p;Student s;PrintVFTable(*(VFPtr**)&p);PrintVFTable(*(VFPtr**)&s);return 0;
}
动态绑定与静态绑定
-
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
-
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体 行为,调用具体的函数,也称为动态多态
多继承关系的虚函数表(拓展)
多继承派生类的虚表个数与继承基类个数相关
多继承派生类的虚表生成顺序与继承声明顺序有关
多继承派生类的未重写的虚函数 放在 第一个继承基类部分的虚函数表(第一个虚表) 的最后
面试题
以下关于纯虚函数的说法,正确的是( )
- 声明纯虚函数的类不能实例化对象
- 子类必须实现基类的纯虚函数
- 声明纯虚函数的类是虚基类
- 纯虚函数必须是空函数
关于虚表说法正确的是( )
- 一个类只能有一张虚表
- 基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
- 虚表是在运行期间动态生成的
- 一个类的不同对象共享该类的虚表
关于虚函数的描述正确的是( )
- 派生类的虚函数与基类的虚函数具有不同的参数个数和类型
- 内联函数不能是虚函数
- 派生类必须重新定义基类的虚函数
- 虚函数可以是一个static型的函数
A D B
程序结果
菱形继承初始化顺序问题
class A {
public:A(char* s) { cout << s << endl; }~A() {}
};
class B :virtual public A
{
public:B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:D(char* s1, char* s2, char* s3, char* s4) :B(s2, s2), C(s3, s3), A(s1){cout << s4 << endl;}
};
int main() {char a[] = "class A";char b[] = "class B";char c[] = "class C";char d[] = "class D";D* p = new D(a, b, c, d);delete p;return 0;
}
class A class B class C class D
多继承的指针偏移问题
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;cout << p1 << endl;cout << p2 << endl;cout << p3 << endl;return 0;
}
p1 == p3 < p2
接口继承参数问题
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main()
{B* p = new B;p->test();return 0;
}
B->1
问答:
- 什么是多态?
多种形态,为了完成某种行为,不同的对象调用会产生不同的结果
- 什么是重载、重写(覆盖)、重定义(隐藏)?
重载是在同一类域中,函数名相同,参数不同,本质上是利用C++函数的修饰规则实现的调用
重定义体现在继承关系中,分别在基类和派生类的两个函数,且函数名相同就构成重定义,基类的函数被隐藏,可以显示调用
重写是在重定义的基础上,两个函数都是虚函数,且函数名,返回值类型和参数类型都相同就构成重写
- 多态的实现原理?
在包含虚函数的类,在编译过程中,会在代码段(常量区)生成一张虚函数表,里面包含虚函数的指针。
通过这个类定义的对象,被实例化的过程中,编译器会通过初始化列表定义一个虚表指针指向这个虚表。
基类对象的指针或引用调用虚函数时,编译器就会通过对象的虚表指针进一步找到虚函数的地址
- 虚函数可以加inlin吗?
可以,加inline可以,但加了也没用
要变成内联函数,由于内联函数是直接展开代码,并不存在函数调用,即没有函数地址,但如果要是虚函数就必须要有虚表,虚表就是存虚函数地址的
是内联函数就不是虚函数,是虚函数就不是内联函数
- 静态成员函数可以是虚函数吗
不行,静态成员函数不可以是虚函数。静态函数是属于类的,不属于对象本身,静态成员函数没有this指针,就找不到虚函数表指针
- 构造函数可以是虚函数吗?
不行,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。实例化对象需要构造函数,调用构造函数需要虚表指针,虚表指针还没有被实例化出来
- 析构函数可以是虚函数吗?
可以,并且最好把基类的析构函数定义成虚函数
- 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为多态调用,运行时调用虚函数需要到虚函数表中去查找
- 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
10.C++菱形继承的问题?虚继承的原理?
数据冗余和二义性
在虚继承的基类成员统一开辟一块区域存储,用虚基表记录偏移量,通过虚基表指针找到偏移量进而找到
11.什么是抽象类?抽象类的作用?
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)
抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
- 定义接口
- 实现多态
- 间接强制子类实现虚函数重写