Làm thế nào để kế thừa ảo giải quyết sự mơ hồ "kim cương" (đa kế thừa)?


95
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(); 
} 

Tôi hiểu vấn đề kim cương, và đoạn mã trên không có vấn đề đó.

Chính xác thì kế thừa ảo giải quyết vấn đề như thế nào?

Điều tôi hiểu: Khi tôi nói A *a = new D();, trình biên dịch muốn biết liệu một đối tượng kiểu Dcó thể được gán cho một con trỏ kiểu hay không A, nhưng nó có hai đường dẫn mà nó có thể đi theo, nhưng không thể tự quyết định.

Vì vậy, làm thế nào để kế thừa ảo giải quyết vấn đề (giúp trình biên dịch đưa ra quyết định)?

Câu trả lời:


109

Bạn muốn: (Có thể đạt được với kế thừa ảo)

  A  
 / \  
B   C  
 \ /  
  D 

Và không: (Điều gì xảy ra nếu không có kế thừa ảo)

A   A  
|   |
B   C  
 \ /  
  D 

Kế thừa ảo có nghĩa là sẽ chỉ có 1 thể hiện của Alớp cơ sở chứ không phải 2.

Kiểu của bạn Dsẽ có 2 con trỏ vtable (bạn có thể thấy chúng trong sơ đồ đầu tiên), một cho Bvà một cho Cai hầu như kế thừa A. DKích thước đối tượng của được tăng lên vì nó lưu trữ 2 con trỏ bây giờ; tuy nhiên Abây giờ chỉ có một cái .

Vì vậy B::AC::Agiống nhau và do đó không thể có các cuộc gọi mơ hồ từ D. Nếu bạn không sử dụng kế thừa ảo, bạn có sơ đồ thứ hai ở trên. Và bất kỳ lệnh gọi nào đến một thành viên của A sau đó đều trở nên mơ hồ và bạn cần chỉ định con đường nào bạn muốn đi.

Wikipedia có một tóm tắt hay và ví dụ khác ở đây


2
Con trỏ Vtable là một chi tiết triển khai. Không phải tất cả các trình biên dịch sẽ giới thiệu con trỏ vtable trong trường hợp này.
tò mò

19
Tôi nghĩ sẽ đẹp hơn nếu đồ thị được phản chiếu theo chiều dọc. Trong hầu hết các trường hợp, tôi đã tìm thấy các sơ đồ kế thừa như vậy để hiển thị các lớp dẫn xuất bên dưới các cơ sở. (xem "nhìn xuống", "bị ném lên trời")
peterh - Khôi phục Monica

Làm cách nào tôi có thể sửa đổi mã của anh ấy để sử dụng cách triển khai Bcủa 's hoặc C' thay thế? Cảm ơn!
Minh Nghĩa

44

Các thể hiện của lớp dẫn xuất "chứa" các thể hiện của các lớp cơ sở, vì vậy chúng trông như thế trong bộ nhớ:

class A: [A fields]
class B: [A fields | B fields]
class C: [A fields | C fields]

Do đó, không có kế thừa ảo, thể hiện của lớp D sẽ giống như sau:

class D: [A fields | B fields | A fields | C fields | D fields]
          '- derived from B -' '- derived from C -'

Vì vậy, lưu ý hai "bản sao" của dữ liệu A. Kế thừa ảo có nghĩa là bên trong lớp dẫn xuất có một con trỏ vtable được đặt trong thời gian chạy trỏ đến dữ liệu của lớp cơ sở, sao cho các thể hiện của các lớp B, C và D trông giống như:

class B: [A fields | B fields]
          ^---------- pointer to A

class C: [A fields | C fields]
          ^---------- pointer to A

class D: [A fields | B fields | C fields | D fields]
          ^---------- pointer to B::A
          ^--------------------- pointer to C::A


43

Tại sao câu trả lời khác?

Chà, nhiều bài viết trên SO và các bài báo bên ngoài nói rằng, vấn đề kim cương được giải quyết bằng cách tạo một phiên bản duy nhất Athay vì hai (một cho mỗi cha của D), do đó giải quyết được sự mơ hồ. Tuy nhiên, điều này không cung cấp cho tôi hiểu biết toàn diện về quy trình, tôi đã kết thúc với nhiều câu hỏi hơn như

  1. điều gì xảy ra nếu BCcố gắng tạo ra các trường hợp khác nhau, ví Adụ như gọi hàm tạo tham số với các tham số khác nhau ( D::D(int x, int y): C(x), B(y) {})? Trường hợp nào Asẽ được chọn để trở thành một phần của D?
  2. điều gì sẽ xảy ra nếu tôi sử dụng kế thừa không phải ảo B, mà là kế thừa ảo để làm Cgì? Nó có đủ để tạo một phiên bản duy nhất của Ain Dkhông?
  3. Tôi có nên luôn sử dụng kế thừa ảo theo mặc định từ bây giờ như một biện pháp phòng ngừa vì nó giải quyết vấn đề kim cương có thể xảy ra với chi phí hiệu suất nhỏ và không có nhược điểm nào khác không?

Không thể dự đoán hành vi mà không thử các mẫu mã có nghĩa là không hiểu khái niệm. Dưới đây là những gì đã giúp tôi xoay quanh vấn đề thừa kế ảo.

Gấp đôi

Đầu tiên, hãy bắt đầu với mã này mà không có kế thừa ảo:

#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;
}

Cho phép đi qua đầu ra. Việc thực thi B b(2);sẽ tạo ra A(2)như mong đợi, tương tự cho 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);cần cả hai BC, mỗi người trong số họ tạo ra của riêng mình A, vì vậy chúng tôi có gấp đôi Atrong d:

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

Đó là lý do d.getX()gây ra lỗi biên dịch vì trình biên dịch không thể chọn thể hiện mà Anó sẽ gọi phương thức. Vẫn có thể gọi các phương thức trực tiếp cho lớp cha đã chọn:

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

Đức hạnh

Bây giờ hãy thêm thừa kế ảo. Sử dụng cùng một mẫu mã với các thay đổi sau:

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
...

Hãy chuyển sang tạo d:

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

Bạn có thể thấy, Ađược tạo với hàm tạo mặc định bỏ qua các tham số được truyền từ các hàm tạo của BC. Khi sự mơ hồ biến mất, tất cả các lệnh gọi getX()trả về cùng một giá trị:

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

Nhưng nếu chúng ta muốn gọi hàm tạo tham số để làm Agì? Nó có thể được thực hiện bằng cách gọi nó một cách rõ ràng từ hàm tạo của D:

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

Thông thường, lớp có thể chỉ sử dụng các hàm tạo của cha mẹ trực tiếp một cách rõ ràng, nhưng có một trường hợp loại trừ đối với trường hợp thừa kế ảo. Việc khám phá quy tắc này đã "kích" tôi và giúp tôi hiểu rất nhiều về giao diện ảo:

class B: virtual Acó nghĩa là bất kỳ lớp nào được kế thừa từ Bbây giờ chịu trách nhiệm tạo Abởi chính nó, vì Bnó sẽ không tự động thực hiện.

Với câu nói này, thật dễ dàng để trả lời tất cả các câu hỏi tôi có:

  1. Trong quá trình Dtạo Bcũng không Cchịu trách nhiệm về các tham số của A, nó hoàn toàn Dchỉ.
  2. Csẽ ủy tạo Ađể D, nhưng Bsẽ tạo ra ví dụ riêng của mình Ado đó mang vấn đề kim cương trở lại
  3. Việc xác định các tham số lớp cơ sở trong lớp cháu thay vì lớp con trực tiếp không phải là một phương pháp hay, vì vậy nó nên được chấp nhận khi có vấn đề kim cương và biện pháp này là không thể tránh khỏi.

10

Vấn đề không phải là con đường mà trình biên dịch phải đi theo. Vấn đề là điểm cuối của con đường đó: kết quả của phép ép kiểu. Khi nói đến chuyển đổi kiểu, đường dẫn không quan trọng, chỉ có kết quả cuối cùng.

Nếu bạn sử dụng kế thừa thông thường, mỗi đường dẫn có điểm cuối đặc biệt của riêng nó, có nghĩa là kết quả ép kiểu không rõ ràng, đó là vấn đề.

Nếu bạn sử dụng kế thừa ảo, bạn sẽ có một hệ thống phân cấp hình kim cương: cả hai đường dẫn đều dẫn đến cùng một điểm cuối. Trong trường hợp này, vấn đề chọn đường dẫn không còn tồn tại (hay chính xác hơn là không còn quan trọng nữa), bởi vì cả hai đường dẫn đều dẫn đến cùng một kết quả. Kết quả không còn mơ hồ nữa - đó là điều quan trọng. Con đường chính xác không.


@Andrey: Trình biên dịch thực hiện kế thừa như thế nào ... Ý tôi là tôi hiểu được lập luận của bạn và tôi muốn cảm ơn bạn đã giải thích nó một cách sáng suốt..nhưng sẽ thực sự hữu ích nếu bạn có thể giải thích (hoặc chỉ vào một tài liệu tham khảo) về cách trình biên dịch thực sự thực hiện quyền thừa kế và những gì thay đổi khi tôi làm ảo thừa kế
Bruce

8

Trên thực tế, ví dụ sẽ như sau:

#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;
}

... theo cách đó đầu ra sẽ là đầu ra chính xác: "EAT => D"

Kế thừa ảo chỉ giải quyết được sự trùng lặp của ông đồ! NHƯNG bạn vẫn cần chỉ định các phương thức là ảo để ghi đè các phương thức một cách chính xác ...

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.