GameHollywood 面试笔记
Intro
面试的职位是 C++开发工程师,主要聊的还是C++。在过程中自我感觉面得还行,至少没上次那么蠢。
聊的内容主要集中在STL和线程安全、资源管理的层面。
惯例的,填完面试信息表并简历一起上交,然后等面试官来客套完,就开始聊技术了。
注意,面试官的提问并非原话,有修饰和脑补。
0. 预热:你用哪个版本的C++?
客套话什么的就略了。
面试官:...行,那我们就聊聊C++吧。你常用哪个版本的C++?
我:我比较常用的是C++11。
C++版本这个问题面试里应该不多见,不过作为引入的话题还行,标准之神会瞑目的。
对于C++版本这个词,很大概率上大家说的应该就是C++标准委员会WG21制定的C++标准了,最新版本的标准文档是C++17定稿N4659,制定中的C++20标准文档可以访问WG21/docs/papers/2018查阅。
需要注意的是,如果答成了我用VC6之类的骚话,很大概率会留下不好的映像——或者对方也是忠实的VC6神教教徒的话,达成共识也说不定。
闲话少叙。
1. 起手式:std::shared_ptr
面试官:说说
std::shared_ptr
是怎么实现的?一般怎么去使用它?
答:
shared_ptr
是通过引用计数实现的,它可以作为容器元素,在程序里传递blabal.....而且shared_ptr
不是线程安全的,它不能跨线程传递,要额外做一层包装blabla......
正巧最近有想写一篇智能指针相关的博客,面试官的第一问就提到了。
说到智能指针,就必须提一下RAII了。
1.1 异常安全和RAII
std::shared_ptr
和其他智能指针类型都在<memory>
头文件里定义,主要的作用是实现自动化的资源管理,基于RAII的理念设计和实现。
RAII指的是获取资源即初始化,英文全写是Resource Acquisition Is Initialization,属于一种面向对象编程语言中常见的惯用法。
它的思路是这样子的:初始化即获取资源,离开作用域就自动销毁。
RAII解决的问题是,当异常发生时,如何确保资源释放。这是个异常安全的问题。
常见的非RAII风格代码里,如果要确保资源被正确释放,就要用try {} catch() {} finally {}
块捕获异常,然后执行资源释放的代码,再将异常重新抛出。
而RAII的理念是,让资源的生命周期和一个栈上的对象严格绑定,确保栈上对象被析构的时候,资源也就被一同释放了。
在C++中,有大量的代码都是以RAII风格进行设计的,其中智能指针也是。
1.2 std::shared_ptr
的实现
引用计数,大概了解过智能指针的人都能回答得出来。
虽然说实现方式并没有规定只能是引用计数,但实际上大家都是这么写的,万一哪天有个GC实现的std::shared_ptr
也别太震惊。
实现思路也挺简单。
所有指向同一实例的std::shared_ptr
应当持有同一个引用计数,来保持所有std::shared_ptr
计数同步,所以它们共同拥有一个计数器指针long *p
。
在复制时,shared_ptr
管理的对象指针和引用计数器指针被同时复制,然后引用计数器指针保存的引用计数+1——销毁同理,减少引用,直到删除。
1.3 std::shared_ptr
和CopyAssignable
std::shared_ptr
满足CopyContructiable
、CopyAssignable
和LessThanComparable
这些标准库的具名要求,因此可以作为STL容器的元素。
顺便一提
Concept
有很大可能出现在 C++20 标准里。
1.4 线程安全性
std::shared_ptr
不是线程安全的,不然不满足C++对Zero Cost Abstraction
的要(吹)求(逼)。
依据官方说法,多线程访问不同的std::shared_ptr
实例是没问题的(大多容器也是);多线程访问同一个std::shared_ptr
实例,但是只调用const
方法,那么也是没问题的(多线程读);多线程访问同一个std::shared_ptr
实例,调用非const
方法,那么会产生数据竞争(多线程读写)。
如果希望在线程间传递 std::shared_ptr
得靠 STL 提供的原子操作库std::atomic
。
std::atomic
可以快速帮助包装一个线程安全的对象或者指针,不过这东西对std::shared_ptr
的特化是目前还在制定的C++20
标准的一部分,所以能不用则不用,直到标准制定完成稳定,并且各编译器支持完善后再行考虑。
除此之外,如果确实有这方面的考虑,引入boost
是一个不错的选择。
无论如何,跨线程使用std::shared_ptr
我不怎么支持。
跨线程传递std::shared_ptr
本身就是个非常危险的行为。std::shared_ptr
作为标准库的一员,背负了C++的历史包袱,它随时可能被取出裸指针使用,或者意外复制了一次或几次,而这些对线程安全几乎就是意味着作死的行为却没有任何管束。
1.5 其他智能指针
std::auto_ptr
std::weak_ptr
std::unique_ptr
其中std::auto_ptr
已经被扫进历史的垃圾堆了,作为替代者,std::unique_ptr
有更明确的语义和更高的可定制性。
std::weak_ptr
是对于std::shared_ptr
的补充,对于希望使用std::shared_ptr
作为使用了指针的数据结构之间的连接方式,又不希望产生循环引用恶劣情况的一个解决方案。弱指针的存在不影响引用计数工作。
最后是std::unique_ptr
,它的语义是明确唯一持有某一资源,依照约定,被std::unique_ptr
持有的资源不应该再有第二人持有,std::unique_ptr
是唯一访问该资源的入口。
这些智能指针都有一个共同点:为了兼容C代码,所以它们随时可以被取出裸指针而不影响自身的工作,但这种使用方式造成的一切后果自负。
2. std::vector
面试官:...知道
std::vector
吧?讲讲它是怎么实现的。
我:vector保存了一个一定长度的buffer,当插入时可以避免插入一次就分配一次空间blabla...当插入长度超过了buffer长度,buffer会依照内部算法来重新分配一次内存,扩张长度。
回答不全对。其实面试官之后又强调了一次,但面试时没有听出来。
面试官:那之前分配的buffer呢?
我:之前分配的buffer先复制到新的buffer里,然后旧buffer会被释放。
这里对于释放旧buffer的说法其实是有问题的,可以具体看看下面。
2.1 内存布局
std::vector
的内存布局是连续的,这一点除了几乎每个人都有所了解之外(...),标准给出的要求也可以看出点端倪。
26.3.11.1 Class template vector overview
A vector is a sequence container that supports (amortized) constant time insert and erase operations at the end; insert and erase in the middle take linear time. Storage management is handled automatically, though hints can be given to improve efficiency.
关键点集中在这里:
... constant time insert and erase operations at the end;
末端插入和删除是常数时间
... insert and erase in the middle take linear time.
中间插入和删除需要线性时间(就是 O(n)
)。
典型的数组插入和删除的特征,不同的是std::vector
可以变长,所以真正插入大量数据的时候会有多次重新分配内存和复制的操作。
2.2 CopyAssignable
的约定
std::vector
要求储存的对象满足DefautConstructible
、CopyContructiable
和CopyAssignable
的具名要求,文档参考26.3.11.1
第2节。
26.3.11.1
A vector satisfies all of the requirements of a container and of a reversible container (given in two tables in 26.2), of a sequence container, including most of the optional sequence container requirements (26.2.3), of an allocator-aware container (Table 86), and, for an element type other than bool, of a contiguous container (26.2.1).
其中提到的Table 86
中列出了DefaultConstructible
、CopyAssignable
和CopyConstructiable
。
发挥一下脑洞,这些要求完美符合了之前对于重新分配内存的猜测对不对?
对象要可以被默认构造,因为vector
的实现可能是new
了一个新的对象数组(更可能是字节数组,到时候再placement new
);对象要可以被复制构造,因为对象可能被从旧数组移动到新数组;对象要可以被复制构造.....
当然更可能的原因是vector
本身是可复制的,上面的就当我吹逼吧。
除此之外还有CopyInsertable
和MoveInsertable
的具名需求,就像其字面意义那样,不多做解释。
2.3 内存重新分配的方式
对C稍有经验的人应该知道C语言有一个API叫做realloc
,它做的事情是这样的:
- 如果可能的话,扩张原先分配的内存的长度。
- 否则重新分配一块内存,然后把旧的内存复制过去,释放旧内存,返回新指针。
- 如果找不到足够长度的连续内存,则返回NULL,不释放旧内存。
C++自然不会少。
面试时没有想起来,本来认为是一种优化方案,但STL本身就算是优化方案了吧(...)。正确的解答应该是
用realloc的方式尝试扩展buffer长度,如果无法扩展长度,则拷贝旧buffer到新buffer,再释放旧buffer。
还行,失误就是失误,认错复习一遍。
3. 比较三个容器:vector
,map
,list
面试官:说说看
vector
、list
、map
有什么不同,分别在什么样的上下文环境里去使用它们吧。我:vector可以被随机访问,支持随机访问迭代器,迭代器算法有些不适用在
list
和map
上blabla...list
通常是链表实现,在插入删除的性能上有优势blabla......
顺便一提还没说到map
,面试官就换话题了。
这一题我大概又没有 get 到面试官的 point,单谈论容器的话可说的东西不少,我觉得面试官可能更想了解下我对这些容器的性能和内存方面的认知,可惜我答的有些太浅白了。
3.1 迭代器
先从迭代器的角度比较三个容器。
vector
是个典型的随机访问容器,显然支持forward iterator
、reversible iterator
和random access iterator
。典型的实现是dynamic array
。
list
是个线性结构容器,支持forward iterator
、reversible iterator
。典型的实现是链表。
map
是个树形容器,支持forward iterator
和reversible iterator
。典型的实现是红黑树。
3.2 内存布局和访问效率
讨论常见实现。
vector
是连续分配,访问成本低,插入和删除的成本高,会重分配内存。
list
是不连续分配,访问成本高,任意位置插入删除成本相对低,插入删除不会导致重新分配整块内存。
map
是不连续分配,插入删除访问成本不应和线性容器比较,毕竟它是关联容器。插入删除的成本都比较高,因为需要重新平衡树。访问时间在标准中的要求是对数时间复杂度,插入时间懒得继续翻标准文档了。
3.3 使用上下文
显而易见vector
适合高频读,而list
适合大量插入删除,map
和前面两个迭代器都搭不上调,在需要复杂索引的地方再合适不过了。
3.4 线程安全性
这些容器都不是线程安全的。
依照标准,多线程访问不同的容器实例一切都安好,访问同一个实例的const
方法也ok,但是非const
方法就会引起数据竞争。
尤其注意迭代器的选择,这玩意儿有时候不比指针好多少。
4. 如何管理内存资源
面试官:你在项目里一般是怎么管理内存的呢?
我:一个是尽可能用智能指针,然后是需要频繁构造对象的场合下可以用placement new blabla...
内存管理是一个非常广阔的话题,我的回答太过于浅显了。常见的内存管理策略有很多,智能指针只能算是RAII这种常见的范式,placement new 算是内存池/对象池的一种写法大概,还有其他很多策略我并不了解也未能涉及。
4.1 再论 RAII
RAII的范式可以确保异常安全,避免手贱忘记回收内存以及底层设计变更抛出的异常无法处理时导致意外的资源泄露。
诸如此类等等。
有一些约定可以关注一下。
4.1.1 获取资源失败抛异常
首先RAII的全写是获取资源即初始化,连资源都没能获取的话,构造理应失败,而不是静默给出一个无效的对象。
4.1.2 析构绝不抛异常
很好理解,如果析构又抛个异常出来的话,这个对象还析构不析构?父类还析构不析构?
4.2.3 常见设计
在STL里除了智能指针以RAII设计以外,还有加锁解锁相关的内容也是:std::lock_guard
。
诸如此类的guard
模式也在其他语言中有出现:比如说C#的using (var file = File.Open(...)) {}
。
4.2 内存池和对象池
内存池和对象池算是常见的设计范式,基本考虑到大量对象的构造删除的情况都会考虑到使用这两个模式,因为真的很好用(
内存池的模式主要是预先分配内存,然后在这片内存上构造对象,主要的适用场景是大量频繁构造小对象,构造成本低,生命周期短,内存分配成本居高不下的情况。当然,不仅是这里提到的场景,根据具体业务逻辑可能还会有不同的理由去选择内存池模式。
对象池区别于内存池的地方在于,对象池的对象构造成本要更高,频繁构造和析构是无法接受的,这种时候就需要一个候选备用的对象池,对象池实现需要对象本身允许被复用在不同的地方,一般来说性能会比较好。内存池则没这个顾虑:反正你需要就构造一个呗。
这两个池都可以用factory
模式来提供构造对象的服务,而工厂的消费者不需要了解对象是怎么构造出来的。结合RAII的话,内存池、对象池里的对象还可以用一层RAII设计的“智能指针”封装,使其完成使命后能自动返还资源,等待下一个工厂访客。
5. 玩过哪些游戏,对游戏制作流程了解多少?
面试官:喜欢玩游戏吗?都玩过哪些游戏?
我:我的话...主要玩的是音游,和贵公司业务可能并没有太多关联。
面试官:除了音乐游戏,有玩过RPG、ARPG类型的游戏吗?
我:像是辐射啊,老滚啊这些...开放世界类型的游戏游戏性没那么好,比起来我更喜欢电影式的游戏,比如说最近比较火的《底特律:变人》。
面试官:......(你丫来捣乱的是吧)
面试官:说说你对游戏行业的看法吧。
我:游戏行业前景好啊blablabla...娱乐崛起blabla...经济增长blabla....
面试官:......(????)
面试官:你上一家公司也是制作游戏的吧?就是说,你们游戏制作啊,都有哪方面的人在负责做什么东西,大概是怎么个分工合作的样子。(提醒+强调) 我:哦!哦哦,大概就是一个人负责策划整个游戏的玩法和系统,设计每个细节,然后程序负责去实现,自动测试blabla...内部试玩blabla...
还行,这波操作其实我也是挺佩服自己的。
5.1 陷阱:玩过哪些游戏
我注意到一件事:在多次面试游戏行业的职位时,都提到这这个问题:
你玩过哪些游戏?
也许形式上有所区别:
你玩过的游戏里,有哪些特别喜欢的?
换位思考,如果我是面试官,我为什么要问这个问题?我想知道什么?
熟悉游戏吗?
知道游戏有哪些元素吗?
能理解(我们招你进来要做的游戏)要你做什么吗?
不必太过刻意地表达出对游戏行业的崇拜或者抬高之类的,这一关主要的目的还是引出下文,聊聊对游戏制作流程的理解。如果对面试的公司出的产品有所了解的话可能算是加分项。
但是,从一个游戏玩家的角度出发,表现出不好的情绪容易留下坏映像——特别是,绝对不要明显地表达出对国产网游、手游、页游的鄙视!!
从一个玩家的角度出发,我也不喜欢大部分国产的页游手游,但是当着游戏行业公司的面试官的面,表现出我看不起你的态度,知道什么叫作死吗?
更何况并不是所有国产游戏都是屎,举例来说我现在超喜欢 MUSE DASH 这款国产音游的,手感比兰空 voze、节奏大师之类的好得多,界面也没有像节奏大师那样糊成屎,要不是我的 Unity3D 水平太差我真想给这家 pero pero game 工作室(公司?)投个简历看看。
除此之外还有就是抱着拯救国产游戏的想法或者态度,又或者劳资教你们什么才是真正的游戏这样的想法或者态度,作死无极限啊。
比较稳妥的回答方案应该是常见的几个网游,比如说LOL,DNF,王者荣耀,诸如此类。实际上玩过没玩过.....咳,不被戳穿就无所谓了。
5.2 游戏行业
加班是家常便饭,好像所有游戏行业的公司都会这么说。
大概了解下几个术语,算是加班界的黑话吧。
一个是996。什么意思呢?上午9点上班,晚上9点下班,一周上6天,加班费不用考虑了,不存在的,最多给调休。
再有一个是大小周。一周上6天,一周上5天,如此循环。同样,大周加班不算加班费,给调休。
另外就是调休。如果加班一天,将来某天就可以不扣工资休息一天,直白吧。攒下半年的调休然后一口气给自己放6个月假这种事情还是做梦比较好,调休基本上就等于无偿加班了,忙起来的时候劝你别休,不然人手就不够了;那闲下来的时候还能让你一周休6天?你敢休公司也不敢让你随便休啊,其他员工怎么看。
发薪日。网上有人总结,发薪日越接近月中的,或者超过月中的,大多都是怕员工流失的公司,而这些公司往往都不是什么好公司。听起来还是挺有道理的(
当然,最后还是要靠自己的眼睛去确认这一点。
5.3 游戏的制作流程
之前待得确实是一家小公司,甚至算得上工作室级别的超小初创公司,游戏制作方面的知识储备不算充足,写这篇博客的时候又去补习了一下。
主要的工种分为策划、美术、程序。
细分的话,策划可能有数值方面的,世界背景人物背景方面的,对话文本方面的,甚至可能有长篇幅的资料啊故事啊这方面的需求。
美术有UI方面的,人物、场景的原画师,3d模型制作,动画制作,骨骼制作,特效制作,等等方面的。程序经常需要和美术方面的沟通交流。
程序的话主要分前后端和测试,再加上运维和DBA之类的角色。
细分的话前端根据开发平台不同也有不同的技术栈,图像特效上可能会有更专业的大牛负责,team leader带队设计架构,分配工作,诸如此类。后端也一样,根据不同的技术抉择,可能整体的人员配置也有所区别,但大家都是程序嘛。
测试算是比较独立的,编写测试代码是一件很痛苦的事情(
所以这份疼痛有专人负责承受了:)
持续集成啊什么的也被承包了,测试或者运维会去负责的。
DBA一般公司也用不到,运维多少会两手SQL,规模更大的公司可能会设置这个专门职位。
流程上来说,策划给出游戏方案,美术可能会配合做个初稿效果图之类的(更可能是策划自己做个简单的效果图之类的方便说明),程序疯狂实现(崩溃-爆发-认命 循环),测试则配合给出反馈,让程序的脱发状况持续恶化,最后发布,项目黄了。
哦不是,我是说项目火了,程序们一跃成为CTO,迎娶白富美,走上人生巅峰。
(并没有)
6. 尾声
其实这次面试的自我感觉还是不错的,没有犯下太蠢的错误,但是可以改进的地方依然很多,语言组织能力需要进一步提高。
这篇博客的目的是自我反省,但是这次自我反省的效果并不算好,因为面试官的问题基本上都戳在我懂,但又没真正去深入挖掘的领域。日常使用自然没有问题,但理解却谈不上了。
如果面试官在细节上稍作追究:比如说placement new和user-defined new 之类的话题上深入,异常安全,或者问个map用红黑树实现,红黑树什么原理,那么这次我基本又要挂了。
关于给出的待遇的问题......我其实很好奇......
因为我真的才工作一年,不懂啊...
一年工作年限,C++我也不知道算什么水平,不知道怎么去横向对比,要8k是要多了么...
初级职位的意思是待遇初级还是能力初级啊...
还有主程一般指的是 team leader 对吗,游戏行业程序是不是干到 team leader 就算到头了...只能转管理岗了...