일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- more effective c++
- effective stl
- Smart Pointer
- 게임
- 상속
- 메타테이블
- 반복자
- 다형성
- 오블완
- exception
- 스마트 포인터
- 영화 리뷰
- 영화
- Effective c++
- 루아
- operator new
- lua
- virtual function
- UE4
- 예외
- c++
- resource management class
- 함수 객체
- reference
- 참조자
- 비교 함수 객체
- implicit conversion
- 언리얼
- 암시적 변환
- 티스토리챌린지
Archives
- Today
- Total
스토리텔링 개발자
[More Effective C++] 24. 다형성의 비용 본문
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에서 해당 클래스를 전혀 참조하지 않을수도 있다.
- 대체적으로 사용하는 두 가지 방법
- tbl이 필요한 목적 파일마다 사본을 만들어 둔다.
- 그리고 링커가 중복되는 사본을 제거해 가면서 최종 실행 파일이나 라이브러리에는 하나만 남긴다.
- 휴리스틱으로 클래스의 vtbl을 가질 목적 파일을 결정한다.
- 클래스의 함수(인라인도 아니고 순수 가상 함수도 아닌)중 가장 첫 번째 것의 정의 부분을 포함하는 목적 파일 안에 vtbl을 넣는다.
- 위의 예(C1)를 예로 들어보자면..
- 클래스 C1의 경우, C1::~C1의 정의 부분을 가지고 있는 목적 파일에 vtbl이 들어갈 것이다.
- 클래스 C2의 경우, C2::~C2의 정의 부분을 가지고 있는 목적 파일에 vtbl이 들어갈 것이다.
- 하지만, 가상 함수를 인라인으로 선언하는 경우가 너무 많으면 문제이다.
- 극단적으로 생각하면 모든 가상 함수가 인라인이라면?
- 강제적으로 첫 번째 방법을 사용하게 될 것이다.
- 이 때 가상 함수도, 클래스도 어마무시하게 많다면?! 성능 저하로 이어진다.
- tbl이 필요한 목적 파일마다 사본을 만들어 둔다.
가상 테이블 포인터(vptr, virtual table pointer)
- 어떤 객체에 대해 어떤 vtbl을 사용할 것인가를 연결하는 수단이다.
- 가상 함수를 포함하는 클래스의 인스턴스에는 가상 함수를 가리키는 데이터 멤버가 하나 숨겨져 있는데, 이것이 vptr이다.
- vptr가 놓이는 객체 내의 위치는 컴파일러만 알고 있다.
- 객체가 별로 크지 않을수록, 이 가상 테이블 포인터의 크기는 부담이다.
- 객체 데이터 멤버가 4바이트이고, 포인터 크기가 4바이트라고 치면, 실제 객체의 크기는 두배가 되는 셈이다.
- 소프트웨어의 수행 성능에 손해를 본다.
- 덩치가 큰 객체는 캐시나 가상 메모리 페이지에 잘 맞지 않게 되므로,
- 결국 OS가 메모리 페이징을 더 많이 하는 결과를 낳는다.
가상 함수의 비용 톺아보기
void makeACall(C1* pC1)
{
pC1->f1(); // C1 타입일지, C2 타입일지 현 상황에서는 파악 불가.
}
// 하지만 컴파일러는 makeACall 안에서 f1을 호출하는 코드를 만들어야 한다.
- 컴파일러가 코드를 만드는 과정
- pC1이 가리키는 객체의 vptr을 따라 vtbl로 간다.
- 비용 : vptr의 위치로 가기 위한 오프셋 조정과 vptr 참조
- vtbl을 뒤져서 f1 함수 포인터를 찾아온다.
- 비용 : vtbl 배열 내 오프셋 조정
- 가져온 함수 포인터를 호출한다.
- pC1이 가리키는 객체의 vptr을 따라 vtbl로 간다.
(*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
'개발 > More Effective C++' 카테고리의 다른 글
[More Effective C++] 26. 클래스 인스턴스 개수 제한 (0) | 2024.09.05 |
---|---|
[More Effective C++] 25. 함수를 가상 함수처럼 만들기 (4) | 2024.09.04 |
[More Effective C++] 23. 적절한 라이브러리 선택하기 (0) | 2024.09.02 |
[More Effective C++] 22. 대입 형태 연산자 선호하기 (0) | 2024.08.30 |
[More Effective C++] 21. 오버로딩으로 암시적 변환 막기 (0) | 2024.08.28 |
Comments