史上最全的C++/游戏开发面试问题总结(一)——C++基础

共 13647字,需浏览 28分钟

 ·

2023-08-18 09:30

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

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

个人觉得如果这些问题你全部搞懂的话,大部分面试官在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++

Const 用法总结(快速区分指针常量与常量指针) https://blog.csdn.net/u012999985/article/details/49009531


问: 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
字面意思上是强制内联,一般可能只是对代码体积不做限制了,但是对于上面的那些情况仍然不会内联,如果没有内联他会返回一个警告。   构造函数析构函数不建议内联,里面可能会有编译器优化后添加的内容,比如说初始化列表里面的东西。

参考书籍与资料:

WiKihttps://zh.wikipedia.org/wiki/%E5%86%85%E8%81%94%E5%87%BD%E6%95%B0)

MSDN(https://msdn.microsoft.com/zh-cn/magazine/z8y1yy88(v=vs.110).aspx)


问: 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. (析构函数,拷贝构造函数,赋值运算符尽可能一起声明。如果你只定义一个,编译器会帮助你定义另外两个,而编译器定义的版本也许不是你想要的)

参考书籍与资料: WIKI Rule of three 

https://en.wikipedia.org/wiki/Rule_of_three_(C%2B%2B_programming))


问: 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 》    nullptr 0 NULL  https://www.cnblogs.com/porter/p/3611718.html)


问: Delete 数组的一部分会发生什么?为什么出现异常?(提问概率:★★★★)

VC 下是异常,实际删除的时候整个数组的内存不仅仅是数据大小还包括 CRTHeader ,数组长度等信息。如果删除一部分会从数量的位置开始传入,是有问题的。 VC 下数组的内存布局参考下面公式,

公式 1 _CrtMemBlockHeader + <Your Data>+gap[nNoMansLandSize]; 这类数据用 delete delete[] 都一样!

公式 2 _CrtMemBlockHeader + 数组元素个数 + <Your Data>+gap[nNoMansLandSize];

如果其他编译器,有可能不会报错。但是只释放一个数组对象也是有问题的,其他的对象既没有释放也没有析构。

参考书籍与资料:为何 new 出的对象数组必须要用 delete[] 删除,而普通数组 delete delete[] 都一样( https://www.cnblogs.com/sura/archive/2012/07/03/2575448.html)


问:系统是如何知道指针越界的?(提问概率:★★)

VC 下有一个结构体 _CrtMemBlockHeader ,里面有一个 Gap 属性,这个 Gap 数组放在你的指针数据的后面,默认为 0xFD ,当检测到你的数据后不是 0xFD 的时候就说明的你的数据越界了。

参考书籍与资料:为何 new 出的对象数组必须要用 delete[] 删除,而普通数组 delete delete[] 都一样 https://www.cnblogs.com/sura/archive/2012/07/03/2575448.html)


问: 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++ 对象模型)

RVO NRVO 的区别是什么?  

https://www.zhihu.com/question/32237405/answer/55440484)

Copy elision 

https://en.wikipedia.org/wiki/Copy_elision#Return_value_optimization)

RVO V.S. std::move

https://www.ibm.com/developerworks/community/blogs/5894415f-be62-4bc0-81c5-3956e82276f3/entry/RVO_V_S_std_move?lang=en)

C++ 中的 RVO NRVO 

https://blog.csdn.net/yao_zou/article/details/50759301)

详解 RVO NRVO

https://blog.csdn.net/virtual_func/article/details/48709617)


问:听说过 mangling 么? (提问概率:★★)

mangling 指编译器给函数变量等添加很多的描述信息到名称上用于传递更多信息。常用函数重载,编译时可以把返回值类型等与原函数名称进行组合达到区分的效果,具体规则看编译器。

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

Name mangling 

( https://en.wikipedia.org/wiki/Name_mangling)

Why can't C functions be name-mangled?

https://stackoverflow.com/questions/36621845/why-cant-c-functions-be-name-mangled)


问:成员函数指针了解么?可以转换为 Void* 么?为什么?(提问概率:★★★)

不可以转换成 Void* ,因为成员函数指针大小并不是 4 个字节( 32 位机器上),除了地址还需要 this delta ,索引等信息。成员函数指针比较复杂,建议好好读一下下面给出的文章。

写法:函数指针 float (*my_func_ptr)(int, char *);

成员函数指针 float (SomeClass::*my_memfunc_ptr)(int,char *);

参考书籍与资料:

成员函数指针与高性能的 C++ 委托(中文) https://www.cnblogs.com/jans2002/archive/2006/10/13/528160.html)

Member Function Pointers and the Fastest Possible C++Delegates (英文)

https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible)


问:描述一下 C/C++ 代码的编译过程?(提问概率:★★★★)

预处理——编译——汇编——链接。预处理器先处理各种宏定义,然后交给编译器;编译器编译成 .s 为后缀的汇编代码;汇编代码再通过汇编器形成 .o 为后缀的机器码(二进制);最后通过链接器将一个个目标文件(库文件)链接成一个完整的可执行程序(或者静态库、动态库)。

参考书籍与资料:《深入理解计算机系统》

c++ 编译过程简介  

http://www.cnblogs.com/dongdongweiwu/p/4743709.html)


问:了解静态库与动态库么?说说静态链接与动态链接的实现思路(提问概率:★★★)

静态库:任意个 .o 文件的集合,程序 link 时,被复制到 output 文件。这个静态库文件是静态编译出来的,索引和实现都在其中,可以直接加到内存里面执行。

对于 Windows 上的静态库 .lib 有两种,一种和上面描述的一样,是任意个 .o 文件的集合。程序 link 时,随程序直接加载到内存里面。另一种是辅助动态链接的实现,包含函数的描述和在 DLL 中的位置。也就是说,它为存放函数实现的 dll 提供索引功能,为了找到 dll 中的函数实现的入口点,程序 link 时,根据函数的位置生成函数调用的 jump 指令。( Linux .a 为后缀)

动态库:包含一个或多个已被编译、链接并与使用它们的进程分开存储的函数。在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。( Linux .so 为后缀)

参考书籍与资料:《深入理解计算机系统》

Static library 

https://en.wikipedia.org/wiki/Static_library)

Dynamic-link library 

https://en.wikipedia.org/wiki/Dynamic-link_library)

lib dll 的关系  

https://blog.csdn.net/u012999985/article/details/50429715)

程序的静态链接,动态链接和装载 https://www.cnblogs.com/acSzz/p/5743789.html)

程序运行流程——链接、装载及执行 https://www.xuebuyuan.com/1730287.html)


问:知道内部链接与外部链接么?(提问概率:★★)

内部链接:如果一个名称对于他的编译单元是局部的,并且在链接时不会与其他的编译单元中同样的名字冲突,那么这个名称就拥有内部链接。

外部链接:一个多文件的程序中,一个实体可以在链接时与其他编译单元交互,那么这个实体就拥有外部链接。换个说法,那些编译单元( .cpp )中能想其他编译单元( .cpp )提供其定义,让其他编译单元 (.cpp) 使用的函数、变量就拥有外部链接

参考书籍与资料: What is external linkage and internallinkage?

https://stackoverflow.com/questions/1358400/what-is-external-linkage-and-internal-linkage)

C++ 编译与链接( 2 - 浅谈内部链接与外部链接 https://www.cnblogs.com/magicsoar/p/3840682.html)

理解 C++ 的链接: C++ 内链接与外链接的意义 https://blog.csdn.net/u012999985/article/details/50429769)


问: extern static (提问概率:★★★)

extern 声明一个变量定义在其他文件,这样当前文件就可以使用这个变量,否则会编译失败,如果两个全局变量名称一样会出现链接失败。 extern c 的作用更重要,因为 c++ 的编译方式与 c 是不同的,比如函数重载利用 mangling 的优化。 static 变量,就是在全局声明一个变量判断是否初始化,是的话之后就不做操作了。 static 成员函数其实在编译后与 class 完全没有关系。 static 成员其实也没关系,但是 private 的需要通过类去调用。 static 全局只能在本文件使用 ( 内链接 ) ,与其他无关。全局函数变量是外链接,可以跨单元调用。

参考书籍与资料:《 C++ primer

extern "C" 

https://baike.baidu.com/item/extern%20%22C%22)


问: delegate 是什么?实现思路?与 event 的区别?(提问概率:★★★)

代理简单来说就是让对象 B 去代理 A 执行 A 本身的操作,本质上就是通过指向其他成员函数或者全局函数的函数指针去代理执行。而函数指针有两种,成员函数指针与普通的函数指针,我们一般就是通过对这两种指针的封装来实现代理的效果。常见的实现方式有两种,一种是通过多态接口,另一种是通过宏。代理也分为单播代理与多播代理,单播就是一个调用只代理执行一个函数功能,多播代理就是一个调用可以绑定多个代理函数,可以触发多个代理的函数操作。  
Event
是一种特殊的多播 delegate ,只有声明事件的类可以调用事件的触发操作。最常见的也容易理解的就是 MFC 里面的按钮的鼠标点击事件了,他的调用只能在 Button 里面去执行。

参考书籍与资料: [C++] 实现委托模型 https://www.cnblogs.com/zplutor/archive/2011/09/17/2179756.html)


问:使用过模板么?了解哪些特性?(提问概率:★★★★)

模板分为函数模板与类模板,其根本目的是将类型“参数化”,实现编译时的“动态化”,避免重复代码的书写。另一种运行时的“动态化”就是多态。

模板使用常见的特性有“特化”,“偏特化”,“非类型模板参数”,“设置模板参数默认类型”,“模板中的 typename 的使用”,“双重模板参数 Template Template Parameters ”,“成员模板 Member Template ”,理解这些内容我们就基本上可以看 STL 标准库了。

另外,模板的实例化过程也是需要理解的。

参考书籍与资料:“ STL 源码”, C++ Template 》,《 C++ Primer


问:听说过转发构造么?(提问概率:★★)

通过 foward 关键字可以同时考虑到参数为左值以及右值的情况,然后把函数的参数完美的转发到其他函数的参数里面。这个里面涉及到左值、右值、 move forward 、引用折叠等技术点。

参考书籍与资料:《 C++ Primer 》《 Effective Modern C++

The Forwarding Problem: Arguments 

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1385.htm)

A Brief Introduction to Rvalue References https://www.artima.com/cppsource/rvalue.html)

C++11 forward 完美转发  

https://blog.csdn.net/rankun1/article/details/78354153)

Effective Modern C++ 条款 28 理解引用折叠 https://blog.csdn.net/big_yellow_duck/article/details/52433305)

移动语义( move semantic )和完美转发( perfect forward

https://codinfox.github.io/dev/2014/06/03/move-semantic-perfect-forward/)


问: 描述一下函数调用过程中栈的变化(提问概率:★★★★)

回答这个问题需要对栈的使用过程,函数调用,汇编都有一定的理解才行。首先,要清楚一个概念“栈帧”。

栈帧 (stack frame) :机器用栈来传递过程参数,存储返回信息,保存寄存器用于以后恢复,以及本地存储。为单个过程 ( 函数调用 ) 分配的那部分栈称为栈帧。栈帧其实是两个指针寄存器,寄存器 ebp 为帧指针(指向该栈帧的最底部),而寄存器 esp 为栈指针(指向该栈帧的最顶部)。

然后我们再简单描述一下函数调用的机制,每个函数有自己的函数调用地址,里面会有各种指令操作(这端内存位于“代码段”部分),函数的参数与局部变量会被创建并压缩到“栈”的里面,并由两个指针分别指向当前帧栈顶和帧栈尾。当进入另一个子函数时候,当前函数的相关数据会被保存到栈里面,并压入当前的返回地址。子函数执行时也会有自己的“栈帧”,这个过程中会调用 CPU 的寄存机进行计算,计算后再弹出“栈帧”相关数据,通过“栈”里面之前保存的返回地址再回到原来的位置执行前面的函数。参考下图 (改编自 https://www.cnblogs.com/zlcxbb/p/5759776.html 的图片):

 

dba8d65a9eda32224351e5365a31b8e7.webp

                         

参考书籍与资料:《深入理解计算机系统》

函数调用栈帧过程带图详解  

https://blog.csdn.net/IT_10/article/details/52986350)

函数调用栈浅析  

https://www.cnblogs.com/coderland/p/5902719.html)

函数调用过程栈帧变化详解  

https://www.cnblogs.com/zlcxbb/p/5759776.html)


问: __cdecl/__stdcall 是什么意思(提问概率:★★★)

常见的函数调用有如下

__cdecl/__stdcall/__thiscall/__fastcall

cdecl 按照 c 语言标准,从右到左,可以实现可变参数,调用者弹出参数。

stdcall pascal 调用约定)按照 c++ 标准,函数参数从右到左,不支持可变参数,函数返回自动清空。但是有的时候编译器会识别并优化成 cdecl

Pascal 语言中参数就是从左到右入栈的不支持可变长度参数

(注: __stdcall 标记的函数结束后, ret 8 表示清理 8 个字节的堆栈,函数自己恢复了堆栈)

参考书籍与资料:“建议查看反汇编代码”

x86 calling conventions 

 https://en.wikipedia.org/wiki/X86_calling_conventions)

What is __stdcall?  

https://stackoverflow.com/questions/297654/what-is-stdcall)

__stdcall 

 https://msdn.microsoft.com/zh-cn/library/zxk0tw93.aspx)


问: 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 设计模式》《设计模式:可复用面向对象软件的基础》

常见设计模式的解析和实现 C++ https://wenku.baidu.com/view/7488c59f0508763231121295.html)

Design Patterns 

( https://en.wikipedia.org/wiki/Design_Patterns)


浏览 41
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报