C++ 다형성의 개념과 동작 원리 심층 분석

1. 다형성의 개념

다형성(Polymorphism)은 동일한 인터페이스를 통해 다양한 타입의 객체가 서로 다른 방식으로 동작할 수 있게 하는 객체지향 프로그래밍의 핵심 개념입니다. C++에서는 크게 두 가지 형태의 다형성을 지원합니다.

  • 컴파일 타임 다형성(정적 다형성): 함수 오버로딩과 템플릿이 대표적입니다. 함수 이름은 같지만 매개변수의 타입이나 개수가 다를 때, 컴파일러가 컴파일 시점에 어떤 함수를 호출할지 결정합니다.
  • 런타임 다형성(동적 다형성): 상속 관계에 있는 클래스들이 동일한 함수 이름을 재정의(오버라이딩)하고, 이를 기반 클래스의 포인터나 참조를 통해 호출할 때 실행 시점에 실제 객체의 타입에 따라 적절한 함수가 선택됩니다.

예를 들어, "입장권 구매"라는 동작이 있다고 가정해봅시다. 일반인은 정가로 구매하고, 학생은 할인된 가격으로, 군인은 우선 구매 권한을 가집니다. 이처럼 동일한 동작이 객체의 타입에 따라 다르게 구현되는 것이 런타임 다형성의 핵심입니다.

2. 다형성의 정의 및 구현

다형성은 상속 관계에 있는 클래스의 객체들이 동일한 함수 호출에 대해 각자 다른 동작을 수행하는 메커니즘입니다.

앞서 설명한 입장권 구매 예제를 코드로 구현해보겠습니다. 먼저 Person 클래스를 기반으로 StudentSoldier 클래스를 상속받도록 설계합니다.

class Person {};
class Student : public Person {};
class Soldier : public Person {};

BuyTicket 함수를 정의하여 구매 동작을 추상화합니다.

void BuyTicket();

다형성을 구현하기 위해서는 먼저 기반 클래스의 멤버 함수를 virtual 키워드를 사용하여 가상 함수(virtual function)로 선언해야 합니다. 파생 클래스에서 이 함수를 재정의(오버라이딩)할 때, 반환 타입, 함수 이름, 매개변수 목록이 정확히 일치해야 합니다. 파생 클래스에서 virtual 키워드는 생략해도 되지만, 명시적으로 작성하는 것이 좋은 코딩 습관입니다.

class Person {
public:
    virtual void BuyTicket() {
        cout << "정가로 티켓 구매" << endl;
    }
};

class Student : public Person {
public:
    virtual void BuyTicket() override { // override 키워드는 선택 사항이지만 권장
        cout << "할인된 가격으로 티켓 구매" << endl;
    }
};

class Soldier : public Person {
public:
    virtual void BuyTicket() override {
        cout << "우선 구매 권한으로 티켓 구매" << endl;
    }
};

여기서 중요한 점은 재정의(Overriding)입니다. 이는 파생 클래스가 기반 클래스의 가상 함수 구현을 완전히 대체하는 것을 의미합니다. 함수의 시그니처(반환 타입, 이름, 매개변수)가 동일해야 합니다.

다형성을 활용하려면 가상 함수를 반드시 기반 클래스의 포인터나 참조를 통해 호출해야 합니다.

void Pay(Person* ptr) {
    ptr->BuyTicket(); // ptr이 가리키는 실제 객체의 타입에 따라 실행될 함수가 결정됨
}

다형성을 위한 두 가지 핵심 조건은 다음과 같습니다.

  • 조건 1: 기반 클래스의 포인터 또는 참조를 통해 가상 함수를 호출해야 합니다.
  • 조건 2: 호출되는 함수는 가상 함수여야 하며, 파생 클래스에서 이를 반드시 재정의(오버라이딩)해야 합니다.

다음은 전체 실행 예제입니다.

#include <iostream>
using namespace std;

class Person {
public:
    virtual void BuyTicket() { cout << "정가 구매" << endl; }
    virtual ~Person() {}
};

class Student : public Person {
public:
    virtual void BuyTicket() override { cout << "할인 구매" << endl; }
};

class Soldier : public Person {
public:
    virtual void BuyTicket() override { cout << "우선 구매" << endl; }
};

void Pay(Person* ptr) {
    ptr->BuyTicket();
}

int main() {
    Person p;
    Student st;
    Soldier so;

    Pay(&p);
    Pay(&st);
    Pay(&so);
    return 0;
}

실행 결과:

정가 구매
할인 구매
우선 구매

3. 가상 함수 재정의의 심화 주제

3.1 기본 인자(Default Argument)와 가상 함수

다음 코드의 실행 결과를 예측해보세요.

class A {
public:
    virtual void func(int val = 1) {
        cout << "A->" << val << endl;
    }
    virtual void test() { func(); }
};

class B : public A {
public:
    void func(int val = 0) override {
        cout << "B->" << val << endl;
    }
};

int main() {
    B* p = new B;
    p->test();
    delete p;
    return 0;
}

정답은 B->1입니다. test()A 클래스의 멤버 함수이므로, 내부에서 func()를 호출할 때 사용되는 기본 인자 값은 A 클래스에서 정의된 val = 1입니다. 기본 인자는 컴파일 타임에 결정되기 때문입니다. 반면, func의 구현체는 동적 바인딩을 통해 실제 객체 타입인 B의 것이 실행됩니다.

3.2 공변성(Covariance)

파생 클래스가 기반 클래스의 가상 함수를 재정의할 때, 반환 타입이 기반 클래스의 반환 타입과 다를 수 있는 특별한 경우가 있습니다. 기반 클래스 함수가 기반 클래스 객체의 포인터/참조를 반환하고, 파생 클래스 함수가 파생 클래스 객체의 포인터/참조를 반환하는 경우를 공변성이라고 합니다.

class Base {};
class Derived : public Base {};

class Parent {
public:
    virtual Base* getObject() { return new Base(); }
};

class Child : public Parent {
public:
    virtual Derived* getObject() override { return new Derived(); }
};

3.3 소멸자와 가상 함수

기반 클래스의 소멸자는 반드시 가상 함수로 선언해야 합니다. 그렇지 않으면 메모리 누수가 발생할 수 있습니다.

class Base {
public:
    Base() { cout << "Base()" << endl; }
    virtual ~Base() { cout << "~Base()" << endl; } // 가상 소멸자
};

class Derived : public Base {
public:
    Derived() { cout << "Derived()" << endl; }
    ~Derived() { cout << "~Derived()" << endl; }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 가상 소멸자 덕분에 Derived의 소멸자가 먼저 호출됨
    return 0;
}

3.4 override와 final 키워드

C++11에서는 overridefinal 키워드를 도입했습니다. override는 개발자가 의도한 재정의가 올바른지 컴파일러가 검증하도록 돕습니다. final은 파생 클래스에서 더 이상 재정의할 수 없도록 만듭니다.

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

가상 함수 뒤에 = 0을 붙이면 순수 가상 함수(pure virtual function)가 됩니다. 순수 가상 함수를 하나 이상 포함하는 클래스는 추상 클래스(abstract class)가 되어 객체를 직접 생성할 수 없습니다.

class Shape {
public:
    virtual double getArea() const = 0; // 순수 가상 함수
    virtual ~Shape() {}
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    double getArea() const override {
        return 3.14159 * radius * radius;
    }
};

class Rectangle : public Shape {
private:
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    double getArea() const override {
        return width * height;
    }
};

int main() {
    Shape* shapes[2];
    shapes[0] = new Circle(5.0);
    shapes[1] = new Rectangle(3.0, 4.0);

    for (int i = 0; i < 2; ++i) {
        cout << "Area: " << shapes[i]->getArea() << endl;
        delete shapes[i];
    }
    return 0;
}

5. 다형성의 내부 동작 원리

5.1 가상 함수 테이블(vtable)과 vptr

클래스에 가상 함수가 하나라도 존재하면, 컴파일러는 해당 클래스에 대해 가상 함수 테이블(vtable)이라는 정적 배열을 생성합니다. 이 배열에는 모든 가상 함수의 주소가 저장됩니다. 또한, 각 객체에는 vptr(virtual table pointer)이라는 숨겨진 포인터 멤버가 추가되어 자신의 클래스에 해당하는 vtable을 가리킵니다.

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    void func3() { cout << "Base::func3" << endl; }
private:
    int _b;
};

int main() {
    Base b;
    cout << "sizeof(b): " << sizeof(b) << endl; // 32비트 시스템에서 8 출력 (vptr + int)
    return 0;
}

5.2 가상 함수 재정의와 vtable

파생 클래스가 기반 클래스의 가상 함수 중 일부를 재정의하면, 파생 클래스의 vtable에서 해당 함수의 주소가 재정의된 함수의 주소로 교체됩니다. 재정의되지 않은 가상 함수는 기반 클래스의 함수 주소가 그대로 유지됩니다. 파생 클래스에 새로운 가상 함수가 추가되면, 해당 함수의 주소가 vtable의 끝에 추가됩니다.

class Derived : public Base {
public:
    void func1() override { cout << "Derived::func1" << endl; } // func1 재정의
    virtual void func4() { cout << "Derived::func4" << endl; } // 새로운 가상 함수
};

5.3 동적 바인딩(Dynamic Binding)

가상 함수가 기반 클래스 포인터/참조를 통해 호출되면, 컴파일러는 직접 함수 주소를 호출하지 않고, 런타임에 객체의 vptr을 통해 vtable을 조회하여 실제 함수 주소를 찾아 호출합니다. 이 메커니즘을 동적 바인딩이라고 합니다. 반면, 일반 함수 호출은 컴파일 타임에 주소가 결정되는 정적 바인딩입니다.

5.4 vtable 관련 주요 사항

  • 동일한 클래스의 모든 객체는 동일한 vtable을 공유합니다.
  • 파생 클래스는 자신만의 고유한 vtable을 가지며, 기반 클래스의 vtable과는 별개입니다.
  • vtable은 일반적으로 코드 섹션(Read-Only Memory)에 저장됩니다.
  • 가상 함수 자체는 일반 함수와 마찬가지로 코드 섹션에 저장되며, vtable에는 해당 함수들의 주소만 저장됩니다.
  • 일부 컴파일러(VS 계열)는 vtable의 끝에 null 포인터(0x00000000)를 표시로 추가하기도 합니다.

태그: C++ 다형성 가상함수 vtable 오버라이딩

7월 26일 12:33에 게시됨