我寫C++喜歡用繼承有問題么?
故事的背景是這樣的:
我想在我們的遊戲伺服器里加簡單的協程邏輯,需要控制協程的超時,因此實現了一個簡單的Timer,同時遊戲里的其他服務也可以使用這個Timer。
Timer的基本結構是這樣的,它繼承自一個侵入式的雙鏈表,這樣可以在O(1)的時間複雜度內刪除這個Timer。同時有一個TimerMgr採用時間輪的方法來管理這些Timer。
TimerMgr有一個registerTimer和unregisterTimer的方法,定時器到期時,會調用Timer的onTimerOut方法,這個onTimeOut方法是一個純虛函數,需要業務層繼承Timer,重寫onTimeOut方法。
採用繼承的方法有一個好處,就是當子類析構的時候會調用父類的析構函數,這樣可以直接同TimerMgr中remove掉Timer,防止超時時出現訪問已經析構的對象。
現狀:
這個Timer我很早就實現好了,而且在幾個系統里用過了,今天開會的時候說要重新實現一個Timer,有兩個方案,一個是繼承一個是組合,但我覺得組合的方法沒辦法避免父類析構導致的Timer超時時可能會出現的訪問已經析構的對象的問題,而且代碼的整體性也沒有繼承好,但是同事們都覺得繼承不好理解。
在寫C++的時候大家都習慣用組合而不用繼承么,在上面的應用場景中我用繼承有問題么?我覺得用繼承比用組合有明顯的優勢,為什麼同事並不這麼認為?
用繼承做事件回調是 20 年前流行的做法,已經過時很久了。
如果有個 class Foo,需要同時使用兩個 Timer,一個每 3 秒觸發一次 Foo::slowEvent(),一個每 500ms 觸發一次 Foo::fastEvent(),你打算讓它怎麼繼承?
還不如像 muduo EventLoop 那樣用 std::function&
class EventLoop
{
public:
void runAfter(double seconds, std::function&
void runEvery(double seconds, std::function&
};
再舉一個例子,如果有個 class Bar,繼承你的 Timer,每 500ms 回調一次 Bar::onTimeOut()。現在來了一個新需求,用戶執行某項「臨時加速 10%」操作之後,在接下來的 1 分鐘里,其對應的 Bar::onTimeOut() 要變成每 450ms 回調一次。你打算先析構 Bar 再構造一個新的,然後過一分鐘再換回來?那這「一分鐘延遲」 是不是又要寫一個新的 class 來繼承 Timer 呢?
@陳碩 的介面設計要比你這個好很多。
- 不必讓若干類繼承你的Timer類,這真的略煩人;
- std::function可以接受一個C函數、C++成員函數、Functor或lambda表達式,更靈活。
- 我覺得最方便的一點,std::function可以順便保存響應函數的參數,你的響應函數可能不是無參數的,一個非同步的代碼保存一點信息很常見的需求,萬一有參數放在哪?
- 萬一你的類需要響應不同的事件,屆時那代碼就更加不怎麼好看了。
- 至於你提到的析構自動unregister掉,還是手動管理比較靈活,registerTimer的時候返回一個id,可以隨時unregisterTimer,也可以在某個類的析構函數里調用,我之前看過一篇文章叫《優秀的程序員都手動管理他們的timer》,哈哈。
你這個設計使用的範圍比較局限。比如有個類Monster已經有自己的一個繼承樹了,它的父類NPC,如果要使用定時功能就去繼承你這個類。那麼問題是用Monster去繼承呢還是用NPC。如果是用NPC的話,那這個NPC如果是第三方的庫的某些類,為功能改父類基本都是不合理的。如果用Monster去繼承,那就是多重繼承,這個多重繼承本質上是mixin,說白了還是組合,只不過實現方式不同。不過現在回調都是functor搞定,簡潔方便清晰靈活:)
個人並不排斥繼承,不過C++的繼承語法由於太過複雜,實踐中很容易造成濫用,以及一些隱晦的問題,所以很多項目在代碼規範上就不提倡了,其實你看其他一些語言中,繼承還是挺常見的,但它們的繼承語法相對簡明,也有一些限制,如果你能保證不濫用當然可以,但很多時候團隊合作不太好控制,規範上一刀切反而是成本最小的辦法
歪個樓,說說對題主面臨問題的看法
因為對題主的具體業務場景不是很熟悉,單從論述來看,抽象一下,其實你是要解決兩個問題:
1 框架希望有統一的介面來管理某種對象,但這類對象可能有多種實現,實現的負責人是框架的使用者
2 對象的銷毀管理是使用者在做,框架希望在銷毀操作時候得到通知
第一個方法OOP可以解決,不少答主也給出了一些其他解決方式,不過第二個需求,我覺得只是利用了C++的析構(畢竟其他OOP語言不一定提供析構語法),如果使用者註冊timer的做法是先new,再register,那反操作應該是先unregister,再delete,也就是說你把unregister做成了delete的一個觸發,就框架來說,這樣可能有點太干涉使用者了,比如人家只是希望把timer從框架拿下來,但是不刪除留著做其他事情呢
而從使用者角度說,只delete就自動unregister是很便利,不過這個便利也可以通過再封裝一層來實現,這樣層次清晰對於維護性會更好一點(推薦用組合而不是繼承,有一點也是因為可以使得對象之間的關係比較清晰)
一、先說對問題的看法:「我寫C++喜歡用繼承有問題么」這個問題本身沒有絕對答案。在我看是關鍵在於是不是用對了。(我不認為把所有繼承都改成組合或「函數綁定」是一個好選擇)
二、接著說一下我對你的說的過程的理解,看理解得對不對。你意思是:
1)「Timer」(派生類的)對 象會析構 =&>
2) 析構時會先調用基類的析構 =&>
3) 基類析構時會從鏈表中主動移除自己 =&>
4) 這樣Mgr下次輪詢時在鏈表中就不會找到這已經析構的Timer節點……
這樣看起來很好,類庫的良好設計,使得不用擔心寫派生類的程序員因為忘記將Timer取消註冊而犯下錯誤。三、但是,如果你的程序是多線程程序,以上過程就不能保障。程序是多線程的,這個可能性還是挺大的。比如你說的:
TimerMgr使用"時間輪"來管理鏈上的一個個Timer
這「時間輪」不知道是不是你遊戲服務端框架的一個什麼術語?我理解Mgr自己也要能以很快的速度定時地檢查每個Timer是否到點了,所以Mgr本身也要有定時功能,並且定時間隔要很小(總是要小於所有Timer中的最小定時周期吧)。這個Mgr有兩個實現方法,一個方法就是使用一個獨立的線程,這時程序就鐵定是多線程的;另一個是使用操作系統自帶的定時器(或基於應用空閑)的方法,這時候Mgr沒有帶來新線程,但並不能鐵定你的程序就不是多線程的,因為其它Timer也有可能運行在不同的線程里的。
四、先假設就是單線程。注意,單線程就意味著,所謂的定時器鐵定是不精準的,通常是十毫秒級的不可控誤差(多數操作系統或框架基於應用空閑或基於自身的定時器,就在這個級別上)。更加不可控的是每個Timer在觸發Timeout之後執行的代碼,因為是單一線程的,所以實際某個Timer到點後執行的事件超長(比如叫醒一個周末早上的睡客),那就得所有Timer都在等。 為什麼要在單線程環境下,上來就強調這一點呢?因為在定時周期的時長本身就在毫秒級別不可控的情況下,你同樣是上來就強調的那個 :
"這樣可以在O(1)的時間複雜度內刪除這個Timer"
它也就沒有意義了。定時都不準,你追求刪除一個定時器的複雜度是O(1)幹嘛?好,就算你要追求快,先看看你會有幾萬個Timer呢?100個以下,使用list遍歷一次也不會慢出百分之一毫秒。一兩萬個以下,使用map,多一份在樹中找到Timer然後刪除,變成對數複雜度,2萬個定時器也就相當於遍歷和比較10個節點,還是慢不出百分之一毫秒。並且關鍵是,單線程程序中,如果有2萬個定時任務要執行,基本就癱在那裡了。
五、(繼續單線程的情況)。很多時候,為了性能或安全等非功能性要求,我們才不得不割捨某些設計思維(常見的如OO思維)下設計指導。但現在既然希望有O(1)複雜度的刪除定時器的需求不再是一個強大到必須嚴重影響我們的程序設計的需求。那我們就拋棄這個需求,重新考慮正常的設計。比如以OO,也就是「面向對象」的設計思維來看,我們將要在程序中一直打交道的對象,當成是可以說話的角色,和它們對話。當下我們有兩個對象:定時器Timer和定時器管理者:TimerMrg。我們以前者為例,進行對話:
1) 和定時器的對話(程代表程序員,定代表定時器):
程:定時器同學,你來到這個代碼世界,擔負著什麼職責?
定:我就是一個指定的時間點到了(或指定時長過去了)以後,我可以執行一點什麼事的東西啊。
程:你這樣的表達是混淆不清的。比如,其實你自己根本不負責檢查你要的時間點是否到了這件事。說到底你就是用來"記得"一件事,就是指定時間到了以後要執行的那件事。並且「執行」這件事也不是你自己發起的,因為我一會兒還要設計一個Mgr,它才是負責檢查時間是否到點,以及執行事件的發起者。
定(不爽地):噢,好吧。你們愛怎樣就怎樣吧,我樂得輕鬆。
程:那你是一個鏈表的節點嗎?
定: 切,我一個定時器,幹嘛要是什麼鏈表的節點? 什麼叫鏈表的節點?我一個定時器不懂這麼多,但是你要非覺得我應該是,那我就是吧。我就一個內存中的對象,我能怎樣?
六:(繼續單線程的情況)你的同事說對你的繼承設計表示不理解,我認為正是如此:誰能理解?定時器自己也不能理解為什麼自己要有一個基類是某個鏈表的節點。因為這樣派生關係太過違反直覺。相比組合,繼承本來就應當少用,當一個繼承不是is a關係,你只是希望從父類得到某些功能以期供派生類自身(類內部)使用,那好歹這是C++語言,你乾脆要求派生都採用「私有派生」的方式使用「Timer」的基類吧;但那應該是萬不得已的設計(比如說基類不是你設計的)時採用的。
你的觀點是:我為了避免派生類的設計者忘記在析構時將當前對象從鏈表中幹掉自己,所以我才用繼承。我的觀點是:一個定時器本來就不應當知道自己有一個什麼「某個鏈表中的節點」這樣的額外身份,如果它不是(準確講叫『不需要知道』是)鏈表中一個節點,那哪來這麼一個必須在臨死前記得必須從鏈表中刪除自己的這麼一件事呢? 從這個結論上說,你所謂的「好的設計」,其實是先製造一個本不應該有的問題,然後再用一個美好的方法解決它……方法再好,但問題是你額外造出來的,那就不值得讚揚。
七:(現在開始,和單線程多線程無關)。現在說Mgr。「Mgr」曾經和"Wrap"一樣是一個萬惡的類名後綴。只不過因為屢禁不止,慢慢的也說明這個名字有它使用的價值(存在即合理?人民,也只有人民才是名稱的創造者?),大家也一看到它就知道它是「Manager/管理者」的縮寫,但規則還是要有的。限於篇幅不胡扯對話了,直接說結論:在一個C++程序里,如果一個"Mgr"不負責(不持有)它所管理的對象們的生殺大權,那它通常並不是所謂的「管理者」。所謂的管理者應該管理什麼呢? 這個我也說不全,也說不清楚,也無法說得很合理(因為如果我能說得全,清,精確,當初就不會有那麼一大票人說「Mgr」是萬惡的命名)。但不管怎樣,如果不具備直接或間接主動幹掉所掌握的對象,它就很可能應該有一個別的名字。當然,說是生殺(生和殺),意思是Mgr最好也是一個Timer的工廠類,通過它的某個方法,比如「CreateTimer()」或「RegistTimer(Action)」來生成一個Timer。這個是「最好如此」,如果不負責生,那至少應該在取得管理權的那一瞬之後,也就是你現在設計的「RegistTimer(Timer)」之後,就接管Timer的生命周期,這是「應該如此」。
此處結論是:用來存儲Timer的那鏈條表,就應該交給 Mgr 管理。如果追求速度,就用map或hash(unorder_map),下面還是就叫它鏈表。依據C++的設計,此時Timer對象是鏈表上某個節點所存儲的值(而不是節點本身),Timer對象不需要知道,通常也不應當設限自己將會被存儲在什麼容器里,一個std::list?還是std::map?,或者其它任何容器?這就像一顆茄子長什麼樣子,理應和它將要被放進什麼容器(洗菜池子、麻袋、菜籃子、快遞包裝箱)無關,茄子自由自在,只要也只能長成茄子的樣子。這才是它的基類(因)決定的事。 正如本點一開頭所說的,這是理應如此的設計,和多線程或單線程無關。
八:現在說多線程的情況。如果不加鎖,一個Timer剛剛要析構,同時Mgr發現這個Timer到點了,於是執行它的OnTimerout()事件,然後那個Timer析構完成,世界崩了……如果要加鎖,那麼為了達到你所要的「析構時自動地從鏈表中刪除」的效果,就得一邊在Timer的析構函數的調用中加鎖(別一邊是OnTimeout())。「在析構函數中加鎖以期得到資源訪問的安全」,這件事一展就是一大篇文章。我就不展開了,你可以上網搜索一下相關資料。 @陳碩 的《當析構函數遇到多線程》應該也會說到。簡單描述是這樣:
1) Timer某對象正要析構,並取得鎖,但還沒有從鏈表幹掉自己。
2) Mgr從鏈表中取得這個對象,開始調用這個對象的OnTimeout(),因為鎖被佔用,暫時不能執行。
3)那邊Timer對象析構完成,此時鎖如果是Timer的成員,因為也被析構,它的狀態是未定義的,事情會亂套。如果鎖不是成員(這對管理各個Timer的鎖會很難搞),於是因為那邊析構結束,鎖釋放,然後此時obj.OnTimeout() 取得鎖,開始在一個已經析構的Timer對象(obj)上執行它的超時任務。這是相當危險的(除非整個過程中不會直接或間接地用到this)。
給一個並不是我定的粗暴判定: 凡是必須依賴在析構函數里加鎖的設計,都是不好的設計;在好的設計下,一個對象在析構(死的過程),必然不應也不會有別的線程來打擾(這並不難做到)。
九: 最後給一個(我認為是)好的設計,不管多線程環境還是單線程。當然,如果事先可以鐵定肯定程序 就是單線程的,那肯定是可以針不「不用加鎖」這個因素來做進一步優化,這和這裡想說的好的OO設計無關。
1)先說兩個設計考慮:
a) 一個被管理的對象,不能強制要求脫離管理,否則管理者就不叫管理者了。但是被管理者可以向管理者「申請」脫離管理。這和我們熟悉生活中規則是一致的。比如員工不能突然自己不爽直接不幹了,但是員工可以提出辭職申請。
b) Timer對象沒有什麼「析構」的需求。事實上任何對象都沒有什麼「析構」的需求,只有表達」我完事了「的需求。因果關係是「因為我完事了,所以可以析構(處理後事)」,而不是「我想死了,所以我完事了」。對於Timer對象來說,對應的需求應該是「我這次已經把任務全部搞定了,我不想再被輪了」
然後是設計:
1) TimerMgr是管理器,它提供std::map(或hasha或list其實也無所謂),用來存儲所有它管理的Timer對象。
2) 而Timer正如前面對話所說,它其實並不是一個能夠主動發起OnTimeout()的對象。它的本質就是一個「動作/Action」或「任務/Task」;所以確實可以使用 @陳碩 或他人回答中的一個"std::function&
3)這種設計下,我們還有一邊Timer自己要尋死(要析構),另一邊Mgr卻還逼著它幹活(執行OnTimeout()) 的情況嗎? 不會有。問題解決了。除非——
除非你搞了一個OnTimeout()重入的bug。定時器通常是不是能重入的。比如,假設我們希望每5分鐘叫醒一次主人,並且不醒不休。但主人硬是在鬧鐘響起之後5分零1秒了,還完全沒反應。也就是說這時候第二個5分鐘也到了。假設鬧鐘設的聲音是"小蘋果"歌曲。請問智能手機這時候是只播放一個」小蘋果「的旋律,還是一前一後同時在播放兩個」小蘋果「旋律疊加? 正常的設計是只播放一個。
4) 結論是:Mgr負責使用Timer,Mgr也負責解聘(包括強行開除,比如程序要退出,此時Mgr相當於公司解散了,也包括或正常辭退)Timer。但Timer可以申請辭職。由於一切都由Mgr處理,所以你所擔心的問題消除了。
十:但是我們還是要回答「喜歡 用繼承 有問題 么」? 假設我們就讓Mgr也負責創建Timer,使用「RegiestTimer(Action a) 」。注意,入參類型乾脆就叫Action ,而不是Timer,當然,如果你希望提醒使用者,這是一個定時器的操作,那就叫「TimerAction」吧。Action里有定時器所需的信息,比如:默認定時周期多長,然後,問題的結論來了:還可以有一個剛才說的那個「function &
最後一個結論:繼承不僅帶來語法關係上的耦合,還帶生死關係上的耦合(派生類死,基類必然要死)——但如果這就是設計上需要的,那就當然是好的設計。如果你一時搞不清是怎樣的關係,可是項目經理又死催進度,那就三者取其中,先設計成組合吧。至少將來含淚重構時,進退會自如些。
繼承是強關係,描述的是is a關係。這種關係適合刻畫本質上「是一種」的業務關聯。只有反映了本質關聯,才是穩定關係(因為業務本身如此要求),才不會造成@陳碩提到的那些問題。
我覺得有句話說得很好:繼承(至少公有繼承)不是為了重用,而是為了被重用。如果想重用,組合關係會更適合。
至於提問者提到的「組合的方法沒辦法避免父類析構導致的Timer超時時可能會出現的訪問已經析構的對象的問題」,@陳碩也給了解決方案:用智能指針,實現弱回調。我以前在項目中參考過他的很多智能指針使用實踐,內存管理從此輕鬆不少。NPC和Monster都不是Timer,所以不要用繼承。
其次,盡量使用智能指針,讓NPC和Monster繼承 std::enable_shared_from_this ,不要在lambda里捕獲this裸指針,就不用擔心訪問已經無效的對象了。
更新:
之前手機上回答完美錯過了 removeTimer。
題主很不錯,能考慮到從設計層面來自動管理資源,避免手工管理帶來的問題。
其實我們可以用: std:;unique_ptr std::unique_ptr - cppreference.com。
比如我們把 std::unique_ptr&
不過用 std::unique_ptr 的話,你就只能把Timer放在一個地方。
我倒是喜歡Timer::WeakPtr,因為可能在業務邏輯里會把它放在多個地方,然後多個地方都可能因為某種邏輯主動調用 weakTimer-&>Cancel();
你寫的時候爽,但是維護的人比較麻煩。單一兩個繼承閱讀沒問題,但是一旦繼承很多,這個套這個那個new那個,閱讀起來非常麻煩。而且在需求發生變動的時候,比如刪減修改一些功能的時候,繼承會非常不好修改。簡單來說,繼承會讓可讀性差,不容易修改維護。我的原則是一般盡量不使用繼承,除非需要進行多態的時候才進行繼承。
不好。你可以看看Gtk的回調方式。
對backend來說,把繼承從編程字典里刪除會發現世界美好很多。
@陳碩 的回答基本上就是基於modern cpp的教科書級別的實現了,使用funciton + 弱回調的方式處理這個場景乾淨利落,沒有問題。
所以基於這個結論往回看,為什麼你會有這樣一個設計?
首先如果你有一個TimerManager來管理Timer,那麼一個樸實的設計是TimerManager自然而然的承擔起Timer的構造和銷毀的工作 這裡可以有一個explicit constructor和一個explicit destructor。
class TimerManager
{
public:
Timer* createTimer();
void removeTimer(Timer* timer);
};
當然你們也可以自己new然後往裡塞,或者自行選用smart_ptr,這裡的健壯性和你們的選擇是否strict正相關。
所以如果是Manager統一構造銷毀、管理生命周期,那麼為什麼會有「訪問已經析構的對象」這種問題呢?
因為你設計意圖混亂,把對象的所有權交出去了。
所以你所說的好處,不過是為一個沒有盡責的TimerManager買單而已。
所以當你的業務Manager和你的TimerManager都盡責的時候,代碼應該是什麼樣的呢?
很顯然,「業務」Object和「Timer」Object是兩個獨立的類,各管理各的。
你看,這就是一個天生的組合問題。
所以兩個Manager管理下的Object應該怎麼互相訪問呢?
一個樸素的方式當然是:
auto* timer = TimerManager-&>getTimer(id);
if (timer)
timer-&>do(...);
auto* object = ObjectManager-&>getObject(id);
if (object)
object-&>do(...);
其中通過提供id &<--&> ptr這一層間接性來提供查找功能,和weak_ptr等價。
這個do可以是個std::function,也可以是一個帶有function和context的對象,又或者是其他。
你看,這天生就是一個弱調用的問題。
而且這段代碼可以做一個什麼樣的擴展呢?也很簡單。
for (auto id : ids_)
{
auto* timer = TimerManager-&>getTimer(id);
if (timer)
timer-&>do(...);
}
for (auto id : ids_)
{
auto* object = ObjectManager-&>getObject(id);
if (object)
object-&>do(...);
}
你看,這是一個天生支持多對多的關係。而cpp的繼承是一個典型的single dispatch,有時候我們說一個繼承結構僵硬,也是在說這個事兒。
所以在我看來你這個設計的問題就是:
1、嘗試通過繼承解決對象所有權不清晰的問題。
2、因為引入了繼承給你們的代碼結構帶來了不必要的約束。
關於single dispatch 和 multiple dispatch的論述,你可以看 @vczh 早年的博客。
以上代碼嘗試用一個非常樸實的方式來解決樓主的問題,也沒有用什麼高深的技巧,只是在設計意圖上嘗試努力保持一致性,我本人對cpp的理解深度也有限,希望能給樓主一些幫助。
最近我寫過一點我認為和本問題的內容和背景都有點相關的文章開源一個超簡單的無棧協程原型(續1)。
我在該文里的態度是:
- 把不用繼承和不用虛函數2個事情分開來看。沒有虛函數的時候,繼承就相當於持有了一個基類成員而已(無論是從軟體工程還是二進位角度來說),沒啥大不了的。
- 用繼承而不用虛函數的時候,記著基類只能當弱引用而不把他當強引用。我的處理方法是基類析構函數是保護的。
綜合你問題內容的態度是:
- 看起來你們基類被當強引用了,那麼說明你們的生命周期管理比較複雜了,如果用shared_ptr 能夠簡化一些。
- 你們都用協程了,可以考慮實踐下我在引文中提到的「消費者」模式。大大簡化你這類困惑。
- 結合你的問題去出方案,沒有什麼萬能的「優良設計」。用函數指針(或者函數對象)來代替虛函數其實完全是另外一種api設計模式,前者用函數參數來侵入修改函數被調用者的執行流程,未必就比函數調用者創建特定的子類(重寫了某虛函數)更時髦(前者限死了調用者只能侵入修改那一個地方。。。我真的不認為c/c++有什麼過時或者時髦的方法論),虛函數的二進位實現使用了虛表而不是結構體持有指針的方式本身就決定他比後面那種方式的非侵入擴展性更強。
- 你並不是什麼開源庫的設計者而是團隊合作的一分子,考慮一下庫調用者的意見和習慣。當然還有,領導的習慣和口味。為什麼開源庫的style比teamwork要好,莫過於眾口難調之下,妥協出來的都是讓每個人都帶有點不滿意的結果。
- 個人經常覺得在實際工作中的設計問題,很多時候if else 比你想像中要有力和保險的多。。單元測試通過率高很多。 我是真的不怕大佬來嘲笑--連虛函數都會考慮if else 的devirtualize了(看Optimize Options 里的-fdevirtualize-speculatively),if else 真的不比 虛函數重寫,設置函數指針或者函數對象要挫。
大多數情況組合更合適你這個timer組合更合適就你目前情況的話建議實現一個繼承Timer的DefaultTimer供他人組合使用,皆大歡喜,改動又不大,現有系統不受影響
面向對象的一個設計原則就是--優先使用組合,而非繼承。既是法則,就來自前輩總結的經驗,既是經驗,就有它的道理。
可以使用仿函數包一層就行了么。比如我寫的這個O(1)的通用定時器 https://github.com/atframework/atframe_utils/blob/master/include/time/jiffies_timer.h
你非要用繼承沒什麼不可以。無非就是設計模式比較老套而且適配兼容性比組合的方式弱一點。一句話,多用組合,少用繼承,一般來說,組合的威力更大。