가상 함수 테이블과 가상 포인터 관련 자료

2015. 4. 2. 21:51프로그래밍/지식창고

728x90
728x90

참고https://isocpp.org/wiki/faq/virtual-functions

이 글은 iso c++ 웹페이지의 가상함수 쪽 자주 묻는 질문과 답변란 일부를 옮겨온 것입니다.


Q: 가상 멤버 함수와 비가상 멤버 함수가 호출될 때의 차이점은 무엇인가?

 


A:비가상 멤버 함수는 정적으로 결정된다이는멤버 함수가 정적으로(컴파일시점에객체를 가리키고 있는 포인터 (혹은 레퍼런스)의 자료형을 기준으로 선택된다는 뜻이다.


 

역으로가상 멤버 함수는 동적으로(실행 중에결정된다멤버 함수가 동적으로(실행중에객체를 가리키고 있는 포인터/레퍼런스의 자료형이 아니라객체 그 자신의 자료형을 기준으로 선택된다는 말이다이것이 동적 바인딩이다대다수컴파일러는 다음과 같은 방식을 자체적으로 사용한다객체가 하나 이상의 가상 함수를 가지고 있는 경우,컴파일러는 객체 내부에 가상 포인터” 혹은 “v-포인터라불리는 숨겨진 포인터를 포함시킨다이 가상 포인터는 가상테이블” 혹은 “v-테이블이라불리는 전역 테이블을 가리킨다.


 

컴파일러는 1개 이상의 가상 함수를 가지는 각 클래스마다 v-테이블을 생성한다이를테면(Circle)이라는클래스가 draw(), move()와 resize()라는가상 함수를 가지고 있다면,수없이 많은 Circle 객체가존재할 지라도, Circle 클래스에 관련된 가상 테이블은 단 1개존재하며모든 Circle 객체의 v-포인터는 Circle 가상 테이블을 가리키게 된다가상 테이블에는 그 클래스의 가상 함수 포인터들이 들어간다예를들어, Circle 가상 테이블에는 Circle::draw(), Circle::move(), Circle::resize()라는3개의 함수 포인터가 존재하게 된다.


 

가상 함수가 호출되는 동안실시간 체계에서는 클래스의 가상 테이블을 가리키는 해당 객체의 가상 포인터를 따라 정보를 확인하고가상 테이블에서 그에 맞는 슬롯을 확인하여 메소드 코드를 찾아간다.


 

위와 같은 방식의 공간비용 오버헤드는 명목적이다객체당 1개의 추가 포인터(동적 바인딩을 수행해야 하는 객체인 경우만)에 더해메소드당 1개의 추가 포인터(가상메소드만 해당). 시간 비용 오버헤드 역시 명목적이다일반함수 호출에 비해가상 함수 호출은 가상 포인터의 값을 얻고메소드의주소를 얻는다는 두 가지 측면에서 2회의 추가 반입을 필요로 한다이러한런타임 작업은 비가상 함수의 경우 컴파일러가 포인터의 자료형을 기준으로 컴파일 시점에 결정해버리기 때문에 그 경우에는 발생하지 않는다.


- 원문

 

What’s the difference betweenhow virtual and non-virtual member functions are called?  

 

Non-virtual member functions are resolved statically. That is, the member function isselected statically (at compile-time) based on the type of the pointer (orreference) to the object.

 

In contrast, virtual member functions are resolved dynamically (at run-time).That is, the member function is selected dynamically (at run-time) based on thetype of the object, not the type of the pointer/reference to that object. Thisis called “dynamic binding.” Most compilers use some variant of the followingtechnique: if the object has one or more virtual functions, the compiler puts ahidden pointer in the object called a “virtual-pointer” or “v-pointer.” Thisv-pointer points to a global table called the “virtual-table” or “v-table.”

 

The compiler creates a v-table for each class that has at least one virtual function. Forexample, if class Circle has virtual functions for draw() and move() andresize(), there would be exactly one v-table associated withclass Circle, even if there were a gazillion Circle objects, and the v-pointer of each of those Circleobjects wouldpoint to the Circle v-table. The v-table itself has pointers to each of the virtual functionsin the class. For example, the Circle v-table would have three pointers: a pointer to Circle::draw(), a pointerto Circle::move(), and a pointer to Circle::resize().

 

During a dispatch of a virtual function, the run-time system follows the object’sv-pointer to the class’s v-table, then follows the appropriate slot in thev-table to the method code.

 

The space-cost overhead of the above technique is nominal: an extrapointer per object (but only for objects that will need to do dynamic binding),plus an extra pointer per method (but only for virtual methods). The time-costoverhead is also fairly nominal: compared to a normal function call, a virtual function callrequires two extra fetches (one to get the value of the v-pointer, a second toget the address of the method). None of this runtime activity happens with non-virtualfunctions, sincethe compiler resolves non-virtual functions exclusively at compile-time based on the typeof the pointer.


 

 

Q: 가상 함수를 호출하면 하드웨어 상에서 어떤 일이 벌어지는건가몇 개의 계층에 걸쳐 간접 참조가 일어나는가오버 헤드의 크기는 어느 정도인가?


A: 앞의질문에 대해 좀더 상세하게 알아보자일단 답을 하자면가상함수의 호출 기법은 순수하게 컴파일러 종속적이므로 실제 작업 내용이 달라질 수는 있지만대다수 C++컴파일러는 아래의 설명과 유사한 방식을 취한다.

예를 들어 기반 클래스가 virt0( )부터virt4( )까지 총 5개의 가상 함수를 가진다.

1.  // C++ 소스 코드
2.   
3.  class Base {
4.  public:
5.    virtual 임의의 반환형 virt0( /*...임의의 인수 목록...*/ );
6.    virtual 임의의 반환형 virt1( /*...임의의 인수 목록...*/ );
7.    virtual 임의의 반환형 virt2( /*...임의의 인수 목록...*/ );
8.    virtual 임의의 반환형 virt3( /*...임의의 인수 목록...*/ );
9.    virtual 임의의 반환형 virt4( /*...임의의 인수 목록...*/ );
10.  // ...
11.};

Step#1: 컴파일러는 5개의 함수 포인터를 가지는 정적 테이블을 생성하여 그 테이블을 정적 메모리 어딘가에 잡아둔다이를 가상 테이블이라 부르고기술적으로Base::__vtable이라 하자함수 포인터가 해당 하드웨어 플랫폼 상의 1워드를 차지한다면, Base::__vtable은 5워드만큼의 보이지 않는 메모리를 사용하게 된다인스턴스마다 5워드 혹은 함수마다 5워드가 아니고,딱 5워드만 사용한다아래의 수도 코드를 참고하자:

1.  // Base.cpp 파일 내에 정의된 정적 테이블의 수도 코드 (C++이나 C형 코드 아님)
2.   
3.  // FunctionPtr는 일반 멤버 함수를 가리키는 일반 포인터라고 하자.
4.  // (다시 말하지만 아래는 수도 코드이며, C++ 코드가 아님)
5.  FunctionPtr Base::__vtable[5] = {
6.    &Base::virt0, &Base::virt1, &Base::virt2, &Base::virt3, &Base::virt4
7.  };

Step#2: 컴파일러는 Base라는 클래스의 각 객체마다 숨겨진 포인터(통상 1워드)를 추가한다이는가상 포인터라 불리며컴파일러가 다음과 같이 클래스를 재작성하는 것처럼 숨겨진 데이터 멤버로 포함된다:

1.  // C++ 소스 코드
2.   
3.  class Base {
4.  public:
5.    // ...
6.    FunctionPtr* __vptr;  // 컴파일러가 제공하며, 프로그래머에게는 보이지 않는다.
7.    // ...
8.  };

Step#3: 컴파일러는 각생성자 내에서 this->__vptr를 초기화한다. 이는 각 객체의 가상 포인터가 소속 클래스의 가상 테이블을가리키도록 하기 위함이다마치 다음과 같은 명령 구문이 생성자의 초기화 목록에 추가되는 것과 같다:

1.  Base::Base( /*...임의의 인수 목록...*/ )
2.    : __vptr(&Base::__vtable[0])  // 컴파일러가 제공하며, 프로그래머에게는 보이지 않는다.
3.    // ...
4.  {
5.    // ...
6.  }

이제 파생 클래스를 살펴보자. C++ 코드에서 클래스 Base로부터 상속받은클래스 Der을 정의하였다고 생각한다컴파일러는 1번과 3번 단계를 수행한다(2번은수행하지 않는다). 1번 단계에서숨겨진 가상 테이블을생성할 때, Base::__vtable과 동일한 함수 포인터를 가지지만 오버라이드된 함수들은 주소가오버라이드된 함수의 포인터로 대체된다.예를 들어클래스 Der에서 virt0()부터 virt2()함수를 오버라이드하고다른 함수들을 그대로 상속하는 경우클래스 Der의 가상 테이블은 아래와 같은 모습일 것이다(Der에 새로운 가상 함수가 추가되지는 않았다):

1.  // Der.cpp 파일 내에 정의된 정적 테이블의 수도 코드 (C++이나 C형 코드 아님)
2.   
3.  // FunctionPtr는 일반 멤버 함수를 가리키는 일반 포인터라고 하자.
4.  // (다시 말하지만 아래는 수도 코드이며, C++ 코드가 아님)
5.  FunctionPtr Der::__vtable[5] = {
6.    &Der::virt0, &Der::virt1, &Der::virt2, &Base::virt3, &Base::virt4
7.                                               ↑↑↑↑          ↑↑↑↑ // Base의 가상 함수를 그대로 상속
8.  };

3번 단계에서컴파일러는 Der의 생성자 초입마다 포인터 대입문을 추가한다이는 Der 클래스의 각 객체의 가상 포인터를 변경하여 소속 클래스의 가상 테이블을 가리키게 하기 위함이다.(이 포인터는 2차 가상 포인터가 아니며기반 클래스인 클래스 Base에 정의된 가상 포인터와 같다컴파일러가 Der 클래스에서 2번단계를 수행하지 않았음을 기억하자.)

마지막으로컴파일러가 가상 함수를 호출하는 방식을 확인해보자:

1.  // C++ 소스 코드
2.   
3.  void mycode(Base* p)
4.  {
5.    p->virt3();
6.  }

컴파일러는 이코드에서 Base::virt3()을 호출할 것인지 Der::virt3()을호출할 것인지를 알 수 없다저 virt3()은 심지어아직 존재하지도 않는 또다른 파생 클래스의 virt3()이 될 수도 있다컴파일러는 단지 virt3() 함수를 호출하며이 함수가 가상 테이블의 3번 슬롯에 있는 함수라는 점만 안다이 호출은 다음과 같이 다시 작성된다:

1.  // C++ 컴파일러가 생성하는 수도 코드
2.   
3.  void mycode(Base* p)
4.  {
5.    p->__vptr[3](p);
6.  }

일반 하드웨어에서기계어 코드로는 두 번의 ‘load’에 1회 호출에 해당된다:

  1. 첫 ‘load’는 가상 포인터를 얻어 레지스터에 저장한다이 레지스터를 r1이라 하자.
  2. 두 번째 ‘load’는 r1 + 3*4 위치의 워드를 얻는다(함수 포인터가 4바이트라면 r1 + 12는 오른쪽 클래스의 virt3() 함수의 포인터가 된다). 이를 레지스터 r2에 저장한다고 하자.
  3. 세 번째 명령은 r2에 있는 코드를 호출한다.

결론:

  • 가상 함수를 가지는 클래스의 객체는 가상 함수를 가지지 않은 클래스의 객체에 비해 적은 메모리 오버헤드가 소요된다.
  • 가상 함수의 호출은 빠르다 비가상 함수의 호출 속도에 거의 맞먹는다.
  • 상속 관계에서 파생의 깊이에 관계 없이 호출마다 추가 오버헤드를 필요로 하지 않는다. 10단계의 상속이 발생하더라도, “연쇄” 작업은 발생하지 않는다 언제나 반입,반입호출이다.

주의이 FAQ의 모든 내용은 컴파일러 종속적이며실제 구현 방식은 다를 수 있다.


 


- 원문

Whathappens in the hardware when I call a virtual function? How many layers ofindirection are there? How much overhead is there?

This is a drill-down of theprevious FAQ. The answer is entirely compiler-dependent, so your mileagemay vary, but most C++ compilers use a scheme similar to the one presentedhere.

Let’s work an example. Suppose class Base has 5 virtual functions: virt0() through virt4().

12.// Your original C++ source code
13. 
14.class Base {
15.public:
16.  virtual arbitrary_return_type virt0( /*...arbitrary params...*/ );
17.  virtual arbitrary_return_type virt1( /*...arbitrary params...*/ );
18.  virtual arbitrary_return_type virt2( /*...arbitrary params...*/ );
19.  virtual arbitrary_return_type virt3( /*...arbitrary params...*/ );
20.  virtual arbitrary_return_type virt4( /*...arbitrary params...*/ );
21.  // ...
22.};

Step #1: the compiler builds a static table containing 5function-pointers, burying that table into static memory somewhere. Many (notall) compilers define this table while compiling the .cpp that defines Base’s first non-inline virtualfunction. We call that table the v-table; let’s pretend its technical name is Base::__vtable. If a function pointerfits into one machine word on the target hardware platform, Base::__vtable will end up consuming5 hidden words of memory. Not 5 per instance, not 5 per function; just 5. Itmight look something like the following pseudo-code:

8.  // Pseudo-code (not C++, not C) for a static table defined within file Base.cpp
9.   
10.// Pretend FunctionPtr is a generic pointer to a generic member function
11.// (Remember: this is pseudo-code, not C++ code)
12.FunctionPtr Base::__vtable[5] = {
13.  &Base::virt0, &Base::virt1, &Base::virt2, &Base::virt3, &Base::virt4
14.};

Step #2: the compiler adds a hidden pointer (typically alsoa machine-word) to each object of class Base. This is called the v-pointer. Think of this hidden pointer as ahidden data member, as if the compiler rewrites your class to something likethis:

9.  // Your original C++ source code
10. 
11.class Base {
12.public:
13.  // ...
14.  FunctionPtr* __vptr;  // Supplied by the compiler, hidden from the programmer
15.  // ...
16.};

Step #3: the compiler initializes this->__vptr within eachconstructor. The idea is to cause each object’s v-pointer to point at itsclass’s v-table, as if it adds the following instruction in each constructor’s init-list:

7.  Base::Base( /*...arbitrary params...*/ )
8.    : __vptr(&Base::__vtable[0])  // Supplied by the compiler, hidden from the programmer
9.    // ...
10.{
11.  // ...
12.}

Now let’s work out a derived class. Suppose your C++ codedefines class Derthat inherits from class Base. The compiler repeats steps #1 and #3 (but not #2). In step #1, thecompiler creates a hidden v-table, keeping the same function-pointers as in Base::__vtable but replacing thoseslots that correspond to overrides. For instance, if Deroverrides virt0() through virt2() and inherits the othersas-is, Der’sv-table might look something like this (pretend Der doesn’t add any new virtuals):

9.  // Pseudo-code (not C++, not C) for a static table defined within file Der.cpp
10. 
11.// Pretend FunctionPtr is a generic pointer to a generic member function
12.// (Remember: this is pseudo-code, not C++ code)
13.FunctionPtr Der::__vtable[5] = {
14.  &Der::virt0, &Der::virt1, &Der::virt2, &Base::virt3, &Base::virt4
15.                                          ↑↑↑↑          ↑↑↑↑ // Inherited as-is
16.};

In step #3, the compiler adds a similar pointer-assignmentat the beginning of each of Der’s constructors. The idea is to change each Der object’s v-pointer so it pointsat its class’s v-table. (This is not a second v-pointer; it’s the samev-pointer that was defined in the base class, Base; remember, the compiler does not repeat step #2 in classDer.)

Finally, let’s see how the compiler implements a call to avirtual function. Your code might look like this:

7.  // Your original C++ code
8.   
9.  void mycode(Base* p)
10.{
11.  p->virt3();
12.}

The compiler has no idea whether this is going to call Base::virt3() or Der::virt3() or perhaps the virt3() method of another derivedclass that doesn’t even exist yet. It only knows for sure that you are calling virt3() which happens to be thefunction in slot #3 of the v-table. It rewrites that call into something likethis:

7.  // Pseudo-code that the compiler generates from your C++
8.   
9.  void mycode(Base* p)
10.{
11.  p->__vptr[3](p);
12.}

On typical hardware, the machine-code is two ‘load’s plus acall:

  1. The first load gets the v-pointer, storing it into a register, say r1.
  2. The second load gets the word at r1 + 3*4 (pretend function-pointers are 4-bytes long, so r1 + 12 is the pointer to the right class’s virt3() function). Pretend it puts that word into register r2 (or r1 for that matter).
  3. The third instruction calls the code at location r2.

Conclusions:

  • Objects of classes with virtual functions have only a small space-overhead compared to those that don’t have virtual functions.
  • Calling a virtual function is fast ? almost as fast as calling a non-virtual function.
  • You don’t get any additional per-call overhead no matter how deep the inheritance gets. You could have 10 levels of inheritance, but there is no “chaining” ? it’s always the same ? fetch, fetch, call.

Caveat:I’ve intentionally ignored multiple inheritance, virtual inheritance and RTTI.Depending on the compiler, these can make things a little more complicated. Ifyou want to know about these things, DO NOT EMAIL ME, but instead ask comp.lang.c++.

Caveat:Everything in this FAQ is compiler-dependent. Your mileage may vary.


728x90
반응형