본문으로 건너뛰기

안동민 개발노트

본문 시작

다형성과 가상 함수

기반 클래스 포인터와 가상 함수가 실제 객체의 재정의 메서드를 선택하는 동적 바인딩과 슬라이싱 문제를 이해합니다.

상속은 A is a B 관계를 통해 코드 재사용성을 높이는 주요 수단입니다.

상속과 함께 객체 지향 프로그래밍의 또 다른 핵심 원리인 다형성(Polymorphism)을 다룹니다.

다형성은 많은 형태를 의미하며, 하나의 인터페이스로 다양한 타입의 객체를 다루게 하는 C++의 핵심 기능입니다.

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

C++ 다형성은 Base로 부르고 Derived가 실행되는 구조다

값 복사를 피하고 참조/포인터와 virtual을 함께 써야 실제 override가 호출된다.

  1. 호출 모양

    Base& 또는 Base*

  2. 실제 객체

    Dog, Cat처럼 다른 타입

  3. 분기 지점

    virtual 멤버 함수

  4. 결과

    Derived 구현 실행

호출 코드컴파일러가 보는 타입실행 때 결정되는 것
a.sound()Animal 인터페이스Dog::sound 또는 Cat::sound
draw(shape)Shape 참조Circle, Rect의 draw
delete baseBase 포인터virtual 소멸자 체인

다형성 (Polymorphism)이란?

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

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

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

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

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


객체 슬라이싱 (Object Slicing)과 다형성 API 설계

다형성을 다룰 때 가장 자주 놓치는 실수 중 하나가 객체 슬라이싱(Object Slicing)입니다.

기반 클래스 타입으로 값 전달을 하면, 파생 클래스 고유 데이터/동작 정보가 복사 과정에서 잘려 나갑니다.

즉, 호출 시점에 파생 객체를 넘겼더라도 함수 내부에서는 기반 객체처럼 취급되어 다형성이 깨질 수 있습니다.

값 전달 시 객체 슬라이싱이 발생하는 예시
#include <iostream>
#include <string>

class Animal {
public:
    virtual std::string sound() const { return "Animal sound"; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    std::string sound() const override { return "Woof"; }
};

void printByValue(Animal a) { // 복사 발생 -> Dog의 파생 부분이 잘림
    std::cout << "By value: " << a.sound() << "\n";
}

void printByRef(const Animal& a) { // 다형성 유지
    std::cout << "By ref: " << a.sound() << "\n";
}

int main() {
    Dog d;
    printByValue(d); // Animal sound
    printByRef(d);   // Woof
}
실무 설계 원칙
  • 다형성이 필요한 API는 기반 클래스 값 타입이 아니라 Base&, const Base&, Base*로 받습니다.
  • 다형성 객체 컬렉션은 값 컨테이너보다 포인터 컨테이너(특히 스마트 포인터) 사용을 우선 고려합니다.
  • 이 원칙은 연산자 오버로딩 시 시그니처를 참조 기반으로 설계하는 이유와도 같습니다. 자세한 내용은 ‘연산자 오버로딩 기초’, ‘프렌드 함수와 프렌드 클래스’에서 함께 확인할 수 있습니다.

정적 바인딩과 동적 바인딩

호출 결과는 전달 방식, virtual 여부, 실제 객체 순서로 판정한다

같은 draw 호출도 값 잘림, 가상 호출, 런타임 타입 보존 여부에 따라 결과가 달라진다.

  1. 1
    값 전달

    복사 순간 Derived가 잘림

  2. 2
    비가상 호출

    정적 타입의 함수로 고정

  3. 3
    virtual 호출

    vptr로 실제 구현 탐색

  4. 4
    삭제

    소멸자도 가상 계약 필요

질문아니오
값으로 넘겼나?Base 부분만 남음다음 질문
함수가 virtual인가?런타임 타입으로 분기Base 함수로 고정
삭제가 Base*인가?virtual destructor 확인호출 결과만 확인

함수 호출이 어떤 함수를 실행할지 결정되는 시점을 바인딩(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할 때 파생 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있습니다.
virtual 호출은 정적 타입이 아니라 실제 객체의 override로 간다

호출 코드는 Base를 보지만 virtual 함수는 런타임 객체의 구현을 찾는다.

  1. Base 선언

    virtual void draw()

  2. Derived 선언

    void draw() override

  3. 호출 경로

    Base&로 draw 호출

  4. 선택 결과

    실제 객체의 override

조건맞으면틀리면
Base 함수가 virtual동적 바인딩 후보정적 바인딩
시그니처가 일치override로 교체새 함수가 되어 숨김
객체가 잘리지 않음Derived 정보 유지Base 부분만 호출

다형성을 실제 컬렉션이나 함수 인자로 사용할 때는 객체를 값으로 복사하지 않도록 주의해야 합니다.

기반 클래스 값으로 복사하면 파생 클래스 부분이 잘려 나가는 객체 슬라이싱이 발생하므로, 참조·포인터·스마트 포인터 중 의도에 맞는 전달 방식을 선택해야 합니다.

다형성 객체는 값으로 복사하지 않고 참조나 포인터로 전달한다

Base 값으로 복사하면 파생 클래스 부분이 잘리므로 API 경계에서 전달 방식을 먼저 정한다.

  1. Base 값

    복사하며 파생 부분 손실

  2. const Base&

    원본 객체의 타입 유지

  3. Base*

    null과 소유권을 함께 판단

  4. unique_ptr

    다형성과 수명까지 표현

API 형태다형성 보존읽는 법
void f(Base)아니오Base로 복사한다
void f(Base&)외부 객체를 다룬다
vector<unique_ptr<Base>>여러 Derived를 한 컬렉션에 둔다
가상 함수를 사용한 다형성 구현 예시
#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()가 호출되었을 것입니다.

가상 함수가 기대와 다르게 동작한다면 호출 경로에서 값 잘림, virtual 여부, override 시그니처, 소멸자 규칙을 차례로 확인합니다.

가상 함수가 기대와 다르면 값 잘림, virtual, override, 소멸자 순서로 본다

다형성 오류는 실제 객체를 잃었거나 가상 호출 계약이 불완전할 때 생긴다.

  1. 1
    1. 잘림

    잘림 Base 값 복사 여부

  2. 2
    2. virtual

    virtual Base 선언 위치 확인

  3. 3
    3. override

    override const, 매개변수까지 일치

  4. 4
    4. 소멸자

    소멸자 Base* delete 경로 확인

증상가장 먼저 볼 것수정 방향
항상 Base 출력값 전달 또는 비가상 함수Base&와 virtual
override가 안 잡힘시그니처 불일치override 키워드로 검증
삭제 때 누수소멸자 비가상virtual ~Base()

다형성은 Base 참조와 포인터, virtual 함수, 객체 슬라이싱을 함께 구분해야 제대로 동작합니다.

다형성 문제는 값 복사, virtual, 수명 계약 순서로 좁힌다

Base 참조/포인터 모양만으로 충분하지 않고 호출과 삭제 경로까지 확인해야 한다.

  1. 1
    복사 금지

    Base 값 컨테이너 피하기

  2. 2
    호출 계약

    virtual + override

  3. 3
    수명 계약

    virtual destructor

  4. 4
    소유권

    unique_ptr<Base>로 표현

설계 질문A급 답피해야 할 답
여러 Derived를 담나?참조/포인터 컬렉션vector<Base>
호출이 바뀌어야 하나?Base virtual + Derived override이름만 같은 함수
Base로 지우나?virtual destructor비가상 소멸자