腾讯社招面试复习系列之一,C++篇

腾讯社招面试复习系列之一,C++语言篇

最近在准备复习面试腾讯游戏开发,接下来会出一系列复习文章,总结一些他人的面试题与经验,以及之前自己面试时经验,并给出一些自己的见解,供大家一起学习。


1.C++的编译过程

(.h/.cpp) -> 预编译处理 -> 编译、优化->汇编 -> 链接^

  • 预编译:宏替换,条件编译指令处理,头文件包含处理,特殊符号处理
  • 编译:词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
  • 优化:主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。
  • 汇编:汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。
  • 链接:链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。

2. 既然有C语言为什么有C++

C++的目的是为了提高生产效率,主要体现在:

  • C++是对C的扩充,同时兼容C,不是新的文法跟程序设计模型,但C++的编译器更严格
  • C++堵塞了C语言中许多的漏洞,并提供更好的类型检查和编译时的分析
  • 预处理器增加了对值替换和宏的限制,减少了查错难度
  • C++增加了引用,允许对函数参数和返回值的地址进行更方便的处理
  • 增加了重载,命名空间等加强了对名字的控制
  • C++与C有相同的底层控制能力,性能差距在10%左右
  • C++表达更清楚与理解
  • C++引入的class与库的概念,因为命名空间的存在,不会有像C语言的名字冲突问题
  • C++的模板,隐藏了代码重用的复杂性
  • C++的健壮异常处理机制
  • C++辅助大型的程序设计方面具有更低的查错成本

3. 面向对象的特点

  • 封装:把客观事物封装成程序中抽象的类,该类包含自己的属性和方法,并且只让可信的类或者对象操作
  • 继承:继承是解决代码的复用,是类和类之间的关系,使得子类具备父类中得方法与属性
  • 多态:在具体继承关系的类中,派生类对象的地址可以赋值给基类指针。对于通过基类指针调用基类和派生类中都有的同名、同参数表的虚函数的语句,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就叫作多态。多态又分为 编译时多态和运行时多态。编译时多态:比如重载,运行时多态:比如重写

4. 定义一个对象与new一个对象的区别

  1. 他们的存储空间不同,直接定义一个对象放在栈上,new一个对象放在堆上
  2. 使用场合不同,由于栈较小并且主要用于存储临时变量,所以定义一个对象在{}的作用域生命周期就完了,new的对象放在堆上,可以通过函数返回他的指针,并且需要手工去销毁这个对象否则会出现内存泄漏

5. 指针跟引用的区别

指针是一个变量,存储的是一个地址,指向内存的一个存储单元;
引用是原变量的一个别名,跟原来的变量实质上是同一个东西,实际是占一个指针的内存,定义时必须初始化
在这里插入图片描述
在这里插入图片描述

6. define\const\inline的区别与联系

总结:const用于代替#define一个固定的值,inline用于代替#define一个函数。是#define的升级版,并进行类型安全检查。
const 与 #define的比较
C++ 语言可以用const来定义常量,也可以用 #define来定义常量。但是前者比后者有更多的优点:
(1)const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查。而对后者只进行字符替换,没有类型安全检查,并且在字符替换可能会产生意料不到的错误(边际效应)。
(2)有些集成化的调试工具可以对const常量进行调试,但是不能对宏常量进行调试。
inline与 #define的比较:
(1)define是纯文本的替换,替换完成后进入编译。
(1)inline是先将内联函数编译完成生成了函数体直接插入被调用的地方,减少了压栈,跳转和返回的操作。

7. C++内存管理

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

:局部变量,形参,自动释放,效率高,一般只有几M
  ,就是那些由malloc分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个malloc就要对应一个free。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  自由存储区,而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,很多编译器的new/delete都是以malloc/free为基础来实现的,new所申请的内存区域在C++中称为自由存储区。藉由堆实现的自由存储,可以说new所申请的内存区域在堆上。
  全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
  常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

8.堆与栈的区别:

  1. 管理方式不同:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
  2. 空间大小不同:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般只有几M。
  3. 碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出。
  4. 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
  5. 分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
  6. 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

9.字节对齐

现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
各成员变量在存放的时候根据在结构中出现的顺序依次申请空间,同时按照4/8的对齐方式调整位置,空缺的字节会自动填充。同时为了确保结构的大小为结构的字节边界数(即该结构中占用最大空间的类型所占用的字节数)的倍数,所以在为最后一个成员变量申请空间后,还会根据需要自动填充空缺的字节。

10.虚函数,虚指针,虚表

虚函数
由virtual 关键字修饰的为虚函数

virtual void Show()		// 声明为虚函数
{
}

虚指针
含有虚函数的class在实例化的时候会默认增加虚指针

  1. 基类没有虚函数,当前类有虚函数,虚指数量针为1
  2. 基类有虚函数,当前类不管有没有有虚函数,n基类有m个基类有虚函数,则虚指针数量为m
#include <iostream>
#include <assert.h>
class Base		//定义基类
{
public:
	Base(int a) :ma(a) {}
	virtual void Show()		// 声明为虚函数
	{
		std::cout << "Base: ma = " << ma << std::endl;
	}
protected:
	int ma;
};

class Base1		//定义基类
{
public:
	Base1(int a) :ma(a) {}
	virtual void Show()		// 声明为虚函数
	{
		std::cout << "Base1: ma = " << ma << std::endl;
	}
protected:
	int ma;
};

class Base2
{
public:
	Base2();
	~Base2();
	virtual void Test()
	{
		std::cout << "Base2: "<< std::endl;
	}

private:

};

Base2::Base2()
{
}

Base2::~Base2()
{
}

class Driver : public Base	,public Base1, public Base2	//派生类
{
public:
	Driver(int b) :mb(b), Base(b), Base1(b + 1) {}
	void Show()				// 没有声明为虚函数
	{
		std::cout << "Driver: mb = " << mb << std::endl;
	}
	virtual void Hide()				
	{
		std::cout << "Hide: mb = " << mb << std::endl;
	}
protected:
	int mb;
};

int main()
{
	Base* base = new Driver(10);
	Base1* base1 = dynamic_cast<Base1*>(base);
	Base2* base2 = dynamic_cast<Base2*>(base);
	Driver* driver = dynamic_cast<Driver*>(base);
	system("pause");
}

上述代码debug的结果:
在这里插入图片描述
可以看到driver继承了3个class,因此有3个虚指针,虽然Driver类提供了virtual Hide()方法,但是并没有增加新的虚指针,猜测应该是往第一个Base类的虚表里面了。

虚表
上述实例的结果中,可以看到,每一个__vfptr保存的是一个地址,该地址其实就是虚表的首地址,首地址保存在全局区,不论实例多少个,都只有一份,虚表中保存了该类虚函数的地址。
注意,
new Driver与new Base,关于Base的vfptr是不一样的,一个指向Base类的虚表,一个是指向Driver类的虚表

纯虚函数

void virtual Test() = 0;

类似这种,虚函数,后面带 = 0的函数称为纯虚函数,纯虚函数的类不能被实例化,子类必须实现父类纯虚函数,才能实例化出来

11.什么是晚绑定

晚绑定(late binding)指的是编译器或解释程序在运行前,不知道对象的类型。使用晚绑定,无需检查对象的类型,只需检查对象是否支持属性和方法即可。玩捆绑是通过虚函数实现的,一般是在具有继承关系的类之间,当new一个实例的时候,就已经初始化好了实例对象的虚表,而在调用函数的时候,不需要关心实例是什么类型,只要找到虚表中的方法调用即可。

12.C++显示类型转换

  1. static_cast:
    用于父子类之间指针或者引用的转换,向上是安全的
    用于基本数据类型之间的转换,安全性由程序员保证
    用于void*转换与目标指针之间的转换,不安全
  2. dynamic_cast:
    用于父子类之间指针或者引用的转换,向上是安全的,向下具有类型检查,比static_cast安全
  3. reinterpret_cast:
    用于没有任何关联之间的转换 例如:指针转int,int转指针
  4. const_cast:
    用于将常指针,常引用,常对象转换成非常类型

13.什么情况栈溢出,怎么处理

一般编译器,栈就几M,下面情况将很容易出现:

  1. 递归调用层次很深,导致一直在压栈
  2. 定义了太大的临时变量,大的数组,定义太多的栈变量

解决方法:

  1. 设置来更改栈内存大小(不推荐,容易引发其他问题)
  2. 改高次递归为循环
  3. 将大的或者很多临时变量封装起来,在堆中申请空间存取

14.构造函数,析构函数可以是虚函数么?

构造函数不能是析构函数:原因是虚指针都还没有初始化,也没指向该类的虚表,如果是虚函数,那就找不到构造函数的位置,没办法正确初始化。所以虚机制在构造函数中不工作。
析构函数常常必须是虚的:为了保证在析构函数中调用某一基类的成员函数是安全的,析构顺序是按照与构造顺序相反的顺序析构的,也就是析构函数自最晚派生的类开始,向上到基类。如果不把析构函数设为虚函数,会带来隐匿的错误,因为它常常不会对程序产生直接影响,但它不知不觉了引入了存储器泄露。类似下面写法就又可能有问题:

Base* base = new Driver(10);
delete base;

这里如果不是虚函数的话,将只调用Base类的析构函数,而不会Driver类的析构函数

15.NULL 和 nullptr 区别

在C语言中,NULL通常被定义为:#define NULL ((void *)0)
所以说NULL实际上是一个空指针,如果在C语言中写入以下代码,编译是没有问题的,因为在C语言中把空指针赋给int和char指针的时候,发生了隐式类型转换,把void指针转换成了相应类型的指针。

int  *pi = NULL;
char *pc = NULL;

但是问题来了,以上代码如果使用C++编译器来编译则是会出错的,因为C++是强类型语言,void*是不能隐式转换成其他类型的指针的,所以实际上编译器提供的头文件做了相应的处理:

#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

可见,在C++中,NULL实际上是0.因为C++中不能把void*类型的指针隐式转换成其他类型的指针,所以为了结果空指针的表示问题,C++引入了0来表示空指针,这样就有了上述代码中的NULL宏定义。
但是实际上,用NULL代替0表示空指针在函数重载时会出现问题,程序执行的结果会与我们的想法不同,举例如下:

#include <iostream>
using namespace std;
 
void func(void* i)
{
    cout << "func1" << endl;
}
 
void func(int i)
{
    cout << "func2" << endl;
}
 
void main(int argc,char* argv[])
{
    func(NULL);
    func(nullptr);
    getchar();
}

结果如下:
在这里插入图片描述
在这段代码中,我们对函数func进行可重载,参数分别是void*类型和int类型,但是运行结果却与我们使用NULL的初衷是相违背的,因为我们本来是想用NULL来代替空指针,但是在将NULL输入到函数中时,它却选择了int形参这个函数版本,所以是有问题的,这就是用NULL代替空指针在C++程序中的二义性。

为解决NULL代指空指针存在的二义性问题,在C++11版本(2011年发布)中特意引入了nullptr这一新的关键字来代指空指针,从上面的例子中可以看到,使用nullptr作为实参,确实选择了正确的以void*作为形参的函数版本。

16.什么是placementNew

placement new的作用就是:创建对象(调用该类的构造函数)但是不分配内存,而是在已有的内存块上面创建对象。用于需要反复创建并删除的对象上,可以降低分配释放内存的性能消耗
用法:

A* p = new (ptr)A;

其中ptr就是程序员指定的内存首地址。

17.如何获取虚函数表以及虚函数地址

class A    //定义一个类A,类中有3个虚函数
{
public:
	int x;
	int y;
	virtual void f(){ cout << "f() called !" << endl; };
	virtual void f1(){ cout << "f1() called !" << endl; };
	virtual void f2(){ cout << "f2() called !" << endl; };
};

然后在主函数中声明一个对象a,&a得到对象a的地址。对象的内存布局中前4个字节数据为vfptr,其中保存了虚函数表的地址,因此将(&a)强制类型转换为(int ),来把从&a开始的4个字节当作一个整体,然后对其进行解引用,就相当于取出这4个字节中的数据,取出的数据就是虚函数表的地址了。因此虚函数表的地址就是(int *)(&a)。

18.template怎么编译的

模板不是代码,而是产生代码的指令,只有模板的实例化才是真正的代码。

模板定义:

模板就是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数, 从而实现了真正的代码可重用性。模版可以分为两类,一个是函数模版,另外一个是类模版。

模板实例化:

模板定义本身不参与编译,而是编译器根据模板的用户使用模板时提供的类型参数生成代码,再进行编译。用户提供不同的类型参数,就会实例化出不同的代码。

为什么声明跟定义在一起:

普通函数,声明放在头文件中,定义放在源文件中,其它的地方要使用该函数时,仅需要包含头文件即可,因为编译器编译时是以一个源文件作为单元编译的,当它遇到不在本文件中定义的函数时,若能够在.h中找到其声明,则会将此符号放在本编译单元的外部符号表中,链接的时候自然就可以找到该符号的定义了,而在.cpp中已经由编译器编译了实现的代码了,因此不会有问题,
模板,如果声明与定义分开,当调用模板时,按道理说应该实例化模板函数,即生成特例函数的相应代码,但是此时.h文件中仅有声明,找不到定义,因此此时,它只会实例化函数的符号,并不会实例化函数的实现,因此并没有生成函数对应的代码。此时编译单元不会报错,但链接就会出现函数未定义的错误。

多个编译单元存在同一个实例化:

标准 C++ 为编译模板代码定义了两种模型。分别是包含编译模型和分别编译模型。
包含编译模型,说白了,就是将函数模板的声明与定义放在头文件中。 包含编译模型有个问题,如果两个或多个单独编译的源文件使用同一模板,这些编译器将为每个文件中的模板产生一个实例。因此给定模板会产生多个相同的实例,在链接的时候,编译器会选择一个实例化而丢弃其他的。
分离编译模型,编译器会为我们跟踪相关的模板定义。但是,我们必须让编译器知道要记住给定的模板定义,因此需要使用 export 关键字。但是,实际上很多编译器都不支持这个关键字,而且C++11 将这个关键字设置为 unsued 和 reserved 了。
所以,结论就是,把模板的定义和实现都放到头文件中。

19.template 特化 偏特化 全特化

特化:模板的特化就是一个一个实体的一般化,因为它在一般条件下描述了某个范围内的一簇类或者函数。给定模板参数时,这些模板参数决定了这一族函数获类的许多可能实例中的一个独一无二的实例,因此这样的结果就被称为模板的一个特化。不管我们是显示声明还是通过参数类型推断获得的,由编译器生成的结果代码都是这个模板的一个特化。生成的代码也被认为是这个模板的实例化。
全特化:特化其实就是特殊化的意思,在模板类里,所有的类型都是模板(template),而一旦我们将所有的模板类型T都明确化,并且写了一个类名与主模板类名相同的类,那么这个类就叫做全特化类。C++模板全特化之后已经失去了Template的属性了。
偏特化:介于主版本模板和全特化之间的模板,它的模板名与主版本模板名相同,但是它的模板型中,有被明确化的部分和没有被明确化的部分。
具体区别可以看下面示例:

//template define
template <class T1, class T2>
bool Compare(T1 var1, T2 var2)
{
	cout << "template " << endl;
	return var1 > var2 ? true : false;
}

//半特化
template<class T2>
bool Compare(int var1, T2 var2)
{
	cout << "template half" << endl;
	return var1 > var2 ? var1 : var2;
}

//全特化
template<>
bool Compare(int var1, int var2)
{
	cout << "template full" << endl;
	return var1 > var2 ? var1 : var2;
}

int main()
{
	Compare(0.5, 0.7);	//特化
	Compare(1, 2);	//全特化
	Compare(1.0f, 2);	//偏特化
	system("pause");
}

结果如下:
在这里插入图片描述
需要注意的点:

  1. 函数模板只有特化,没有偏特化;
  2. 模板、模板的特化和模板的偏特化都存在的情况下,编译器在编译阶段进行匹配,优先选择特化程度最高的;
  3. 模板函数不能是虚函数;因为每个包含虚函数的类具有一个virtual table,包含该类的所有虚函数的地址,因此vtable的大小是确定的。模板只有被使用时才会被实例化,将其声明为虚函数会使vtable的大小不确定。所以,成员函数模板不能为虚函数。

20.常见的内存错误

  1. 内存分配不成功缺使用了。 可以在分配后,增加异常处理
  2. 分配成功,未初始化。 记住初始化
  3. 操作越界。
  4. 忘记释放内存,造成内存泄露。
  5. 释放了内存,还使用它。
    方法:内存分配后,增加异常处理,内存分配后记住初始化,使用数组的时候小心数组访问越界,对象或内存使用完记住delete free,或者使用智能指针,指针释放记得置null,使用记得判空,

21.怎么检查内存泄露,泄露了多少

检测内存泄漏的关键原理就是,检查malloc/new和free/delete是否匹配,一些工具也就是这个原理。要做到这点,就是利用宏或者钩子,在用户程序与运行库之间加了一层,用于记录内存分配情况。

debug模式下:

在Debug环境下,通过VLD这个库或者CRT库本身的内存泄漏检测函数能够分析出内存泄漏,相对而言比较简单下面举例:
原理:Windows平台下面Visual Studio 调试器和 C++ 运行时 (CRT) 库为我们提供了检测和识别内存泄漏的有效方法,内存分配要通过CRT在运行时实现,只要在分配内存和释放内存时分别做好记录,程序结束时对比分配内存和释放内存的记录就可以确定是不是有内存泄漏。在vs中启用内存检测的方法如下:

#define _CRTDBG_MAP_ALLOC
#include <crtdbg.h>
int main()
{
   char *p = (char*)malloc(sizeof(char) * 10);//使用new也能够检测出来
   _CrtDumpMemoryLeaks();
   Driver* d = new Driver(1);
   _CrtDumpMemoryLeaks();
   system("pause");
}

输出窗口将会打印如下:
在这里插入图片描述
上述代码 #define _CRTDBG_MAP_ALLOC开启还会显示在其中分配泄漏的内存的文件。 文件名后括号中的数字(本示例中为 10)是该文件中的行号。由于上述是在本地测试的,并不是上面示例代码的真是行号。

release模式下:

  1. 对象计数
    方法:在对象构造时计数++,析构时–,每隔一段时间打印对象的数量
    优点:没有性能开销,几乎不占用额外内存。定位结果精确。
    缺点:侵入式方法,需修改现有代码,而且对于第三方库、STL容器、脚本泄漏等因无法修改代码而无法定位。
  2. 重载new和delete
    方法:重载new/delete,记录分配点(甚至是调用堆栈),定期打印。
    优点:没有看出
    缺点:侵入式方法,需将头文件加入到大量源文件的头部,以确保重载的宏能够覆盖所有的new/delete。记录分配点需要加锁(如果你的程序是多线程),而且记录分配要占用大量内存(也是占用的程序内存)。
  3. Hook Windows系统API
    方法:使用微软的detours库,hook分配内存的系统Api:HeapAlloc/HeapRealloc/HeapFree(new/malloc的底层调用),记录分配点,定期打印。
    优点:非侵入式方法,无需修改现有文件(hook api后,分配和释放走到自己的钩子函数中),检查全面,对第三方库、脚本库等等都能统计到。
    缺点:记录内存需要占用大量内存,而且多线程环境需要加锁。
  4. 工具
    Valgrind
    微软出品的内存泄漏分析工具 DiagLeak
    Visual Leak Detector(VLD)
    Checkpoint/DumpStatistics
    Windbg
    腾讯WeTest - TMM
    IBM purify
    VMMap
    Application Verifier
    Cppcheck
    具体可以参考: C/C++ 内存泄漏检测工具汇总

22.动态库 静态库的区别

静态库
静态库即静态链接库(Windows 下的 .lib,Linux 和 Mac 下的 .a)。之所以叫做静态,是因为静态库在编译的时候会被直接拷贝一份,复制到目标程序里,这段代码在目标程序里就不会再改变了。
静态库的好处很明显,编译完成之后,库文件实际上就没有作用了。目标程序没有外部依赖,直接就可以运行。当然其缺点也很明显,就是会使用目标程序的体积增大。
动态库
动态库即动态链接库(Windows 下的 .dll,Linux 下的 .so,Mac 下的 .dylib)。与静态库相反,动态库在编译时并不会被拷贝到目标程序中,目标程序中只会存储指向动态库的引用。等到程序运行时,动态库才会被真正加载进来,进行调用。
所以区别如下:
动态库把队一些库函数的链接载入推迟到运行期,可以实现进程间共享,将程序升级变得简单,甚至可以做到链接载入完全有程序员控制
静态库对库函数的链接在编译器间完成,程序运行期与静态库再无瓜葛,移植方便,但是因为每次调用就是拷贝一份代码,浪费空间跟资源。

23.智能指针

为什么要有智能指针:

在处理C/C++程序的时候,常会遇到程序运行突然退出,或者内存占用逐渐升高,最后不得不重启的情况。这些问题可以追溯道C/C++的显示内存管理上。通常情况下,这些症状都是由于没有正确处理堆内存的分配与释放造成的。我们归纳为如下问题

  • 野指针:释放了内存,但是指向它的指针还在使用。这些内存有可能被重新分配给程序使用,导致无法预测的错误。
  • 重复释放:程序试图去释放已经释放过的内存单元,或者释放已经被重新分配过的内存单元,导致重复释放错误。导致运行时系统打印大量错误诊断信息。
  • 内存泄露L:不再需要使用的内存单元没有被释放,导致内存占用加剧。

虽然显示的管理内存在性能上有一定的优势,但也被广泛认为是容易出错的。

auto_ptr

C++98中智能指针通过一个模板类型auto_ptr实现。auto_ptr以对象管理内存的方式管理堆分配的内存,并且在适当的时间析构,释放所获得的内存。这种堆内存管理的方式只需要程序员将new操作返回的指针作为auto_ptr的初始值即可,不需要再显示的delete了。
一定程度上避免了堆内存忘记释放的问题。不过auto_ptr有一些缺点:拷贝时返回的是一个左值,不能调用delete[]
因此C++11废除了auto_ptr,改为unique_ptr,shared_ptr,weak_ptr等智能来自动回收堆对象。

shared_ptr

下面看一段示例代码:
在这里插入图片描述
在这里插入图片描述
shared_ptr:通过上面示例可以发现shared_ptr形如其名,允许多个智能指针共享同一个分配对象的内存,实现上采用引用计数,虽然s调用reset(智能指针自带的方法)函数释放了所有权,但是只是计数减了1,还有同一个计数s2,不会导致内存释放,而当超出指针作用域,计数为0,调用了析构函数。

unique_ptr

再看下面的示例:
在这里插入图片描述
unique_ptr:通过上面示例可以发现unique_ptr不能与其他unique_ptr共享所指对象的内存。比如unique_ptr s2 = s; 则会发生编译错误,但是我们可以通过右值move来转移所有权,一旦所有权转移了,原来的s就不能再调用Fun0方法了,虽然能通过编译,但是运行时会发生错误。

weak_ptr

再看下面的示例:
在这里插入图片描述

由上面代码可以看出weak_ptr,不会引起引用计数,也不能调用指像对象的方法,只有调用lock函数时,将返回一个share_ptr对象供使用,当shared_ptr计数为0的时候,lock函数将返回nullptr。

区别

通过上面的例子可以总结如下:
shared_ptr:有引用计数,可以赋值给多个shared_ptr,可以调用对象方法,计数为0自动释放,但是存在互相引用,导致无法释放的问题。
unique_ptr:独占式指针,可以通过move操作转移,不能赋值给多个unique_ptr,可以调用对象方法.
weak_ptr:不会引起引用计数,也不能调用指像对象的方法,只有调用lock函数时,将返回一个share_ptr对象供使用,当shared_ptr计数为0的时候,lock函数将返回nullptr。

垃圾回收的方式:

  1. 基于引用计数:(reference counting garbage collector)
    方法:记录对象的引用次数,计数为0则释放。
    优点:实现简单,不会造成程序暂停
    缺点:环形引用问题,计数带来的额外开销

  2. 标记 - 清除(mark-sweep)
    方法:从roots查找引用的堆空间,并标记,标记结束后,所有被标记的对象都是可达对象或者活对象,没有标记的对象在清除阶段被回收。
    优点:对象不会被移动
    缺点:内存碎片

  3. 标记 - 整理(mark-compact)
    方法:标记阶段同方法2,但是标记完成后,不再遍历所有对象清扫垃圾了,而是将活的对象向“左"靠齐
    优点:解决了内存碎片的问题
    缺点:移动活的对象,程序中所有对堆的引用需要更新

  4. 标记 - 拷贝(mark-copy)
    方法:将堆空间分为From和To。开始系统只从From的堆空间分配内存,当From分配满开始垃圾回收,从From空间找到所欲偶alive的对象,拷贝到To堆空间里。这样To堆就紧凑排列了。然后交换From与To的角色,继续从From分配即可。
    优点:解决了内存碎片的问题
    缺点:移动活的对象,程序中所有对堆的引用需要更新,内存使用率也只有一半了

24.右值

在C++中,所有的值必须属于左值,将亡值,纯右值三者之一。有一个被广泛认可的说法,可以取地址有名字的就是左值,反之为右值
例如:a = b + c;
a是一个左值,而 b + c的结果,为右值。因为b+c的结果没有名字,也取不到地址,如&(b + c)会引发编译错误。
右值由2个概念构成,一个是将亡值,一个是纯右值。
纯右值:非引用返回的函数返回的临时变量值,运算表达式产生的临时值(1 + 3 ),不跟对象关联的字面量值(2,‘c’ , true)
将亡值:C++11新提出的将要被“移动”的对象,比如右值引用T&&的函数返回值,std::move的返回值。
右值引用:对一个右值进行引用,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在,我们可以通过右值表达式获取其引用,右值引用不能绑定左值:

T && a = ReturnRvalue();
int num = 0;
//int && d = num;	错误,因为num是左值

上面表达式中,ReturnRvalue返回一个右值,我们声明一个名为a的右值引用,其值等于ReturnRvalue返回的临时变量的值。

左值引用VS右值引用:都需要在声明的时候立即初始化,原因理解为一个别名,右值引用是一个不具备名的内存的一个别名。
当然你会想到,我直接用下面的方法有什么区别:

T b = ReturnRvalue();

本身ReturnRvalue返回的的右值在表达式结束后,生命也就终结了,但是通过右值引用,该右值又重获新生了,省去了一次对象的析构以及一次对象的构造。而b是临时值,会先通过拷贝构造函数,利用右值对象构造一个新的对象给b,然后将右值对象析构掉。
几个特殊的例子:

//T & e = ReturnRvalue();	 //左值引用,编译报错
const T & f = ReturnRvalue();	 //常量左值引用,C++98中的万能引用类型,但是相对于右值来说是只读的
const bool & judgement = true;	//常量左值引用绑定右值
const bool judgement = true;	//赋值,与前面引用的区别是在于右值结束后是否被销毁了

25.变长模板

变长的参数

#include <stdarg.h>
double Test(int count, ...)
{
	va_list ap;
	double sum = 0;
	va_start(ap, count);	//获取变长列表的句柄ap
	for (size_t i = 0; i < count; i++)
	{
		sum += va_arg(ap, double);	//逐个取出参数
	}
	va_end(ap);
	return sum;
}

int main()
{
	double result = Test(3, 0.5f, 0.8f, 2.2f);
	std::cout << "result = " << result << std::endl;
	system("pause");
}

变长参数函数的解析,使用到三个宏va_start,va_arg 和va_end,再看va_list的定义 typedef char* va_list; 只是一个char指针。
这几个宏如何解析传入的参数呢?
函数的调用,是一个压栈,保存,跳转的过程。简单的流程描述如下:

  1. 把参数从右到左依次压入栈;

  2. 调用call指令,把下一条要执行的指令的地址作为返回地址入栈;(被调用函数执行完后会回到该地址继续执行)

  3. 当前的ebp(基址指针)入栈保存,然后把当前esp(栈顶指针)赋给ebp作为新函数栈帧的基址;

  4. 执行被调用函数,局部变量等入栈;

  5. 返回值放入eax,leave,ebp赋给esp,esp所存的地址赋给ebp;(这里可能需要拷贝临时返回对象)
    从返回地址开始继续执行;(把返回地址所存的地址给eip)

由于开始的时候从右至左把参数压栈,va_start 传入最左侧的参数,往右的参数依次更早被压入栈,因此地址依次递增(栈顶地址最小)。va_arg传入当前需要获得的参数的类型,便可以利用 sizeof 计算偏移量,依次获取后面的参数值。

变长的模板

以tuple为例,升声明一个tuple是一个变长的类模板:

template<typename...  Elements> class Tuple;

在标识符Elements之前使用了3个点的省略表示改参数时变长的。C++11中。Elements被称作是一个“模板参数包”,有了这样的参数包,就可以接受任意多个参数作为模板参数。例如:

Tuple<int, char, double>

与普通的模板参数类似,模板参数包可以是非类型的,比如:

template<int... A> class NonTypeValiadicTemplate {};
NonTypeValiadicTemplate<1, 2, 3> ntvt;

编译期将多个模板参数打包成“单个的”模板参数包Elements。一个模板包在模板推导时被认为是模板的单个参数(虽然实际上它将会打包 任意数量的实参)。为了使用模板参数,我们总是需要将其解包。在C++11中,这通常是通过一个名为包扩展的表达式来完成的:

template<typename...  Elements> class Tuple {};	//变长模板的声明

template<typename Head, typename... Tail>	//递归的偏特化定义
class Tuple<Head, Tail...> : public Tuple<Tail...>
{
public:
	Tuple()
	{
		printf("type: %s\n", typeid(Head).name());
	}
private:
	Head head;
};

template<> class Tuple<> {};  // 边界条件

Tuple<int, char, float> tuple;

调试结果如下:
在这里插入图片描述
变长的函数模板举例:

//递归模板函数
template<typename T0>
void Debug(T0 value)
{
	std::cout << value << std::endl;
}
template<typename T, typename... Ts>
void Debug(T value, Ts... args)
{
	std::cout << value << std::endl;
	Debug(args...);
}

int main()
{
	Debug(1, 2, "123", 1.1);
	system("pause");
}

结果:
在这里插入图片描述

参考

[1].C/C++程序编译过程详解
[2].C++ const用法 const与#define区别 内联函数
[3].C++内存管理
[4].C++中NULL和nullptr的区别
[5].C++知识积累:如何获取虚函数表以及虚函数地址
[6].C++内存泄露检查的5个方法

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值