본문 바로가기

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

CHAPTER08 - 상속과 다형성(Polymorphism)

객체지향에서 가장 중요하다고 할 수 있는 '다형성(Polymorphism)'을 공부하는 챕터이다.

이 책 전체의 내용 중 가장 중요하다고 할 수 있다.


1. 객체 포인터의 참조관계

(1) 객체 포인터 변수 : 객체의 주소 값을 저장하는 포인터 변수

이전에 학습 내용에서 보았듯이, 클래스를 기반으로 포인터 변수를 선언할 수 있다.

    A *ptr;
    ptr = new A();


그런데 클래스 A형 포인터는, A 객체 뿐만 아니라, A를 상속하는 유도 클래스의 객체도 가르킬 수 있다.

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

int main(void)
{
    A *ptr1 = new B();

    A *ptr1 = new C();

    B *ptr2 = new C();

    return 0;
}


위의 예제에서 볼 수 있듯이, A 클래스 포인터 변수는 A 객체 또는 A를 직접 혹은 간접적으로 상속하는

모든 객체를 가르킬 수 있다.


(2) 유도 클래스에서 기초 클래스의 함수를 오버라이딩(overriding)하는 이유

오버 라이딩이란 "기초 클래스와 동일한 이름과 매개변수로 함수를 정의하는 것"이다.

상속의 관계에서 오버라이딩을 하지 않아도 기초 클래스와 동일한 함수를 호출 가능하지만,

몸체 부분이 동일하더라도 기초 클래스의 함수가 호출이 된다.


2. 가상함수(Virtual Function)

가상함수는 C++에서 매우 중요한 위치를 차지하는 문법이다.

class A
{
};

class B : public A
{
public:
    void BFunc(void)
    {
        cout << "B Function" << endl;
    }
};

int main(void)
{
    A *ptr  = new B;
   
    ptr->BFunc(); // compile error

    return 0;
}



위의 예제에서, 'BFunc()'를 호출하는 과정에서 "해당 함수는 A 클래스의 멤버가 아닙니다."라는

메시지와 함께 컴파일 에러를 발생시킨다. 포인터형 변수가 A 클래스이기 때문이다.

"실제로 가르키는 대상은 B 클래스이니까 컴파일되어야 정상 아닌가?"

이 부분에서 다소 혼란스러울 수 있는데, C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때,

포인터의 자료형을 기준으로 판단하지, 가리키는 객체의 자료형을 기준으로 판단하지 않는다.

따라서 다음 코드도 컴파일 에러를 일으킨다.

    A *ptr  = new B;
    B *bptr = ptr;


따라서 다음과 같이 이해했으면 좋겠다. 먼저 첫 문장을 보면서,

"B 클래스는 A 클래스의 유도 클래스이니, A 클래스의 포인터 변수로 참조가 가능하다."

그리고 그 다음 문장은,

"ptr은 A 클래스형 포인터니까, ptr이 가르키는 대상이 A 클래스일 수도 있다.

그래서 B 클래스 포인터 변수로 참조가 안된다."
위의 개념으로 아래와 같은 예제도 가능하다.

    B *bptr = new B;
    A *aptr = bptr;


즉, bptr은 B 클래스의 포인터 변수이기 때문에, A 클래스를 직접 또는 간접적으로 상속하는 객체를 가리킨다.

때문에 A 클래스로 참조가 가능하다.


복습 차원에서 아래 예제를 보고 이해를 더하길 바란다.

class A
{
public:
    void AFunc(void) {}
};
class B : public A
{
public:
    void BFunc(void) {}
};
class C : public B
{
public:
    void CFunc(void) {}
};

int main(void)
{
    C *cptr = new C; // (O)
    B *bptr = cptr; // (O)
    A *aptr = bptr; // (O)

    cptr->AFunc(); // (O)
    cptr->BFunc(); // (O)
    cptr->CFunc(); // (O)

    bptr->AFunc(); // (O)
    bptr->BFunc(); // (O)
    bptr->CFunc(); // (X)

    aptr->AFunc(); // (O)
    aptr->BFunc(); // (X)
    aptr->CFunc(); // (X)

    return 0;
}


결론적으로, 포인터 형에 해당하는 클래스에 정의된 멤버에만 접근이 가능한 것이다.


※ C++ 컴파일러는 포인터를 이용한 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단한다.

(1) 함수의 오버라이딩과 포인터 형

시작에 앞서 다음 예제를 살펴보자.

class A
{
public:
    void Func(void) { cout << "A" << endl; }
};
class B : public A
{
public:
    void Func(void) { cout << "B" << endl; }
};
class C : public B
{
public:
    void Func(void) { cout << "C" << endl; }
};

int main(void)
{
    C *cptr = new C;
    B *bptr = cptr;
    A *aptr = bptr;

    cptr->Func();
    bptr->Func();
    aptr->Func();

    return 0;
}
C
B
A


실행결과만 놓고 보면 다음과 같이 이야기할 수 있다. "각 포인터형 변수에 정의된 함수가 호출된다."

즉, 가르키는 객체와 상관없이, C 클래스 포인터 변수면 C 클래스에 선언된 함수, B 클래스 포인터 변수면

B 클래스에 선언된 함수이고 A 클래스 포인터 변수면 A 클래스에 선언된 함수이다.


(2) 가상함수(Virtual Function)

함수가 가상함수로 선언되면, 해당 함수 호출 시, 포인터의 자료형을 기반으로 호출대상을 결정하지 않고,

포인터 변수가 실제 가르키는 객체를 참조하여 호출의 대상을 결정한다.

#include <iostream>
#include <cstring>
using std::cout;
using std::cin;
using std::endl;

class A
{
public:
    virtual void Func(void) { cout << "A" << endl; }
};
class B : public A
{
public:
    void Func(void) { cout << "B" << endl; }
};
class C : public B
{
public:
    void Func(void) { cout << "C" << endl; }
};

int main(void)
{
    C *cptr = new C;
    B *bptr = cptr;
    A *aptr = bptr;

    cptr->Func();
    bptr->Func();
    aptr->Func();

    return 0;
}
C
C
C


위의 예제에서 보이듯이, 가상함수의 선언은 'virtual' 키워드의 선언을 통해서 이루어진다.

그리고 이렇게 가상함수가 선언되고 나면, 이 함수를 오버라이딩 하는 함수도 가상함수가 된다.


※ 상속을 하는 이유

- 상속을 통해 연간된 일련의 클래스에 대해 공통적인 규약을 정의할 수 있다.


(3) 순수 가상함수(Pure Virtual Function)와 추상 클래스(Abstract Class)

'순수 가상함수'란 '함수의 몸체가 정의되지 않는 함수'를 의미한다.

class A
{
public:
    virtual void Func(void) = 0; // pure virtual
};
class B : public A
{
public:
    void Func(void) { cout << "B" << endl; }
};
class C : public B
{
public:
    void Func(void) { cout << "C" << endl; }
};


'순수 가상함수'를 표현하기 위해서, 위의 예제에서 보이듯이 '0의 대입'을 표현한다.

이것은 대입을 의미하는 것이 아니고, '명시적으로 몸체를 정의하지 않았음'을 컴파일러에게 알리는 것이다.

또한, A 클래스는 순수 가상함수를 지닌, 완전하지 않은 클래스가 되기 때문에 객체를 생성하려 들면

컴파일 에러가 발생한다. 따라서, 잘못된 객체의 생성을 예방할 수 있게 되었다.

이렇듯 하나 이상의 멤버함수를 순수 가상함수로 선언한 클래스를 가르켜 '추상 클래스(abstract class)'라 한다.


(4) 다형성(Polymorphism)

지금까지 설명한 가상함수의 호출관계에서 보인 특성을 가리켜 '다형성'이라 한다.

'다형성'이란 '동질이상'을 의미한다. 즉, "모습은 같은데 형태는 다르다."

class A
{
public:
    virtual void Func(void) { cout << "A" << endl; }
};
class B : public A
{
public:
    void Func(void) { cout << "B" << endl; }
};

int main(void)
{
    A *ptr  = new A;
    ptr->Func();
    delete ptr;

    ptr = new B;
    ptr->Func();
    delete ptr;

    return 0;
}
A
B


3. 가상 소멸자와 참조자의 참조 가능성
가상함수 말고도 'virtual' 키워드를 붙여줘야 할 대상이 하나 더 있다. 바로 '소멸자'이다.


(1) 가상 소멸자(Virtual Destructor)

'virtual'로 선언된 소멸자를 가리켜 '가상 소멸자'라 한다. 다음 예제를 통해서 학습해보자.

class A
{
private:
    char *str1;
public:
    explicit A(const char *name)
    {
        str1    = new char[strlen(name)+1];
    }
    ~A(void)
    {
        cout << "called A destructer" << endl;
    }
};
class B : public A
{
private:
    char *str2;
public:
    explicit B(const char *name1, const char *name2) :A(name1)
    {
        str2    = new char[strlen(name2)+1];
    }
    ~B(void)
    {
        cout << "called B destructer" << endl;
    }
};

int main(void)
{
    A *aptr = new B("test", "test");
   
    delete aptr;

    return 0;
}
called A destructer


실행결과에서 보이듯이, A 클래스 포인터 변수로 객체 소멸을 명령하니, A 클래스의 소멸자만 호출되었다.

따라서 이러한 경우에는 메모리 누수(leak)가 발생하게 된다. 그러니 객체의 소멸과정에서는

delete 연산자에 사용된 포인터 변수의 자료형에 상관없이 모든 소멸자가 호출되어야 한다.

이를 위해서 다음과 같이 'virtual' 선언을 해준다.


    virtual ~A(void)
    {
        cout << "called A destructer" << endl;
    }
called B destructer
called A destructer


가상함수와 마찬가지로, 상속의 계층구조상 맨 위에 존재하는 기초 클래스의 소멸자만 'virtual'로 선언하면

이를 상속하는 유도 클래스의 소멸자들도 모두 '가상 소멸자'로 선언이 된다. 그렇다면 상속의 계층구조상

맨 아래에 존재하는 유도 클래스의 소멸자가 대신 호출되면서, 기초 클래스의 소멸자가 순차적으로 호출된다.

class A
{
public:
    virtual ~A(void)
    {
        cout << "called A destructer" << endl;
    }
};
class B : public A
{
public:
    virtual ~B(void)
    {
        cout << "called B destructer" << endl;
    }
};
class C : public B
{
public:
    virtual ~C(void)
    {
        cout << "called C destructer" << endl;
    }
};

int main(void)
{
    A *aptr = new C;

    delete aptr;

    return 0;
}
called C destructer
called B destructer
called A destructer


(1) 참조자의 참조 가능성

앞서 포인터와 관련해서 다음과 같이 이야기 하였다.

"C++에서 A형 포인터 변수는 A 객체 또는 A를 직접 혹은 간접적으로 상속하는 모든 객체를 가리킬 수 있다"

이러한 특성은 참조자에도 적용이 된다.

"C++에서 A형 참조자는 A 객체 또는 A를 직접 혹은 간접적으로 상속하는 모든 객체를 참조할 수 있다"

또한, 함수 호출에 관해서

"A형 포인터 변수를 이용하면 A 클래스에 정의된 함수가 호출되고, B형 포인터 변수를 이용하면 B 클래스에

정의된 함수가 호출되고, C형 포인터 변수를 이용하면 C 클래스에 정의된 함수가 호출된다."

이러한 특성도 참조자에 그대로 적용된다.

"A형 참조자를 이용하면 A 클래스에 정의된 함수가 호출되고, B형 참조자를 이용하면 B 클래스에

정의된 함수가 호출되고, C형 참조자를 이용하면 C 클래스에 정의된 함수가 호출된다."








class A
{
public:
    void AFunc(void) {}
    void myFunc(void) { cout << "A's myFunc" << endl; }
    virtual void virFunc(void) { cout << "A's virFunc" << endl; }
};
class B : public A
{
public:
    void BFunc(void) {}
    void myFunc(void) { cout << "B's myFunc" << endl; }
    virtual void virFunc(void) { cout << "B's virFunc" << endl; }
};
class C : public B
{
public:
    void CFunc(void) {}
    void myFunc(void) { cout << "C's myFunc" << endl; }
    virtual void virFunc(void) { cout << "C's virFunc" << endl; }
};

int main(void)
{
    C c;
   
    c.AFunc();
    c.BFunc();
    c.CFunc();
    c.myFunc();
    c.virFunc();

    cout << endl;

    B &bref = c;

    bref.AFunc();
    bref.BFunc();
    // bref.CFunc(); Can't call the derived class's function.
    bref.myFunc();
    bref.virFunc();

    cout << endl;

    A &aref = c;

    aref.AFunc();
    // aref.BFunc(); Can't call the derived class's function.
    // aref.CFunc();
    aref.myFunc();
    aref.virFunc();

    return 0;
}
C's myFunc
C'
s virFunc

B's myFunc
C'
s virFunc

A's myFunc
C'
s virFunc


따라서 다음과 같이 정의된 함수를 보게 되면,

void goodfunction(const A &ref) {}


다음과 같이 판단할 수 있어야 한다.

"A 객체 또는 A 객체를 직접 혹은 간접적으로 상속하는 클래스의 객체가 인자의 대상이 된다."

"인자로 전달되는 객체의 실제 자료형에 상관없이, 함수 내에서는 A 클래스에 정의된 함수만 호출 할 수 있다."

그리고 이러한 사실을 고려해서 함수를 정의할 수 있어야 한다.


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