一、栈和堆的区别、优缺点
栈:
- 栈是一种具有特定限制的数据结构,遵循“先进后出”(LIFO)的原则,即最后压入栈的数据元素最先弹出。
- 在计算机中,栈通常用来存储函数调用时的局部变量、函数参数、返回地址等信息。每当一个函数被调用时,相关的信息就会被压入栈中,当函数执行完毕后,这些信息会被弹出栈。
- 栈的大小通常是固定的,并且由操作系统预先分配。因为栈的分配和释放都很快,所以它非常适合用来存储函数调用相关的临时数据。
堆:
- 堆是一种动态分配的内存区域,用于存储程序运行时动态创建的数据。堆中的内存分配和释放由程序员来管理。
- 在堆中分配内存通常涉及到更复杂的内存管理算法,例如分配器需要考虑内存碎片化、分配效率等问题。
- 堆中的内存可以被任何函数使用,它的生命周期由程序员显式控制,直到显式释放内存。
"堆"通常指的是一种数据结构,也可以指代动态分配的内存区域。这两个概念有些相关,但又不完全相同。
堆(数据结构):堆是一种特殊的树形数据结构,通常指的是二叉堆(Binary Heap)。堆的特点是节点的键值总是保持固定的关系(比如最大堆中父节点的键值大于或等于子节点),并且堆中每个节点的子树都是一个堆。堆常常用来实现优先级队列等数据结构,可以高效地找到最大值或最小值,插入和删除操作的时间复杂度为 O(log n)。
堆(内存):在计算机内存管理中,堆指的是动态分配的内存区域,由程序员进行手动申请和释放。在堆内存中,存储了程序运行时动态分配的数据,比如通过 malloc
或 new
函数分配的内存。堆内存的生命周期由程序员控制,需要手动释放,否则可能导致内存泄漏。
栈的优点:
- 快速分配和释放:栈的内存分配和释放速度很快,因为它只需要简单地移动栈指针。
- 数据局部性:栈上存储的数据通常具有良好的局部性,这意味着对这些数据的访问很可能会利用到缓存,从而提高访问速度。
栈的缺点:
- 固定大小:栈的大小通常是固定的,由操作系统预先分配,因此栈空间有限。如果使用过多的栈空间,可能会导致栈溢出错误。
- 局部生存期:栈上的数据的生存期受到函数调用的限制,一旦函数执行完毕,相关数据就会被销毁,因此无法长期存储数据。
堆的优点:
- 动态分配:堆允许动态分配内存,程序可以根据需要灵活地分配和释放内存空间。
- 无固定大小限制:堆的大小通常受系统剩余内存的限制,因此可以动态增长,更适合存储大量、动态变化的数据。
堆的缺点:
- 内存分配速度较慢:堆的内存分配涉及到更复杂的算法,会引入一定的开销,因此分配速度相对较慢。
- 内存泄漏和碎片化:由于堆的动态分配特性,容易发生内存泄漏和内存碎片化问题,需要程序员显式管理内存的分配和释放。
栈适合存储局部临时数据、函数调用相关的数据等生命周期短暂的数据,而堆适合存储动态变化、生命周期较长的数据。
二、inline的优缺点
如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline。
内联函数是C++的增强特性之一,用来降低程序的运行时间。当内联函数收到编译器的指示时,即可发生内联:把内联函数的函数体在编译器预处理的时候替换到函数调用处,这样代码运行到这里时候就不需要花时间去调用函数(减少了函数调用过程的入栈出栈等开销),注意这种替代行为发生在编译阶段而非程序运行阶段,且对内联函数进行任何修改,都需要重新编译函数的所有客户端,因为编译器需要重新更换一次所有的代码,否则将会继续使用旧的函数。
值得注意的是,内联函数仅仅是对编译器的内联建议,编译器是否觉得采取你的建议取决于函数是否符合内联的有利条件。如果函数体非常大,那么编译器将忽略函数的内联声明,而将内联函数作为普通函数处理。
优点
- 通过避免函数调用所带来保存现场、变量弹栈压栈、跳转新函数、存储函数返回值、执行完返回原现场等开销,提高了程序的运行速度。
- 通过将函数声明为内联,你可以把函数定义放在头文件内。编译器需要把inline函数体替换到函数调用处,所以编译器必须要知道inline函数的函数体是啥,所以要将inline函数的函数定义和函数声明一起写在头文件中,便与编译器查找替换。
缺点
- 因为代码的替换扩展,内联函数会增大可执行程序的体积,进而导致程序变得更慢。
- C++内联函数的展开是在编译阶段,这就意味着如果你的内联函数发生了改动,那么就需要重新编译代码。
- 当你把内联函数放在头文件中时,它将会使你的头文件信息变多,不过头文件的使用者不用在意这些。
什么时候可以使用内联函数
- 对程序执行性能有要求且函数不超过10行
- 在类内部定义的函数会默认声明为inline函数,这有利于类实现细节的隐藏
什么函数不适合内联
- 函数体过于庞大,超过10行的函数
- 内联中包含循环或 switch 语句的函数常常是得不偿失 (除非在大多数情况下, 这些循环或 switch 语句从不被执行):因为如果内联函数本身就很复杂,那么将导致调用该内联函数的函数更为复杂,内存处理上更麻烦,可能会让程序整体的效率更低下
- 构造函数和析构函数也不适合内联:构造函数不适合的原因是即使是看似琐碎或空的构造函数通常也可能包含大量由编译器隐式生成的代码,而实际的构造函数可能会非常大,这可能会导致代码膨胀。析构函数不适合的原因是因为它可能是虚函数
- 虚函数(会不会内联要分情况讨论):可以内联:当虚函数被调用时它的入口地址是在编译阶段静态确定的(静态调用),那么就可能会被内联;不可以内联:当虚函数使用父类的指针或者引用动态的调用子类的虚函数功能时(动态调用),由于inline是在编译器将函数类容替换到函数调用处,是静态编译的。而此时虚函数是动态调用的,在编译器并不知道需要调用的是父类还是子类的虚函数,所以不能够inline声明展开,所以编译器会忽略
- 通常递归函数不应该声明成内联函数,大多数编译器都不支持内联递归函数:因为递归调用堆栈的展开并不像循环那么简单, 比如递归层数在编译时可能是未知的
三、构造函数和析构函数是否可以调用虚函数
在C++ primer中说到过是最好不要调用,不是不能调用,所以构造函数跟析构函数里面都是可以调用虚函数的,并且编译器不会报错。
但是由于类的构造顺序先构造基类然后再派生类,所以在构造函数中调用虚函数,虚函数是不会呈现出多态的。
类的析构顺序是先析构派生类然后再析构基类,所以当调用继承层次中某一层次的类的析构函数时,这代表其派生类已经进行了析构,所以也并不会呈现多态。
class A
{
public:
A()
{
Fuction();
}
virtual void Fuction()
{
cout << "A::Fuction" << endl;
}
};
class B : public A
{
public:
B()
{
Fuction();
}
virtual void Fuction()
{
cout << "B::Fuction" << endl;
}
};
B b;
输出结果是:
A::Fuction
B::Fuction
为什么呢?
当在构造基类部分时,派生类还没被完全创建。即当A::A()执行时,B类对象还没被完全创建,此时它被当成一个A对象,而不是B对象,因此Function()绑定的是A的Function()。
- 在C++中,提倡不在构造函数和析构函数中调用虚函数;
- 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
- 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数是不安全的,故而C++不会进行动态联编;
- 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
四、构造函数是否可以定义为虚函数
1、创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型;
2、虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了;
3、虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数。
注:C++中基类采用virtual虚析构函数是为了防止内存泄漏。
具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。
为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
五、怎么实现某个方法只调用一次?
某些场景下,我们需要代码只被执行一次,比如单例类的初始化,考虑到多线程安全,需要进行加锁控制。C++11中提供的call_once()可以很好的满足这种需求,使用又非常简单。
#include<mutex>
template <class Fn, class... Args>
void call_once (once_flag& flag, Fn&& fn, Args&&...args);
第一个参数是std::once_flag的对象(once_flag是不允许修改的,其拷贝构造函数和operator=函数都声明为delete),第二个参数可调用实体,即要求只执行一次的代码,后面可变参数是其参数列表。
call_once保证函数fn只被执行一次,如果有多个线程同时执行函数fn调用,则只有一个活动线程(active call)会执行函数,其它的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态)——不会直接返回,直到活动线程对fn调用结束才返回。对于所有调用函数fn的并发线程,数据可见性都是同步的(一致的)。
如果活动线程在执行fn时抛出异常,则会从处于”passive execution”状态的线程中挑一个线程成为活动线程继续执行fn,依此类推。一旦活动线程返回,所有”passive execution”状态的线程也返回,不会成为活动线程。(实际上once_flag相当于一个锁,使用它的线程都会在上面等待,只有一个线程允许执行。如果该线程抛出异常,那么从等待中的线程中选择一个,重复上面的流程)。
程序示例:
static std::once_flag oc; // 用于call_once的局部静态变量
Singleton* Singleton::m_instance = nullptr;
Singleton* Singleton::getInstance()
{
std::call_once(oc, [&] () { m_instance = newSingleton(); });
return m_instance;
}
注:once_flag的生命周期,它必须要比使用它的线程的生命周期要长,所以通常定义成全局变量比较好。