簡體   English   中英

調用非虛擬基方法時,C++ 中的虛擬 inheritance 是否有任何懲罰/成本?

[英]Is there any penalty/cost of virtual inheritance in C++, when calling non-virtual base method?

當我們從其基數 class 調用常規 function成員時,在 C++ 中使用虛擬 inheritance 是否會在編譯代碼中產生運行時懲罰? 示例代碼:

class A {
    public:
        void foo(void) {}
};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

// ...

D bar;
bar.foo ();

可能有,是的,如果您通過指針或引用調用成員函數,並且編譯器無法絕對確定該指針或引用指向或引用的對象類型。 例如,考慮:

void f(B* p) { p->foo(); }

void g()
{
    D bar;
    f(&bar);
}

假設對f的調用不是內聯的,編譯器需要生成代碼來查找A虛擬基類子對象的位置,以便調用foo 通常這個查找涉及檢查 vptr/vtable。

如果編譯器知道您調用函數的對象的類型(如您的示例中的情況),則應該沒有開銷,因為可以靜態調度函數調用(在編譯時)。 在您的示例中, bar的動態類型已知為D (它不能是其他任何東西),因此可以在編譯時計算虛擬基類子對象A的偏移量。

是的,虛擬繼承有運行時性能開銷。 這是因為對於任何指向對象的指針/引用,編譯器在編譯時都找不到它的子對象。 相比之下,對於單繼承,每個子對象都位於原始對象的靜態偏移處。 考慮:

class A { ... };
class B : public A { ... }

B 的內存布局看起來有點像這樣:

| B's stuff | A's stuff |

在這種情況下,編譯器知道 A 在哪里。 但是,現在考慮 MVI 的情況。

class A { ... };
class B : public virtual A { ... };
class C : public virtual A { ... };
class D : public C, public B { ... };

B的內存布局:

| B's stuff | A's stuff |

C的內存布局:

| C's stuff | A's stuff |

可是等等! 當 D 被實例化時,它看起來不是那樣的。

| D's stuff | B's stuff | C's stuff | A's stuff |

現在,如果你有一個 B*,如果它真的指向一個 B,那么 A 就在 B 的旁邊——但是如果它指向一個 D,那么為了獲得 A*,你真的需要跳過 C sub -object,並且由於任何給定的B*都可以在運行時動態指向 B 或 D,因此您需要動態更改指針。 這至少意味着您將不得不通過某種方式生成代碼以找到該值,而不是在編譯時將值嵌入,這是單繼承發生的情況。

至少在典型的實現中,虛擬繼承對(至少一些)數據成員的訪問會帶來(小!)懲罰。 特別是,您通常會獲得額外的間接級別來訪問您從中虛擬派生的對象的數據成員。 這是因為(至少在正常情況下)兩個或多個單獨的派生類不僅具有相同的基類,而且具有相同的基類object 為了實現這一點,兩個派生類都有指向最派生對象的相同偏移量的指針,並通過該指針訪問這些數據成員。

盡管從技術上講這不是由於虛擬繼承,但可能值得注意的是,一般來說,多重繼承有一個單獨的(同樣是小的)懲罰。 繼承的典型實現中,您在對象中的某個固定偏移處有一個 vtable 指針(通常是最開始的)。 在多重繼承的情況下,顯然不能在相同的偏移量處有兩個 vtable 指針,因此最終會得到許多 vtable 指針,每個指針在對象中的單獨偏移量處。

IOW,具有單繼承的 vtable 指針通常只是static_cast<vtable_ptr_t>(object_address) ,但是通過多重繼承,您會得到static_cast<vtable_ptr_t>(object_address+offset)

從技術上講,兩者是完全獨立的——當然,虛擬繼承幾乎唯一的用途是與多重繼承結合使用,所以無論如何它都是半相關的。

具體而言,在 Microsoft Visual C++ 中,指向成員的指針大小存在實際差異。 請參閱#pragma pointers_to_members 正如您在該清單中所看到的 - 最通用的方法是“虛擬繼承”,它與多重繼承不同,而多重繼承又與單繼承不同。

這意味着在存在虛擬繼承的情況下需要更多信息來解析指向成員的指針,並且如果僅通過 CPU 緩存中占用的數據量就會對性能產生影響 - 盡管也可能在成員查找的長度或所需的跳轉次數。

您的問題主要集中在調用虛擬基類的常規函數上,而不是虛擬基類(在您的示例中為 A 類)的虛擬函數的(遠)更有趣的情況——但是,是的,可能會有成本。 當然,一切都依賴於編譯器。

當編譯器編譯 A::foo 時,它假定“this”指向 A 的數據成員在內存中的起始位置。 此時,編譯器可能不知道類 A 將是任何其他類的虛擬基類。 但它很高興地生成了代碼。

現在,當編譯器編譯 B 時,實際上不會有任何變化,因為雖然 A 是虛擬基類,但它仍然是單繼承的,在典型情況下,編譯器將通過將類 A 的數據成員緊跟其后來布局類 B由 B 類的數據成員 - 因此 B * 可以立即轉換為 A * 而不改變值,因此不需要進行任何調整。 編譯器可以使用相同的“this”指針(即使它是 B * 類型)調用 A::foo 並且沒有任何危害。

類 C 的情況相同——它仍然是單繼承,典型的編譯器將 A 的數據成員緊跟在 C 的數據成員之后放置,因此 C * 可以立即轉換為 A * 而不改變任何值。 因此,編譯器可以使用相同的“this”指針(即使它是 C* 類型)簡單地調用 A::foo 並且沒有任何危害。

但是,D 類的情況完全不同。D 類的布局通常是 A 類的數據成員,然后是 B 類的數據成員,然后是 C 類的數據成員,然后是 D 類的數據成員。

使用典型的布局,D * 可以立即轉換為 A *,因此 A::foo 沒有損失——編譯器可以調用它為 A::foo 生成的相同例程,而無需對“this”進行任何更改一切都很好。

但是,如果編譯器需要調用諸如 C::other_member_func 之類的成員函數,即使 C::other_member_func 是非虛擬的,情況也會改變。 原因是當編譯器為 C::other_member_func 編寫代碼時,它假定“this”指針引用的數據布局是 A 的數據成員,緊接着是 C 的數據成員。 但對於 D 的實例則不然。編譯器可能需要重寫並創建一個(非虛擬的)D::other_member_func,只是為了處理類實例內存布局的差異。

請注意,當使用多重繼承時,這是一種不同但相似的情況,但在沒有虛基的多重繼承中,編譯器可以通過簡單地向“this”指針添加位移或修正來解決所有問題,以說明基類的位置“嵌入”在派生類的實例中。 但是對於虛擬基礎,有時需要重寫函數。 這一切都取決於被調用的(甚至是非虛擬的)成員函數訪問了哪些數據成員。

例如,如果類 C 定義了一個非虛成員函數 C::some_member_func,編譯器可能需要這樣寫:

  1. C::some_member_func 從 C 的實際實例(而不是 D)調用時,在編譯時確定(因為 some_member_func 不是虛函數)
  2. C::some_member_func 當從類 D 的實際實例調用相同的成員函數時,在編譯時確定。 (從技術上講,這個例程是 D::some_member_func。盡管這個成員函數的定義是隱式的,並且與 C::some_member_func 的源代碼相同,但生成的目標代碼可能略有不同。)

如果 C::some_member_func 的代碼碰巧使用了在類 A 和類 C 中定義的成員變量。

我認為,虛擬繼承沒有運行時懲罰。 不要將虛繼承與虛函數混淆。 兩者是兩種不同的東西。

虛擬繼承確保您在D實例中只有一個子對象A 所以我不認為單獨會有運行時懲罰。

但是,可能會出現在編譯時無法知道此子對象的情況,因此在這種情況下,虛擬繼承會導致運行時懲罰。 詹姆斯在他的回答中描述了一個這樣的案例。

虛擬繼承必須有成本。

證明是虛擬繼承的類占用的空間大於部分的總和。

典型:

struct A{double a;};

struct B1 : virtual A{double b1;};
struct B2 : virtual A{double b2;};

struct C : virtual B1, virtual B2{double c;}; // I think these virtuals are not strictly necessary
static_assert( sizeof(A) == sizeof(double) ); // as expected

static_assert( sizeof(B1) > sizeof(A) + sizeof(double) ); // the equality holds for non-virtual inheritance
static_assert( sizeof(B2) > sizeof(A) + sizeof(double) );  // the equality holds for non-virtual inheritance
static_assert( sizeof(C) > sizeof(A) + sizeof(double) + sizeof(double) + sizeof(double) );
static_assert( sizeof(C) > sizeof(A) + sizeof(double) + sizeof(double) + sizeof(double) + sizeof(double));

( https://godbolt.org/z/zTcfoY )

額外存儲什么? 我不完全明白。 我認為它類似於虛擬表,但用於訪問單個成員。

有額外的 memory 成本。 例如,x86-64 上的 GCC 7 給出以下結果:

#include <iostream>

class A { int a; };
class B: public A { int b; };
class C: public A { int c; };
class D: public B, public C { int d; };
class BV: virtual public A { int b; };
class CV: virtual public A { int c; };
class DV: public BV, public CV { int d; };


int main()
{
    std::cout << sizeof(A) << std::endl;
    std::cout << sizeof(B) << std::endl;
    std::cout << sizeof(C) << std::endl;
    std::cout << sizeof(D) << std::endl;
    std::cout << sizeof(BV) << std::endl;
    std::cout << sizeof(CV) << std::endl;
    std::cout << sizeof(DV) << std::endl;
    return 0;
}

這打印出來:

4
8
8
20
16
16
40

如您所見,使用虛擬 inheritance 時添加了一些額外的字節。

好吧,在許多好的答案解釋之后,在 memory 中查找虛擬基地 class 的確切 position 會導致性能損失,有一個后續問題:“可以減少這種損失嗎?” 幸運的是,有一個(尚未提及的) final關鍵字形式的部分解決方案。 特別是,從原始示例的 class D到最里面的基數A的調用通常(幾乎)沒有懲罰,但僅在一般情況下,如果您final D

為什么這是必要的,讓我們看一下多級 class 層次結構:

class Base {};

class ExtA : public virtual Base {};
class ExtB : public virtual Base {};
class ExtC : public virtual Base {};

class App1 : public Base {};
class App2 : public ExtA {};
class App3 : public ExtB, public ExtC {};

class SuperApp : public App2, public App3 {};

因為我們的App lication 類可以使用我們的基類 class 的各種Ext類,所以這些Ext類都無法在編譯時知道Base子對象將位於 object 中的何處,它們將被調用。 相反,他們必須在運行時查詢虛擬表才能找到答案。 這是因為各種ExtApp類都可以在不同的翻譯單元中定義。

但是App lication 類也存在同樣的問題:因為App2App3通過Ext ension 類繼承了一個虛擬化的Base ,所以它們在編譯時不知道Base子對象在它們自己的對象中的位置。 因此App2App3的每個方法都必須查詢虛擬表以找到Base子對象在其本地對象中的位置。 這是因為稍后進一步組合這些App類在語法上是合法的,如上面層次結構中的SuperApp class 所示。

另請注意,如果Base class 調用在Ext ension 或App lication 級別上定義的任何虛擬方法,則會有進一步的懲罰。 那是因為將調用虛方法時this指向一個Base object,但他們必須通過再次查詢虛表將其調整為自己的 object 的開頭。 如果ExtApp層(虛擬或非虛擬)方法調用在Base class 上定義的虛擬方法,則該懲罰會發生兩次:第一次是為了找到Base子對象,然后再次是為了從Base找到真正的 object 相對對象子對象。

但是,如果我們知道,不會創建結合多個AppSuperApp ,我們可以通過將App類聲明為 final 來改進很多事情:

class App1 final : public Base {};
class App2 final : public ExtA {};
class App3 final : public ExtB, public ExtC {};

// class SuperApp : public App2, public App3 {};   // illegal now!

因為final使布局不可變, App lication 類的方法不再需要通過虛擬表 go 來查找Base子對象。 在調用任何Base方法時,它們只是將已知常量偏移量添加到this指針。 App層的虛擬回調可以通過減去一個常量已知偏移量輕松地再次修復this指針(或者甚至根本不修復它,而是從 object 的中間引用各個字段)。 Base class 的方法本身也不會受到任何懲罰,因為在 class 內部,一切正常。 所以在這個最外層finalized classes的三層場景中,只有Ext final層的方法執行比較慢,如果他們需要引用Base class的字段或方法,或者如果他們是虛擬調用Base

final關鍵字的缺點是,它不允許所有擴展。 您不能再從App2派生App2a ,即使它不需要任何這些Ext 並聲明一個非finalApp2Base然后從中聲明finalApp2aApp2b ,將再次對App2Base中引用原始Base的所有方法產生懲罰。 不幸的是,C++ 大神們並沒有給我們一個方法來對一個基數 class 進行非虛擬化,而是讓非虛擬擴展成為可能。 他們也沒有給我們聲明“主” Ext class 的方法,其布局保持固定,即使還添加了具有相同虛擬Base class 的其他Ext (在這種情況下,所有非主Ext將引用主Ext ension 中的Base子對象)。

虛擬 inheritance 的替代方法通常是將所有擴展內容添加到Base class。根據應用程序的不同,這可能需要大量額外且經常未使用的字段和/或大量額外的虛擬方法調用和/或很多dynamic_cast s,它們都會帶來性能損失。

另請注意,在現代 CPU 中,錯誤預測虛擬 function 調用后的懲罰高於錯誤預測this指針修正后的懲罰。 第一個需要丟棄在錯誤的執行路徑上獲得的所有結果,並在正確的路徑上重新開始。 后者仍然需要重復直接或間接依賴於this的所有操作碼,但不需要再次加載和解碼指令。 順便說一句:使用未知指針修復的推測執行是 CPU 易受 Spectre/Meltdown 類型數據泄漏影響的原因之一。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM