본문 바로가기

프로그래밍 언어들/C++

CHAPTER09 - 가상(Virtual)의 원리와 다중상속

1. 멤버함수와 가상함수와 동작원리

객체 내에 멤버함수가 존재한다고 설명해왔다. 이번 챕터에서는 멤버함수가 실제로 어디에 존재하는지를 설명하고자 한다.

단, 진실을 알고 난 이후에도 객체 내에 멤버함수가 존재한다고 인식하고 객체를 바라보기 바란다.

개념적으로는, 그리고 객체지향적 논리를 기준으로는, 객체 내에 멤버함수가 존재한다고 인식하는 게 옳으니 말이다.


(1) 객체 안에 정말로 멤버함수가 존재하는가?

실제로는 객체가 생성되면 멤버변수는 객체 내에 존재하지만, 멤버함수는 메모리의 한 공간에 위치하고선

이 함수가 정의된 클래스의 모든 객체가 이를 공유하는 형태를 취한다.

즉 이것을 C언어 스타일의 구조체로 표현하면 다음과 같다.

typedef struct A
{
    int data;
    void (*showdata)(A*);
    void (*add)(A*, int);
}A;

void showdata(A* THIS) { cout << THIS->data << endl; }
void add(A* THIS, int num)
{
    THIS->data  = num;
}


함수 포인터에 대한 지식이 있어야 이해할 수 있다.

즉 구조체 'A'의 함수 포인터는 각각 'showdata'와 'add' 함수를 가르킨다.

따라서 생성되는 모든 A 구조체 변수들은 동일한 함수들을 가르키는 것이다.


(2) 가상함수의 동작원리와 가상함수 테이블

class A
{
public:
    virtual void func1(void) {}
    virtual void func2(void) {}
};

class B : public A
{
public:
    virtual void func1(void) {}
    void func3(void) {}
};


위와 같이 가상 함수를 포함하는 클래스에 대해서는 컴파일러가 다음 표와 같은 '가상함수 테이블'이란 것을 만든다.

이를 간단히 'V-Table(Virtual Table)'이라고도 하는데, 이는 실제 호출되어야 할 함수의 위치정보를 담고 있다.


[ A's V-Table ]

key

value 

 void A::func1(void)

0x1024 

 void A::func2(void)

0x2048 


[ B's V-Table ]

key

value

void B::func1(void)

0x3072

void B::func2(void)

0x2048

void B::func3(void)

0x4096


위의 가상함수 테이블을 보면, 'key'와 'value'가 존재한다.

key는 호출하고자 하는 함수를 구분 지어주는 구분자 역할이다.

value는 구분자에 해당하는 함수의 주소정보를 알려주는 역할이다.
또한, 위의 테이블을 보면 다음의 특징을 발견할 수 있다.

"A 클래스의 오버라이딩 된 가상함수 Func1에 대한 정보가 존재하지 않는다."

이렇듯, 오버라이딩 된 가상함수의 주소정보는 유도 클래스의 가상함수 테이블에 포함되지 않는다.

때문에 오버라이딩 된 가상함수를 호출하면, 무조건 가장 마지막에 오버라이딩을 한 유도 클래스의

멤버함수가 호출되는 것이다.

(3) 가상함수 테이블의 참조되는 방식

앞서 소개한 예제가 실행되면, main 함수가 호출되기 이전에 가상함수 테이블이 메모리 공간에 할당된다.

참고로 가상함수 테이블은 객체의 생성과 상관없이 메모리 공간에 할당된다.

이는 가상함수 테이블이 멤버함수의 호출에 사용되는 일종이 데이터이기 때문이다.

그리고 A 클래스 객체를 생성하면, A 클래스의 가상함수 테이블의 주소 값이 저장되고,

B 클래스 객체를 생성하면, B 클래스의 가상함수 테이블의 주소 값이 저장된다.


※ 클래스에 가상함수가 포함되면, 가상함수 테이블이 생성되고, 또 이 테이블을 참조하여 호출될 함수가

결정되기 때문에 실행속도가 감소하기 마련이다. 하지만 그 속도 차이가 극히 미미하고 또 이러한 단점에도

불구하고 가상함수는 많은 장점을 제공하기 때문에 유용하게 활용되는 것이다.


2. 다중상속(Multiple Inheritance)에 대한 이해

다중상속이란, 둘 이상의 클래스를 동시에 상속하는 것을 말한다. 그리고 C++은 다중상속을 지원하는

객체지향 언어이다. 그런데 다중상속은 제법 논란이 되는 문법이다.


(1) 다중상속에 대한 견해

다중상속에 대한 프로그래머들의 첫 번째 의견은 다음과 같다.

"다중상속은 득보다 실이 더 많은 문법이다. 그러니 절대로 사용하지 말아야 하며, 가능하다면 C++의 기본문법에서

제외 시켜야 한다."

"일반적인 경우에서 다중상속은 다양한 문제를 동반한다. 따라서 가급적 사용하지 않아야 함에는 동의를 한다.

그러나 예외적으로 매우 제한적인 사용까지 부정할 필요는 없다고 본다."


실제로 다중상속으로만 해결이 가능한 문제는 존재하지 않으니, 굳이 다중상속을 하기 위해 노력할 필요는 없다.

하지만 우리가 접하는 라이브러리에는 다중상속을 적용한 예가 있기 때문에, 그리고 누군가는 다중상속을

예외적으로, 매우 제한적으로 적용할 수도 있는 일이므로, 이에 대한 이해를 위해서라도 공부할 필요는 있다.


(2) 다중상속의 기본방법

다음은 다중상속에 관련된 예제이다.

class A
{
public:
    void afunc(void) { cout << "A" << endl; }
};
class B
{
public:
    void bfunc(void) { cout << "B" << endl; }
};
class C : public A, protected B
{
public:
    void complexfunc(void)
    {
        afunc();
        bfunc();
    }
};

int main(void)
{
    C c;

    c.complexfunc();

    return 0;
}


(3) 다중상속의 모호성(Ambiguous)

다중상속의 대상이 되는 두 기초 클래스에 동일한 이름의 멤버가 존재하는 경우에 문제가 발생한다.

이러한 경우에는 유드 클래스 내에서 멤버의 이름만으로 접근이 불가능하기 때문이다. 따라서 컴파일러는

"도대체 어느 클래스에 선언된 멤버에 접근을 하라는 거야?"

그럼 이러한 상황을 보이는 예제를 제시하면서, 동시에 해결책도 제시하겠다.

class A
{
public:
    void func(void) { cout << "A" << endl; }
};
class B
{
public:
    void func(void) { cout << "B" << endl; }
};
class C : public A, protected B
{
public:
    void complexfunc(void)
    {
        A::func();
        B::func();
    }
};


(4) 가상 상속(Virtual Inheritance)

함수 호출관계의 모호함은 다른 상황에서도 발생할 수 있다.

class A
{
public:
    explicit A(void) { cout << "A's constructor" << endl; }
    void Afunc(void) { cout << "A" << endl; }
};
class B : public A
{
public:
    explicit B(void) : A() { cout << "B's constructor" << endl; }
    void Bfunc(void) { cout << "B" << endl; }
};
class C : public A
{
public:
    explicit C(void) : A() { cout << "C's constructor" << endl; }
    void Cfunc(void) { cout << "C" << endl; }
};
class D : public B, public C
{
public:
    explicit D(void) : B(), C()
    {
        cout << "D's constructor" << endl;
    }
    void complexfunc(void)
    {
        Bfunc();
        Cfunc();
        B::Afunc();
        C::Afunc();
    }
};

int main(void)
{
    D d;

    d.complexfunc();

    return 0;
}
A's constructor
B'
s constructor
A's constructor
C'
s constructor
D's constructor
B
C
A
A


위의 예제를 보면, A 클래스를 B와 C 클래스가 상속하고, D 클래스가 B와 C 클래스를 다중상속한 구조이다.

실행결과를 보면, 두 가지 문제점이 있다.

- A 클래스의 생성자가 두 번 호출되었다.

- D 클래스에서 함수 이름만으로 A 클래스의 함수를 호출할 수 없다.

이러한 문제점은, D 클래스가 A 클래스의 멤버를 두 번(B, C) 포함하기 때문에 발생한다. 따라서,

위의 상황에서는 A 클래스의 멤버가 D의 객체에 하나씩만 존재하는 것이 타당한 경우가 대부분이다.

즉, A 클래스를 딱 한번만 상속하게끔 하는 것이 더 현실적인 해결책이다.

그리고 이것을 위한 문법이 바로 '가상 상속'이다.

class B : virtual public A {};
class C : virtual public A {};


즉, B와 C 클래스가 A 클래스를 가상 상속함으로 써, B와 C 클래스를 다중상속하는 D는, A 클래스의 멤버를

한 가지씩만 포함하게 되는 것이다. 또한 A 객체의 멤버를 하나만 포함하기 때문에,

D에서 함수 이름만으로 A 클래스의 함수를 호출할 수 있게 된다.

class A
{
public:
    explicit A(void) { cout << "A's constructor" << endl; }
    void Afunc(void) { cout << "A" << endl; }
};
class B : virtual public A
{
public:
    explicit B(void) : A() { cout << "B's constructor" << endl; }
    void Bfunc(void) { cout << "B" << endl; }
};
class C : virtual public A
{
public:
    explicit C(void) : A() { cout << "C's constructor" << endl; }
    void Cfunc(void) { cout << "C" << endl; }
};
class D : public B, public C
{
public:
    explicit D(void) : B(), C()
    {
        cout << "D's constructor" << endl;
    }
    void complexfunc(void)
    {
        Bfunc();
        Cfunc();
        B::Afunc();
        C::Afunc();
    }
};

int main(void)
{
    D d;

    d.complexfunc();

    return 0;
}
A's constructor
B'
s constructor
C's constructor
D'
s constructor
B
C
A
A


출처 : 윤성우 열혈 C++ 프로그래밍