C++에서 다형성 구현 원리 완전 정복

가상 함수와 다형성 개념

클래스 내에서 virtual 키워드가 붙은 멤버 함수는 가상 함수로 분류됩니다. 이 키워드는 함수 선언부에만 사용되며, 실제 구현부에는 포함되지 않습니다.

class Parent 
{
    virtual int process() ; // 가상 함수 선언
};

int Parent::process() // 구현 시 virtual 불필요
{ 
    // 함수 내용
}

다형성의 첫 번째 형태

파생 클래스의 인스턴스 주소는 기반 클래스 포인터에 할당할 수 있습니다. 이러한 포인터를 통해 가상 함수를 호출할 때:

  • 포인터가 기반 클래스 인스턴스를 가리키면 기반 클래스의 가상 함수가 실행됩니다.
  • 포인터가 파생 클래스 인스턴스를 가리키면 해당 파생 클래스의 가상 함수가 호출됩니다.

이러한 동작 방식을 다형성이라 하며, 호출되는 함수는 실제 객체의 타입에 따라 결정됩니다.

// 기반 클래스
class Parent 
{
public:
    virtual void execute() { } // 가상 함수
};

// 파생 클래스
class Child : public Parent 
{ 
public:
    virtual void execute() { }
};

int main() 
{
    Child obj;
    Parent *ptr = &obj;
    ptr->execute(); // 호출 함수는 ptr이 가리키는 객체 타입에 따라 결정
    return 0;
}

위 예제에서 ptrChild 타입의 객체를 참조하므로, Child 클래스의 execute 함수가 실행됩니다.

다형성의 두 번째 형태

파생 클래스의 객체는 기반 클래스의 참조자로도 바인딩될 수 있습니다. 참조자를 통한 가상 함수 호출 시:

  • 참조자가 기반 클래스 인스턴스를 가리키면 기반 클래스의 가상 함수가 실행됩니다.
  • 참조자가 파생 클래스 인스턴스를 가리키면 파생 클래스의 가상 함수가 호출됩니다.

이 또한 다형성의 한 형태이며, 호출되는 함수는 참조되는 객체의 실제 타입에 따라 결정됩니다.

// 기반 클래스
class Parent 
{
public:
    virtual void execute() { } // 가상 함수
};

// 파생 클래스
class Child : public Parent 
{ 
public:
    virtual void execute() { }
};

int main() 
{
    Child obj;
    Parent &ref = obj;
    ref.execute(); // 호출 함수는 ref가 참조하는 객체 타입에 따라 결정
    return 0;
}

예제에서 refChild 타입의 객체를 참조하므로, Child 클래스의 execute 함수가 실행됩니다.

다형성 활용 예시

class Base 
{
public:
    virtual void display() { cout << "Base::display"<<endl ; }
};

class Derived1: public Base 
{
public:
    virtual void display() { cout << "Derived1::display" <<endl; }
};

class Derived2: public Base 
{
public:
    virtual void display() { cout << "Derived2::display" << endl ; }
};

class SubDerived: public Derived1 
{
    virtual void display() { cout << "SubDerived::display" << endl ; }
};

클래스 간 상속 관계는 다음과 같습니다:

int main() 
{
    Base base; Derived1 d1; SubDerived sub; Derived2 d2;
    
    Base * ptr = &base; 
    Derived1 * pd1 = &d1;
    Derived2 * pd2 = &d2; 
    SubDerived * psub = &sub;
    
    ptr->display();  // base.display() 호출, 출력: Base::display
    
    ptr = pd1;
    ptr -> display(); // d1.display() 호출, 출력: Derived1::display
    
    ptr = pd2;
    ptr -> display(); // d2.display() 호출, 출력: Derived2::display
    
    ptr = psub;
    ptr -> display(); // sub.display() 호출, 출력: SubDerived::display
    
    return 0;
}

다형성의 장점

객체 지향 프로그래밍에서 다형성을 활용하면 코드의 확장성이 향상되어 새로운 기능 추가나 수정 시 필요한 변경 사항을 최소화할 수 있습니다.

게임 개발 예시

리그 오브 레전드 게임의 캐릭터 시스템을 설계한다고 가정해보겠습니다. 각 캐릭터는 고유한 클래스로 표현되며 서로 공격할 수 있어야 합니다.

선택된 캐릭터들:

  • 에즈리얼 - Explorer
  • 가렌 - Defender
  • 리 신 - Monk
  • 마스터 이 - Swordsman
  • 라이즈 - Wizard

기본 설계:

  1. 각 캐릭터 클래스에 attack, counter, takeDamage 함수 구현
  2. 기반 클래스 Character 생성 후 모든 캐릭터 클래스가 이를 상속받도록 구성

비다형적 접근법

// 기반 클래스
class Character 
{
protected:  
    int attackPower ; // 공격력
    int health ; // 생명력
};

// 마스터 이 클래스
class Swordsman : public Character 
{
public:
    // 가렌 공격 함수
    void attack(Defender * target) 
    {
        // 공격 애니메이션 코드
        target->takeDamage(attackPower);
        target->counter(this);
    }

    // 라이즈 공격 함수
    void attack(Wizard * target) 
    {
        // 공격 애니메이션 코드
        target->takeDamage(attackPower);
        target->counter(this);
    }
    
    // 피해 입기
    void takeDamage(int damage) 
    {
        // 피격 애니메이션 코드
        health -= damage;
    }
    
    // 가렌 반격 함수
    void counter(Defender * target) 
    {
        // 반격 애니메이션 코드
        target->takeDamage(attackPower/2);
    }
    
    // 라이즈 반격 함수
    void counter(Wizard * target) 
    {
        // 반격 애니메이션 코드
        target->takeDamage(attackPower/2);
    }
};

n개의 캐릭터가 존재할 경우, Swordsman 클래스는 n개의 attack 함수와 n개의 counter 함수를 필요로 합니다. 다른 클래스들도 동일한 패턴을 따릅니다.

새로운 캐릭터 Ashe가 추가되면 모든 기존 클래스에 다음 함수들을 추가해야 합니다:

void attack(Ashe * target);
void counter(Ashe * target);

이는 매우 비효율적이며 유지보수에 큰 부담을 줍니다.

다형적 접근법

// 기반 클래스
class Character 
{
public:
    virtual void attack(Character *target){}
    virtual void counter(Character *target){}
    virtual void takeDamage(int damage){}

protected:  
    int attackPower ; // 공격력
    int health ; // 생명력
};

// 파생 클래스 Swordsman:
class Swordsman : public Character {
public:
    // 공격 함수
    void attack(Character * target) 
    {
        // 공격 애니메이션 코드
        target->takeDamage(attackPower); // 다형성
        target->counter(this);  // 다형성
    }
    
    // 피해 입기
    void takeDamage(int damage) 
    {
        // 피격 애니메이션 코드
        health -= damage;
    }
    
    // 반격 함수
    void counter(Character * target) 
    {
        // 반격 애니메이션 코드
        target->takeDamage(attackPower/2); // 다형성
    }
};

Ashe라는 새로운 캐릭터가 추가될 경우, 기존 클래스들을 수정할 필요 없이 새로운 Ashe 클래스만 작성하면 됩니다.

사용 예시:

void Swordsman::attack(Character * target) 
{
    target->takeDamage(attackPower); // 다형성
    target->counter(this);  // 다형성
}

Swordsman master; 
Defender garren; 
Monk lee; 
Explorer ezreal;

master.attack(&garren);  //(1)
master.attack(&lee); //(2)
master.attack(&ezreal); //(3)

다형성 규칙에 따라 위의 (1), (2), (3)은 각각 다음 함수들을 호출합니다:

Defender::takeDamage
Monk::takeDamage
Explorer::takeDamage

다형성 심화 예제

다음 코드의 실행 결과는 무엇일까요?

class Base 
{
public:
    void method1() 
    { 
        method2(); 
    }
    
    virtual void method2()  // 가상 함수
    { 
        cout << "Base::method2()" << endl; 
    }
};

class Derived : public Base 
{
public:
    virtual void method2()  // 가상 함수
    { 
        cout << "Derived:method2()" << endl; 
    }
};

int main() 
{
    Derived obj;
    Base * basePtr = & obj;
    basePtr->method1();
    return 0;
}

basePtr이 파생 클래스 객체를 가리키지만 파생 클래스에 method1이 없으므로 기반 클래스의 method1이 호출되고, 그 안에서 기반 클래스의 method2가 호출되어 "Base::method2()"가 출력될 것 같지만...

코드를 변환해보면:

class Base 
{
public:
    void method1() 
    { 
        this->method2();  // this는 기반 클래스 포인터, method2는 가상 함수이므로 다형성 적용
    }
}

this 포인터는 해당 멤버 함수가 작동하는 객체를 가리킵니다. basePtr이 파생 클래스 객체를 가리키고 있으므로, Base::method1() 내에서 this->method2()는 파생 클래스의 method2를 호출하게 됩니다.

따라서 올바른 출력 결과는:

Derived:method2()

중요 포인트: 일반 멤버 함수 내에서 가상 함수를 호출할 경우 다형성이 적용됩니다!

생성자/소멸자에서의 다형성

생성자와 소멸자 내에서는 다형성이 적용되지 않습니다. 컴파일 시점에 호출할 함수가 결정되며, 항상 해당 클래스나 기반 클래스에 정의된 함수만 호출됩니다.

// 기반 클래스
class Parent 
{
public:
    virtual void greet() // 가상 함수
    {
        cout<<"greet from parent"<<endl; 
    }
    
    virtual void farewell() // 가상 함수
    {
        cout<<"farewell from parent"<<endl; 
    }
};

// 파생 클래스
class Child : public Parent
{ 
public:
    Child() // 생성자
    { 
        greet(); 
    }
    
    ~Child()  // 소멸자
    { 
        farewell();
    }

    virtual void greet() // 가상 함수
    { 
        cout<<"greet from child"<<endl;
    }
};

int main()
{
    Child obj;
    Parent *parentPtr;
    parentPtr = & obj;
    parentPtr->greet(); //다형성
    return 0;
}

출력 결과:

greet from child  // 객체 생성 시 생성자 실행
greet from child  // 다형성 적용
farewell from parent // 객체 소멸 시 Child 클래스에 farewell 없어 기반 클래스 함수 호출

다형성 구현 메커니즘

다형성의 핵심은 기반 클래스 포인터나 참조자를 통해 가상 함수를 호출할 때, 컴파일 시점에는 어떤 함수가 호출될지 알 수 없고 런타임에 결정된다는 점입니다.

가상 함수가 있는 클래스와 없는 클래스의 크기를 비교해보면:

class ClassA 
{
public:
    int value;
    virtual void show() { } // 가상 함수
};

class ClassB
{
public:
    int data;
    void show() { } 
};

int main() 
{
    cout << sizeof(ClassA) << ","<< sizeof(ClassB);
    return 0;
}

64비트 시스템에서 실행 결과:

16,4

가상 함수를 포함한 클래스는 8바이트가 더 큽니다. 64비트 시스템에서 포인터 크기가 8바이트이므로, 이 추가된 8바이트는 무엇을 위한 것일까요?

가상 함수 테이블

가상 함수를 가진 모든 클래스(또는 가상 함수를 가진 기반 클래스를 상속받은 클래스)는 가상 함수 테이블을 가지고 있습니다. 해당 클래스의 모든 객체는 이 테이블의 주소를 저장하는 포인터를 포함합니다. 테이블에는 해당 클래스의 가상 함수들의 주소가 나열되어 있습니다.

추가된 8바이트는 바로 이 가상 함수 테이블 주소를 저장하기 위한 공간입니다.

// 기반 클래스
class Base 
{
public:
    int data;
    virtual void display() { } // 가상 함수
};

// 파생 클래스
class Derived : public Base
{
public:
    int extra;
    virtual void display() { } // 가상 함수
};

Derived 클래스는 Base 클래스를 상속받고 두 클래스 모두 가상 함수를 가지고 있으므로, 가상 함수 테이블 구조는 다음과 같습니다:

다형성 함수 호출문은 기반 클래스 포인터가 가리키는 객체에 저장된 가상 함수 테이블 주소를 기반으로 테이블에서 함수 주소를 찾아 호출하는 일련의 명령어로 컴파일됩니다.

가상 함수 테이블 포인터 검증

가상 함수를 포함한 클래스의 크기를 측정했을 때 8바이트가 증가한 이유는 바로 가상 함수 테이블 포인터 때문입니다.

// 기반 클래스
class Base 
{
public: 
    virtual void action()  // 가상 함수
    { 
        cout << "Base::action" << endl; 
    }
};

// 파생 클래스
class Derived : public Base 
{
public: 
    virtual void action()  // 가상 함수
    { 
        cout << "Derived::action" << endl;
    }
};

int main() 
{
    Base baseObj;
    
    Base * derivedPtr = new Derived();
    derivedPtr->action(); // 다형성
    
    // 64비트 시스템에서 포인터는 8바이트
    int * baseVtable = (int *) & baseObj;
    int * derivedVtable = (int *) derivedPtr;
    
    * derivedVtable = * baseVtable;
    derivedPtr->action();
    
    return 0;
}

출력 결과:

Derived::action
Base::action
  1. 25-26행: derivedPtrDerived 클래스 객체를 가리키므로 Derived::action()이 호출되어 "Derived::action" 출력
  2. 29-30행: Base 클래스의 처음 8바이트(가상 함수 테이블 포인터)를 baseVtable에, Derived 클래스의 처음 8바이트를 derivedVtable에 저장
  3. 32행: Base 클래스의 가상 함수 테이블 포인터를 Derived 클래스의 포인터에 할당하여 Derived의 테이블 포인터를 Base 것으로 대체
  4. 33행: Derived의 가상 함수 테이블 포인터가 Base 것으로 교체되었으므로 Base::action()이 호출되어 "Base::action" 출력

이 예제를 통해 가상 함수 테이블 포인터의 역할이 명확히 검증되었습니다. 포인터는 가상 함수 테이블을 가리키고, 테이블은 클래스의 가상 함수 주소를 저장하므로 다형성 구현이 가능합니다.

가상 소멸자

소멸자는 객체 삭제나 프로그램 종료 시 자동으로 호출되어 리소스를 해제하는 함수입니다.

다형성 상황에서 기반 클래스 포인터를 통해 파생 클래스 객체를 삭제할 때, 일반적으로 기반 클래스의 소멸자만 호출되어 파생 클래스의 소멸자가 호출되지 않아 리소스 누수가 발생할 수 있습니다.

// 기반 클래스
class Base 
{
public: 
    Base()  // 생성자
    {
        cout << "construct Base" << endl;
    }
    
    ~Base() // 소멸자
    {
        cout << "Destructor Base" << endl;
    }
};

// 파생 클래스
class Derived : public Base 
{
public: 
    Derived()  // 생성자
    {
        cout << "construct Derived" << endl;
    }
    
    ~Derived()// 소멸자
    {
        cout << "Destructor Derived" << endl;
    }
};

int main() 
{
    Base *basePtr = new Derived();
    delete basePtr;
    
    return 0;
}

출력 결과:

construct Base
construct Derived
Destructor Base

출력 결과를 보면 basePtr 객체를 삭제할 때 Derived 클래스의 소멸자가 호출되지 않았습니다.

해결 방법: 기반 클래스의 소멸자를 가상 함수로 선언합니다.

  • 파생 클래스의 소멸자는 virtual 선언이 필요 없습니다.
  • 기반 클래스 포인터를 통해 파생 클래스 객체를 삭제할 때 먼저 파생 클래스의 소멸자가 호출되고 그 다음 기반 클래스의 소멸자가 호출됩니다. 이는 "생성 후 소멸" 원칙을 따릅니다.

위 코드에서 기반 클래스의 소멸자를 가상 소멸자로 변경:

// 기반 클래스
class Base 
{
public: 
    Base()  
    {
        cout << "construct Base" << endl;
    }
    
    virtual ~Base() // 가상 소멸자
    {
        cout << "Destructor Base" << endl;
    }
};

출력 결과:

construct Base
construct Derived
Destructor Derived
Destructor Base

좋은 습관:

  • 가상 함수를 정의한 클래스는 소멸자도 가상 함수로 정의해야 합니다.
  • 기반 클래스로 사용될 클래스는 소멸자를 가상 함수로 정의해야 합니다.
  • 생성자는 가상 함수로 정의할 수 없습니다.

순수 가상 함수와 추상 클래스

순수 가상 함수는 구현부가 없는 가상 함수입니다.

class AbstractClass 
{
public:
    virtual void print() = 0 ; // 순수 가상 함수
private: 
    int data;
};

순수 가상 함수를 포함하는 클래스는 추상 클래스라고 합니다.

  • 추상 클래스는 새로운 클래스를 파생시키기 위한 기반 클래스로만 사용할 수 있으며, 객체를 생성할 수 없습니다.
  • 추상 클래스의 포인터와 참조자는 추상 클래스로부터 파생된 클래스의 객체를 가리킬 수 있습니다.
AbstractClass obj;         // 오류, 추상 클래스는 객체 생성 불가
AbstractClass * ptr ;      // 가능, 추상 클래스 포인터 및 참조자 선언 가능
ptr = new AbstractClass ;  // 오류, 추상 클래스는 객체 생성 불가

태그: C++ 다형성 가상함수 객체지향 상속

6월 6일 21:39에 게시됨