史上最全的C++/游戏开发面试问题总结(二)——虚函数,内存,STL

笔者毕业两年,最近通过猎头拿到了腾讯游戏以及网易游戏的两个客户端研发offer(UE4/C++)。在面试前夕,笔者对C++进行了较为全面的复习和总结,乐观fd7790336210ee8f4f21a0e1ba918fbc.png估计可以涵盖80%左右的面试基础问题。

这个系列的文章预计有《C++基础》、《内存、STL、虚函数相关》、《数据结构与算法》、《操作系统与网络》四篇(当前是第二篇),每篇都是以问答的形式分享并给出了参考资料的链接地址。大部分问题回答的比较简洁,需要大家去仔细阅读参考资料的具体内容,当然也可以直接问我~文中涉及到很多书籍与资料,后续我会把相关的电子书分享给大家。


虚函数机制相关

问:virtual function的优缺点(提问概率:★★★★)

优点:实现多态 
缺点:MFC中的消息机制以及Qt中都没有采用C++中的虚函数机制,原因大概如下~

1.在子类很少覆盖基类函数实现的时候内存开销太大,每个类需要产生一张虚表,每生成一个对象的时候,需要在对象里存放一个vptr。

2.基类指针指向派生类对象的情况下,无法内联优化

3.在执行效率上肯定多了一些开销,需要寻找函数地址4.虚表的存在可能破坏一些封装安全,可以通过vptr绕过private的限制

参考书籍与资料:《C++ Primer》《The Design and Evolution of C++》(C++语言的设计与演化)

问:多继承的优缺点(提问概率:★★★)

好处:简单来讲就是为了实现多个基类特有的功能

缺点:菱形继承;二义性

参考书籍与资料:《C++ Primer》《The Design and Evolution of C++》(C++语言的设计与演化)

问:为什么要用virtual destructor?为什么没有virtual constructor?(提问概率:★★★★)

第一点,构造函数执行前,对象的内存信息都没有,vptr也没有初始化,如何找到虚函数表,实现虚调用。其次,需要理解虚函数的意义,他是为了实现多态,让子类去覆盖父类的操作,但是前提是你需要知道当前的类型是什么,而C++里面构造函数可以说决定了他的类型。另外,构造函数与析构函数的执行不同,他是从父类开始一步一步的构造成一个子类(即使父类没有构造函数也可能会被编译器合成一个),所以你不能跳过父类的构造函数,子类的很多成员可能需要在父类的基础上去初始化。 
不过,从设计的角度来考虑的话,虚构造的思想还是有意义的,和我们设计模式中的工厂模式有点类似。我们希望不受语言的限制,在声明的时候直接自动的调用子类的构造函数,参考Delphi语言。

参考书籍与资料:构造函数不能是虚函数? https://www.zhihu.com/question/35632207/answer/63936329

问:类的内存布局是什么样的?考虑有虚函数、多继承、虚继承几种情况。(提问概率:★★★★★)

简单总结一下就是类只有成员变量占用内存(静态成员不占类内部内存,函数不占内存)。如果有虚函数,每个类对象都会有一个虚函数指针Vptr(占用一个指针大小的内存),vptr指向一个虚函数表,表里面记录了各项标记virtual的函数,子类如果覆盖父类虚函数,对应虚表位置的虚函数会被子类的替换(虚表在运行时其位置与大小就被决定了,一个类只有一个虚表)

  https://www.cnblogs.com/demian/p/6538301.html,侵删

f47c18f0110cd3921a89aea9aaa5e22b.jpeg

单继承GrandChild->Child-> Parent

63e051e035da24f814222dd95ba345c6.jpeg

多继承Derive->Base1Derive->Base2; Derive->Base1

8f6a609efce67cdbd9b13e0809503840.jpeg

多继承菱形继承D->Base1 D->Base2;Base1->B; Base2->B

e9b644861b26079270ef89d10a1a1450.jpeg

多继承菱形虚继承D->Base1 D->Base2;Base1->B; Base2->B

参考书籍与资料:《The Design and Evolution of C++》(C++语言的设计与演化)《Inside the C++ Object Model》(深度探索C++对象模型)

虚继承及继承的内存布局

https://www.cnblogs.com/demian/p/6538301.html

问:dynamic_cast是怎么实现的(提问概率:★★★★)

dynamiccast属于RTTI,运行时类型识别的一个内容,他是c++realise1.0的主要扩充功能之一。主要内容是typeid与typeinfo的实现,基本思路就是在有虚函数的类的虚表的头部位置存放RTTI的相关信息。在VC里面可以看到是一个叫做RTTI Complete Object Locator的结构体里面存放相关的信息。在强转的时候,会读取里面对应的类的信息进而判断是否能转换成功 

9bb5e9bd47c2b10a691125f2b7a04c11.jpeg

参考书籍与资料:《The Design and Evolution of C++》(C++语言的设计与演化)

c _rtti机制深度剖析  http://www.docin.com/p-1097232112.html

Classes, Methodsand RTTI  

http://www.openrce.org/articles/full_view/23

Visual C++ RTTI Inspection  

https://blog.quarkslab.com/visual-c-rtti-inspection.html

C++内存管理相关

问:C++内存模型是什么?如何理解自由存储区与堆的区别?(提问概率:★★★★)

在C++中,内存区分为5个区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区。malloc在堆上分配的内存,使用free释放内存,而new所申请的内存则是在自由存储区上,使用delete来释放,不过这只是表象。进一步来讲,基本上所有的C++编译器默认使用堆来实现自由存储,即缺省的全局运算符new和delete也会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。 
所以,new所申请的内存区域在C++中称为自由存储区,如果是通过malloc实现的,那么他也是在堆上的。 
参考书籍与资料:《The Design and Evolution of C++》(C++语言的设计与演化)

https://www.cnblogs.com/QG-whz/p/5060894.html

问:memory alignmentand padding, 内存对齐的原理与意义(提问概率:★★★★)

结构体以及类成员对齐,意义就是减少cpu读取的次数,提高效率。比如一个int变量长度为4个字节,cpu一次读4个字节,当然是一次读取比较好。但是如果前面有一个char,地址为0-1。那么这个int的地址就为1-4。导致cpu,分两次读取int值。 
具体的对齐规则,要说的非常准确可能比较麻烦,简单来讲就是,每个变量看后面的变量,如果后面的变量大,就和后面的大小对齐并补充字节。最后一个变量,按照成员内最大的对齐值,对齐并补充字节。

参考书籍与资料:

对内存对齐的深一步理解

https://www.cnblogs.com/coding-my-life/p/5374562.html

5分钟搞定内存字节对齐

https://blog.csdn.net/hairetz/article/details/4084088

内存对齐与内存分配原则

https://blog.csdn.net/tingyun_say/article/details/51443803

问:std::shared_ptr的实现,reference count在哪里定义(提问概率:★★★)

shared_ptr作为一种智能指针,本质上一个模板类,表现上与指针相同,是因为重载了&以及*这两个运算符(当然还有=等运算符)。由于其本身的计数机制,防止资源泄露上面很有意义。 
Shareptr在实现上有两个核心的成员,一个是指向资源对象的指针变量,另一个是指向引用计数的指针变量。第一个参数不言而喻,第二个参数为什么也是指针?因为多个shared_ptr对象指向同一资源时,其中一个shared_ptr对象析构了count = count -1,是不会影响到其他shared_ptr对象的,所以使用指针可以达到多个shared_ptr对象指向同一资源的能够共享count目的。另外,原生的shared_ptr的读写在多线程当中是不安全的,因为读写不具有原子性,所以多线程使用shared_ptr一定要加锁。循环引用会造成内存泄露。

参考书籍与资料:

std::shared_ptr  

https://en.cppreference.com/w/cpp/memory/shared_ptr

为什么多线程读写 shared_ptr 要加锁?

https://blog.csdn.net/solstice/article/details/8547547

问:new expression,operator newmalloc的联系(提问概率:★★★★)

malloc:是从C语言移植过来的语义,表示申请一定大小的内存并返回void*指针,在堆上申请内存,申请失败会返回Null 
new:C++里的关键字,针对对象而言,申请一块内存的然后并在内存上构造对应的对象,最后返回该对象的指针。深入:new是从自由存储区分配的内存,自由存储区可能不在堆上,在静态存储区(全局变量做的对象池)。申请失败会抛出异常。可以通过new[]对数组进行内存申请与构造 
Operator new:C++里面与Malloc类似的语义,只申请内存而不构造对象 
New操作的第一步就是调用OperatorNew来执行内存的申请。深入:operator new可以基于malloc实现,一般也都是这么做的,可以被重载 
参考书籍与资料: 《C++ Primer》《The Design and Evolution of C++》(C++语言的设计与演化)
http://blog.csdn.net/wudaijun/article/details/9273339 
http://blog.jobbole.com/102002/#article-comment

问:placement new的意义(提问概率:★★★)

placement new作用不太像new,或者说只是一个不完整的new,因为他不分配内存,只是在给定地址上调用构造函数,在c++里面,placement new实际上就是operator new的一种重载方式之一。 
一个普通的new可以拆分成两步,第一步,申请内存返回指针,执行一个非placement的operator new(可能通过malloc实现)。第二步,在指定位置上构造对象(这个操作一般是编译器帮我们做了,但是并不是执行placement new,虽然功能上差不多,这里面有细微的差别)。c++单独提供给我们一个placement new 来做第二步操作,因为有的时候我们可能想使用自己的内存申请方式(比如malloc),但是你发现除了placement new以外你好像没有其他办法在指定位置上调用构造函数。 
另外,想要使用placement new,底层系统并不告诉你这个地址上是否已经有对象了,如果你在已经构造的对象上面执行,那么前一个对象就会被销毁,理论上析构函数也不会执行。总之,要避免这种情况。

参考书籍与资料:《Inside the C++ Object Model》(深度探索C++对象模型)

What uses are there for “placement new”?

https://stackoverflow.com/questions/222557/what-uses-are-there-for-placement-new

Placement new operator in C++  

https://www.geeksforgeeks.org/placement-new-operator-cpp/

:allocator的实现与使用意义(提问概率:★★★)

c++11新标准里面加入,他本身是一个类模板,功能上其实就是把new拆开,把内存申请和对象构造分成两个步骤,是不是很熟悉?这不就是operator new和placement new么?实际上allocator就是利用operator new来实现的。 
为什么要这么做?举个例子,假如我要new一个长度为100的string数组,那我在申请内存的同时还要构造100个string对象,而实际上我整个程序从开始到结束可能只用了10个string。那为了减小构造的开销,这里就将内存申请与对象构造分开,也就有了allocator。当然,我们也可以使用operator new来做这个操作,只不过allocator帮我们做了一些简单的封装,类型检查等。另外,说到allocator就不得不提一下stl里面的空间配置器alloc,他除了上面简单的封装外还还考虑到了以下三个方面:1.多线程内存分配(内存池互斥访问)2.内存分配失败的异常处理3.实现一个轻量级内存池来解决小块内存导致的内存碎片问题 
第一种情况,基本的实现就是在构造时加锁,析构时解锁。 
内存分配失败的话,给用户提供一个函数去定义异常时的处理逻辑。 
针对内存碎片问题,sgi stl里面的allocator里面设计了一个双层配置器,当申请内存超过128byte,就认为足够大,直接调用malloc分配。反之,调用第二级配置器使用内存池来处理,内存池实现思路就是先申请一块比较大的内存,分成n块,每次有小的内存申请时就给他一块。

参考书籍与资料:《STL源码剖析》

https://en.cppreference.com/w/cpp/memory/allocator

https://zh.wikipedia.org/wiki/%E5%88%86%E9%85%8D%E5%99%A8_(C%2B%2B)

问:RAII是什么?有什么意义?应用场景?(提问概率:★★★★)

RAII 是 resource acquisition is initialization 的缩写,意为“资源获取即初始化”。其核心是把资源和对象的生命周期绑定,对象创建获取资源,对象销毁释放资源。常见的例子就是智能指针,通过声明一个包含资源对象指针的类,在这个类执行析构的时候释放指针指向的对象。

参考书籍与资料:

RAII https://zh.wikipedia.org/wiki/RAII

RAII cppreference.com

https://zh.cppreference.com/w/cpp/language/raii

问:内存泄露是什么意思?如何检测与避免内存泄漏?(提问概率:★★★★)

指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费

检测:windows可以使用crtdumpmemoryleaks替换free为freedebug还可以使用crtmemcheckpoint结合difference比较。

避免:智能指针,如ue垃圾回收机制。

参考书籍与资料:C/C++内存泄漏及检测

https://www.cnblogs.com/skynet/archive/2011/02/20/1959162.html

问:成员函数调用delete this会发生什么?之后再进行读写会怎么样?(提问概率:★★★★)

在类的成员函数中能不能调用delete this?答案是肯定的,能调用,而且很多老一点的库都有这种代码。假设这个成员函数名字叫release,而delete this就在这个release方法中被调用,那么这个对象在调用release方法后,还能进行其他操作,如调用该对象的其他方法么?答案仍然是肯定的,调用release之后还能调用其他的方法,但是有个前提:被调用的方法不涉及这个对象的数据成员和虚函数。

如果这时候调用普通的成员函数应该没有问题,因为这些成员函数与普通函数区别不大也在代码段,也需要走函数栈的逻辑。

如果调用虚函数,那就需要获取类内存的虚函数指针,这就涉及到堆内存的操作了,因为这时候虚函数指针也会被设置成未初始化的值,会有问题。

如果操作非指针成员变量,可能读和写都没有问题。

如果操作指针成员变量,指针可能设置成未初始化的值,很可能指向不合法的地方,强制赋值可能会导致崩溃

简单来说就是不要再去操作他的内存数据,否则很有可能崩溃,因为释放后,这个内存不确定系统如何处理。

另外,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

参考书籍与资料:what will happen if you do “deletethis;” in a member function?

https://stackoverflow.com/questions/7039597/what-will-happen-if-you-do-delete-this-in-a-member-function

在类的成员函数中调用delete this

http://blog.sina.com.cn/s/blog_4b4cf2af0100ywgv.html

STL相关

问:vector的实现与增长(提问概率:★★★★★)

vector是stl提供的动态数组,想了解他就要从他的特性开始分析。首先,他是一个模板类,意味着可以存放各种类型的元素,同时他也是一个数组,存储是连续的。 里面保存了三个指针,分别指向头、数据尾、数组尾。
内存分配:常规的数组必须在定义的时候就分配好固定的大小,而vector可以动态的改变,也就说明他可以动态的申请与释放内存。我们要知道,频繁的申请与释放内存对程序的效率影响是非常大的,因为如果当前地址空间不够用的话,就需要重新找一块更大的空间来装数据,再把数据全部都拷贝过去。所以vector为了达到比较好的效果,在添加元素的时候会多申请一定大小的内存,从而减少内存分配的次数。capacity()返回的就是包括缓冲区在内的空间大小,而size()返回的就是当前实际使用的空间大小。如果想主动的提前分配内存,可以使用reserve(n),会强制重新分配一次内存,超出实际使用的部分就会成为缓存区。如果想直接构造出长度为n的动态数组可以使用resize(n),实际分配的空间肯定要比n大,不过如果n比当前size小的话,大于size的数据都会被清空,如果比capacity还大的话就会重新执行一次内存分配。 
关于内存释放,如果只是简单的调用 clear()全部清空数据,erase()清空部分数据都只是单纯的清空里面的数据并不会释放掉。默认只会在调用vector的析构函数的时候才会真正释放空间,所以如果想强制释放那就新建一个空的vector,然后对这个vector使用swap讲内存交换,那么原来的vector就会释放,新的vector呢? 
另外,由于涉及到模板,也就会涉及到迭代器,凡是重新申请过内存,插入删除数据的,迭代器都会失效,理解上也很容易就是指针可能指向的不是你原来的那个位置了。

参考书籍与资料:《STL源码解析》(SGI的STL),VC STL里面的源码(打开VS自己看,读起来比较吃力)

问:map的实现 unordered_map的原理;如果从空的table开始一直增加元素,会出现什么情况?(提问概率:★★★★★)

map分为有序map和无序map(unordered_map),实现的基本数据结构分别是红黑树与哈希表。(set同理)里面每一个元素是一个pair< const key_type,mapped_type >类型,注意key是const的,不可以修改。对于一个数据结构,常见的操作无非是查找,插入,删除。红黑树作为一种二叉搜索树,具有log(n)的查找效率,不过前提是数据具有足够的随机性。!!! 
hashmap理想上则是具有常数平均时间的效率,或者说一次或几次就可以查到,当然如果数据量过大,散列表空间就不能和数据量保持1:1,这时候就要靠hash函数来处理数据,将数据尽可能的分散在不同的桶bucket里面。 
sgi stl的hash使用的是开链操作来避免hash表空间过大又想保持一定效率的问题,开链就是在一个位置用链表来存储所有冲突项。其实hashmap里面常说的桶bucket就是vector数组的一个元素,每个桶里面的数据是以链表(开链)的形式存储,进一步来说这些操作与定义都是通过一个基本的数据结构hashtable来实现的,所有的无序关联容器都是。hashtable里面的hash函数就是常说的取模函数,根据存储数据key值(注意,是对key而不是value)对桶的长度取余数来存放。默认提供的hash函数无法处理常见内置类型以外的数据,如各种自定义类,其实string本身也算是特殊类型,但是语言内部可以转为const char*处理,他经过函数处理也会得到一个size类型(一般对字符串的哈希函数比较特别,参考各种字符串Hash函数比较)。

什么时候需要rehash当你的桶里面的平均数量(Map大小/桶的数量)大于max(这个可以自己设置),就需要rehash。也可以主动调用rehash(n),保证桶的数量大于n,注意n是桶的数量。改变桶的数量就相当于改变Vector的长度,如果超过vector的capacity就会调用Vector的扩容机制(但是实际上他每次hash的时候都会直接调用vector的reserve进行扩容)。 
什么时候执行reserveJava里好像是resize?注意reserve与vector的reserve不一样,他的目的并不是扩容,而是希望当前哈希表里面可以容纳n个元素。如果n>桶的数量*负载因子的时候就会触发rehash,否则不会触发。rehash有可能进一步触发vector的扩容。参考下面的英文注释。

Request acapacity change Sets the number of buckets in the container 
(bucket_count) to the most appropriate to contain at least n elements. 
If n is greater than the current bucket_count multiplied by the 
max_load_factor, the container’s bucket_count is increased and a 
rehash is forced. If n is lower than that, the function may have no 
effect.

不过我发现 VC的STL里面的处理方式好像不太一样,他是自动先检测是否满足当前负载因子>最大负载因子,满足就会先触发重新设置桶的数量,如果桶的数量小于512就直接乘以8,否则如果小于Vector容量的一半的话就乘以2。这个过程我们看到他直接设置桶的数量并没有调用rehash函数,如果是主动调用rehash的话是直接翻倍的。而且不论是手动还是自动调整桶的数量,他都会触发Vector的扩容函数。

//手动调用:重新分配桶的数量

void rehash(size_type _Buckets)

{  // rebuild table with at least _Bucketsbuckets

   size_type _Maxsize = _Vec.max_size() / 4;

   size_type _Newsize = _Min_buckets;

   for (; _Newsize < _Buckets && _Newsize <_Maxsize; )

        _Newsize *= 2;  // doubleuntil big enough

   if (_Newsize< _Buckets)

        _Xout_of_range("invalid hash bucket count");

   while (!(size() / max_load_factor() < _Newsize)

        && _Newsize < _Maxsize)

        _Newsize *= 2;  // doubleuntil load factor okay

   _Init(_Newsize);

   _Reinsert();

}

//自动检查:设置桶的数量

void _Check_size()

{  // grow table as needed

   if(max_load_factor() < load_factor())

   {   // rehash to bigger table

        size_type _Newsize = bucket_count();

        if (_Newsize <512)

            _Newsize *= 8;  //multiply by 8

        elseif (_Newsize <_Vec.max_size() / 2)

           _Newsize *= 2;  //multiply safely by 2

        _Init(_Newsize);

        _Reinsert();

   }

}

//重新分配桶的数量,同时进行扩容

void _Init(size_type _Buckets = _Min_buckets)

{  // initialize hash table with _Bucketsbuckets, leave list alone

   _Vec.reserve(2 * _Buckets); // avoidcurdling _Vec if exception occurs

   _Vec.assign(2 * _Buckets, _Unchecked_end());

   _Mask = _Buckets - 1;

   //赋值_Maxidx,重新设置桶的数量

   _Maxidx = _Buckets;

}

参考书籍与资料:《STL源码解析》(SGI的STL),VC STL里面的源码(打开VS查看)

std::map::erase http://www.cplusplus.com/reference/map/map/erase/

问:stldeque是如何实现的?(提问概率:★★★)

其实deque由一段一段内存构成的,他是分段连续,而不是内存连续。当走向段的尾端时候自动跳到下一段。map记录着一系列的固定长度的数组的地址,这个map仅仅保存的是数组的地址,真正的数据在数组中存放着。deque先从map中央间的位置(因为双向队列,前后都可以插入元素,前面需要留出一定的空间)找到一个数组地址,向该数组中放入数据,数组不够时继续在map中找空闲的数组来存数据。当map也不够时重新分配内存当作新的map,把原来map中的内容copy的新map中。所以使用deque的复杂度要大于vector,尽量使用vector。(图片来自网络,侵删)

2ccf40c11908c0ae06fe030165cba523.jpeg

df02f36b542990b99c13e5e905bfc597.jpeg

参考书籍与资料:《STL源码解析》(SGI的STL),VC STL里面的源码(打开VS查看)

问:stlheappriority_queue?(提问概率:★★)

heap是基于vector来实现的,不过他不属于容器组件,因为他的主要是为优先级队列priority_queue的实现提供基础结构。所谓的优先级队列,其实就是队首元素一定是当前队列中优先级最高的那一个,只能通过 top() 函数来访问队首元素。我们知道最大堆与最小堆拥有这种特性,所以很适合用来实现priority_queue,当然其他数据结构也可以实现,不过从实现复杂度与计算复杂度等方面heap最为合适。

参考书籍与资料:《STL源码解析》(SGI的STL),VC STL里面的源码(打开VS查看)

std::priority_queue https://en.cppreference.com/w/cpp/container/priority_queue

问:stl里面各个容器的基础数据结构是?(提问概率:★★★★)

图截自STL源码分析一书,常问的是优先级队列,hashmap,map底层的数据结构是什么。答案分别是Vector,hashtable以及RB—tree(红黑树),具体细节大家可以仔细看一下关于容器的这两章内容。 

3a0f8400986881b0e6e6cf0849480ba9.jpeg

参考书籍与资料:“STL源码”,《STL源码剖析》


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值