icon
10장 : 객체 지향 프로그래밍 심화

다형성과 가상 함수

상속은 "A is a B" 관계를 통해 코드 재사용성을 높이는 강력한 도구입니다.

이제 상속과 더불어 객체 지향 프로그래밍의 또 다른 핵심 원리인 다형성(Polymorphism) 에 대해 알아보겠습니다.

다형성은 '많은 형태'를 의미하며, 하나의 인터페이스로 다양한 타입의 객체를 다룰 수 있게 해주는 C++의 강력한 기능입니다.

이 다형성을 구현하는 데 필수적인 요소가 바로 가상 함수(Virtual Function) 입니다.


다형성 (Polymorphism)이란?

다형성은 하나의 타입 또는 인터페이스로 여러 다른 형태의 객체를 다룰 수 있는 능력을 의미합니다.

즉, 동일한 이름의 함수를 호출하더라도, 실제 객체의 타입에 따라 다른 동작을 수행하도록 하는 것입니다.

예시

  • 현실 세계: 리모컨의 '전원' 버튼은 TV, 에어컨, 오디오 등 어떤 기기를 조작하느냐에 따라 다르게 동작합니다. (TV는 켜고 끄고, 에어컨은 켜고 끄고, 오디오도 켜고 끄고)
  • 프로그램: draw()라는 함수가 있다고 가정해 봅시다. Circle 객체에서 draw()를 호출하면 원을 그리고, Rectangle 객체에서 draw()를 호출하면 사각형을 그립니다. 동일한 draw()라는 호출이지만, 객체의 종류에 따라 실제 수행되는 기능은 달라지는 것입니다.

다형성은 주로 포인터(Pointer) 또는 참조자(Reference) 를 사용하여 구현됩니다.

특히, 기반 클래스 포인터/참조자가 파생 클래스 객체를 가리킬 때 다형성이 빛을 발합니다.


정적 바인딩과 동적 바인딩

함수 호출이 어떤 함수를 실행할지 결정되는 시점을 바인딩(Binding) 이라고 합니다.

  • 정적 바인딩 (Static Binding / Early Binding)

    • 컴파일 시점에 호출될 함수가 결정됩니다.
    • C++에서 일반 함수 호출(비가상 함수)은 기본적으로 정적 바인딩을 사용합니다.
    • 단점: 기반 클래스 포인터로 파생 클래스 객체를 가리킬 때, 기반 클래스의 함수만 호출됩니다. 이는 다형성을 구현할 수 없습니다.
  • 동적 바인딩 (Dynamic Binding / Late Binding)

    • 런타임 시점에 호출될 함수가 결정됩니다.
    • 가상 함수(Virtual Function) 를 사용하여 구현됩니다.
    • 장점: 기반 클래스 포인터로 파생 클래스 객체를 가리키더라도, 실제 객체의 타입에 따라 적절한 파생 클래스의 함수가 호출되어 다형성을 구현할 수 있습니다.

가상 함수 (Virtual Functions)

가상 함수는 기반 클래스에서 virtual 키워드를 사용하여 선언된 멤버 함수이며, 파생 클래스에서 재정의(override)될 수 있습니다.

가상 함수는 동적 바인딩을 가능하게 하여 다형성을 구현하는 핵심 메커니즘입니다.

가상 함수의 특징

  • 기반 클래스에서 함수 선언 앞에 virtual 키워드를 붙입니다.
    가상 함수 선언 형식
    class Base {
    public:
        virtual void print() { /* ... */ }
    };
  • 파생 클래스에서 가상 함수를 재정의할 때 override 키워드를 사용할 수 있습니다 (C++11부터). override는 필수는 아니지만, 재정의 오류를 컴파일 시점에 잡아주므로 사용하는 것이 좋습니다.
    파생 클래스에서 가상 함수 재정의
    class Derived : public Base {
    public:
        void print() override { /* ... */ } // virtual 키워드는 파생 클래스에서는 생략 가능
    };
  • 가상 함수 테이블(vtable)과 가상 함수 포인터(vptr)를 사용하여 런타임에 호출될 함수를 결정합니다.
  • 가상 소멸자: 기반 클래스의 소멸자는 반드시 virtual로 선언하는 것이 좋습니다. 그렇지 않으면 기반 클래스 포인터로 파생 클래스 객체를 delete할 때 파생 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있습니다.
가상 함수를 사용한 다형성 구현 예시
#include <iostream>
#include <string>
#include <vector> // std::vector를 사용하기 위해

// 기반 클래스
class Shape {
public:
    // 가상 함수 선언: 동적 바인딩을 가능하게 함
    virtual void draw() const {
        std::cout << "도형을 그립니다." << std::endl;
    }

    // 가상 소멸자: 파생 클래스 객체가 기반 클래스 포인터로 delete 될 때 올바른 소멸자 호출 보장
    virtual ~Shape() {
        std::cout << "Shape 소멸자 호출." << std::endl;
    }
};

// 파생 클래스 1
class Circle : public Shape {
public:
    void draw() const override { // virtual 함수 재정의 (override 키워드 사용)
        std::cout << "원을 그립니다." << std::endl;
    }
    ~Circle() override {
        std::cout << "Circle 소멸자 호출." << std::endl;
    }
};

// 파생 클래스 2
class Rectangle : public Shape {
public:
    void draw() const override { // virtual 함수 재정의
        std::cout << "사각형을 그립니다." << std::endl;
    }
    ~Rectangle() override {
        std::cout << "Rectangle 소멸자 호출." << std::endl;
    }
};

// 파생 클래스 3
class Triangle : public Shape {
public:
    void draw() const override { // virtual 함수 재정의
        std::cout << "삼각형을 그립니다." << std::endl;
    }
    ~Triangle() override {
        std::cout << "Triangle 소멸자 호출." << std::endl;
    }
};

int main() {
    // 1. 개별 객체로 호출 (다형성 X, 정적 바인딩)
    std::cout << "--- 개별 객체 호출 ---\n";
    Shape s;
    Circle c;
    Rectangle r;
    Triangle t;

    s.draw(); // Shape::draw() 호출
    c.draw(); // Circle::draw() 호출
    r.draw(); // Rectangle::draw() 호출
    t.draw(); // Triangle::draw() 호출

    std::cout << "\n--- 기반 클래스 포인터/참조자 사용 (다형성 O, 동적 바인딩) ---\n";

    // 2. 기반 클래스 포인터로 파생 클래스 객체 가리키기
    Shape* shapePtr1 = new Circle();
    Shape* shapePtr2 = new Rectangle();
    Shape* shapePtr3 = new Triangle();

    shapePtr1->draw(); // Circle::draw() 호출
    shapePtr2->draw(); // Rectangle::draw() 호출
    shapePtr3->draw(); // Triangle::draw() 호출

    delete shapePtr1; // Circle 소멸자 -> Shape 소멸자 호출
    delete shapePtr2; // Rectangle 소멸자 -> Shape 소멸자 호출
    delete shapePtr3; // Triangle 소멸자 -> Shape 소멸자 호출

    std::cout << "\n--- std::vector를 이용한 다형성 ---\n";
    // 3. std::vector에 다양한 Shape 객체 저장 (포인터로)
    std::vector<Shape*> shapes;
    shapes.push_back(new Circle());
    shapes.push_back(new Rectangle());
    shapes.push_back(new Triangle());
    shapes.push_back(new Shape()); // 기반 클래스 객체도 추가

    for (Shape* shape : shapes) {
        shape->draw(); // 실제 객체 타입에 따라 다른 draw() 함수 호출
    }

    // 메모리 해제
    for (Shape* shape : shapes) {
        delete shape;
    }
    shapes.clear(); // 벡터 비우기

    return 0;
}

위 예시에서 Shape 클래스의 draw() 함수가 virtual로 선언되어 있기 때문에, Shape 포인터(Shape* shapePtr1)가 Circle 객체를 가리키더라도 shapePtr1->draw()를 호출하면 Circle::draw()가 호출됩니다.

이것이 바로 다형성입니다. 만약 draw() 함수가 virtual이 아니었다면, 항상 Shape::draw()가 호출되었을 것입니다.