C++知识点整理第三弹-提高篇
抽象类
抽象类是一种特殊的类,为了抽象以及设计的目的而引入,处于继承层次结构的较上层
定义
带有纯虚函数的类为抽象类
作用
- 将相关的操作作为接口组织在继承层次结构中
- 能为派生类提供一个公共的根,派生类需要将具体的实现其基类中作为接口的操作
- 抽象类为派生类刻画了一系列的操作结构通用语义
- 派生类需要按照语义具体实现,也可再将这些语义传给自己的子类
注意
- 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。
- 如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。
- 如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
- 抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。
纯虚函数
定义
1 | virtual void func() = 0; |
- 在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。
- 纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。
- 凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。
- 除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
引入原因
- 为了方便使用多态特性,我们常常需要在基类中定义虚函数。
- 在很多情况下,基类本身生成对象是不合情理的。
例如,动物作为一个基类可以派生出老虎、孔 雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数。 - 若要使派生类为非抽象类,则编译器要求在派生类中,必须对纯虚函数予以重载以实现多态性。
- 同时含有纯虚函数的类称为抽象类,它不能生成对象。
继承机制中对象间转换
向上类型转换
- 将派生类指针或引用转换为基类的指针或引用被称为向上类型转换
- 向上类型转换会自动进行
- 而且向上类型转换是安全的。
向下类型转换
- 将基类指针或引用转换为派生类指针或引用被称为向下类型转换
- 向下类型转换不会自动进行
- 因为一个基类对应几个派生类,向下类型转换时不知道对应哪个派生类
- 所以在向下类型转换时必须加动态类型识别技术(RTTI技术),用dynamic_cast进行向下类型转换。
组合和继承
继承
继承是Is a 的关系
比如说Student继承Person,则说明Student is a Person。
继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
缺点:
- 父类的内部细节对子类是可见的。
- 子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
- 如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
组合
- 组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
优点:
- 当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
- 当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
- 当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
缺点:
- 容易产生过多的对象。
- 为了能组合多个对象,必须仔细对接口进行定义。
函数指针
函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
声明方式
1 | int (*pf)(const int&, const int&); |
上面的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数。注意*pf两边的括号是必须的
注意区分:
1 | int *pf(const int&, const int&); |
声明了一个函数pf,其返回类型为int *, 带有两个const int&参数。
引入原因
- 将函数作为参数传递给函数
函数名其实就是一个指针,它指向函数的代码,而函数地址就是该函数的入口。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。
赋值方式
- 指针名 = 函数名
1 | #include <iostream> |
- 指针名 = &函数名
内存泄漏
定义
内存泄露是指由于疏忽或错误造成程序未能释放掉不再使用的内存的情况。大多是应用程序分配某段内存后,由于设计的错误,失去了对该内存段的再次使用的控制。
后果
只发生一次小的内存泄露可能不会引发重大错误甚至可以不被察觉到,但泄露大量的内存会导致程序出现各种问题,可能内存逐渐减少导致性能下降最后导致本程序甚至其他程序崩溃。
解决方案
使用智能指针
检查定位内存泄露
- 在main函数最后面一行,加上一句
_CrtDumpMemoryLeaks()
。 - 调试程序,自然关闭程序让其退出,查看输出。
- 输出这样的格式:
{453}normal block at 0x02432CA8,868 bytes long
- 被{}包围的453就是我们需要的内存泄漏定位值,868 bytes long就是说这个地方有868比特内存没有释放。
- 接着定位代码位置。
- 在main函数第一行加上
_CrtSetBreakAlloc(453);
意思就是在申请453这块内存的位置中断。然后调试程序,程序中断了,查看调用堆栈。需要导入头文件#include <crtdbg.h>
智能指针和RAII
RAII
- RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”。
- 也就是说在构造函数中申请分配资源,在析构函数中释放资源。
RAII引出智能指针
- C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。
- 所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。
智能指针
- 智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现。
- 使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。
- 毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了。
智能指针的实现
- 智能指针是一个数据类型,一般用模板实现,模拟指针行为的同时还提供自动垃圾回收机制。
- 它会自动记录SmartPointer<T*>对象的引用计数,一旦T类型对象的引用计数为0,就释放该对象。
- 除了指针对象外,我们需要一个引用计数的指针记录指向对象的数量,并将引用计数计为1。
- 还需要一个构造析构函数。新增对象调用构造函数,析构函数负责引用计数减少和释放内存。
- 通过重写赋值运算符,才能将一个旧的智能指针赋值给另一个指针,同时旧的引用计数减1,新的引用计数加1。
- 还需要拷贝构造函数、复制构造函数、析构函数、移走函数。
内存对齐
- 分配内存的顺序的按照声明的顺序
- 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
- 结构体的大小必须是其中变量类型最大值的整数倍
- 添加了
#pragma pack(n)
后规则就变成了下面这样:- 偏移量取n和当前变量大小两者中取较小值的整数倍
- 结构体整体大小取n和最大变量大小两者中取较小值的整数倍
- n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
结构体变量比较
- 重载
==
操作符
1 | struct foo { |
- 指针直接比较,如果保存的是同一个实例地址,则(p1==p2)为真;
- 逐个比对元素
函数调用时栈的变化
- 调用者会把被调用的函数所需要的参数会按照形参顺序相反的顺序压入栈中(由右至左)
- 调用者会使用call指令调用函数,并把call指令的下一条指令作为返回地址压入栈中
- 被调用的函数会依次保存调用者的栈底地址以及栈顶地址
- 在被调用的函数中,栈指针的位置开始存放被调函数的局部变量以及临时变量
- 并且这些变量的地址会按照定义的顺序依次减少(栈的增长方向是由上至下)
define/const/typedef/inline
define&const的区别
- const定义的常量是带变量类型的;而define定义的只是一个常数,并不带有类型
- const在编译、链接的过程中起作用;define仅在预编译的阶段起作用
- const是有数据类型的,是要进行类型判断的,可以避免低级错误;define仅在文本的层面上进行字符串替换,没有类型的检测
- const编译后,占用数据段空间;define预处理后,占用代码段空间
- const不能重定义;define可以通过
#undef
取消某个符号的定义,进行重定义 - 使用
#ifndef``#define``#endif
能够用来防止文件的重复引用
define和typedef的区别
- 执行时间不同。define是宏定义,发生在预编译阶段,不会进行类型检测;typedef在编译阶段有效,会进行类型检测
- 功能不同。define不只是可以为类型取别名,还能定义常量、变量等;typedef用来定义类型的别名,定义与平台无关的数据类型,常与struct结合使用
- 作用域不同。define没有作用域的限制,只要在之前预定义过的宏以后的程序都能使用;typedef有自己的作用域。
define和inline的区别
- define是关键字;inline是函数
- define在预编译阶段进行文本替换;inline在编译阶段进行替换
- define没有类型检查;inline函数有类型检测,相比来说更加的安全。
printf函数的实现
- 函数参数由右至左压入栈中
printf()
函数最后压入栈中的是字符串,也就是说字符串在函数中最先被找到- 函数通过判断字符串里面的控制参数来判断参数的个数以及数据的类型
- 通过参数的个数以及类型便可以算出需要栈指针的偏移量了。
lambda函数的了解
- lambda表达式常用于编写内嵌的匿名函数,用以替换独立函数或者函数对象
- 当程序员定义一个lambda表达式后,编译器会自动生产一个匿名类(这个类重载了()运算符),我们称它为闭包类型
- 在运行时,这个lambda表达式就会返回一个匿名的闭包实例,实质上是一个右值
- 所以lambda表达式的结果就是一个闭包,闭包可以通过传值或引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们称它为lambda捕捉块
1 | []() int -> {cout << "hello World";return 0;}; |
字符串打印到屏幕的过程
- 用户告知OS执行打印程序
- OS会找到程序的相关信息,例如:检查其文件类型是否为可执行文件、通过程序首部信息确定代码和数据在可执行文件的位置并计算出对应的磁盘块地址。
- OS创建一个新进程,将可执行文件映射到PCB中
- OS为程序设置CPU执行的上下文,并跳转到程序的开始处
- 执行程序的第一行指令,若发生缺页异常
- OS会分配一页物理内存,并把代码从磁盘读入内存,然后继续执行程序
- 程序会执行一个
puts()
的系统调用,在显示器上打印一段字符串 - OS会找到字符串需要送往的显示设备中,通常设备是由一个进程控制,所以OS会将该字符串送往负责显示器打印的进程中去。
- 控制设备的进程会告诉显卡,我现在需要显示该字符串,显卡确定这是一个合法操作后,就会将该字符串转换成像素点并写入显卡的存储映像去中
- 显卡将像素转换成显示器可接受和控制的数据信号后,传给显示器
- 显示器会解释信号并激发液晶屏
- 大功告成!我们看到的我们需要打印的字符串
模板类和模板函数
- 函数模板的实例化是由编译程序在处理函数调用时自动完成的。
- 类模板的实例化必须由程序员在程序中显式地指定。
- 函数模板允许隐式调用和显式调用。
- 类模板只能显示调用,使用时必须添加
<>
。
类成员的访问/继承权限
访问权限
public
:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被 访问,在类外也是可以被访问的,是类对外提供的可访问接口;private
:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;protected
:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。
继承权限
- 若继承方式是
public
,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限; - 若继承方式是
private
,基类所有成员在派生类中的访问权限都会变为私有(private)权限; - 若继承方式是
protected
,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。
cout和printf的区别
cout<<
是一个函数,函数后面可以跟不同类型是因为cout<<
已经存在针对各种类型的数据重载- 所以会自动识别数据的类型。
- 输出过程会首先将输出字符放入缓存区,然后输出到屏幕中
1 | cout << "abc " <<endl; |
运算符重载
- 我们只能重载已有的运算符,而无权发明新的运算符;
- 对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;
- 不能改变运算符操作数个数;
- 两种重载方式:成员运算符和非成员运算符,下标运算符、箭头运算符必须是成员运算符;
- 成员运算符比非成员运算符少一个参数;
- 引入运算符重载,是为了实现类的多态性;
- 当重载的运算符是成员函数时,this绑定到左侧运算符对象。
- 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
- 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;
函数重载的匹配原则和顺序
- 名字查找
- 确定候选函数
- 寻找最佳匹配
定义和声明的区别
- 变量的声明和定义
- 从编译原理上来说
- 声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。
- 而定义就是分配了内存。
- 函数的声明和定义
- 声明:一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。
- 定义:一般在源文件里,具体就是函数的实现过程 写明函数体。
全局变量和静态变量的区别
注意:全局变量之前再冠以static就构成了静态的全局变量。
- 全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。
- 非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。
- 静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
普通函数和静态函数的区别
- 静态函数的作用域不同。仅在本文件中。只在当前源文件中使用的函数应该说明为静态函数
- 对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。
静态成员和普通成员的区别
- 声明周期
- 静态成员变量从类被加载开始到类被释放,一直存在;
- 普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;
- 共享方式
- 静态成员变量是全类共享;
- 普通成员变量是每个对象单独享用的;
- 定义位置
- 普通成员变量存储在栈或堆中;
- 静态成员变量存储在静态全局区;
- 初始化位置
- 普通成员变量在类中初始化;
- 静态成员变量在类外初始化;
- 默认实参
- 可以使用静态成员变量作为默认实参
- 而普通成员变量却不行
#ifdef/#endif
- 一般情况下,源代码的所有行都会参与到编译中,但如果有些情况我们希望对其中的一部分内容选择性的编译(例如:满足一定条件才进行编译,对一部分内容指定编译条件,也就是条件编译)。希望满足某条件时对一组语句进行编译,否则编译另外一组语句。
- 常见形式:
1 | #ifdef 标识符 |
当标识符已被定义过(一般是用#define命令定义)时,则对程序段1进行编译,否则对程序段2进行编译
- 没有
#else
的形式:
1 | #ifdef 标识符 |
- 一般在大型的软件工程里面,可能会有多个文件包含同一个头文件,当这些文件编译链接成一个可执行文件时,就会出现大量的”重定义”错误。所以我们在头文件中常使用
#define
、#ifdef
、#else
、#endif
来避免头文件重定义。
隐式转换
- C++的基本类型并非完全对立的,部分数据类型之间是可以进行隐式转换。隐式转换指的就是不需要用户干预,编译器会自动进行的类型转换的行为。
- C++面向对象的多态特性,通过父类的类型实现子类的封装。通过隐式转换可以将一个子类对象以父类类型返回。
- 基本数据类型的转换是以取值范围作为转换基础(为了保证精度不丢失)。隐式转换是从小变大的转换(例如:char可以转换为int,int可以转换为long)。
- 自定义对可以隐式的转换为父类对象
explicit
关键字,在构造函数声明的时候加上explicit
关键字,能够禁止隐式转换。- 若构造函数只接受一个参数,它实际上定义了转换为此类类型的隐式转换机制。可以通过
explicit
关键字禁止隐式转换的操作。 explicit
关键字仅对一个实参的构造函数有效,需要多个实参的构造函数不能执行隐式转换,所以无需多参构造函数指定为explicit
。
菱形继承
- 两个子类继承同一个父类,而子类又同时继承该两个子类。
- 若没有使用虚继承的话会产生重复的数据成员
- 若涉及到菱形继承需要使用虚继承
如何看待多继承
- C++允许为一个派生类指定多个基类,这样的继承结构被称做多重继承。
- 多重继承的优点很明显,就是对象可以调用多个基类中的接口;
- 如果派生类所继承的多个基类有相同的基类,而派生类对象需要调用这个祖先类的接口方法,就会容易出现二义性
- 需要加上全局符确定调用哪一份拷贝。
- 使用虚拟继承,使得多重继承类只拥有父类的一份拷贝。
++it和it++
++it
前置自加返回一个引用,不会产生临时对象
1 | int& operator++(){ |
it++
后置自加返回一个对象,会产生临时对象,会导致效率降低
1 | int operator++(int){ |
处理多个异常
- C++中的异常情况:
- 语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。
- 运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。
- 为了有效处理程序运行时错误,C++中引入异常处理机制来解决此问题。
- C++异常处理机制:
- 异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。
- C++异常处理机制由3个模块组成:try(检查)、throw(抛出)、catch(捕获) 抛出异常的语句格式为:throw 表达式;如果try块中程序段发现了异常则抛出异常。
1 | try{ |
模板和实现能否分开
- 因为在编译时模板并不能生成真正的二进制代码
- 而是在编译调用模板类或函数的CPP文件时才会去找对应的模板声明和实现,
- 在这种情况下编译器是不知道实现模板类或函数的CPP文件的存在
- 所以它只能找到模板类或函数的声明而找不到实现,而只好创建一个符号寄希望于链接程序找地址。
- 但模板类或函数的实现并不能被编译成二进制代码,结果链接程序找不到地址只好报错了。
- 《C++编程思想》第15章(第300页)说明了原因:模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间
- 它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。
- 所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
成员函数调用delete this
- 出现问题:
- 在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容
- 类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个
this
指针,让成员函数知道当前是哪个对象在调用它。 - 当调用
delete this
时,类对象的内存空间被释放。 - 在
delete this
之后进行的其他任何函数调用,只要不涉及到this
指针的内容,都能够正常运行。一旦涉及到this
指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。
- 原因:
delete this
之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?- 这个问题牵涉到操作系统的内存管理策略。
delete this
释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。 - 此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。
- 析构函数调用
delete this
- 会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后释放内存”。
- 显然,
delete this
会去调用本对象的析构函数,而析构函数中又调用delete this
,形成无限递归,造成堆栈溢出,系统崩溃。
两数交换
- 平平无奇的交换
1 | temp = x; |
- 算术交换
1 | x = x + y; |
- 异或交换
1 | x = x ^ y; |
strcpy和memcpy
- 复制的内容不同:
- strcpy只能复制字符串
- memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
- 复制的方法不同:
- strcpy不需要指定长度,它遇到被复制字符的串结束符”\0”才结束,所以容易溢出。
- memcpy则是根据其第3个参数决定复制的长度。
- 用途不同:
- 通常在复制字符串时用strcpy。
- 而需要复制其他类型数据时则一般用memcpy。
执行int main()的内存结构
1 | int main(int argc, char *argv[]) |
- 参数的含义是程序在命令行下运行的时候
- 需要输入argc 个参数
- 每个参数是以char 类型输入的,依次存在数组里面,数组是 argv[]
- 所有的参数在指针char * 指向的内存中,数组的中元素的个数为 argc 个。
volatile关键字的作用
- volatile关键字是一种类型修饰符
- 不用它声明的类型变量表示可以被某些编译器未知的因素更改(例如:操作系统、硬件或者其它线程等)
- 遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化
- 从而可以提供对特殊地址的稳定访问。
- 声明时语法:int volatile vInt;
- 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。
使用场景:
- 中断服务程序中修改的供其它程序检测的变量需要加volatile
- 多任务环境下各任务间共享的标志应该加volatile
- 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义
空类包含的函数
1 | Empty(); //缺省构造函数 |
C++的标准库
- 标准函数库:这个库由通用的、独立的、不属于任何类的函数组成。函数库继承自C语言。
- 输入输出IO
- 字符串和字符处理
- 数学
- 时间
- 日期和本地化
- 动态分配
- 宽字符函数
- 面向对象类库:这个库是类及其相关函数的集合。
- 标准C++IO类
- String类
- 数值类
- STL容器类
- STL算法
- STL函数对象
- STL迭代器
- STL分配器
- 本地化库
- 异常处理类
const char*和string
string是C++标准库中的其中一种,封装了对字符串的操作
日常操作中,我们可以用
const char*
来初始化string类
1 | //string 转 const char* |
拷贝构造函数必须传引用
- 拷贝构造函数的作用就是用来复制对象的,在使用这个对象的实例来初始化这个对象的一个新的实例。
- 参数传递过程到底发生了什么?
- 将地址传递和值传递统一起来,归根结底还是传递的是”值”(地址也是值,只不过通过它可以找到另一个值)
- 值传递: 对于内置数据类型的传递时,直接赋值拷贝给形参(注意形参是函数内局部变量); 对于类类型的传递时,需要首先调用该类的拷贝构造函数来初始化形参(局部对象);
- 引用传递: 无论对内置类型还是类类型,传递引用或指针最终都是传递的地址值!而地址总是指针类型(属于简单类型), 显然参数传递时,按简单类型的赋值拷贝,而不会有拷贝构造函数的调用。
- 拷贝构造函数用来初始化一个非引用类类型对象,如果用传值的方式进行传参数,那么构造实参需要调用拷贝构造函数,而拷贝构造函数需要传递实参,所以会一直递归。
- 这就是为什么拷贝构造函数使用值传递会产生无限递归调用,内存溢出。
空类的大小
- 空类的大小不为0,不同编译器设置不一样,vs为1;
- C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;
- C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。
- 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;
指针&引用作为参数的情况
- 使用引用参数的主要原因:
- 程序员希望能修改调用函数中数据对象的内容
- 通过传递引用可以提高程序的运行速度
- 对于仅使用参数值而不做修改的函数:
- 如果数据对象很小,如内置数据类型或者小型结构,则按照值传递
- 如果数据对象是数组,则使用指针(唯一的选择),并且指针声明为指向
const
的指针 - 如果数据对象是较大的结构,则使用
const
指针或者引用,以提高程序的效率。这样可以节省结构所需的时间和空间 - 如果数据对象是类对象,则使用
const
引用(传递类对象参数的标准方式是按照引用传递)
- 对于需要修改函数中数据的函数:
- 如果数据是内置数据类型,则使用指针
- 如果数据对象是数组,则只能使用指针
- 如果数据对象是结构,则使用引用或者指针
- 如果数据是类对象,则使用引用
静态函数不能定义为虚函数
- 静态成员不数据任何对象,所以定义为虚函数没有任何意义
- 静态与非静态成员函数之间最主要的区别,那就是静态函数没有
this
指针 - 虚函数依靠
vptr
和vtable
处理,vptr
是一个指针,在类的构造函数中生成,并且只能用this
指针来访问它 vptr
是类的一个成员,并且vptr
指向保存虚函数地址的vtable
。- 对于静态成员函数,它没有this指针,所以无法访问
vptr
。 - 虚函数的调用关系:
this
->vptr
->vtable
->virtual function
,所以这就是为何静态函数不能为virtual
this调用成员变量时的堆栈
- 当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。
- 即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。
- 例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
静态绑定和动态绑定
- 对象的静态类型:采用声明时的类型,编译器便确定类型。
- 对象的动态类型:对象的类型在运行期才决定。
- 静态绑定:绑定的是对象的静态类型和特性在编译期确定。
- 动态绑定:绑定的是对象的动态类型和特性在运行期确定。
计算子类的个数
- 为类设计一个static静态变量count作为计数器
- 类定义结束后初始化count
- 在构造函数中对count进行+1
- 设计拷贝构造函数,在进行拷贝构造函数中进行count +1,操作
- 设计复制构造函数,在进行复制函数中对count+1操作
- 在析构函数中对count进行-1
快速定位错误
- 简单错误:
直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。
- 复杂的模板错误,最好使用生成输出窗口:
多数情况下出现错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析
虚函数的代价
- 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类
- 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小
- 不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内敛函数
类对象大小影响因素
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的
- 虚函数的话,会在类对象插入
vptr
指针,加上指针大小 - 当该该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展
移动构造函数
有时候我们会遇到这样一种情况,我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
- 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制;
- C++引入了移动构造函数,专门处理这种,用a初始化b后,就将a析构的情况;
- 与拷贝类似,移动也使用一个对象的值设置另一个对象的值。
- 但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。
- 移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。
- 典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用。
- 因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对象进行构造初始化的时候,调用移动构造函数。
- 类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作;
1 | Example (Example&& x) : ptr(x.ptr){ |
自动生成构造函数的条件
- 如果一个类没有任何构造函数,但他含有一个成员对象,该成员对象含有默认构造函数,那么编译器就为该类合成一个默认构造函数,因为不合成一个默认构造函数那么该成员对象的构造函数不能调用;
- 没有任何构造函数的类派生自一个带有默认构造函数的基类,那么需要为该派生类合成一个构造函数,只有这样基类的构造函数才能被调用;
- 带有虚函数的类,虚函数的引入需要进入虚表,指向虚表的指针,该指针是在构造函数中初始化的,所以没有构造函数的话该指针无法被初始化;
- 带有一个虚基类的类
注意:
- 根据《深度探索C++对象模型》书上的说法,编译器只在一定需要默认构造函数时,才会创建默认构造函数
- 所以并不是任何没有构造函数的类都会合成一个构造函数
- 编译器合成出来的构造函数并不会显式设定类内的每一个成员变量
自动生成拷贝构造函数的条件
- 对一个对象做显示的初始化操作
1 | Person p1; |
- 当对象被当做参数交给某个函数时
- 当函数传回一个类对象时
- 如果一个类没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,此时编译器会为该类合成一个拷贝构造函数
- 如果一个类没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类,此时编译器会为该类合成一个拷贝构造函数
- 如果一个类没有拷贝构造函数,但是该类声明或继承了虚函数,此时编译器会为该类合成一个拷贝构造函数
- 如果一个类没有拷贝构造函数,但是该类含有虚基类,此时编译器会为该类合成一个拷贝构造函数
成员初始化列表
- 需要使用的情况:
- 初始化一个应用成员变量
- 初始化一个const成员变量
- 调用一个基类的构造函数,而构造函数拥有一组参数
- 调用一个成员类的构造函数,而构造函数拥有一组参数
- 调用过程:
- 编译器会逐一操作初始化列表
- 在用户的代码前以适当的顺序在构造函数之内安插初始化操作
- 初始化的顺序时由类中的成员声明顺序决定的
- 而非初始化列表中的排列顺序决定的
构造函数的执行顺序
- 派生类构造函数中,所有虚基类以及上层基类的构造函数调用
- 对象的
vptr
初始化 - 如果有成员初始化列表,将在构造函数体内扩展开,这步骤需要在
vptr
初始化完再进行 - 执行程序员提供的代码
构造函数的扩展过程
- 记录在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序
- 如果一个成员并没有出现在成员初始化列表中,但它有一个默认构造函数,那么默认构造函数必须被调用
- 如果类有虚表,那么虚表指针必须被设定初值
- 所有上一层的基类构造函数必须被调用
- 所有虚基类的构造函数必须被调用
扩展顺序和执行顺序刚好时相反,栈的逻辑
不能为虚函数的函数
- 构造函数
构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
- 内联函数
内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
- 静态函数
静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数
友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数
普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
我们说人话就是,使用虚函数前提就是必须是类的成员,但类的成员函数不一定可以是虚函数(例如:构造函数和静态成员函数)
strcpy、sprintf和memcpy的区别
操作对象不同:
- strcpy的两个操作对象均为字符串。
- sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串。
- memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
执行效率不同
- memcpy最高,strcpy次之,sprintf的效率最低。
实现功能不同
- strcpy主要实现字符串变量间的拷贝
- sprintf主要实现其他数据类型格式到字符串的转化
- memcpy主要是内存块间的拷贝。
引用作为参数的好处
传递引用和传递指针的效果是一致的:
- 这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,
- 所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作:
- 使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本。
- 如果传递的是对象,还将调用拷贝构造函数。
- 因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
虽然使用指针作为函数的参数虽然也能达到与使用引用的效果,但是:
- 在被调函数中同样要给形参分配存储单元,且需要重复使用
*指针变量名
的形式进行运算,这很容易产生错误且程序的阅读性较差。 - 另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
- 在被调函数中同样要给形参分配存储单元,且需要重复使用
数组和指针的区别
- 数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:
sizeof(数组名)
;数组大小:sizeof(数组名)/sizeof(数组元素数据类型)
- 用运算符
sizeof
可以计算出数组的容量(字节数)。sizeof(p)
,其中p为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量。 - 编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。
- 在向函数传递参数的时候,如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率;
- 在使用下标的时候,两者的用法相同,都是原地址加上下标值,不过数组的原地址就是数组首元素的地址是固定的,指针的原地址就不是固定的。
如何阻止类被实例化
- 将类定义为抽象基类或者将构造函数声明为private
- 不允许类外部创建类对象,只能在类内部创建对象
禁止自动生成拷贝构造函数
- 为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成private,防止被调用。
- 类的成员函数和friend函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误;
- 针对上述两种情况,我们可以定一个base类,在base类中将拷贝构造函数和拷贝赋值函数设置成private,那么派生类中编译器将不会自动生成这两个函数,且由于base类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。
debug和release的区别
- debug调试版本:
- 包含调试信息,所以容量比Release大很多。
- 并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。
- Debug模式下生成两个文件,除了.exe或.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息;
- release发布版本:
- 不对源代码进行调试
- 编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。
- Release模式下生成一个文件.exe或.dll文件。
实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。
main函数的返回值
程序运行过程入口点main()
函数,main()
函数返回值类型必须是int
,这样返回值才能传递给程序激活者(如操作系统)表示程序正常退出。
简单的模板函数
1 | #include<iostream> |
智能指针解决循环引用的问题
- 弱指针用于专门解决
shared_ptr
循环引用的问题,weak_ptr
不会修改引用计数,即其存在与否并不影响对象的引用计数器。 - 循环引用就是:两个对象互相使用一个
shared_ptr
成员变量指向对方。 - 弱引用并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
strcpy函数和strncpy区别
1 | char* strcpy(char* strDest, const char* strSrc) |
strcpy()
函数:
如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用strncpy()来取代。
strncpy()
函数:
用来复制源字符串的前n个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置n个字符。
- 如果目标字符串长度>指定字符串长度>源字符串长度,则将源字符串全部拷贝到目标字符串,自动加上’\0’。
- 如果指定指定字符串长度<源字符串长度,则将源字符串中按指定字符串长度拷贝到目标字符串,不包括’\0’。
- 如果指定字符串长度>目标字符串长度,运行时错误。
static_cast转换的优点
- 更加安全
- 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转换;可读性更好,能体现程序员的意图。
成员函数调用memset(this,0,sizeof(*this))
- 有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,
- 我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,
- 所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。对于这种情形可以很好的工作。
但是下面几种情形是不可以这么使用的:
- 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
- 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
回调函数
- 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;
- 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:
- 声明
- 定义
- 设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用
- 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
- 因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。
纯虚函数和虚函数的区别
- 虚函数:
- 虚函数是为了实现动态编联产生的
- 目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数。
- 虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。
- 当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
- 纯虚函数:
- 纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。
- 纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用
=0
。 - 既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体。因此它在虚函数表中的值就为0,而具有函数体的虚函数则是函数的具体地址。
- 一个类中如果有纯虚函数的话,称其为抽象类。
- 抽象类不能用于实例化对象,否则会报错。
- 抽象类一般用于定义一些公有的方法。
- 子类继承抽象类也必须实现其中的纯虚函数才能实例化对象。
1 | #include <iostream> |
代码到可执行程序的过程
- 预编译:处理
#
打头的预编译指令 - 编译:生成汇编代码
- 汇编:生成机器语言
- 链接
- 静态链接
- 空间浪费
- 更新困难
- 运行速度快
- 动态链接
- 共享库
- 更新方便
- 性能损耗
- 静态链接
C语言实现继承
C++的继承:
1 | #include <iostream> |
C语言实现继承与多态:
1 | #include <stdio.h> |
动态编译与静态编译
- 动态编译:
- 动态编译的可执行文件需要附带一个动态链接库
- 在执行时,需要调用其对应动态链接库的命令。
- 所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。
- 缺点是哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。
- 静态编译:
- 编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来
- 连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库;
经典的锁
读写锁
- 多个读者可以同时进行读
- 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
- 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
互斥锁
- 一次只能一个线程拥有互斥锁,其他线程只有等待
- 互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒
- 而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
- 互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右
- 而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁
条件变量
- 互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。
- 而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,
- 他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。
- 一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。
- 总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
自旋锁
- 如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。
- 如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功
- 但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
左值引用和右值引用
C++11正是通过引入右值引用来优化性能,具体来说是通过移动语义来避免无谓拷贝的问题,通过move语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去。
左值和右值
- 左值:
表示的是可以获取地址的表达式,它能出现在赋值语句的左边,能对该表达式进行赋值。但是修饰符const的出现使得可以声明如下的标识符,它可以取得地址,但是没办法对其进行赋值。
- 右值:
表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。
左值引用和右值引用
- 左值引用:
传统的C++中引用被称为左值引用
- 右值引用:
C++11中增加了右值引用,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置。
右值引用的特点
- 通过右值引用的声明,右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样长,只要该变量还活着,该右值临时量将会一直存活下去。
- 右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值。
- T&& t在发生自动类型推断的时候,它是左值还是右值取决于它的初始化。
我们举个栗子:
1 | #include<iostream> |
感谢
转载自https://github.com/forthespada/InterviewGuide,感激大佬的整理和分享!