鲸鱼游戏面试笔记

作于: 2018 年 6 月 20 日,预计阅读时间 28 分钟

Intro

简单介绍下面试的前置情况。

面试的公司是鲸鱼游戏,职位是后端开发工程师,开发语言 C++。

这篇博文主要是为了记录面试中发现的自身不足。

这次面试里,因为面试约得比较匆忙,所以基本没做任何准备。讲道理的说我是有点盲目自信了,毕竟 C/C++是我的第一语言来着,本来以为考察语言的部分不会有什么问题,但没想到因为紧张而错漏百出。

那么接下来就直接进入正题,以下是对面试中遇到的问题重新思考后的回答和想法。

下面面试官的提问并非原话,有经过脑补润色。

起手式:面向对象

面试官:讲讲面向对象,继承,还有多态。我们都知道程序设计有两种常见的范式,面向过程和面向对象,讲讲面向对象给我们带来了什么好处?

实话说第一问就已经有点出乎意料,但想想其实还是在意料之中。初级职位更注重于基础概念和技能,中高级职位可能会在数据结构和并发一类的问题上更深入。

答:抽象,归类 blabla...易于维护 blabla...

全错。

现在回忆起来,面试官想问的其实只有一点,就是那句封装

封装是面向对象的核心概念之一

封装使代码成为一个黑箱,让我们不必关注它的实现,而是关注它的行为接口

这产生了面向接口编程的概念,我们不再关注封装后的对象内部的逻辑,我们给封装后的对象以输入,然后从封装后的对象里取出数据。

封装并不只是一系列接口的集合,更包含了数据状态,它就是一个微型化的服务,调用者告诉它去做什么事,而不关心它怎么做。

第二招:继承

面试官:讲讲继承。

我:代码复用,blabla......

代码复用,这是核心。

代码复用是继承最主要的作用,大家都知道。面试官并没有在这方面继续深入,所以能答出代码复用其实已经差不多了。

除非再抠上语言相关的语法细节:多继承单继承

多继承

C++ 采用了多继承模型,即一个子类可以有多个父类。

Father ------|
             |====> child
Mother ------|

多继承可以允许一些特殊的编程范式。比如说mixin模式。但是多继承也存在其固有的复杂性,主要表现在运行时多态上。

举几个多继承上常见的问题。

  1. 父类成员冲突

典型场景如下

class ParentA {
public:
	void func(){}
};

class ParentB {
public:
	void func(){}
};

class Child: public ParentA,ParentB {};

int main() {
	Child c;
	c.func(); // error
	return 0;
}

解决办法也很简单

int main() {
	Child c;
    c.ParentA::func();
    return 0;
}

之所以如果不调用 func 就不会出错,是因为 func 在编译后的 ABI 导出的名字并没有产生冲突。但如果主动调用了func,编译器则需要插入一个函数调用,但这里的func语义却是不明确的,所以编译阶段就会报告错误。

  1. dynamic_cast会改变指针

dynamic_cast是基于 RTTI 的运行时类型安全的标准类型转换,dynamic_cast本身是一个关键字,这里就说一说dynamic_cast的行为和多继承。

多继承下的dynamic_cast会修改指针绝非危言耸听。事实上只要稍作思考就能得出这样的结论:多继承下的内存布局应该是什么样子的?

v Pointer to Child
            v Pointer to ParentB
v Pointer to ParentA
|  ParentA  | ParentB           | Child          |
[-----------====================>>>>>>>>>>>>>>>>>]

C++ 鼓吹Zero cost abstraction也不是一天两天的事情了,成果如何不予置评,但显然,专门为多继承下的指针附加类型信息,以允许ParentB*类型的指针指向的地址和Child*相同是不可能的。

遑论 C++标准里根本没地址这回事儿了,指针指向的是啥玩意儿都有可能。

单继承

单继承就简单得多,只允许一个父类存在,根据语言设计也可能允许实现多个接口。比如说JavaC#。以我比较熟悉的 Rust 为例(暂不提继承,因为Rust就没继承这码事儿,全是Trait),一个struct可以实现多个Trait,然后以Trait object来实现对象多态。

单继承更多是在多态、重载、接口等方面的取舍,就不细谈了。

第三招:多态

多态和面向接口编程

面试官:知道多态吗?多态有什么好处?

答:多态就是...blabla...不去关注子类细节,归类成 xxx......blabla

多态算是面向对象基本概念之一了。

多态最基本的解释就是同一个接口的不同实现,但我理解中的多态解释则更趋向于类型擦除,即我不在乎你是什么黑人、白人、黄种人、香蕉人,我只要你能做到某件事。本质上来说,多态的主要作用就是擦除细节

举个例子,我打算去面试一家公司,面试官想要的是什么呢?他想要的是能干活的人

class Worker {
public:
	const int declarePay;
    const int declareEfficiency;
    BOOL testWorkEfficiency(SomeShit);
	virtual ~Worker()=0;
};

class Company {
public:
	BOOL hire(Worker) {
    	...
    }
}

面试者可能是HardWorkerFxxkWorker都是Worker实例,但他们也同时是Human,可能是Wife,可能是Husband,也可能是FatherMother,但是这些我们都不关心。

我们不可能为每个People某某某各自定义一个BOOL hirePeople某某某() {},我们关注的是工作能力,所以我们要在类型里擦除掉这些无关的细节,保留关注的部分。

多态做的就是这样的一件事:我不在乎你是谁,我在乎你是不是能干好这件事的人。

这么说其实有些脱离主题了,因为这是面向接口编程的思想,而不是对多态的学术解释,但这确实就是我对多态的理解,它的主要作用就是隐藏差异,进而发展为擦除细节

我的回答其实根本没到点上,也没 Get 到面试官的 point,所以面试官很快就换了下一个问题。

谈谈虚函数

面试官:虚函数的作用是什么?

答:啊?实现多态啊?...

可以说是最差的回答。

面试中没有反应过来问的啥,知道被拒绝了才突然明白。

o( ̄ヘ ̄ o#)

这已经问到语言细节了,所以咱们就从语言出发来讲。

多态

首先虚函数是什么?虚函数是 C++实现多态的手段,这么答没错,学过 C++都知道。不过虚函数不仅仅是这一点。

咱先从这一点讲起。

虚函数通过一个叫虚函数表的东西来实现多态,这个虚函数表是实现定义的,标准没有对vtable做什么规定,比如说必须放在类指针的前后几个字节处啊什么的......不存在的。所以也不谈虚表是怎么实现的,这已经是具体到平台和编译器上的差别了,要抠这个的话必须去读编译器和平台相关的各种文档了,PE 格式啊 DLL 啊 SharedObject 啊什么的。

如果问起来的话......嗯......这个职位应该很厉害。

所以我就跳过了。

直接给个虚函数的实例,真的没什么好说的。

#include <iostream>

class ParentA {
public:
	virtual vFunc() {
    	std::cout << "ParentA" << std::endl;
    }
};

class Child: public ParentA {
public:
	virtual vFunc() override {
    	std::cout << "Child" << std::endl;
        // 顺便写调用父类的
        ParentA::vFunc();
    }
};

虚析构函数

C++虚函数的另一个重要用途就是虚析构函数。

因为......C++对象模型中,析构函数的位置十分尴尬。

构造函数也就算了,无论如何也要显式调用一次。

析构函数则因为多态的存在而十分尴尬:给你一个父类指针列表,你显然不能一个一个检查这些指针指向是什么对象,然后再转回去,最后才 delete 它。

光是听起来就麻烦得要死,更别提有时候根本做不到。C++脆弱的RTTI和基本不存在的Reflection可是出了名的。

C++对这个问题的解决办法就是虚析构函数。

和一般的虚函数不同,一般的虚函数一旦被override,除非你主动调用指定父类的虚方法,否则调用的必然是继承链最后一个override了这个虚方法的类的虚方法实现。

析构函数的话就稳了,它会链式的调用继承链上每个类的析构方法,多继承的情况下则是按照继承的顺序调用析构方法。

不用主动写ParentA::~ParentA(),是不是特别爽?

还行,这就是个语法糖。

纯虚函数和抽象类

最后是纯虚函数。

其实这玩意儿我更愿意称他为接口

本质上来说,纯虚函数规定了一个方法,这个方法接收固定的输入,并保证提供一个输出,相应的可能还有异常声明,来说明这个方法可能抛出的异常。

怎么样,看起来眼熟不?

还没完,纯虚方法没有实现(你开心的话也可以写个实现),强制要求子类必须实现,而定义了纯虚方法的类被称之为抽象类

我想就算是叫它接口类它也不会反对的吧。

纯虚函数可以类比于C#interface,或者typescriptinterface,总之就是各种语言的interface。这些interface在具体的规定上可能有所差异,比如说不允许写数据成员啦,数据成员写了不算在实现interface的类上还要再声明一次啦,interface的方法可不可以有个默认实现啦,这些都是细节。

还记得上面我说多态吗?多态的目的是擦除类型细节,所以这些长得各不相同百花齐放的interface做的事情其实都是一回事:你能做啥,那么你是啥。

这里再说个细节,纯虚函数作为析构函数的时候,析构函数应该有个实现......

听起来挺奇怪的?不写纯虚析构函数实现的话,会报个链接错误...至于为什么要这么做,其中的取舍就不得而知了。

C++的纯虚函数和抽象类很灵活,没有其他语言interface种种限制,如果要追问纯虚函数

when? where? why?

那就要看到具体场景了,C++这些灵活的特性一不小心就会变成滥用,反正这么问我应该也就答interfacemixin以及其他具体需求的场景这样子了。

Mixin 模式

Mixin模式在Python里比较常见,不过 C++也并不是没有。通过定义纯虚析构函数,来给一个对象混入特定功能而又不允许自己被独立构建,算是个常见的范式。

举个例子,引用计数,如果发现自己引用归零了就释放资源,线程安全之类的问题先不管,仅仅是展示这个范式。

#include <iostream>

class RcMixin {
private:
	using deleter = ()->void;
	int *_rc = nullptr;
    deleter resDeleter;
public:
	RcMixin(deleter resDeleter):resDeleter(resDeleter) {
		*_rc+=1; // 线程安全就先放一边
	}

	RcMixin(const RcMixin& other) {
        resDeleter = other.resDeleter;
        *_rc+=1;
	}

    virtual ~RcMixin() = 0 {
        *_rc-=1;
        if(*_rc <= 0) {
            resDeleter();
        }
    }
};

// 虽然是个RcMixin但是外界并不需要知道它是RcMixin
class SomeShit: private RcMixin {
private:
	int* res = nullptr;
public:
	SomeShit()
    	: RcMixin([&this]() {
    	std::cout << "" << std::endl;
    	delete this.res;
    }) {
    	res=new int(10);
    }
    virtual ~SomeShit() {}
};

int main() {
	SomeShit a;
    auto b = a;
    auto c = b;
}

代码没测过,反正大概就是这种感觉,将某些功能混入一个现存的类,而不需要做太多的工作。在 C++里没那么方便,强类型下的 Mixin 需要很多变通技巧才能愉快地混入新功能,而鸭子类型Duck typing的语言则舒爽很多,当然,最好的还是具有完善 ReflectionAttribute 支持的语言,完全避免了对Mixin类型的构造和需要利用的数据的绑定一类的不必要的关注。

扩展:虚继承

同样是 virtual 关键字,虚继承和虚函数关系就不怎么大了。

虚继承面对的问题是多继承时,多个父类继承自同一个基类这一问题。

听起来是不是有点奇怪?这些父类继承自同一个基类会有什么问题?

事实上,这个问题取决于写出多继承代码的人,也取决于这多个父类是否有对多继承方面做过考虑。

举个简单的例子,ParentAParentB都继承自DataAParentA修改了DataA的数据,但ParentB不知道。如果ParentB需要根据DataA的某些数据进行操作——很遗憾,这个行为可能与预期的不同。

之所以引入虚继承,是为了解决要不要共享同一个基类实例的问题,选择虚继承,则选择共享基类实例。

共享基类实例的优势是,多个父类的功能可以无缝结合。ParentAParentB可以共享基类定义的Mutex等状态资源——当然,前提是设计父类的人有过这方面的考虑。

不然的话,不共享基类实例是个保守但更安全,不易出现歧义的选择。

第四招:数组和链表

面试官:我们聊一下数据结构方面吧.....讲一下数组和链表?可以从访问和删除两方面来说。

答:数组允许随机访问,只需要一步就能找到对应元素,而链表需要......blabla,数组删除元素如果需要移动后续元素的话,会产生复制操作性能损失,链表只需要修改几个指针...blabla。

实际上答到这里我已经不知道自己在说啥了。

数组和链表的区别还是挺大的,我应该算是 Get 到了几个点?下面是重新整理了语言后的回答。

数组和链表的内存布局

数组和链表两者都是线性数据结构,表现上都是一条有头有尾的有序序列,但是储存方式上有区别。

数组的储存方式是一端连续的内存空间,索引只需要进行一次指针运算即可获得目标元素的位置,也可以理解为访问时间始终是O(1)

PS: 还能写出 0[array] 这样的骚写法,不怕被打死的话。

链表的内存布局则是分散的,通常的链表实现往往是插入元素时动态分配一个元素的空间,而删除的时候再释放,长此以往对内存是不友好的,容易产生内存碎片,导致分配较大空间时无法寻得足够长的连续内存片段而造成分配失败。

......当然,是长期才会产生的问题,而且是切实存在的问题。

索引

对于数组来说的话,可以理解成标准库的 std::array,也可以理解成原始数组,但不变的是索引方式始终是O(1)复杂度,而且支持随机访问迭代器。

对于链表来说,不考虑优化后的变体,索引方式在本质上都是顺序访问迭代器——指针也算是概念上的迭代器。所以对于链表,访问时间的复杂度最坏情况应该是O(n)n是链表长度。不用说,索引性能自然是不如数组的。

删除

数组删除元素其实是比较烦的,复杂度应该是O(n)n是数组长度减去删除元素在数组中的位置。最麻烦的是万一数组很长,那么复制元素到上一个位置将会是噩梦。

当然也不是不能优化......把移动的操作推迟到插入新元素的时候就好了,用一个占位符表示这里已经被删除,同时记录前面有多少个元素被删除。这样一来索引性能会下降(因为要找到上一个被删除的元素,然后更新索引位置,直到找到正确的元素),删除性能提高(只要找到上一个被删除的元素然后记录自己作为被删除元素的位置就好),整体实现的复杂度提升,索引删除插入都要另外编写实现,感觉得不偿失。

链表删除元素很简单,索引到需要删除的元素的时间复杂度是O(n),删除操作的时间复杂度是O(1),而且实现简单。

扩展:结合两者?

好吧,这个问题面试官没问到。

链表和数组结合一下能解决一部分内存碎片的问题,基本思路的话......咱预先分配 100 个元素,如果插入的元素超过了 100 个,咱再分配 100 个元素的空间,然后索引的时候再去找第二个池?

这个思路术语叫什么记不起来了。

哦不!他到底想问什么?

猜一猜面试官到底想问些什么?

  1. 动态内存分配:数组定长,而链表变长。我感觉这个特征基本没什么好说的,工作中基本没有机会自己重新实现一个线性容器,除非要定制一些特殊的结构,环形链表之类的东西。其他像是链表,数组,队列,标准库都有相应的实现。也许是考虑自行编写线程安全版本的 STL?
  2. std::arraystd::list。所以问的是啥呢...?提供的保证和implement specified还有undefined behavior吗?STL 现在还没有concept,但是早早就有了SFINAEenable_if之类的东西,constexpr if 更是极大地强化了编译期元编程方面的能力。如果是问标准模板库方面的东西的话,我觉得问标准库线程安全啊,迭代器算法之类的东西要合适得多。所以......大概也不是想问这个。
  3. 迭代器。如果是这个的话我真的希望面试官大人能直接说出迭代器三个字......不过好歹回答出随机访问了,应该不至于吧。

第四招:数据库索引

面试官:讲一下数据库的索引有什么作用。

我:懵逼......

还行,直接懵了。

因为完全没搞明白面试官的意图:索引指的是啥?面试官是想问数据库索引的方式吗?B+树该怎么实现?

回来路上我考虑了一下,这几方面可能可以作为回答的方向。

索引的实现

数据库索引的常见实现方式是 B+ 树,我数据结构学的不好,只知道 B+ 树是个很厉害的数据结构.....所以博文写到这里,不得不开始查资料了。

B+ 树是一种树数据结构,通常用于数据库和操作系统的文件系统中。B+ 树的特点是能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。B+ 树元素自底向上插入,这与二叉树恰好相反。

如果问起 B+树实现,或者让手写个 B+树的话,我也只能望而兴叹了。

postgres 数据库的索引属性

对于数据库的实现我了解不多。

大概就是建立个独立的 B+ 树索引......吧?

emmmmmm

真想不出了...

第五招:Primary key

面试官:说下主键的作用。

我:emmmmmm.....

到这里我基本已经萌的不行了。(无错字)

内心 OS:我是谁?我在哪?我要干什么?

甚至连zhujian都听成了zujian

被面试官提醒了一下

面试官 B:就是那个 key

我也没反应过来......

有啥用啊(天真脸)

主键的话,具有唯一性的索引?

emmmmm,不然还有什么作用呢......

看来数据库必须下功夫学一学才行啊......

叮叮叮——You fxxk up

面试官:十动然拒。

我:理解理解,谢谢谢谢。

还行,回顾完整个面试流程,除了 C++部分可能是因为发挥失常之外,数据库方面的确是没有下够功夫,以至于连索引和 PrimaryKey 这两问都在持续懵逼。

而且实话说面试,确实有技巧这回事......

面试官提的问题也存在着范式——网络上面试真题什么的,看起来像是玩笑,但面试官提出这些问题的时候却是认真的。

尽管......这种

聊聊 xxxx(某技术/概念/工具),xxx 的作用是什么

的提问确实让人不容易抓住重点......

考察基础的角度来说,现场白板写一个程序,然后再深入聊聊这么写的用意,有没有优化方案,考察对语言的理解和 api 设计、代码架构能力,比单纯的说说 xxx,问 xxx 作用要实际的多。当然并不是说这么问不好,这些概念的掌握也是非常重要的基础,而且能有效考察面试者语言组织能力和对这方面知识的掌握程度。

唯一不好的就是,面试者和面试官聊的过程就像是用黑话交流一样......

不说了,学这黑话去......

/interview/ /c++/