스토리텔링 개발자

[More Effective C++] 24. 다형성의 비용 본문

개발/More Effective C++

[More Effective C++] 24. 다형성의 비용

김디트 2024. 9. 3. 13:02
728x90

항목 24. 가상 함수, 다중 상속, 가상 기본 클래스, RTTI에 들어가는 비용을 제대로 파악하자

 

 

 

가상 함수
  • C++ 언어는 어떻게 구현되었느냐에 따라 객체의 크기와 멤버 함수의 속도가 현저히 변한다.
  • 그 중 가장 큰 영향을 끼친다고 볼 수 있는 것이 바로 가상 함수이다.
  • 가상 함수는 가상 테이블(vtbl, virtual table) 및 가상 테이블 포인터(vptr, virtual table pointer)로 구현한다.
    • 그리고 이 두 가지가 성능에 가장 큰 쟁점이 된다.

 

 

가상 테이블(vtbl, virtual table)
  • 가상 함수를 포함하는 클래스가 가지는 함수 포인터의 배열을 말한다.
  • 가상 함수를 선언 혹은 상속받은 클래스(클래스 인스턴스가 아님!)에 반드시 생긴다.
  • 가상 테이블은 해당 클래스의 가상 함수 함수 포인터들을 가지고 있다.
// 가상 함수를 가지는 클래스
class C1()
{
public:
    C1();
    
    virtual ~C1();
    virtual void f1();
    virtual int f2(char c) const;
    virtual void f3(const string& s);
    
    void f4() const;
    ...
};

// C1의 vtbl
// [ C1::~C1 함수 포인터 | C1::f1 함수 포인터 | C1::f2 함수 포인터 | C1::f3 함수 포인터 ]
// 가상 함수가 아닌 f4는 물론 포함되지 않는다.

// C1을 상속받는다.
class C2 : public C1
{
public:
    C2();
    virtual ~C2(); // 가상 함수 재정의
    virtual void f1(); // 가상 함수 재정의
    virtual void f5(char* str); // 추가된 가상 함수
    ...
}

// C2의 vtbl
// [ C2::~C2 함수 포인터 | C2::f1 함수 포인터 | C1::f2 함수 포인터 | C1::f3 함수 포인터 | C2::f5 함수 포인터]

 

  • 아래에서 이 가상 테이블이 성능에 미치는 영향들을 이어서 알아보도록 한다.

 

 

가상 테이블의 크기
  • 클래스에 선언된 모든 가상 함수(상속받는 것을 포함)의 수에 비례하여 커진다.
  • 클래스 갯수가 막대하거나, 각 클래스의 가상 함수 갯수가 많을 때는 무시 못할 정도의 메모리 부담일 수 있다.

 

 

 

가상 테이블의 위치
  • 한 클래스의 vtbl은 프로그램 이미지 안에 딱 하나만 있어야 하는데..
  • 컴파일러는 이를 어디에 둘건지 고민하게 된다.
  • 목적 파일이 여러 개이기 때문이다. 
    • 프로그램과 라이브러리는 여러 개의 목적 파일의 총합이다.
    • 목적 파일 각각은 독립적으로 컴파일되어 만들어진다.
    • 근데 다수의 목적 파일에서 같은 클래스가 사용된다면?
    • 이 클래스의 vtbl은 어느 목적 파일이 가지고 있어야 할까..
  • main을 가지는 목적파일이 가지고 있으면 어떨까?
    • 하지만 라이브러리는 main이 없다.
    • 더군다나 main에서 해당 클래스를 전혀 참조하지 않을수도 있다.
  • 대체적으로 사용하는 두 가지 방법
    1. tbl이 필요한 목적 파일마다 사본을 만들어 둔다.
      • 그리고 링커가 중복되는 사본을 제거해 가면서 최종 실행 파일이나 라이브러리에는 하나만 남긴다.
    2. 휴리스틱으로 클래스의 vtbl을 가질 목적 파일을 결정한다.
      • 클래스의 함수(인라인도 아니고 순수 가상 함수도 아닌)중 가장 첫 번째 것의 정의 부분을 포함하는 목적 파일 안에 vtbl을 넣는다.
      • 위의 예(C1)를 예로 들어보자면..
        • 클래스 C1의 경우, C1::~C1의 정의 부분을 가지고 있는 목적 파일에 vtbl이 들어갈 것이다.
        • 클래스 C2의 경우,  C2::~C2의 정의 부분을 가지고 있는 목적 파일에 vtbl이 들어갈 것이다.
      • 하지만, 가상 함수를 인라인으로 선언하는 경우가 너무 많으면 문제이다.
        • 극단적으로 생각하면 모든 가상 함수가 인라인이라면?
        • 강제적으로 첫 번째 방법을 사용하게 될 것이다.
        • 이 때 가상 함수도, 클래스도 어마무시하게 많다면?! 성능 저하로 이어진다.

 

 

 

가상 테이블 포인터(vptr, virtual table pointer)
  • 어떤 객체에 대해 어떤 vtbl을 사용할 것인가를 연결하는 수단이다.
  • 가상 함수를 포함하는 클래스의 인스턴스에는 가상 함수를 가리키는 데이터 멤버가 하나 숨겨져 있는데, 이것이 vptr이다.
    • vptr가 놓이는 객체 내의 위치는 컴파일러만 알고 있다.
  • 객체가 별로 크지 않을수록, 이 가상 테이블 포인터의 크기는 부담이다.
    • 객체 데이터 멤버가 4바이트이고, 포인터 크기가 4바이트라고 치면, 실제 객체의 크기는 두배가 되는 셈이다.
  • 소프트웨어의 수행 성능에 손해를 본다.
    • 덩치가 큰 객체는 캐시나 가상 메모리 페이지에 잘 맞지 않게 되므로,
    • 결국 OS가 메모리 페이징을 더 많이 하는 결과를 낳는다.

 

 

 

가상 함수의 비용 톺아보기
void makeACall(C1* pC1)
{
    pC1->f1(); // C1 타입일지, C2 타입일지 현 상황에서는 파악 불가.
}

// 하지만 컴파일러는 makeACall 안에서 f1을 호출하는 코드를 만들어야 한다.
  • 컴파일러가 코드를 만드는 과정
    1. pC1이 가리키는 객체의 vptr을 따라 vtbl로 간다.
      • 비용 : vptr의 위치로 가기 위한 오프셋 조정과 vptr 참조
    2. vtbl을 뒤져서 f1 함수 포인터를 찾아온다.
      • 비용 : vtbl 배열 내 오프셋 조정
    3. 가져온 함수 포인터를 호출한다.
(*pC1->vptr[i])(pC1); // 대충 이런 코드를 생성하게 된다.
// 넘겨진 pC1은 this에 할당될 것이다.
  • 이 정도면 비용이랄 것도 없지 않나?
  • 하지만.. 인라인에서 진정한 비용이 발생한다고 할 수 있다.
  • 인라인과 가상은 서로 양립할 수 없는 개념 아닌가?
    • 인라인
      • 컴파일타임에, 호출 위치에 함수 전체를 끼워넣는다.
    • 가상
      • 런타임에, 호출할 함수를 결정한다.
  • 즉, 가상 함수는 인라인 효과를 포기해야 한다는 것이 가장 큰 비용이라 할 수 있다.

 

 

 

다중 상속의 경우
  • 한 객체 안에 vptr이 여러개 들어가게 된다.
    • 상속받은 기본 클래스에서 하나씩 가져오기 때문이다.
  • 파생 클래스에 대한 vtbl 외에 기본 클래스에 대한 vtbl까지 꼬여 있을 것이다.
  • 결과적으로..
    • 클래스별 오버헤드와 객체별 오버헤드가 동시에 늘어난다.
    • 런타임 생성, 호출에 들어가는 비용도 높아진다.
  • 그리고 다중 상속의 경우 가상 기본 클래스를 사용해야 한다.

 

 

 

다중 상속의 가상 기본 클래스(virtual base class)
  • 가상 기본 클래스가 아니라면..
    • 상속된 기본 클래스 갯수만큼 기본 클래스의 데이터 멤버가 파생 클래스 내에 중복 생성된다.
  • 즉, 기본 클래스 중복 생성을 없애려면 기본 클래스를 가상 상속해야 한다.
  • 하지만, 가상 기본 클래스도 비용 부담이 있다.
    • 데이터 멤버 중복을 피하기 위해 가상 기본 클래스 부분에 대한 포인터를 객체에 넣어둬야 하기 때문이다.
    • 더군다나 죽음의 다이아몬드(the Deadly Diamond of Death) 가 되면 비용은 배가 된다.

 

 

 

죽음의 다이아몬드(the Deadly Diamond of Death) 시의 비용
class A { ... };

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

// 죽음의 다이아몬드
class D : public B, public C { ... };

메모리 배열 구조

  • 다중 상속이 많아질수록 vptr에 의한 오버헤드가 점진적으로 커질 것이다.

 

 

 

RTTI(Runtime Type Identification, 런타임 타입 식별)
  • 실행 중 객체와 클래스의 정보를 알아낼 수 있게 해주는 기능이다.
    • dynamic_cast
    • typeid
    • type_info
  • 당연하지만 이 정보들을 저장해 둘 공간이 필요하다.
    • type_info 타입의 객체에 저장하고, typeid 연산자로 접근할 수 있다.
  • 클래스마다 하나씩 존재하고, 그걸 어느 객체에서든 참조하면 될 것 같지만...
    • C++ 스펙에 의하면 객체의 동적 타입을 정확하게 추론하려면 그 타입(클래스)에 가상 함수가 하나는 있어야 한다.
    • 어라, 가상 함수와 매커니즘이 비슷한걸.
    • 사실, RTTI는 vtbl을 통해 구현될 수 있도록 설계되어 있다.
  • 즉, 가상 테이블에 RTTI 정보(type_info)의 부하가 추가된다.
    • C1 클래스 예시를 다시 재활용해서 표현하자면, C1의 vtbl은 아래와 같은 형태일 것이다.
    •  [ C1의 type_info 객체 | C1::~C1 함수 포인터 | C1::f1 함수 포인터 | C1::f2 함수 포인터 | C1::f3 함수 포인터 ]

 

 

 

비용 정리
  • 가상 함수
    • 객체 크기 증가 : 있음
    • 클래스별 데이터 증가 : 있음
    • 인라인 불가 : 있음
  • 다중상속
    • 객체 크기 증가 : 있음
    • 클래스별 데이터 증가 : 있음
    • 인라인 불가 : 없음
  • 가상 기본 클래스
    • 객체 크기 증가 : 매우 유의미한 증가가 있음
    • 클래스별 데이터 증가 : 가끔 있음
    • 인라인 불가 : 없음
  • RTTI
    • 객체 크기 증가 : 없음
    • 클래스별 데이터 증가 : 있음
    • 인라인 불가 : 없음
728x90
Comments