순수한 추상 클래스 및 인터페이스 구현


27

비록 이것이 C ++ 표준에서는 필수는 아니지만, 예를 들어 GCC가 순수한 추상 클래스를 포함하여 부모 클래스를 구현하는 방식은 해당 클래스의 모든 인스턴스화에 해당 추상 클래스의 v 테이블에 대한 포인터를 포함시키는 것입니다. .

당연히 이것은이 클래스의 모든 인스턴스 크기를 가지고있는 모든 상위 클래스에 대한 포인터로 팽창합니다.

그러나 많은 C # 클래스와 구조체에는 기본적으로 순수한 추상 클래스 인 많은 부모 인터페이스가 있음을 알았습니다. say의 모든 인스턴스가 Decimal다양한 인터페이스에 대한 6 개의 포인터로 부풀어 오르면 놀랄 것 입니다.

따라서 C #이 인터페이스를 다르게 수행하는 경우 적어도 일반적인 구현에서 인터페이스는 어떻게 수행합니까 (표준 자체는 그러한 구현을 정의하지 않을 수 있음을 이해합니다)? 그리고 C ++ 구현에는 순수한 가상 부모를 클래스에 추가 할 때 객체 크기 팽창을 피할 수있는 방법이 있습니까?


1
C # 객체는 일반적으로 상당히 많은 메타 데이터가 첨부되어 있습니다. 아마도 vtable이
그에

idl 디스어셈블러로 컴파일 된 코드를 검사하는 것으로 시작할 수 있습니다.
max630

C ++는 "인터페이스"의 상당 부분을 정적으로 수행합니다. 비교 IComparerCompare
Caleth

4
예를 들어 GCC는 여러 기본 클래스가있는 클래스에 대해 개체 당 vtable 테이블 포인터 (vtable 테이블 또는 VTT에 대한 포인터)를 사용합니다. 따라서 각 객체에는 상상하고있는 컬렉션이 아닌 하나의 추가 포인터 만 있습니다. 아마도 이는 코드가 제대로 설계되지 않았고 대규모 클래스 계층 구조가 관련되어 있어도 실제로 문제가되지 않음을 의미합니다.
Stephen M. Webb

1
@ StephenM.Webb 이 SO 답변 에서 이해하는 한 VTT는 가상 상속으로 구성 / 파괴를 주문하는 데만 사용됩니다. 그들은 메소드 디스패치에 참여하지 않으며 객체 자체에 공간을 절약하지 않습니다. C ++ 업 캐스트는 효과적으로 객체 슬라이싱을 수행하므로 vtable 포인터를 다른 곳이 아닌 객체 (MI의 경우 객체 중간에 vtable 포인터를 추가)에 넣을 수 없습니다. g++-7 -fdump-class-hierarchy출력 을 보면서 확인했습니다 .
amon

답변:


35

C # 및 Java 구현에서 객체에는 일반적으로 해당 클래스에 대한 단일 포인터가 있습니다. 단일 상속 언어이기 때문에 가능합니다. 그런 다음 클래스 구조에는 단일 상속 계층 구조에 대한 vtable이 포함됩니다. 그러나 인터페이스 메소드를 호출하면 다중 상속의 모든 문제가 있습니다. 이것은 일반적으로 구현 된 모든 인터페이스에 대한 추가 vtable을 클래스 구조에 넣어서 해결됩니다. 이를 통해 C ++의 일반적인 가상 상속 구현에 비해 공간이 절약되지만 인터페이스 메소드 디스패치가 더 복잡해져 캐싱으로 부분적으로 보상 할 수 있습니다.

예를 들어 OpenJDK JVM에서 각 클래스에는 구현 된 모든 인터페이스에 대한 vtable 배열이 포함됩니다 (인터페이스 vtable을 itable 이라고 함 ). 인터페이스 메소드가 호출되면이 배열에서 해당 인터페이스의 itable을 선형으로 검색 한 다음 해당 itable을 통해 메소드를 전달할 수 있습니다. 캐싱은 각 호출 사이트가 메소드 디스패치 결과를 기억하도록 사용되므로 구체적인 오브젝트 유형이 변경 될 때만이 검색을 반복해야합니다. 메소드 디스패치 용 의사 코드 :

// 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
}

OpenJDK HotSpot 인터프리터 또는 x86 컴파일러 의 실제 코드를 비교하십시오 .

C # (또는보다 정확하게는 CLR)은 관련 방법을 사용합니다. 그러나 여기서 이터 블은 메소드에 대한 포인터를 포함하지 않지만 슬롯 맵입니다. 클래스의 주요 vtable에있는 항목을 가리 킵니다. Java와 마찬가지로 올바른 itable을 검색하는 것은 최악의 시나리오 일 뿐이며 호출 사이트에서 캐싱하면이 검색을 거의 항상 피할 수 있습니다. CLR은 다른 캐싱 전략으로 JIT 컴파일 머신 코드를 패치하기 위해 Virtual Stub Dispatch라는 기술을 사용합니다. 의사 코드 :

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
}

OpenJDK 의사 코드와의 주요 차이점은 OpenJDK에서 각 클래스에는 직접 또는 간접적으로 구현 된 모든 인터페이스의 배열이 있지만 CLR은 해당 클래스에서 직접 구현 된 인터페이스에 대한 슬롯 맵 배열 만 유지한다는 것입니다. 따라서 슬롯 맵을 찾을 때까지 상속 계층 구조를 위로 걸어야합니다. 깊은 상속 계층 구조의 경우 공간이 절약됩니다. 이것은 제네릭이 구현되는 방식으로 인해 CLR에서 특히 관련이 있습니다. 제네릭 특수화의 경우 클래스 구조가 복사되고 기본 vtable의 메소드가 특수화로 대체 될 수 있습니다. 슬롯 맵은 올바른 vtable 항목을 계속 가리 키므로 클래스의 모든 일반 특수화간에 공유 할 수 있습니다.

결말로 인터페이스 디스패치를 ​​구현할 가능성이 더 많습니다. vtable / itable 포인터를 객체 또는 클래스 구조에 배치하는 대신 기본적으로 쌍인 객체에 대한 팻 포인터 를 사용할 수 있습니다 (Object*, VTable*). 단점은 포인터 크기를 두 배로 늘리고 콘크리트 유형에서 인터페이스 유형으로의 캐스트는 자유롭지 않다는 것입니다. 그러나 더 유연하고 간접 성이 적으며 인터페이스를 클래스 외부에서 구현할 수 있습니다. 관련 인터페이스는 Go 인터페이스, Rust 특성 및 Haskell 유형 클래스에서 사용됩니다.

참고 문헌 및 추가 자료 :

  • 위키 백과 : 인라인 캐싱 . 값 비싼 분석법 조회를 피하는 데 사용할 수있는 캐싱 접근법에 대해 논의하십시오. 일반적으로 vtable 기반 디스패치에는 필요하지 않지만 위의 인터페이스 디스패치 전략과 같은 더 비싼 디스패치 메커니즘에는 매우 바람직합니다.
  • OpenJDK Wiki (2013) : 인터페이스 호출 . itables를 토론하십시오.
  • Pobar, Neward (2009) : SSCLI 2.0 내부. 이 책의 5 장에서는 슬롯 맵에 대해 자세히 설명합니다. 출판 된 적이 없지만 블로그에서 저자가 사용할 수있게되었습니다 . PDF 링크는 이후 이동했습니다. 이 책은 더 이상 CLR의 현재 상태를 반영하지 않습니다.
  • CoreCLR (2006) : 가상 스텁 디스패치 . 에서 : 런타임의 책. 값 비싼 조회를 피하기 위해 슬롯 맵과 캐싱에 대해 논의하십시오.
  • Kennedy, Syme (2001) : .NET 공용 언어 런타임의 제네릭 디자인 및 구현 . ( PDF 링크 ). 제네릭을 구현하기위한 다양한 접근 방식을 논의하십시오. 메서드는 특수화되어 vtable을 다시 작성해야하므로 제네릭은 메서드 디스패치와 상호 작용합니다.

Java와 CLR이 어떻게 이것을 달성하는지에 대한 추가 세부 사항을 기대하는 @amon에게 감사드립니다.
클린턴

@ 클린턴 게시물을 일부 참조로 업데이트했습니다. VM의 소스 코드를 읽을 수도 있지만 따르기가 어렵다는 것을 알았습니다. 내 참조는 조금 오래된 것입니다. 더 새로운 것을 찾으면 꽤 관심이 있습니다. 이 답변은 기본적으로 내가 블로그 게시물에 대한 주위에 거짓말을했다 노트의 발췌 한 것입니다,하지만 난 결코 그것을 게시 할 주위 없어 : /
아몬

1
callvirtCEE_CALLVIRTCoreCLR의 AKA 는 런타임에서이 설정을 처리하는 방법에 대한 자세한 내용을 알고 싶은 경우 호출 인터페이스 메서드를 처리하는 CIL 명령어입니다.
jrh

점을 유의 call연산 코드가 사용되는 static, 방법 흥미롭게 callvirt클래스가있는 경우에도 사용됩니다 sealed.
jrh

1
Re는 "[C #은] 단일 상속 언어이기 때문에 일반적으로 [C #] 개체는 클래스에 대한 단일 포인터를 가지고 있습니다." C + +에서도 다중 상속 유형의 복잡한 웹에 대한 모든 잠재력을 가지고 있지만 프로그램이 새 인스턴스를 작성하는 시점에서 하나의 유형 만 지정할 수 있습니다. 이론적으로 CTI 컴파일러와 런타임 지원 라이브러리를 설계하여 클래스 인스턴스가 RTTI의 포인터 포인터를 둘 이상 보유하지 않도록해야합니다.
Solomon Slow

2

당연히 이것은이 클래스의 모든 인스턴스 크기를 가지고있는 모든 상위 클래스에 대한 포인터로 팽창합니다.

'부모 클래스'가 '기본 클래스'를 의미하는 경우 gcc에서는 그렇지 않습니다 (다른 컴파일러에서도 기대하지 않음).

C가 B에서 파생 된 경우 A가 다형성 클래스 인 A에서 파생 된 경우 C 인스턴스는 정확히 하나의 vtable을 갖게됩니다.

컴파일러에는 A의 vtable에있는 데이터를 B에, B에있는 C에 데이터를 병합하는 데 필요한 모든 정보가 있습니다.

예를 들면 다음과 같습니다. https://godbolt.org/g/sfdtNh

vtable의 초기화는 하나만 있음을 알 수 있습니다.

여기에 주 함수에 대한 어셈블리 출력을 주석과 함께 복사했습니다.

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

참조를위한 완전한 소스 :

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

우리가 가지고가는 경우에 두 개의 기본 클래스에서 직접 서브 클래스 상속 예 처럼을 class Derived : public FirstBase, public SecondBase두 개의 vtable을있을 수 있습니다. g++ -fdump-class-hierarchy클래스 레이아웃 (내 링크 된 블로그 게시물에도 표시)을보기 위해 실행할 수 있습니다 . 그런 다음 Godbolt 는 두 번째 vtable을 선택하기 위해 호출 전에 추가 포인터 증분 을 표시합니다 .
amon
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.