Thực hiện các lớp và giao diện trừu tượng thuần túy


27

Mặc dù điều này không bắt buộc trong tiêu chuẩn C ++, nhưng có vẻ như cách GCC, thực hiện các lớp cha, bao gồm cả các lớp trừu tượng thuần túy, bằng cách bao gồm một con trỏ tới bảng v cho lớp trừu tượng đó trong mỗi lần xuất hiện của lớp trong câu hỏi .

Đương nhiên, điều này làm tăng kích thước của mọi thể hiện của lớp này bằng một con trỏ cho mỗi lớp cha mà nó có.

Nhưng tôi đã nhận thấy rằng nhiều lớp và cấu trúc C # có rất nhiều giao diện cha, về cơ bản là các lớp trừu tượng thuần túy. Tôi sẽ ngạc nhiên nếu mọi trường hợp nói Decimal, được tạo ra với 6 con trỏ cho tất cả các giao diện khác nhau.

Vì vậy, nếu C # thực hiện các giao diện khác nhau, làm thế nào để thực hiện chúng, ít nhất là trong một triển khai điển hình (tôi hiểu bản thân tiêu chuẩn có thể không định nghĩa việc triển khai như vậy)? Và có bất kỳ triển khai C ++ nào có cách tránh sự phình to kích thước đối tượng khi thêm cha mẹ ảo thuần túy vào các lớp không?


1
Các đối tượng C # thường có khá nhiều siêu dữ liệu được đính kèm, có thể các vtables không lớn so với điều đó
max630

bạn có thể bắt đầu với việc kiểm tra mã được biên dịch bằng trình dịch ngược mã idl
max630

C ++ thực hiện một phần đáng kể các "giao diện" của nó một cách tĩnh. So sánh IComparervớiCompare
Caleth

4
GCC, ví dụ, sử dụng một con trỏ bảng vtable (một con trỏ tới một bảng vtables hoặc VTT) cho mỗi đối tượng cho các lớp có nhiều lớp cơ sở. Vì vậy, mỗi đối tượng chỉ có một con trỏ phụ chứ không phải là bộ sưu tập bạn đang tưởng tượng. Có lẽ điều đó có nghĩa là trong thực tế, đó không phải là vấn đề ngay cả khi mã được thiết kế kém và có một hệ thống phân cấp lớp lớn.
Stephen M. Webb

1
@ StephenM.Webb Theo như tôi hiểu từ câu trả lời SO này , VTT chỉ được sử dụng để đặt hàng xây dựng / phá hủy với sự kế thừa ảo. Họ không tham gia vào việc gửi phương thức và cuối cùng không tiết kiệm bất kỳ khoảng trống nào trong đối tượng. Do C ++ phát sóng thực hiện hiệu quả việc cắt đối tượng, nên không thể đặt con trỏ vtable ở bất kỳ nơi nào khác ngoài đối tượng (mà MI thêm các con trỏ vtable vào giữa đối tượng). Tôi xác minh bằng cách nhìn vào g++-7 -fdump-class-hierarchyđầu ra.
amon

Câu trả lời:


35

Trong các triển khai C # và Java, các đối tượng thường có một con trỏ tới lớp của nó. Điều này là có thể bởi vì chúng là ngôn ngữ kế thừa đơn. Cấu trúc lớp sau đó chứa vtable cho hệ thống phân cấp kế thừa đơn. Nhưng các phương thức giao diện gọi cũng có tất cả các vấn đề của nhiều kế thừa. Điều này thường được giải quyết bằng cách đặt các vtables bổ sung cho tất cả các giao diện được triển khai vào cấu trúc lớp. Điều này tiết kiệm không gian so với các triển khai kế thừa ảo điển hình trong C ++, nhưng làm cho việc gửi phương thức giao diện trở nên phức tạp hơn - có thể được bù một phần bằng bộ đệm.

Ví dụ, trong JVM OpenJDK, mỗi lớp chứa một mảng vtables cho tất cả các giao diện được triển khai (một giao diện vtable được gọi là một itable ). Khi một phương thức giao diện được gọi, mảng này được tìm kiếm tuyến tính cho khả năng của giao diện đó, sau đó phương thức có thể được gửi qua đó. Bộ nhớ đệm được sử dụng để mỗi trang web cuộc gọi ghi nhớ kết quả của việc gửi phương thức, do đó tìm kiếm này chỉ phải được lặp lại khi loại đối tượng cụ thể thay đổi. Mã giả cho công văn phương thức:

// Dispatch SomeInterface.method
Method const* resolve_method(
    Object const* instance, Klass const* interface, uint itable_slot) {

  Klass const* klass = instance->klass;

  for (Itable const* itable : klass->itables()) {
    if (itable->klass() == interface)
      return itable[itable_slot];
  }

  throw ...;  // class does not implement required interface
}

(So ​​sánh mã thực trong trình thông dịch OpenJDK HotSpot hoặc trình biên dịch x86 .)

C # (hay chính xác hơn là CLR) sử dụng một phương pháp liên quan. Tuy nhiên, ở đây, nó không chứa con trỏ tới các phương thức, mà là các bản đồ vị trí: chúng trỏ đến các mục trong vtable chính của lớp. Cũng như Java, việc tìm kiếm chính xác nó chỉ là trường hợp xấu nhất và dự kiến ​​bộ nhớ đệm tại trang web cuộc gọi có thể tránh tìm kiếm này gần như luôn luôn. CLR sử dụng một kỹ thuật gọi là Virtual Stub Dispatch để vá mã máy do JIT biên dịch với các chiến lược bộ đệm khác nhau. Mã giả:

Method const* resolve_method(
    Object const* instance, Klass const* interface, uint interface_slot) {

  Klass const* klass = instance->klass;

  // Walk all base classes to find slot map
  for (Klass const* base = klass; base != nullptr; base = base->base()) {
    // I think the CLR actually uses hash tables instead of a linear search
    for (SlotMap const* slot_map : base->slot_maps()) {
      if (slot_map->klass() == interface) {
        uint vtable_slot = slot_map[interface_slot];
        return klass->vtable[vtable_slot];
      }
    }
  }

  throw ...;  // class does not implement required interface
}

Sự khác biệt chính đối với mã giả OpenJDK là trong OpenJDK, mỗi lớp có một mảng tất cả các giao diện được triển khai trực tiếp hoặc gián tiếp, trong khi CLR chỉ giữ một mảng các bản đồ vị trí cho các giao diện được triển khai trực tiếp trong lớp đó. Do đó, chúng ta cần đưa hệ thống phân cấp thừa kế lên trên cho đến khi tìm thấy bản đồ vị trí. Đối với hệ thống phân cấp thừa kế sâu, điều này dẫn đến tiết kiệm không gian. Những điều này đặc biệt có liên quan trong CLR do cách thức triển khai thuốc generic: đối với chuyên môn hóa chung, cấu trúc lớp được sao chép và các phương thức trong vtable chính có thể được thay thế bằng các chuyên ngành. Các bản đồ vị trí tiếp tục trỏ đến các mục vtable chính xác và do đó có thể được chia sẻ giữa tất cả các chuyên ngành chung của một lớp.

Như một lưu ý kết thúc, có nhiều khả năng hơn để thực hiện giao diện. Thay vì đặt con trỏ vtable / itable trong đối tượng hoặc trong cấu trúc lớp, chúng ta có thể sử dụng các con trỏ chất béo cho đối tượng, về cơ bản là một (Object*, VTable*)cặp. Hạn chế là điều này làm tăng gấp đôi kích thước của con trỏ và việc phát sóng (từ loại cụ thể sang loại giao diện) không miễn phí. Nhưng nó linh hoạt hơn, ít bị gián đoạn hơn và cũng có nghĩa là các giao diện có thể được thực hiện bên ngoài từ một lớp. Các cách tiếp cận liên quan được sử dụng bởi giao diện Go, đặc điểm Rust và kiểu chữ Haskell.

Tài liệu tham khảo và đọc thêm:

  • Wikipedia: Bộ nhớ đệm nội tuyến . Thảo luận về các phương pháp lưu trữ có thể được sử dụng để tránh tra cứu phương pháp đắt tiền. Thông thường không cần thiết cho công văn dựa trên vtable, nhưng rất mong muốn cho các cơ chế công văn đắt tiền hơn như các chiến lược công văn giao diện ở trên.
  • OpenJDK Wiki (2013): Cuộc gọi giao diện . Thảo luận về nó.
  • Pobar, Neward (2009): SSCLI 2.0 Nội bộ. Chương 5 của cuốn sách thảo luận về các bản đồ vị trí rất chi tiết. Không bao giờ được xuất bản nhưng được cung cấp bởi các tác giả trên blog của họ . Các liên kết PDF từ đó đã di chuyển. Cuốn sách này có lẽ không còn phản ánh tình trạng hiện tại của CLR.
  • CoreCLR (2006): Công cụ khai thác ảo . Trong: Cuốn sách của thời gian chạy. Thảo luận về bản đồ vị trí và bộ nhớ đệm để tránh tra cứu đắt tiền.
  • Kennedy, Syme (2001): Thiết kế và triển khai Generics cho .NET Runtime Ngôn ngữ chung . ( Liên kết PDF ). Thảo luận về các phương pháp khác nhau để thực hiện thuốc generic. Generics tương tác với phương thức gửi bởi vì các phương thức có thể là chuyên biệt nên vtables có thể phải được viết lại.

Cảm ơn @amon câu trả lời tuyệt vời mong chờ các chi tiết bổ sung cả về cách Java và CLR đạt được điều này!
Clinton

@Clinton Tôi đã cập nhật bài viết với một số tài liệu tham khảo. Bạn cũng có thể đọc mã nguồn của máy ảo, nhưng tôi thấy khó theo dõi. Tài liệu tham khảo của tôi hơi cũ, nếu bạn tìm thấy bất cứ điều gì mới hơn tôi sẽ khá thích thú. Câu trả lời này về cơ bản là một đoạn trích các ghi chú tôi đã nói dối cho một bài đăng trên blog, nhưng tôi chưa bao giờ có mặt để xuất bản nó: /
amon

1
callvirtAKA CEE_CALLVIRTtrong CoreCLR là hướng dẫn CIL xử lý các phương thức giao diện gọi, nếu có ai muốn đọc thêm về cách thời gian chạy xử lý thiết lập này.
jrh

Lưu ý rằng callopcode được sử dụng cho staticcác phương thức, thú vị callvirtđược sử dụng ngay cả khi lớp là sealed.
jrh

1
Các đối tượng Re, "[C #] thường có một con trỏ tới lớp của nó ... bởi vì [C # là một] ngôn ngữ thừa kế đơn." Ngay cả trong C ++, với tất cả tiềm năng của nó đối với các web phức tạp của các kiểu được thừa kế nhiều lần, bạn vẫn chỉ được phép chỉ định một loại tại điểm mà chương trình của bạn tạo một thể hiện mới. Về lý thuyết, có thể thiết kế một trình biên dịch C ++ và thư viện hỗ trợ thời gian chạy sao cho không có cá thể lớp nào mang nhiều hơn một con trỏ RTTI.
Solomon chậm

2

Đương nhiên, điều này làm tăng kích thước của mọi thể hiện của lớp này bằng một con trỏ cho mỗi lớp cha mà nó có.

Nếu theo 'lớp cha', bạn có nghĩa là 'lớp cơ sở' thì đây không phải là trường hợp trong gcc (tôi cũng không mong đợi ở bất kỳ trình biên dịch nào khác).

Trong trường hợp C xuất phát từ B xuất phát từ A trong đó A là lớp đa hình, thể hiện C sẽ có chính xác một vtable.

Trình biên dịch có tất cả thông tin cần thiết để hợp nhất dữ liệu trong A của vtable vào B's và B's thành C's.

Dưới đây là một ví dụ: https://godbolt.org/g/sfdtNh

Bạn sẽ thấy rằng chỉ có một khởi tạo của một vtable.

Tôi đã sao chép đầu ra lắp ráp cho chức năng chính ở đây với các chú thích:

main:
        push    rbx

# allocate space for a C on the stack
        sub     rsp, 16

# initialise c's vtable (note: only one)
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for C+16

# use c    
        lea     rdi, [rsp+8]
        call    do_something(C&)

# destruction sequence through virtual destructor
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for B+16
        lea     rdi, [rsp+8]
        call    A::~A() [base object destructor]

        add     rsp, 16
        xor     eax, eax
        pop     rbx
        ret
        mov     rbx, rax
        jmp     .L10

Nguồn đầy đủ để tham khảo:

struct A
{
    virtual void foo() = 0;
    virtual ~A();
};

struct B : A {};

struct C : B {

    virtual void extrafoo()
    {
    }

    void foo() override {
        extrafoo();
    }

};

int main()
{
    extern void do_something(C&);
    auto c = C();
    do_something(c);
}

Nếu chúng ta lấy một ví dụ trong đó lớp con kế thừa trực tiếp từ hai lớp cơ sở như thế class Derived : public FirstBase, public SecondBasethì có thể có hai vtables. Bạn có thể chạy g++ -fdump-class-hierarchyđể xem bố cục lớp (cũng được hiển thị trong bài đăng trên blog được liên kết của tôi). Godbolt sau đó hiển thị một gia tăng con trỏ bổ sung trước cuộc gọi để chọn vtable thứ 2.
amon
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.