超全面的C++游戏开发面试问题总结
笔者拿到了腾讯IEG以及网易游戏的两个客户端研发offer(UE4/C++)。在面试前夕,笔者对C++进行了较为全面的复习和总结,乐观估计可以涵盖80%左右的面试基础问题。如果你也是想从事游戏开发方面的工作,可以参考下,希望对大家有帮助!
个人觉得如果这些问题你全部搞懂的话,大部分面试官在C++上就拿你没什么办法或者说不会再进一步为难你了。不过想彻底理解所有内容也并不容易,这里面涉及到操作系统、数据结构、计算机系统原理、汇编等基础内容,涉及到的书籍包括《C++ Primer》《Inside the C++ Object Model》《Effctive C++》《More Effctive C++》《C++ Template》《The Design and Evolution of C++》《STL源码剖析》《深入理解计算机系统》等。
问:了解const么?哪些时候用到const?与宏定义有什么差异?(提问概率:★★★★)
简单理解,const的目的就是定义一个“不会被修改的常量”,可以修饰变量、引用、指针,可以用于函数参数、成员函数修饰。成员变量。使用const可以减少代码出错的概率,我们通常要注意的是区分常量指针(指向常量的指针)和指针常量(地址是常量,指针指向的地址不变)以及合理的在函数参数里面使用。具体的情况可以参考下面的书籍与资料。
参考书籍与资料:《Effctive C++》
问:reference和pointer的区别?哪些情况使用pointer?(提问概率:★★)
1.指针可以为空,而引用不可以指向空值。
2.指针可以不初始化,引用必须初始化。这意味着引用不需要检测合法性
3.指针可以随时更改指向的目标,而引用初始化后就不可以再指向任何其他对象
根据上面的情况我们知道大概知道哪些时候需要使用指针了。不过还有一种情况,在重载如[]符号的时候,建议返回引用,这样便于我们书写习惯也方便理解。因为平时我们都是这样使用, a[10] = 10;而不是 *a[10] = 10;
参考书籍与资料:《More Effctive C++》
问:inline的优劣(提问概率:★★)
优点:减少函数调用开销
缺点:增加函数体积,exe太大,占用CPU资源,可导致cache装不下(减小了cache的命中) ,不方便调试debug下一般不内联, 每次修改会重新编译头文件增加编译时间
注意:inline只是一个请求,编译器有权利拒绝。有7种情况下都会拒绝,虚调用,体积过大,有递归,可变数目参数,通过函数指针调用,调用者异常类型不同,declspec宏等
forceinline字面意思上是强制内联,一般可能只是对代码体积不做限制了,但是对于上面的那些情况仍然不会内联,如果没有内联他会返回一个警告。 构造函数析构函数不建议内联,里面可能会有编译器优化后添加的内容,比如说初始化列表里面的东西。
问:final和override的作用,以及使用场合(提问概率:★★)
final:禁止继承该类或者覆盖该虚函数
override:必须覆盖基类的匹配的虚函数
场合(final):不希望这个类被继承,比如vector,编码者可能不够了解vector的实现,或者说编写者不希望别人去覆盖某个虚函数,顾名思义,final就是最终么
场合(override):第一种,在使用别人的函数库,或者继承了别人写的类时,想写一个新函数,可能碰巧与原来基类的函数名称一样,被编译器误认为要重写基类的函数。第二种情况是想覆写一个基类的函数,但是不小心参数不匹配或者名字拼错,结果导致写了一个新的虚函数
参考书籍与资料:《C++ Primer》
问:The rule ofthree是什么?为什么这么做?(提问概率:★)
If you need to explicitly declare either the destructor,copy constructor or copy assignment operator yourself, you probably need toexplicitly declare all three of them.(析构函数,拷贝构造函数,赋值运算符尽可能一起声明。如果你只定义一个,编译器会帮助你定义另外两个,而编译器定义的版本也许不是你想要的)
问:C++03/98有什么你不习惯或不喜欢的用法?C++11有哪些你使用到的新特性?(提问概率:★★★★★)
这个问题最简单的办法就是看下一个版本的C++有哪些特性,新的特性肯定是有意义的。
如:
auto,有一些迭代器或者map嵌套类型,遍历时比较麻烦,auto写起来很方便。
vector以及其他容器的列表初始化,原来想要像数组一样初始化的话,需要一个一个来,很麻烦。
类内初始值问题,总是需要放到构造函数里面初始化,初始化列表倒是不错,但是初始化数据太多就不行了。
nullptr,C++11前的NULL一般是是这样定义的 #define NULL 0,这可能会导致一些函数参数匹配问题。而nullptr可以避免这个问题。
thread,不需要再使用其他的库来写多线程了。
智能指针shareptr,一定程度上解决内存泄露问题。
右值引用,减少拷贝开销。
lambda function,简化那些结构简单的函数代码。
当然,你要是能说出一些还没有改正或者有待考虑的问题就更好了,比如内存管理的困难(没有GC),没有反射以及一些C#,java里面有而C++没有的特性等,要能深入一点说那就更好了
参考书籍与资料:《C++ Primer》
问:Delete数组的一部分会发生什么?为什么出现异常?(提问概率:★★★★)
VC下是异常,实际删除的时候整个数组的内存不仅仅是数据大小还包括CRTHeader,数组长度等信息。如果删除一部分会从数量的位置开始传入,是有问题的。VC下数组的内存布局参考下面公式,
公式1)_CrtMemBlockHeader + <Your Data>+gap[nNoMansLandSize];这类数据用delete和delete[]都一样!
公式2)_CrtMemBlockHeader +数组元素个数+ <Your Data>+gap[nNoMansLandSize];
如果其他编译器,有可能不会报错。但是只释放一个数组对象也是有问题的,其他的对象既没有释放也没有析构。
问:系统是如何知道指针越界的?(提问概率:★★)
VC下有一个结构体_CrtMemBlockHeader,里面有一个Gap属性,这个Gap数组放在你的指针数据的后面,默认为0xFD,当检测到你的数据后不是0xFD的时候就说明的你的数据越界了。
问:C++编译器有哪些常见的优化?听说过RVO(NRVO)么?(提问概率:★★★)
1.常量替换如int a = 2; int b = a; return b;可能会优化为 int b=2; return b; 进一步会优化为return 2;
2.无用代码消除比如函数返回值以及参数与该表达式完全无关,直接会优化掉这段代码
3.表达式预计算和子表达式提取常量的乘法会在编译阶段就计算完毕,相同的子表达式也会被合并成一个变量来进行计算
4.某些返回值为了避免拷贝消耗,可能会被优化成一个引用并放到函数参数里面,如RVO,NRVO。
RVO:函数返回的对象如果是新构造的值类型就直接通过一个引用作为参数来构造,进而避免创建一个临时的“temp”对象。
NRVO:相比RVO进一步优化。对于RVO,如果函数在返回前创建了一个临时变量,这个临时变量还是会被构造的,参考下面代码
Point3d Factory()
{
Point3d po(1,2, 3);
return po;
}
//RVO优化后
void Factory(Point3d &_result)
{
Point3d po(1,2,3);
_result.Point3d::Point3d(po);
return;
}
//NRVO优化后
void Factory(Point3d &_result)
{
_result.Point3d::Point3d(1, 2, 3);
return;
}
NRVO则直接跳过临时对象的构造。
(补充:上面的优化有的时候不同编译器可能有差别,想一探究竟建议查看反汇编代码。一般来说函数返回的临时值类型对象是右值,通过寄存器存储,所以获取不到地址)
当然,优化还有很多,这里不一一列举。由于这些优化,你在调试过程中可能无法设置断点,所以需要关闭优化。还有一个小的技巧,static变量不会被优化。
参考书籍与资料:
《Inside the C++ Object Model》(深度探索C++对象模型)
问:听说过mangling么?(提问概率:★★)
mangling 指编译器给函数变量等添加很多的描述信息到名称上用于传递更多信息。常用函数重载,编译时可以把返回值类型等与原函数名称进行组合达到区分的效果,具体规则看编译器。
参考书籍与资料:《Inside the C++ Object Model》(深度探索C++对象模型)
问:成员函数指针了解么?可以转换为Void*么?为什么?(提问概率:★★★)
不可以转换成Void*,因为成员函数指针大小并不是4个字节(32位机器上),除了地址还需要this的delta,索引等信息。成员函数指针比较复杂,建议好好读一下下面给出的文章。
写法:函数指针 float (*my_func_ptr)(int, char *);
成员函数指针 float (SomeClass::*my_memfunc_ptr)(int,char *);
问:描述一下C/C++代码的编译过程?(提问概率:★★★★)
预处理——编译——汇编——链接。预处理器先处理各种宏定义,然后交给编译器;编译器编译成.s为后缀的汇编代码;汇编代码再通过汇编器形成.o为后缀的机器码(二进制);最后通过链接器将一个个目标文件(库文件)链接成一个完整的可执行程序(或者静态库、动态库)。
参考书籍与资料:《深入理解计算机系统》
问:了解静态库与动态库么?说说静态链接与动态链接的实现思路(提问概率:★★★)
静态库:任意个.o文件的集合,程序link时,被复制到output文件。这个静态库文件是静态编译出来的,索引和实现都在其中,可以直接加到内存里面执行。
对于Windows上的静态库.lib有两种,一种和上面描述的一样,是任意个.o文件的集合。程序link时,随程序直接加载到内存里面。另一种是辅助动态链接的实现,包含函数的描述和在DLL中的位置。也就是说,它为存放函数实现的dll提供索引功能,为了找到dll中的函数实现的入口点,程序link时,根据函数的位置生成函数调用的jump指令。(Linux下.a为后缀)
动态库:包含一个或多个已被编译、链接并与使用它们的进程分开存储的函数。在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。(Linux下.so为后缀)
参考书籍与资料:《深入理解计算机系统》
问:知道内部链接与外部链接么?(提问概率:★★)
内部链接:如果一个名称对于他的编译单元是局部的,并且在链接时不会与其他的编译单元中同样的名字冲突,那么这个名称就拥有内部链接。
外部链接:一个多文件的程序中,一个实体可以在链接时与其他编译单元交互,那么这个实体就拥有外部链接。换个说法,那些编译单元(.cpp)中能想其他编译单元(.cpp)提供其定义,让其他编译单元(.cpp)使用的函数、变量就拥有外部链接
问:extern与static(提问概率:★★★)
extern 声明一个变量定义在其他文件,这样当前文件就可以使用这个变量,否则会编译失败,如果两个全局变量名称一样会出现链接失败。extern c的作用更重要,因为c++的编译方式与c是不同的,比如函数重载利用mangling的优化。static变量,就是在全局声明一个变量判断是否初始化,是的话之后就不做操作了。static成员函数其实在编译后与class完全没有关系。static成员其实也没关系,但是private的需要通过类去调用。static全局只能在本文件使用(内链接),与其他无关。全局函数变量是外链接,可以跨单元调用。
参考书籍与资料:《C++ primer》
问:delegate是什么?实现思路?与event的区别?(提问概率:★★★)
代理简单来说就是让对象B去代理A执行A本身的操作,本质上就是通过指向其他成员函数或者全局函数的函数指针去代理执行。而函数指针有两种,成员函数指针与普通的函数指针,我们一般就是通过对这两种指针的封装来实现代理的效果。常见的实现方式有两种,一种是通过多态接口,另一种是通过宏。代理也分为单播代理与多播代理,单播就是一个调用只代理执行一个函数功能,多播代理就是一个调用可以绑定多个代理函数,可以触发多个代理的函数操作。
Event是一种特殊的多播delegate,只有声明事件的类可以调用事件的触发操作。最常见的也容易理解的就是MFC里面的按钮的鼠标点击事件了,他的调用只能在Button里面去执行。
问:使用过模板么?了解哪些特性?(提问概率:★★★★)
模板分为函数模板与类模板,其根本目的是将类型“参数化”,实现编译时的“动态化”,避免重复代码的书写。另一种运行时的“动态化”就是多态。
模板使用常见的特性有“特化”,“偏特化”,“非类型模板参数”,“设置模板参数默认类型”,“模板中的typename的使用”,“双重模板参数Template Template Parameters”,“成员模板Member Template”,理解这些内容我们就基本上可以看STL标准库了。
另外,模板的实例化过程也是需要理解的。
参考书籍与资料:“STL源码”,《C++ Template》,《C++ Primer》
问:听说过转发构造么?(提问概率:★★)
通过foward关键字可以同时考虑到参数为左值以及右值的情况,然后把函数的参数完美的转发到其他函数的参数里面。这个里面涉及到左值、右值、move、forward、引用折叠等技术点。
参考书籍与资料:《C++ Primer》《Effective Modern C++》
移动语义(move semantic)和完美转发(perfect forward)
问:描述一下函数调用过程中栈的变化(提问概率:★★★★)
回答这个问题需要对栈的使用过程,函数调用,汇编都有一定的理解才行。首先,要清楚一个概念“栈帧”。
栈帧(stack frame):机器用栈来传递过程参数,存储返回信息,保存寄存器用于以后恢复,以及本地存储。为单个过程(函数调用)分配的那部分栈称为栈帧。栈帧其实是两个指针寄存器,寄存器ebp为帧指针(指向该栈帧的最底部),而寄存器esp为栈指针(指向该栈帧的最顶部)。
然后我们再简单描述一下函数调用的机制,每个函数有自己的函数调用地址,里面会有各种指令操作(这端内存位于“代码段”部分),函数的参数与局部变量会被创建并压缩到“栈”的里面,并由两个指针分别指向当前帧栈顶和帧栈尾。当进入另一个子函数时候,当前函数的相关数据会被保存到栈里面,并压入当前的返回地址。子函数执行时也会有自己的“栈帧”,这个过程中会调用CPU的寄存机进行计算,计算后再弹出“栈帧”相关数据,通过“栈”里面之前保存的返回地址再回到原来的位置执行前面的函数。参考下图
参考书籍与资料:《深入理解计算机系统》
问:__cdecl/__stdcall是什么意思(提问概率:★★★)
常见的函数调用有如下
__cdecl/__stdcall/__thiscall/__fastcall。
cdecl按照c语言标准,从右到左,可以实现可变参数,调用者弹出参数。
stdcall(pascal调用约定)按照c++标准,函数参数从右到左,不支持可变参数,函数返回自动清空。但是有的时候编译器会识别并优化成cdecl。
Pascal语言中参数就是从左到右入栈的不支持可变长度参数
(注:__stdcall标记的函数结束后,ret 8表示清理8个字节的堆栈,函数自己恢复了堆栈)
参考书籍与资料:“建议查看反汇编代码”
问:C++中四种Cast的使用场景是什么?(提问概率:★★★★★)
constcast,去掉常量属性以及volatile,但是如果原来他就是常量去掉之后千万不要修改;比如你手里有一个常量指针引用,但是函数接口是非常量指针,可能需要转换一下;成员函数声明为const,你想用this去执行一个函数,也需要用constcast
staticcast,基本类型转换到void,转换父类指针到子类不安全
dynamiccast,判断基类指针或引用是不是我要的子类类型,不是强转结果就返回null,用于多态中的类型转换
reintercast,可以完成一些跨类型的转换,如int到void*,用于序列化网络包数据
参考书籍与资料:《C++ Primer》《The Design and Evolution of C++》(C++语言的设计与演化)
问:用过或很熟悉的设计模式有哪些?(提问概率:★★★★)
这个问题看好书写写代码就可以自由发挥了,下面给几个例子。
工厂模式,通过简单工厂生成NPC对象,简单处理的话可通过“字符串匹配”动态创建对象。如果有“反射机制”就可以直接传class来实现。当然可以进一步使用抽象工厂,处理不同的生产对象。
单例,实现全局唯一的一个对象。构造函数、静态指针都是私有的,使用前提前初始化或者加锁来保证线程安全。
Adaptor适配器,代码适配原来的相机移动最后调用的是原来的移动,现在加了适配器继承里面放了当前引擎的摄像机,然后覆盖原来摄像机的移动逻辑。
Observer,一个对象绑定多个观察者,然后这个对象一旦有消息就立刻公布给所有的观察者,观察者可以动态添加或删除。在UE4里面,行为树任务节点请求任务后进入执行状态,然后会立刻注册一个观察者observer到行为树(行为树本身就相当于前面提到的那个对象)的observer数组里面同时绑定一个代理函数。行为树tick检测消息发送给所有观察者,观察者收到消息执行代理函数。
参考书籍与资料:《Head First设计模式》《设计模式:可复用面向对象软件的基础》