Post List

2017/12/18

C++__가상함수(Virtual function)

 가상함수란?
- 상속관계에서 파생 클래스가 다시 정의할 멤버 함수를 의미하지만 절대적인 부분은 아닙니다. 
포인터의 정적 타입이 아닌 동적 타입에 따르는 함수를 말합니다.

1
2
3
4
5
6
7
class Object
{
public:
    virtual void PrintMyObject() {
        cout << "Object" << endl;
    }
};
cs
가상함수를 사용하고자 할때 함수 앞에 'virtual' 라는 키워드를 붙여줍니다. 
 (가상함수로 선언하면 포인터의 타입이 아닌 포인터가 가리키는 객체의 타입에 따라 멤버 함수를 호출합니다.)

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
class Object
{
public:
    virtual void PrintMyObject() {
        cout << "Object" << endl;
    }
};
class Human : public Object
{
public:
    virtual void PrintMyObject() {
        cout << "Human" << endl;
    }
};
class Woman : public Human
{
};
class Monster : public Object
{
public:
    virtual void PrintMyObject() {
        cout << "Monster" << endl;
    }
};
cs
위 코드와 같은 상속관계에 있을 때 PrintMyObject()함수는 가상함수입니다.
마찬가지로 재정의는 반드시가 아니기때문에 Woman클래스는 재정의가 되어있지 않은 상태입니다.

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
    Object* pObject = new Monster;
    pObject->PrintMyObject();
    delete pObject;
    pObject = new Woman;
    pObject->PrintMyObject();
cs
위 상속관계를 가지고 객체를 생성하고 가상함수를 호출해보겠습니다.

처음 Object의 포인터 자료형을 가진 변수 pObject에 Monster타입으로 객체를 할당해줍니다. 그리고 PrintMyObject()를 호출하게되면 Object의 함수가 아닌 Monster에 재정의된 함수가 호출되게 됩니다.


여기서 다시 pObject의 메모리 해제한 후 Woman으로 다시 할당해줍니다.
동일하게 PrintMyObject()를 호출하게되면 Humon에 정의된 함수가 호출되게 되지요. 
가상함수는 반드시 재정의하지 않아도 된다는 점과 메모리에 할당된 타입에 따라 재정의된 함수가 호출된다는 점을 보여주고있습니다.

이러한 함수 호출은 런타임에 객체의 타입에 따라 바인딩되며, 이러한 부분을 가능케해주는것이 가상함수테이블입니다. 
이전시간에 정적바인딩과 동적바인딩을 소개할때 함수의 동적바인딩시 4byte의 메모리가 추가적으로 든다고 했었습니다.
그 이유는 가상함수테이블이라고 하였는데요, 가상함수 테이블에 대해서 알아보겠습니다.

가상함수테이블이란?
 - 가상함수를 가지는 클래스는 가상함수 테이블을 가지고 있고, 클래스 안에서는 이 테이블을 지시할 수 있는 포인터를 가지고 있습니다. 이 때문에 가상함수를 가지는 클래스는 크기가 4byte 추가 할당이 되는것입니다. 당연한 이야기이겠지만 가상함수가 없다면 추가되지 않습니다.
- 가상함수테이블은 가상함수들만 들어가고, 가상함수 주소들의 배열형태로 존재합니다. 

아까 위에서 Woman타입으로 메모리 할당했던 pObject객체의 내부를 살펴보겠습니다.


위 사진을 보시면 __vfptr이 보이실겁니다. 이게 가상함수테이블인데요, 지금 Object에는 PrintMyObject() 가상함수가 1개밖에 없기때문에 사이즈가 1인 배열입니다. 클래스는 이 테이블을 포인터로 가지고있기때문에 배열의 크기가 증가되도 4byte인 샘입니다.


실제로 Object의 사이즈를 얻어오면 아무런 멤버변수가 없기 때문에 가상함수테이블 포인터의 크기인 4byte로 나타나게됩니다.

즉, 대다수의 컴파일러는 다음과 같은 방식을 자체적으로 사용합니다. 
객체가 하나 이상의 가상 함수를 가지고 있는 경우 컴파일러는 객체 내부에 '가상 포인터' 혹은 'v-포인터'라 불리는 숨겨진 포인터를 포함시킵니다. 이 가상 포인터는 '가상 테이블' 혹은 'v-테이블'이라 불리는 전역 테이블을 가리키게 됩니다.

컴파일러는 1개 이상의 가상 함수를 가지는 각 클래스마다 v-테이블을 생성합니다. 예를들면, Circle이라는 클래스가, Draw(), Move()와 Resize()라는 가상함수를 가지고 있을 때, 수 많은 Circle객체가 존재할 지라도, Circle클래스에 관련된 가상 테이블은 단 1개 존재하며, 모든 Circle 객체의 v-포인터는 Circle가상 테이블을 가리키게 됩니다.

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

위와 같은 방식은 공간비용 오버헤드가 발생합니다. 객체당 1개의 가상 포인터가 추가되며, 시간 비용 오버헤드 역시 발생합니다. 일반함수 호출에 비해, 가상 함수 호출은 가상 포인터의 값을 얻고, 메소드의 주소를 얻는다는 두 가지 측면에서 두번의 추가 반입을 필요로 합니다. 이러한 런타임 작업은 비가상 함수의 경우 컴파일러가 포인터의 자료형을 기준으로 컴파일 시점에 결정해버리기 때문에 오버헤드가 발생하지 않습니다.

여기서 더나아가 알아볼 내용은 순수가상함수입니다. 위에서 설명했던 가상함수에
순수... 라는 단어가 붙은 함수지요..

순수가상함수란?
- 파생 클래스에서 반드시 재정의해야 하는 함수입니다. 일반 가상함수에서는 재정의가 필수는 아니라고 했었죠, 두 함수의 차이점 입니다. 순수 가상 함수는 일반적으로 함수의 동작을 정의하는 본체를 가지지 않으며 따라서 이 상태에서는 호출할 수도 없고 순수 가상함수를 가진 클래스 타입으로 객체 생성도 불가능 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Object
{
public:
    virtual void DrawObject() = 0;
    virtual void PrintMyObject() {
        cout << "Object" << endl;
    }
};
class Human : public Object
{
public:
    virtual void DrawObject() = 0;
    virtual void PrintMyObject() {
        cout << "Human" << endl;
    }
};
cs
순수가상함수는 함수 뒤에 '= 0' 만 붙여주면 그 함수는 순수가상함수가 됩니다. 순수가상함수를 사용하는 이유는 모든경우는 아니지만 제가 사용한 예로 들면 대부분 상위 클래스에서 사용되며 상속받은 클래스들이 반드시 재정의해야 하는 함수가 있을때 사용됩니다.
아니면, 순수가상함수를 추가해서 해당 타입의 객체를 생성하지 못하도록 막을때도 사용했었던 적이 있습니다. 

지금처럼 Human과 Monster 부류로 나뉜 객체생성만 허용할때 말이지요.


추상클래스를 생성하려고 하거나, 순수가상함수를 재정의 하지 않았을 때 위와같은 컴파일 에러나 발생하니 유의하기시 바랍니다!

위에서 설명 중 순수가상함수가 추가된 클래스 타입으로는 객체 생성이 안된다고 하였습니다. 그러한 클래스를 추상 클래스라고 부르는데요,

추상 클래스란?
 - 보다 구체적인 클래스가 파생될 수 있는 일반 개념의 식 역할을 하는 클래스입니다. 하나 이상의 순수 가상 함수를 가지는 클래스를 추상 클래스라고 하며, 추상 클래스는 동작이 정의되지 않은 멤버 함수를 가지고 있기 때문에 이 상태로는 인스턴스를 생성할 수 없지만, 추상 클래스 형식에 대한 포인터와 참조를 사용할 수 있답니다.