鯨魚遊戲後端開發工程師職位面試過程回顧 原 薦

面試

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作用要實際的多。當然並不是說這麼問不好,這些概念的掌握也是非常重要的基礎,而且能有效考察面試者語言組織能力和對這方面知識的掌握程度。

唯一不好的就是,面試者和面試官聊的過程就像是用黑話交流一樣......

不說了,學這黑話去......

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章