본문 바로가기
C,C++/C++

[C++] 가상 함수와 가상 소멸자에 대해 알아보자(feat. 가상테이블) | virtual

by woohyeon 2019. 12. 10.
반응형

가상 함수와 가상 소멸자는 상속 관계에서 사용되는 용어이며, 멤버 함수에 virtual 키워드가 붙은 함수를 말한다. 이는
C++에서 OOP의 중요한 특징 중 하나인 다형성(Polymorphism)을 지원하기 위한 기능 중 하나이다. C++의 멤버 함수는 기본적으로 컴파일 타임에 호출할 클래스의 형이 결정된다.(정적 바인딩) virtual 키워드는 호출 대상을 컴파일 타임이 아닌 런타임에 판단하여 해당 포인터가 가리키는 오브젝트의 실제 클래스 타입을 찾아 그 클래스의 함수를 동적으로 호출할 수 있게 해준다.

참고로 자바에선 모든 멤버 함수가 가상 함수이다. 따라서 상위 클래스의 함수를 재정의하면 알아서 하위 클래스의 함수가 호출된다.


예를 들어, 아래와 같이 상위 클래스의 포인터 변수 pBase에 하위 클래스 object를 할당했다고 생각해보자. pBase의 무늬(포인터의 타입)는 상위 클래스이지만 실제로는 하위 클래스의 인스턴스를 가리키고 있다.

Base* pBase = new Derived();



그리고 상위 클래스에 정의된 함수 Func()를 하위 클래스에서 override했다고 생각해보자. 메인 함수에서 pBase를 통해 Func() 함수를 호출한다면 상위 클래스의 Func가 호출되는 것이 맞을까 하위 클래스의 Func이 호출되는 것이 맞을까?

pBase->Func();


아마 사용자는 하위 클래스의 Func이 호출되기를 바랄 것이다. 하지만 아까 말했듯이 c++에선 기본적으로 포인터 타입만 보고 해당 개체의 클래스 타입을 결정한다. 따라서 위 코드는 상위 클래스의 Func을 호출한다.

이것을 방지하기위해 우리는 pBase의 무늬만 보고 클래스 타입을 판단하는 것이 아닌, 실제 할당된 메모리(인스턴스)의 클래스 타입을 확인하도록 해야한다. 이것이 가능하려면 포인터에 할당된 동적 메모리를 체크하기위해 런타임에 클래스 타입을 확인하도록 해야한다. 이렇게 런타임에 타입을 확인하여, 실제로 할당된 타입의 함수를 호출하게 해주는 것이 virtual 키워드이며 virtual 키워드가 붙은 함수를 가상 함수라고 한다.


가상 함수는 가상 테이블이란 곳에 주소 형태로 저장되어있다. 멤버 함수의 주소를 하나의 클래스가 공유하듯, 가상 테이블 자체는 
클래스에 1개 존재하며, 각 개체는 그 가상 테이블의 주소를 가진다. 이 주소는 __vfptr이라는 곳에 저장되어 있다. 이는 디버그 모드에서 object 정보에 __vfptr이란 이름으로 확인할 수 있다. 각 원소는 가상 함수의 주소를 저장한다.

Animal 클래스의 가상 테이블

 

각 가상 테이블은 아래와 같이 표현할 수 있다. __vfptr은 아래와 같이 가상 테이블의 첫 원소의 주소를 저장한다. 가상 테이블의 각 원소는 가상 함수의 주소를 가지고 있다. 

가상테이블


멤버 함수를 호출할 때 이 함수가 가상 함수라면 실행 중에 __vfptr을 통해 가상 테이블에 접근 후 가상 테이블에서 원하는 함수의 주소를 찾아 함수를 호출한다. 

가상 함수가 아닐 경우엔, 굳이 가상 테이블에서 찾을 필요없이 일반 멤버 함수의 주소를 통해 call하면 되기에 빠르지만, 가상 함수를 호출할 땐 가상 테이블을 거쳐 인덱스를 찾아 호출해야 하기 때문에 일반 함수를 호출하는 경우보다 느릴 수 밖에 없다.

다음과 같이 가상함수와 비가상함수를 만들고 디스어셈블 뷰에서 호출되는 과정이 어떻게 다른지 살펴보자. 

가상함수와 비가상함수

 

실행하면 다음과 같이 foo, __vfptr, 가상함수의 주소를 확인할 수 있다.

Locals view

 

포인터 foo가 가진 주소값을 확인해보면 가상테이블(포인터) 값인 주소 00294d10이 저장되어 있음을 확인할 수 있다. 

foo(0x00A0F2D0), __vfptr(0x00294d10)

 

그리고 가상 테이블은 가상 함수의 주소를 저장하고 있다. 

가상테이블에 저장된 가상함수의 주소

 

그럼 디스어셈블뷰에서 가상테이블의 주소(00294d10)를 통해 가상 테이블에 저장된 가상 함수를 (0x00281460)를 호출하는지 확인해보자. 우선 아래 그림을 보면 비가상함수는 가상테이블과 관련없이 곧바로 함수를 호출하는 것을 볼 수 있다. 반면 가상 함수는 더 많은 명령어들이 보인다.

eax 레지스터에 저장된 가상함수의 주소

0028AEC9   mov  eax, dword ptr [foo]
-> 가상 테이블에 접근하기 위해 foo의 값(0x00294d10)을 eax 레지스터에 복사한다.

0028AECC   mov  edx, dword ptr [eax]
-> eax(0x00294d10)에 저장된 값(0x00281460)을 edx 레지스터에 복사한다. 

0028AED3   mov  eax, dword ptr [edx]
0028AED5   call   eax   
-> 실제로 호출하는 eax 레지스터의 값을 확인해보면 아까 보았던 가상 함수의 주소와 일치하는 것을 확인할 수 있다.

다른 명령어들은 함수를 호출하기 위한 준비 과정이므로 여기선 크게 중요하지 않다.  



[가상 소멸자]
가상 함수를 이해했다면 가상 소멸자는 가상 함수와 비슷한 목적이라고 생각하면 된다.

상위 클래스 타입의 포인터인 pBase가 하위 클래스인 A의 메모리를 가리킨다면 pBase는 Base와 A의 메모리 모두를 가진다.(상속) 만약 pBase를 delete를 통해 지우려고 하면 pBase의 타입을 보고 상위 클래스의 소멸자를 호출할 것이다. 만약 A 클래스 멤버에 동적으로 할당한 메모리가 있다면 이는 메모리 누수를 일으킨다.
따라서 상속을 하고 하위 클래스에서 힙 메모리를 할당한다면, 소멸자에 virtual 키워드를 붙여 가상 소멸자로 만들어주어야 한다. 그러면 런타임에 실제로 할당된 클래스 타입을 찾아 소멸자를 호출해준다.  
 
참고로, virtual 키워드는 상위 클래스에서 한 번만 붙여줘도 하위 클래스에서 적용되지만 가독성을 위해 하위 클래스에도 붙여주는 것이 좋다. 또한 선언과 정의를 분리하여 작성할 경우 선언에만 virtual을 붙이고 정의엔 virtual을 붙이지 않는다.

ex)

class Base 
{
public:
    ...
    virtual ~Base(); // Declaration
    ...
};

class Derived : public Base
{
public:
   ...
   virtual ~Derived(); // Declaration
   ...
};

Base::~Base() {...}       // Definition
Derived::~Derived() {...} // Definition




 




댓글