ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 가상 함수와 가상 함수 테이블
    개발/C++ 2019. 12. 11. 20:07

    C++은 C에 객체지향 개념이 도입된 언어다. C++은 기본적으로 정적타입의 언어이기 때문에 컴파일 단계에서 변수의 타입을 결정한다. 실행 시간이 아니라 컴파일 시간에 오류를 발견할 수 있으므로 안정성이 좋다. 대신에 일단 컴파일이 끝나면 변경할 수 있는 것이 없다. 무언가 변경됐다면 컴파일을 다시 해야 한다. 적응성이 떨어질 수밖에 없다. 동적타입의 언어는 컴파일러가 없는 인터프리터 언어인 파이썬 등을 말하는데 실행 시간에 변수의 값에 의해 타입이 결정된다. 가장 큰 장점은 유연성이다. 대신에 문법적인 이해나 경험이 부족하다면 안정성을 보장할 수 없다. 

     

    C++에서 virtual 키워드는 동적 바인딩을 위해 사용된다. 바인딩이란 프로그램 구성 요소의 성격을 결정해주는 것을 말하는데, C++은 정적타입의 언어이므로 대부분의 함수 호출은 정적 바인딩이다. virtual을 쓰면 함수의 동적 바인딩이 가능하다고 했는데 동적 바인딩은 컴파일 시간이 아니라 실행 시간에 변수 타입 등이 결정되는 것을 말한다.

     

    함수의 바인딩은 방법은 두 가지다. 정적 바인딩과 동적 바인딩. 정적 바인딩은 앞서 언급했듯이 컴파일 시간에(실행 파일을 만들 때) 호출 함수로 점프할 번지가 결정되는 것을 말한다. 동적 바인딩은 컴파일 시간에 가상 함수 테이블 포인터가 만들어져, 함수의 번지가 가상 함수 테이블에 저장돼 실행 시간에 함수를 호출하면 가상 테이블을 통해 찾는다. 가상 함수 테이블의 포인터는 객체의 맨 앞에 생긴다.

     

    virtual 키워드는 함수의 동적 바인딩을 가능하게 해주는 것으로 C++의 다형성에서 중요한 역할을 한다. 다음 코드는 기반 클래스의 함수가 virtual이 아닐 때의 결과다.

    class Base
    {
    	string s;
    
    public:
    	Base():s("기반")
    	{
    		cout << "기반 클래스 생성자" << endl;
    	}
    
    	void Print()
    	{
    		cout << "기반 Print()" << endl;
    	}
    };
    
    class Derived : public Base
    {
    	string s;
    
    public:
    	Derived() :s("파생")
    	{
    		cout << "파생 클래스 생성자" << endl;
    	}
    
    	void Print()
    	{
    		cout << "파생 Print" << endl;
    	}
    };
    
    int main()
    {
    	Derived d;
    	Base* pB1 = &d;
    	pB1->Print();
    }
    

    개발자가 의도한 결과라면 상관 없지만 애초에 의도했다고 하면 파생 클래스를 만들 이유가 없을 것이다. 기반 클래스의 포인터는 파생 클래스의 인스턴스를 가리키고 있지만 정적타입의 언어인 C++은 컴파일 단계에서 변수 타입을 지정하기 때문에 pB1의 타입은 Base*가 된다. 따라서 기반 클래스의 Print 함수가 호출됐다.

     

    하지만 기반 클래스에서 Print 함수를 virtual로 선언하고 파생 클래스에서 재정의하면 파생 클래스에도 가상 함수 테이블이 생성되기 때문에 파생 클래스의 함수가 호출된다. 이는 정적 바인딩의 특성이 아니다. 정적인 타입이 아니라 실제 가리키고 있는 객체의 인스턴스의 함수를 호출했기 때문이다. 다음 코드를 보자. Set 함수가 추가됐고 파생 클래스에서는 Print 함수만 재정의했다.

    class Base
    {
    	string s;
    
    public:
    	Base():s("기반")
    	{
    		cout << "기반 클래스 생성자" << endl;
    	}
    
    	virtual void Print()
    	{
    		cout << "기반 Print()" << endl;
    	}
    
    	virtual void Set()
    	{
    		cout << "기반 Set()" << endl;
    	}
    };
    
    class Derived : public Base
    {
    	string s;
    
    public:
    	Derived() :s("파생")
    	{
    		cout << "파생 클래스 생성자" << endl;
    	}
    
    	void Print()
    	{
    		cout << "파생 Print()" << endl;
    	}
    };
    
    int main()
    {
    	Derived d;
    	Base b;			// 기반 클래스의 가상 함수 테이블을 보기 위해 선언
    	Base* pB1 = &d;
    	pB1->Print();
    }
    

    디버깅 모드에서 확인해보면 가상 테이블의 존재와 내부적으로 어떻게 되어 있는지 살펴볼 수 있다.

    pB1를 보면 가상 함수 테이블이 두 개가 보이는데 기반 클래스의 것이 생성돼 이를 파생 클래스에서 공유하는 형태임을 알 수 있다(__vfptr의 주소가 같다). 유념할 부분은 파생 클래스에서 오버라이드한 Print 함수다. 알다시피 객체를 많이 만든다고 해서 그 개수만큼 함수가 만들어지는 게 아니라 고유하게 코드 영역에 저장되어 있다. 객체마다 가상 함수 테이블 포인터는 다르지만 함수의 번지수는 다 같다는 이야기다. 기반 클래스의 Print 함수 주소와 파생 클래스의 Print 함수의 주소가 다른 것을 알 수 있다. 다르기 때문에 재정의한 함수가 호출될 수 있는 거다.

     

    함수 호출 부분을 어셈블리로 보면 pB1의 주소를 rax에 할당하는데, 함수를 호출할 때 rax을 기준으로 함수에 접근하고 있음을 알 수 있다. 이는 가상 함수 테이블이 존재해 동적 바인딩이 된다는 의미다.

    댓글

Designed by Tistory.