การสืบทอดเสมือนช่วยแก้ความคลุมเครือของ "เพชร" (มรดกหลายรายการ) ได้อย่างไร


97
class A                     { public: void eat(){ cout<<"A";} }; 
class B: virtual public A   { public: void eat(){ cout<<"B";} }; 
class C: virtual public A   { public: void eat(){ cout<<"C";} }; 
class D: public         B,C { public: void eat(){ cout<<"D";} }; 

int main(){ 
    A *a = new D(); 
    a->eat(); 
} 

ฉันเข้าใจปัญหาเพชรและส่วนข้างบนของรหัสไม่มีปัญหานั้น

การสืบทอดเสมือนช่วยแก้ปัญหาได้อย่างไร

สิ่งที่ฉันเข้าใจ: เมื่อฉันพูดA *a = new D();คอมไพเลอร์ต้องการทราบว่าออบเจ็กต์ประเภทDสามารถกำหนดให้กับตัวชี้ประเภทได้Aหรือไม่ แต่มีสองเส้นทางที่สามารถติดตามได้ แต่ไม่สามารถตัดสินใจได้ด้วยตัวเอง

ดังนั้นการสืบทอดเสมือนจะแก้ไขปัญหาได้อย่างไร (ช่วยคอมไพเลอร์ในการตัดสินใจ)

คำตอบ:


112

คุณต้องการ: (ทำได้ด้วยการสืบทอดเสมือน)

  A  
 / \  
B   C  
 \ /  
  D 

และไม่: (จะเกิดอะไรขึ้นหากไม่มีการสืบทอดเสมือน)

A   A  
|   |
B   C  
 \ /  
  D 

การสืบทอดเสมือนหมายความว่าจะมีเพียง 1 อินสแตนซ์ของAคลาสพื้นฐานที่ไม่ใช่ 2

ประเภทของคุณDจะมี 2 ตัวชี้ vtable (คุณสามารถเห็นพวกเขาในแผนภาพแรก) หนึ่งBและหนึ่งสำหรับผู้ที่แทบสืบทอด C ขนาดวัตถุเพิ่มขึ้นเพราะเก็บ 2 พอยน์เตอร์แล้ว อย่างไรก็ตามตอนนี้ มีเพียงหนึ่งเดียวADA

ดังนั้นB::Aและจะเหมือนกันและจะต้องไม่มีการโทรที่ไม่ชัดเจนจากC::A Dหากคุณไม่ใช้การสืบทอดเสมือนคุณมีแผนภาพที่สองด้านบน และการโทรไปยังสมาชิกของ A จะไม่ชัดเจนและคุณต้องระบุเส้นทางที่คุณต้องการใช้

Wikipedia มีบทสรุปและตัวอย่างที่ดีที่นี่


2
ตัวชี้ Vtable คือรายละเอียดการใช้งาน ไม่ใช่ทุกคอมไพเลอร์ที่จะแนะนำตัวชี้ vtable ในกรณีนี้
ซอกแซก

19
ฉันคิดว่ามันจะดูดีกว่าถ้ากราฟจะสะท้อนในแนวตั้ง ในกรณีส่วนใหญ่ฉันพบแผนภาพการสืบทอดดังกล่าวเพื่อแสดงคลาสที่ได้รับด้านล่างฐาน (ดู "downcast", "upcast")
peterh - Reinstate Monica

ฉันจะแก้ไขโค้ดของเขาให้ใช้B's หรือC' s Implementation แทนได้อย่างไร? ขอบคุณ!
Minh Nghĩa

49

ทำไมต้องตอบอีก?

หลาย ๆ โพสต์เกี่ยวกับ SO และบทความภายนอกกล่าวว่าปัญหาเพชรนั้นได้รับการแก้ไขโดยการสร้างอินสแตนซ์เดียวAแทนที่จะเป็นสองอินสแตนซ์(หนึ่งอันสำหรับพาเรนต์แต่ละตัวD) ซึ่งจะช่วยแก้ไขความคลุมเครือ อย่างไรก็ตามสิ่งนี้ไม่ได้ช่วยให้ฉันเข้าใจกระบวนการได้อย่างครอบคลุมฉันจบลงด้วยคำถามเพิ่มเติมเช่น

  1. จะเกิดอะไรขึ้นถ้าBและCพยายามสร้างอินสแตนซ์ที่แตกต่างกันAเช่นการเรียกตัวสร้างพารามิเตอร์ที่มีพารามิเตอร์ต่างกัน ( D::D(int x, int y): C(x), B(y) {})? ซึ่งตัวอย่างของการAจะได้รับเลือกให้เป็นส่วนหนึ่งของD?
  2. จะเกิดอะไรขึ้นถ้าฉันใช้การสืบทอดที่ไม่ใช่เสมือนBแต่เป็นเสมือนสำหรับC? มันก็เพียงพอสำหรับการสร้างเช่นเดียวของAในD?
  3. ฉันควรใช้การสืบทอดเสมือนโดยค่าเริ่มต้นนับจากนี้ไปเป็นมาตรการป้องกันเนื่องจากสามารถแก้ปัญหาเพชรที่เป็นไปได้ด้วยต้นทุนประสิทธิภาพเล็กน้อยและไม่มีข้อเสียอื่น ๆ ?

การไม่สามารถทำนายพฤติกรรมโดยไม่ลองใช้ตัวอย่างโค้ดหมายความว่าไม่เข้าใจแนวคิด ด้านล่างนี้คือสิ่งที่ช่วยให้ฉันเข้าใจการสืบทอดเสมือน

ดับเบิ้ลเอ

ก่อนอื่นให้เริ่มต้นด้วยรหัสนี้โดยไม่มีการสืบทอดเสมือน:

#include<iostream>
using namespace std;
class A {
public:
    A()                { cout << "A::A() "; }
    A(int x) : m_x(x)  { cout << "A::A(" << x << ") "; }
    int getX() const   { return m_x; }
private:
    int m_x = 42;
};

class B : public A {
public:
    B(int x):A(x)   { cout << "B::B(" << x << ") "; }
};

class C : public A {
public:
    C(int x):A(x) { cout << "C::C(" << x << ") "; }
};

class D : public C, public B  {
public:
    D(int x, int y): C(x), B(y)   {
        cout << "D::D(" << x << ", " << y << ") "; }
};

int main()  {
    cout << "Create b(2): " << endl;
    B b(2); cout << endl << endl;

    cout << "Create c(3): " << endl;
    C c(3); cout << endl << endl;

    cout << "Create d(2,3): " << endl;
    D d(2, 3); cout << endl << endl;

    // error: request for member 'getX' is ambiguous
    //cout << "d.getX() = " << d.getX() << endl;

    // error: 'A' is an ambiguous base of 'D'
    //cout << "d.A::getX() = " << d.A::getX() << endl;

    cout << "d.B::getX() = " << d.B::getX() << endl;
    cout << "d.C::getX() = " << d.C::getX() << endl;
}

ให้ผ่านเอาต์พุต การดำเนินการB b(2);สร้างA(2)ตามที่คาดไว้เช่นเดียวกับC c(3);:

Create b(2): 
A::A(2) B::B(2) 

Create c(3): 
A::A(3) C::C(3) 

D d(2, 3);ต้องการทั้งสองอย่างBและCแต่ละคนสร้างของตัวเองAดังนั้นเราจึงมีสองเท่าAในd:

Create d(2,3): 
A::A(2) C::C(2) A::A(3) B::B(3) D::D(2, 3) 

นั่นเป็นสาเหตุd.getX()ที่ทำให้เกิดข้อผิดพลาดในการคอมไพเลอร์เนื่องจากคอมไพเลอร์ไม่สามารถเลือกAอินสแตนซ์ที่ควรเรียกใช้เมธอดได้ ยังคงเป็นไปได้ที่จะเรียกเมธอดโดยตรงสำหรับคลาสแม่ที่เลือก:

d.B::getX() = 3
d.C::getX() = 2

ความเสมือนจริง

ตอนนี้ให้เพิ่มการสืบทอดเสมือน ใช้ตัวอย่างโค้ดเดียวกันกับการเปลี่ยนแปลงต่อไปนี้:

class B : virtual public A
...
class C : virtual public A
...
cout << "d.getX() = " << d.getX() << endl; //uncommented
cout << "d.A::getX() = " << d.A::getX() << endl; //uncommented
...

ให้ข้ามไปที่การสร้างd:

Create d(2,3): 
A::A() C::C(2) B::B(3) D::D(2, 3) 

คุณสามารถมองเห็นAถูกสร้างขึ้นด้วยคอนสตรัคไม่สนใจค่าเริ่มต้นที่ส่งผ่านจากการก่อสร้างของและB Cเมื่อความคลุมเครือหายไปการเรียกทั้งหมดให้getX()ส่งคืนค่าเดียวกัน:

d.getX() = 42
d.A::getX() = 42
d.B::getX() = 42
d.C::getX() = 42

แต่ถ้าเราต้องการเรียกตัวสร้าง parametrized เพื่อAอะไร? สามารถทำได้โดยเรียกอย่างชัดเจนจากตัวสร้างของD:

D(int x, int y, int z): A(x), C(y), B(z)

โดยปกติคลาสสามารถใช้คอนสตรัคเตอร์ของผู้ปกครองโดยตรงเท่านั้น แต่มีข้อยกเว้นสำหรับกรณีการสืบทอดเสมือน การค้นพบกฎนี้ "คลิก" สำหรับฉันและช่วยให้เข้าใจอินเทอร์เฟซเสมือนจริงได้มาก:

รหัสclass B: virtual Aหมายความว่าคลาสใด ๆ ที่สืบทอดมาBจะต้องรับผิดชอบในการสร้างAด้วยตัวเองเนื่องจากBจะไม่ทำโดยอัตโนมัติ

เมื่อคำนึงถึงข้อความนี้คุณจึงสามารถตอบคำถามทั้งหมดที่มีได้อย่างง่ายดาย:

  1. ในระหว่างDการสร้างค่าBมิได้Cเป็นผู้รับผิดชอบค่าพารามิเตอร์ของAมันทั้งหมดขึ้นอยู่กับDเพียง
  2. Cจะมอบหมายการสร้างAให้Dแต่Bจะสร้างอินสแตนซ์ของตัวเองAเพื่อนำปัญหาเพชรกลับมา
  3. การกำหนดพารามิเตอร์ระดับพื้นฐานในคลาสหลานแทนที่จะเป็นลูกโดยตรงไม่ใช่แนวทางปฏิบัติที่ดีดังนั้นจึงควรยอมรับเมื่อมีปัญหาเพชรอยู่และมาตรการนี้ไม่สามารถหลีกเลี่ยงได้

1
คำตอบนี้ให้ข้อมูลอย่างยิ่ง! โดยเฉพาะอย่างยิ่งการตีความvirtualคำหลักของคุณว่า“ กำหนดไว้ในภายหลัง (ในคลาสย่อย)” กล่าวคือไม่ได้กำหนดไว้“ จริงๆ” แต่เป็นการกำหนด“ แทบ” การตีความนี้ไม่เพียง แต่ใช้ได้กับคลาสพื้นฐานเท่านั้น แต่ยังใช้กับเมธอดด้วย ขอขอบคุณ!
Maggyero

45

อินสแตนซ์ของคลาสที่ได้รับจะเก็บสมาชิกของคลาสพื้นฐานไว้

หากไม่มีการสืบทอดเสมือนเค้าโครงหน่วยความจำจะมีลักษณะอย่างไร (สังเกตสำเนาสองชุดของAสมาชิกในชั้นเรียนD):

class A: [A members]
class B: public A [A members|B members]
class C: public A [A members|C members]
class D: public B, public C [A members|B members|A members|C members|D members]

ด้วยการสืบทอดเสมือนเค้าโครงหน่วยความจำจะมีลักษณะดังนี้ (สังเกตสำเนาเดียวของAสมาชิกในชั้นเรียนD):

class A: [A members]
class B: virtual public A [B members|A members]
                           |         ^
                           v         |
                         virtual table B

class C: virtual public A [C members|A members]
                           |         ^
                           v         |
                         virtual table C

class D: public B, public C [B members|C members|D members|A members]
                             |         |                   ^
                             v         v                   |
                           virtual table D ----------------|

สำหรับแต่ละคลาสที่ได้รับคอมไพลเลอร์จะสร้างพอยน์เตอร์ที่เก็บตารางเสมือนให้กับสมาชิกของคลาสพื้นฐานเสมือนที่เก็บไว้ในคลาสที่ได้รับและเพิ่มตัวชี้ไปยังตารางเสมือนในคลาสที่ได้รับ



แผนผังนั้นยอดเยี่ยมจริงๆ แต่ฉันมีปัญหาในการทำความเข้าใจประโยคสุดท้ายดูเหมือนว่ามันจะเกิดขึ้นซ้ำ ๆ หลังจากเครื่องหมายจุลภาคสุดท้าย "," ไม่ใช่เหรอ?
CharMstr

10

ปัญหาไม่ใช่เส้นทางที่คอมไพเลอร์ต้องปฏิบัติตาม ปัญหาคือจุดสิ้นสุดของเส้นทางนั้นนั่นคือผลลัพธ์ของการแคสต์ เมื่อพูดถึงการแปลงประเภทเส้นทางไม่สำคัญมีเพียงผลลัพธ์สุดท้ายเท่านั้น

หากคุณใช้การสืบทอดแบบธรรมดาแต่ละเส้นทางจะมีจุดสิ้นสุดที่แตกต่างกันซึ่งหมายความว่าผลลัพธ์ของการแคสต์นั้นคลุมเครือซึ่งเป็นปัญหา

หากคุณใช้การสืบทอดเสมือนคุณจะได้ลำดับชั้นรูปเพชร: ทั้งสองเส้นทางนำไปสู่จุดสิ้นสุดเดียวกัน ในกรณีนี้ปัญหาในการเลือกเส้นทางไม่มีอยู่แล้ว (หรือแน่นอนกว่านั้นไม่สำคัญอีกต่อไป) เนื่องจากทั้งสองเส้นทางนำไปสู่ผลลัพธ์เดียวกัน ผลลัพธ์จะไม่คลุมเครืออีกต่อไปนั่นคือสิ่งที่สำคัญ เส้นทางที่แน่นอนไม่ได้


@ แอนเดรย์: คอมไพลเลอร์ใช้มรดกอย่างไร ... ฉันหมายความว่าฉันได้รับข้อโต้แย้งของคุณและฉันอยากจะขอบคุณสำหรับการอธิบายอย่างชัดเจน.. แต่มันจะช่วยได้มากถ้าคุณสามารถอธิบาย (หรือชี้ไปที่ข้อมูลอ้างอิง) เกี่ยวกับ วิธีที่คอมไพเลอร์ดำเนินการสืบทอดจริงและสิ่งที่เปลี่ยนแปลงเมื่อฉันทำการสืบทอดเสมือน
บรูซ

8

จริงๆแล้วตัวอย่างควรเป็นดังนี้:

#include <iostream>

//THE DIAMOND PROBLEM SOLVED!!!
class A                     { public: virtual ~A(){ } virtual void eat(){ std::cout<<"EAT=>A";} }; 
class B: virtual public A   { public: virtual ~B(){ } virtual void eat(){ std::cout<<"EAT=>B";} }; 
class C: virtual public A   { public: virtual ~C(){ } virtual void eat(){ std::cout<<"EAT=>C";} }; 
class D: public         B,C { public: virtual ~D(){ } virtual void eat(){ std::cout<<"EAT=>D";} }; 

int main(int argc, char ** argv){
    A *a = new D(); 
    a->eat(); 
    delete a;
}

... วิธีนั้นผลลัพธ์จะเป็นผลลัพธ์ที่ถูกต้อง: "EAT => D"

มรดกเสมือนแก้ปัญหาการทำซ้ำของปู่เท่านั้น! แต่คุณยังต้องระบุวิธีการที่จะเสมือนเพื่อที่จะได้รับการแทนที่อย่างถูกต้อง ...

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.