read

스물한번째 열혈강의


virtual

이번에는 사실 나중에 다시 공부해도 되는 부분이니, 넘어가도 되지만 파일을 분리하는 OOP 성격을 많이 따라갈 것이기 때문에, 한번쯤은 봐야할 것이다.

이번엔 virtual 의 원리와 다중상속 에 대해 다루어 볼 것이다.


멤버함수와 가상함수의 원리


지금까지는 멤버함수가 객체 내에 존재한다고 알아왔다. 그러나 사실이 아니다. 때문에 어디에 실제 존재하는 지 알아보도록 하자. 그러나 원래 알던 그대로 객체 내에 존재한다고 생각하고 있어야 한다. 왜냐하면 개념적으로는 그게 맞는 말이기 때문이다.

먼저 구조체 변수와 전역함수를 이용해서 클래스의 객체를 흉내내볼 것이다. 즉, C언어의 문법만으로 클래스와 객체를 흉내내 볼것이다.

이는 객체의 멤버함수와 멤버변수가 어떠한 형태로 구성되는지 알기 위함이다.

#include <iostream>

using namespace std;

class Data{
private:
    int data;
public:
    Data(int num): data(num){};
    void ShowData(){cout << "Data : " << data << endl}
    void Add(int num){data+=num}
};

int main(){
    Data obj(15);
    obj.Add(17);
    obj.ShowData();
    return 0;
}

그럼 이어서 구조체와 전역함수를 통해 흉내내보자.

참고로 Data 클래스를 흉내내는 것 뿐이고, Data 클래스 자체가 될 순 없다.

#include <iostream>

using namespace std;

typedef struct Data{
    int data;
    void(*ShowData)(Data*); //함수 포인터 변수가 구조체 멤버 주소값 저장
    void (*Add)(Data*, int); //함수 포인터 변수가 구조체 멤버 주소값 저장
} Data;

void ShowData(Data* THIS){cout <<"Data : " << THIS->data << endl;}
void Add(Data* THIS, int num){THIS->data+=num;}

int main(){
    Data obj1 = {15, ShowData, Add};
    Data obj2 = {7, ShowData, Add};

    obj1.Add(&obj1, 17);
    obj2.Add(&obj2, 9);
    obj1.ShowData(&obj1);
    obj2.ShowData(&obj2);
    return 0;
}

여기서 진행되는 것에서 잘 보면 ShowData라는 함수, 그리고 Add라는 함수는 obj가 공유하고 있다. 그리고 실제로 C++의 객체와 멤버함수는 이러한 관계를 갖는다. 즉, 객체가 생성되면 멤버변수는 객체 내에 존재하지만, 멤버함수는 메모리의 한 공간에 별도로 위치하고 이 함수가 정의된 클래스의 모든 객체가 이를 공유한다.

그리고, 예제에서 보이듯, 객체가 지니는 멤버변수 대상의 연산이 진행되도록 함수를 호출한다.

그럼 가상함수에 대해 알아보자.


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


이제 가상함수의 특성을 좀더 이해해보자. 아마 이 가상함수가 잘 이해되면 C보다 C++이 느린 이유를 조금 느끼게 된다.

예제를 보자.

#include <iostream>

using namespace std;

class AAA{
private:
    int num1;
public:
    virtual void Func1(){cout << "Func1" << endl;}
    virtual void Func2(){cout << "Func2" << endl;}
};

class BBB: public AAA{
private:
    int num2;
public:
    virtual void Func1(){cout << "BBB::Func1" << endl;}
    void Func3(){cout << "Func3" << endl;}
};

int main(){
    AAA* aptr = new AAA();
    aptr->Func1();

    BBB* bptr = new BBB();
    bptr->Func1();
    return 0;
}

AAA 클래스와 BBB 클래스의 멤버변수들은 형식적으로 선언한 것으로 무시해도 된다. 따라서 생성자도 만들지 않았다. 그럼 AAA 클래스부터 보자. virtual로 선언된 가상함수가 존재한다. 이렇게 한 개 이상의 가상함수를 포함하는 클래스에 대해서는 컴파일러가 다음 그림과 같은 형태의 가상함수 테이블 을 만든다. 이것을 V-Table(Virtual Table)이라고도 하는데, 이는 실제 호출되어야 할 함수의 위치정보를 담고 있는 테이블이다.

key value
void AAA::Func1() 0x1024 번지
void AAA:Func2() 0x2048 번지

이 가상 테이블을 보면 keyvalue가 있다. 아마 해쉬와 같은 형식에서 많이 봤으니, 어떻게 구성 되는지 직관적으로 알 수 있을 것이다.

BBB 클래스 역시 다음과 같다.

key value
void BBB::Func1() 0x3072 번지
void AAA:Func2() 0x2048 번지
void BBB:Func3() 0x4096 번지

그럼 다음과 같은 특징이 보인다. (주소를 잘 보도록 하자)

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

이렇게, 오버라이딩 된 가상함수의 주소정보는 유도 클래스의 가상함수 테이블에 포함되지 않는다. 때문에 오버라이딩 된 가상함수를 호출하면, 무조건 가장 마지막에 오버라이딩을 한 유도 클래스의 멤버함수가 호출되는 것이다.

앞선 예제코드가 실행이 되면 main 함수가 호출되기 이전에 가상함수 테이블이 메모리 공간에 할당된다.(0x1024, 0x3072, 0x2048, 0x4096에 각각 할당) 참고로 가상함수 테이블은 객체의 생성과 상관없이 메모리 공간에 할당된다. 이는 가상함수 테이블이 멤버함수의 호출에 사용되는 일종의 데이터 이기 때문이다.

그리고 이후 main 함수가 호출되어 객체가 생성이 되면, 참조관계를 구성하게 된다.

만약 AAA 클래스 객체와 BBB 클래스 객체가 각각 2개씩 만들어졌다면, AAA 클래스 객체 2개는 위 AAA의 가상테이블을 참조하고, BBB 클래스 객체 2개는 BBB의 가상테이블을 참조한다.

즉, 가상함수를 하나이상 멤버로 지니는 클래스의 객체에는 가상함수 테이블의 주소 값이 저장된다. (이 주소는 우리가 직접 참조할 수 없고, 내부적으로 필요에 의해 참조되는 주소값이다.)

그럼 이같은 상황에서 AAA 객체를 통해 Func1이 호출되었다고 하자. 그럼 Func1 함수의 위치 확인을 위해 AAA 클래스의 가상함수 테이블이 참조되고, 결국 0x1024번지에 위치한 함수가 실행된다. 다른 부분도 마찬가지다.

BBB 클래스의 가상함수 테이블을 살펴보면, 오버라이딩 된 AAA 클래스의 Func1 함수에 대한 정보가 없음이 보인다. 따라서 BBB 클래스의 Func1 함수가 대신 호출이 되는데, 이게 가상함수 호출원리 다.


다중상속


다중상속둘 이상의 클래스를 동시에 상속하는 것이다. 그리고 C++은 다중상속을 지원하는 객체지향 언어이다. 그런데 다중상속은 꽤 논란이 되는 문법이라, 여기부터 알아보고 넘어가자.

다중상속에 대해 여러 견해가 있는데, 먼저 다중상속은 득보다 실이 많은 문법이다. 그러니 절대로 사용하지 말아야 하고 가능하다면 C++ 기본문법에서 제외시켜야 한다.

그리고 또다른 의견은 일반적인 경우에서 다중상속은 다양한 문제를 동반한다. 따라서 가급적 사용하지 않아야 함은 동의하지만 예외적이고 매우 제한적인 사용까지 부정할 필요는 없다고 본다.

실제로 다중상속으로만 해결이 가능한 문제는 존재하지 않으니, 굳이 다중상속을 하기 위해 노력할 필요는 없다. 하지만 접하게 될 라이브러리에 다중상속을 사용한 예가 있기 때문에, 혹은 예외적으로 누군가가 사용했을 때를 위해 알아야 할 필요가 있다.

다중상속이 유용해보일만한 예제를 만들기는 어렵고, 단순히 문법의 설명을 목적으로 예제를 하나 보겠다.

#include <iostream>

using namespace std;

class BaseOne{
public:
    void SimpleFuncOne(){cout << "BaseOne" << endl;}
};

class BaseTwo{
public:
    void SimpleFuncTwo(){cout << "BaseTwo" << endl;}
};

class MultiDerived : public BaseOne, protected BaseTwo{
public:
    void ComplexFunc(){
        SimpleFuncOne();
        SimpleFuncTwo();
    }
};

int main(){
    MultiDerived mdr;
    mdr.ComplexFunc();
    return 0;
}

위와 같이 두가지 클래스를 받아서 다중상속을 구현할 수 있다. 그럼 다중상속으로 인해 발생하는 문제와 해결책도 알아보도록 하자.

다중상속의 대상이 되는 두 기초 클래스에 동일한 이름의 멤버가 존재하는 경우에는 문제가 발생할 수 있다. 이러한 경우에는 유도 클래스 내에서 멤버의 이름만으로 접근 불가능하기 때문이다. 어느 클래스에 접근해야 할지 모호해진다.

#include <iostream>

using namespace std;

class BaseOne{
public:
    void SimpleFunc(){cout << "BaseOne" << endl;}
};

class BaseTwo{
public:
    void SimpleFunc(){cout << "BaseTwo" << endl;}
};

class MultiDerived : public BaseOne, protected BaseTwo{
public:
    void ComplexFunc(){
        BaseOne::SimpleFunc();
        BaseTwo::SimpleFunc();
    }
};

int main(){
    MultiDerived mdr;
    mdr.ComplexFunc();
    return 0;
}

이렇게 모호성이 생기면 각각 따로 직접 지정해줘야 한다. 그리고 해결방법 역시 제시가 되었다.


가상상속


함수 호출에서의 모호한 점은 다른 곳에서도 볼 수 있다.

#include <iostream>

using namespace std;

class Base{
public:
    Base(){ cout << "Base Constructor" << endl; }
    void SimpleFunc(){ cout << "BaseOne" << endl; }
};

class MiddleDerivedOne : virtual public Base{ // 가상 상속
public:
    MiddleDerivedOne() : Base(){
        cout << "MiddleDerivedOne Constructor" << endl;
    }
    void MiddleFuncOne(){
        SimpleFunc();
        cout << "MiddleDerivedOne" << endl;
    }
};

class MiddleDerivedTwo : virtual public Base{ // 가상 상속
public:
    MiddleDerivedTwo() : Base(){
        cout << "MiddleDerivedTwo Constructor" << endl;
    }
    void MiddleFuncTwo(){
        SimpleFunc();
        cout << "MiddleDerivedTwo" << endl;
    }
};

class LastDerived : public MiddleDerivedOne, public MiddleDerivedTwo{
public:
    LastDerived() : MiddleDerivedOne(), MiddleDerivedTwo(){
        cout << "LastDerived Constructor" << endl;
    }
    void ComplexFunc(){
        MiddleFuncOne();
        MiddleFuncTwo();
        SimpleFunc();
    }
};

int main(){
    cout << "객체생성 전..." << endl;
    LastDerived ldr;
    cout << "객체생성 후..." << endl;
    ldr.ComplexFunc();
    return 0;
}
출력 :

객체생성 ...
Base Constructor
MiddleDerivedOne Constructor
MiddleDerivedTwo Constructor
LastDerived Constructor
객체생성 ...
BaseOne
MiddleDerivedOne
BaseOne
MiddleDerivedTwo
BaseOne

가상상속에 있어서는 잠시후 보기로 하고, 실행 결과에서 LastDerived 객체 생성시 Base 클래스의 생성자가 한 번만 호출되는 것을 확인할 수 있다.

구조는 간단히 Base클래스를 MiddleDerivedOneMiddleDerivedTwo클래스가 상속하고, LastDerived 클래스가 위 두 클래스를 상속하는 형태다.

그런데 중요한 점은 LastDerived 클래스는 Base클래스를 두번 상속한다는 점이다.

따라서 위 예제에서 virtual 선언이 되지 않은 상태에서 객체가 생성되면 하나의 객체 내에 두개의 Base 클래스 멤버가 존재하기 때문에, ComplexFunc 함수 내에서 이름만 가지고 SimpleFunc을 호출 할 수가 없다.

따라서,

MiddleDerivedOne::SimpleFunc 혹은 MiddleDerivedTwo::SimpleFunc와 같은 형태로 호출을 해야한다.

이런 상황에서 Base 클래스의 멤버가 LastDerived 객체에 하나씩만 존재하는 것이 타당한 경우가 대부분인데, 이때 현실적인 해결책이 가상상속 이다.

이번 포스팅은 여기까지 하겠다.

Blog Logo

구찌


Published

Image

구찌의 나도한번 해블로그

구찌의 개발 블로그 입니다.

Back to Overview