2010-06-16 30 views
11

我剛剛讀到,我們不應該過度使用虛函數。人們認爲虛擬功能越少,缺陷就越少,維護也就越少。爲什麼虛擬功能不應該被過度使用?

由於虛擬功能會出現什麼樣的錯誤和缺點?

我對C++或Java的上下文感興趣。


我能想到的一個原因是由於v-表查找虛函數可能比正常函數慢。

+3

閱讀地點?請上下文。 – 2010-06-16 04:59:53

+0

其實我也在尋找鏈接。我前幾天閱讀,仍在想。 我能想到的一個原因是由於v-table查找,虛函數可能比正常函數慢。 我指的是C++/Java。 – 2010-06-16 05:03:07

+1

值得注意的是,在共享庫中,虛擬函數調用不一定比非虛擬函數調用慢,因爲非虛函數調用將通過PLT條目間接調用。 – jchl 2010-06-18 13:21:37

回答

14

你已經發布了一些總體上的陳述,我認爲大多數實用的程序員會因爲誤解或誤解而聳聳肩。但是,確實存在反虛擬狂熱者,他們的代碼對於性能和維護可能同樣不利。

在Java中,默認情況下一切都是虛擬的。說你不應該過度使用虛函數是非常強大的。

在C++中,您必須聲明一個虛函數,但在適當的時候使用它們是完全可以接受的。

我剛纔讀到,我們不應該過度使用虛函數。

很難定義「過分」......當然「在適當時使用虛擬功能」是很好的建議。

人們覺得較少的虛擬功能往往會減少錯誤並減少維護。 由於虛擬功能,我無法獲得會出現什麼樣的錯誤和缺點。

設計不佳的代碼很難維護。期。

如果您是一位圖書館維護人員,調試代碼被隱藏在一個高層次的層次結構中,那麼很難追蹤代碼實際執行的位置,而沒有強大的IDE的好處,通常很難判斷哪個類別覆蓋行爲。它可能會導致在跟蹤繼承樹的文件之間跳來跳去。

因此,有一些經驗法則,所有有例外:

  • 讓您的層次淺。高大的樹木會讓人感到困惑。如果你的類有虛擬函數,使用虛擬析構函數(如果不是,它可能是一個bug)
  • 與任何層次結構一樣,在派生類和基類之間保持'is-a'關係。
  • 您必須意識到,虛擬函數可能根本不會被調用...所以不要添加隱式期望。
  • 有一個難以辯論的情況下,虛擬功能會變得更慢。它是動態綁定的,所以情況往往如此。在大多數情況下,它的引用無疑是有爭議的。配置文件並進行優化:)
  • 在C++中,不要在不需要時使用虛擬。標記虛擬功能涉及語義 - 不要濫用它。讓讀者知道「是的,這可能會被忽略!」。
  • 將純虛擬接口用於混合實現的層次結構。它更乾淨,更容易理解。

這種情況的真實情況是虛擬功能非常有用,而且這些疑問不可能來自平衡的來源 - 虛擬功能已經被廣泛使用了很長時間。更多更新的語言將採用它們作爲默認值。

6

每個依賴都會增加代碼的複雜性,並且使其更難維護。當你將你的函數定義爲虛函數時,你可以在其他代碼上創建你的類的依賴關係,這個代碼可能暫時不存在。例如,在C中,您可以輕鬆找到foo()所做的 - 只有一個foo()。在沒有虛函數的C++中,它稍微複雜一些:你需要探索你的類和它的基類來找到我們需要的foo()。但至少你可以事先確定性地做,而不是在運行時。使用虛函數,我們無法知道哪個foo()被執行,因爲它可以在一個子類中定義。

(另一件事是你提到的性能問題,由於v表)。

+0

我喜歡這個答案,但我認爲@斯蒂芬的答案更好,因爲它更完整,更具體。但它忽略了你所做的非常重要的依賴點。 – Omnifarious 2010-06-16 16:23:38

2

在C++: -

  1. 虛擬功能有輕微的性能損失。通常情況下它太小而無法做出任何改變,但在一個緊密的循環中它可能是重要的。

  2. 虛擬函數通過一個指針增加每個對象的大小。再次,這通常是微不足道的,但如果你創造了數以百萬計的小物體,這可能是一個因素。

  3. 具有虛擬功能的類一般意味着要從中繼承。派生類可以替換一些,全部或者全部虛擬函數。這會造成額外的複雜性和複雜性,是程序員的致命之敵。例如,派生類可能很難實現虛擬功能。這可能會破壞依賴於虛函數的基類的一部分。

現在讓我明白:我不「不使用虛擬函數」。它們是C++的重要組成部分。只要意識到複雜性的潛力。

7

虛擬功能比常規功能稍慢。但是,這種差異非常小,除了最極端的情況之外,沒有什麼差別。

我認爲避免虛擬功能的最佳理由是防止接口誤用。

這是一個好主意,編寫類可以擴展,但也有這樣的事情,太開放。通過仔細規劃哪些功能是虛擬的,您可以控制(並保護)課程如何擴展。

當一個類被擴展,使得它打破了基類的契約時,錯誤和維護問題就會出現。這裏有一個例子:

class Widget 
{ 
    private WidgetThing _thing; 

    public virtual void Initialize() 
    { 
     _thing = new WidgetThing(); 
    } 
} 

class DoubleWidget : Widget 
{ 
    private WidgetThing _double; 

    public override void Initialize() 
    { 
     // Whoops! Forgot to call base.Initalize() 
     _double = new WidgetThing(); 
    } 
} 

在這裏,DoubleWidget打破了父類,因爲Widget._thing爲空。有解決這個問題相當標準的方法:

class Widget 
{ 
    private WidgetThing _thing; 

    public void Initialize() 
    { 
     _thing = new WidgetThing(); 
     OnInitialize(); 
    } 

    protected virtual void OnInitialize() { } 
} 

class DoubleWidget : Widget 
{ 
    private WidgetThing _double; 

    protected override void OnInitialize() 
    { 
     _double = new WidgetThing(); 
    } 
} 

現在的Widget不會碰上NullReferenceException後。

+2

'虛擬功能比常規功能稍慢。'不,他們不是,因爲他們做得更多。在非虛函數週圍添加必要的if/else鏈或switch語句以便進行一些動態調度之後,非虛函數的性能可能會變差。 – EJP 2010-06-17 01:15:23

+0

@EJP這樣的開關語句很容易優化,因爲所有的信息都在一個地方。 – curiousguy 2015-08-18 14:24:32

2

我們最近有一個完美的例子,說明如何濫用虛函數引入錯誤。

有一個共享庫,設有一個消息處理程序:

class CMessageHandler { 
public: 
    virtual void OnException(std::exception& e); 
    ///other irrelevant stuff 
}; 

的意圖是,你可以從該類繼承和使用它的自定義錯誤處理:

class YourMessageHandler : public CMessageHandler { 
public: 
    virtual void OnException(std::exception& e) { //custom reaction here } 
}; 

錯誤處理機制使用CMessageHandler*指針,所以它不關心對象的實際類型。該函數是虛擬的,所以無論何時存在重載版本,都會調用後者。

很酷,對吧?是的,直到共享庫的開發人員更改基類:

class CMessageHandler { 
public: 
    virtual void OnException(const std::exception& e); //<-- notice const here 
    ///other irrelevant stuff 
}; 

...並且重載剛停止工作。

你看到發生了什麼?在改變基類之後,重載從C++的角度停下來成爲重載 - 它們變成了新的,其他無關的函數

基類具有未標記爲純虛擬的默認實現,所以派生類不會被迫重載默認實現。最後,這個函數只在錯誤處理的情況下被調用,而這個函數並不是每個地方都使用的。所以這個bug被悄無聲息地引入,並且在相當長的一段時間內沒有被人注意過。

徹底消除它的唯一方法是在所有代碼庫上進行搜索並編輯所有相關代碼片段。

+1

很好的例子,但它應該說覆蓋超載的地方。 – Ozan 2010-06-16 07:33:48

+0

C++ 0x有解決這個問題的辦法。它可以讓你聲明,如果函數的名字隱藏在派生類中,而沒有特定的聲明,那麼你會想要一個錯誤,那就是你的意圖。 – Omnifarious 2010-06-16 11:21:17

1

我不知道你在哪裏閱讀,但這不是關於表現。如果你的類/方法不是最終的(可以在這裏主要討論java),但是不是真正爲重用設計的,它可能會出現更多關於「更喜歡關於繼承的組合」和問題。有很多事情可以去真的錯了:

  • 也許你使用虛擬方法,你 構造 - 一旦更有耐力覆蓋, 基類調用重寫 方法,它可以使用在子類初始化ressources 構造函數 - 稍後運行(NPE上升)。

  • 想象一下列表類中的add和addAll方法 。 addAll呼叫多次添加 ,兩者都是虛擬的。 有人可能會重寫它們以計數 已添加了多少項目 全部。如果您不記錄addAll 調用添加,開發人員可能(和 )會覆蓋添加和addAll (並添加一些計數器++的東西到 他們)。但現在,如果你使用addAll, 每個項目計數兩次(add和 addAll),這導致不正確的 結果和很難找到錯誤。

在所有綜上所述這件事,如果你不設計自己的類被擴展(提供鉤子,記錄一些重要的事情執行),你不應該允許繼承,因爲這可能會導致錯誤的意思。如果需要,還可以從其中一個類中輕鬆刪除最終修飾符(也可以重新設計它的可重用性),但是由於其他類可能已經將子類化爲非final類(最後導致錯誤的子類),因此不可能這樣做。

也許這是真的關於表現,然後至少在主題。但如果它不是,那麼你有一些很好的理由,如果你不真的需要它,不要讓你的類可擴展。

有關Blochs Effective Java中類似內容的更多信息(這篇特別的文章是在我讀了第16項(「偏好合成而不是繼承」)和第17項(「設計和文檔用於繼承或者禁止它」 ) - 神奇的書

+0

Matts的答案真的很接近我的方式,我認爲他編輯時,我正在打字 - 抱歉重複的信息。 – atamanroman 2010-06-16 06:20:56

3

我懷疑你誤解了這個說法

過高是一個非常主觀來看,我認爲,在這種情況下,它的意思是「當你不需要它」,而不是你應該避免它什麼時候纔可以有用

根據我的經驗,有的同學在學習虛擬fu時第一次忘記做虛擬功能,認爲簡單地使每個功能虛擬化爲是謹慎的。由於虛擬函數確實會對每個方法調用產生成本(因爲分離編譯通常無法避免使用C++),所以您現在基本上都會爲每個方法調用付費,並且還會阻止內聯。許多教師不鼓勵學生這樣做,儘管「過度」這個詞是一個非常糟糕的選擇。

在Java中,「虛擬」行爲(動態分派)是默認行爲。但是,JVM可以實時優化事物,並且在理論上可以在目標身份清晰時消除一些虛擬呼叫。另外,最終類中的最終方法或方法通常可以在編譯時解析爲單個目標。

+0

是的,我們知道虛擬功能的力量,但有一段時間它會導致一些濫用這些權力。這有時會變成錯誤。我只是在尋找。抱歉沒有很好地描述問題。 – 2010-06-16 19:25:54

0

我在大約7年的時間裏,在同一個C++系統上作爲顧問零星地工作,檢查了大約4-5位程序員的工作。每當我回去時,系統變得越來越糟。在某個時候,有人決定刪除所有的虛擬功能,並用一個非常呆板的工廠/ RTTI系統取而代之,它們基本上完成了虛擬功能所做的一切,但更糟,更昂貴,數千行代碼,大量工作,大量的測試,......完全和完全毫無意義,以及明顯受到未知驅動的恐懼。

當編譯器自動產生錯誤時,他們還手寫了幾十個帶有錯誤的複製構造函數,其中包含大約三個需要手寫版本的例外。

道德:不要對抗語言。它給你的東西:使用它們。

0

爲每個類創建虛擬表,具有虛擬功能或派生自包含虛擬功能的類。這消耗比平常更多的空間。

編譯器需要靜默插入額外的代碼,以確保發生後期綁定而不是早期綁定。這消耗比平時更多。

相關問題