C++ 다형성: 추상 클래스, 가상 함수, 가상 함수 테이블의 동작 원리

다형성의 개념

다형성이란 동일한 인터페이스를 통해 서로 다른 객체가 각자의 방식으로 동작하게 하는 메커니즘이다. 예를 들어 '결제'라는 행위를 일반 회원, 학생, 군인이 각각 수행할 때 전액, 할인, 우선 결제로 서로 다른 결과를 낸다. 이처럼 동일한 메시지에 대해 객체의 타입에 따라 다른 동작이 유발되는 것이 다형성이다.

다형성의 구성 조건

C++에서 상속 관계를 기반으로 다형성을 성립시키려면 두 가지 조건이 필요하다:

  • 기본 클래스의 포인터 또는 참조를 통해 가상 함수를 호출할 것
  • 파생 클래스가 기본 클래스의 가상 함수를 재정의(override)할 것 — 반환 타입, 함수명, 매개변수 목록이 모두 동일해야 함

다형성 예제 코드

class Customer {
public:
    virtual void processPayment() {
        std::cout << "전액 결제" << std::endl;
    }
};

class Member : public Customer {
public:
    virtual void processPayment() {
        std::cout << "할인 결제" << std::endl;
    }
};

void executeTransaction(Customer& ref) {
    ref.processPayment();
}

가상 함수

virtual 키워드로 수식된 클래스 멤버 함수를 가상 함수라 한다. 가상 함수는 파생 클래스에서 재정의할 수 있으며, 기본 클래스 포인터/참조를 통해 호출 시 실제 객체 타입에 따라 동적 바인딩이 이루어진다.

가상 함수의 재정의 (Override)

파생 클래스에서 기본 클래스와 완전히 동일한 시그니처(반환 타입, 함수명, 매개변수 목록)를 가진 가상 함수를 다시 정의하는 것을 재정의 또는 덮어쓰기라 한다.

class Animal {
public:
    virtual void makeSound() {
        std::cout << "동물 소리" << std::endl;
    }
};

class Dog : public Animal {
public:
    virtual void makeSound() {
        std::cout << "멍멍" << std::endl;
    }
};

class Cat : public Animal {
public:
    // virtual 키워드 생략 가능하나 권장하지 않음
    void makeSound() {
        std::cout << "야옹" << std::endl;
    }
};

파생 클래스에서 virtual 키워드를 생략해도 기본 클래스의 가상 함수 속성이 상속되므로 재정의가 성립한다. 그러나 코드 가독성과 명확성을 위해 virtual을 명시하는 것이 좋다.

가상 함수 재정의의 예외 사항

공변 반환 (Covariant Return Type)

기본 클래스 가상 함수가 기본 클래스 객체의 포인터/참조를 반환하고, 파생 클래스 가상 함수가 파생 클래스 객체의 포인터/참조를 반환하는 경우 반환 타입이 달라도 재정의로 인정된다.

class Shape {
public:
    virtual Shape* clone() {
        std::cout << "Shape::clone()" << std::endl;
        return nullptr;
    }
};

class Circle : public Shape {
public:
    virtual Circle* clone() {
        std::cout << "Circle::clone()" << std::endl;
        return nullptr;
    }
};

// 다른 상속 계층도 가능
class Base {};
class Derived : public Base {};

class Container {
public:
    virtual Base* create() {
        std::cout << "Container::create()" << std::endl;
        return nullptr;
    }
};

class Box : public Container {
public:
    virtual Derived* create() {
        std::cout << "Box::create()" << std::endl;
        return nullptr;
    }
};

소멸자의 재정의

기본 클래스의 소멸자가 가상 함수면 파생 클래스 소멸자는 함수명이 달라도 자동으로 재정의로 처리된다. 컴파일러가 소멸자명을 통일된 내부 이름으로 처리하기 때문이다.

class Resource {
public:
    virtual ~Resource() {
        std::cout << "~Resource()" << std::endl;
    }
};

class FileResource : public Resource {
public:
    virtual ~FileResource() {
        std::cout << "~FileResource()" << std::endl;
    }
};

int main() {
    Resource* a = new Resource;
    delete a;

    Resource* b = new FileResource;
    delete b;

    return 0;
}

소멸자를 가상 함수로 선언하지 않으면 기본 클래스 포인터로 파생 클래스 객체를 삭제할 때 파생 클래스 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있다.

디폴트 매개변수와 다형성

다음 코드의 출력 결과를 분석해보자:

class Base {
public:
    virtual void show(int value = 10) {
        std::cout << "Base->" << value << std::endl;
    }
    virtual void invoke() {
        show();
    }
};

class Sub : public Base {
public:
    void show(int value = 20) {
        std::cout << "Sub->" << value << std::endl;
    }
};

int main() {
    Sub* ptr = new Sub;
    ptr->invoke();
    return 0;
}

정답은 Sub->10이다. 이유는 다음과 같다:

  • invoke() 내부의 this 포인터는 Base* 타입이고, 실제 가리키는 객체는 Sub이다.
  • 가상 함수 show()는 동적 바인딩되어 Sub의 함수 본문이 실행된다.
  • 그러나 디폴트 매개변수는 정적 바인딩되므로 컴파일 시점의 포인터 타입인 Base의 디폴트값 10이 사용된다. 가상 함수 재정의는 함수 본문만을 대상으로 한다.

핵심 요약:

  • 다형적 호출 시 포인터가 가리키는 실제 객체 타입의 메서드가 실행된다.
  • 가상 함수 재정의는 본문만 교체되며, 함수 시그니처(디폴트값 포함)는 기본 클래스 것을 사용한다.
  • 비다형적 호출에서는 자신의 디폴트 매개변수를 사용한다.

C++11의 final과 override

final

final은 가상 함수에 붙여 더 이상 파생 클래스에서 재정의하지 못하게 막는다.

class Vehicle {
public:
    virtual void start() final {}
};

class Truck : public Vehicle {
public:
    // 컴파일 에러: final로 지정된 함수는 재정의 불가
    virtual void start() { std::cout << "Truck 출발" << std::endl; }
};

override

override는 파생 클래스 함수가 기본 클래스의 가상 함수를 제대로 재정의했는지 컴파일러가 검증하게 한다. 재정의가 아니면 컴파일 에러를 발생시킨다.

class Vehicle {
public:
    void start() {}
};

class Truck : public Vehicle {
public:
    // 컴파일 에러: 기본 클래스의 start는 가상 함수가 아님
    virtual void start() override { std::cout << "Truck 출발" << std::endl; }
};

오버로딩, 오버라이딩, 오버라이딩(은닉) 비교

개념발생 범위조건
오버로딩 (Overload)동일 클래스 내함수명 동일, 매개변수 목록 상이
재정의 (Override/覆盖)기본-파생 클래스 간가상 함수, 시그니처 동일
재정의 은닉 (Redefine/隐藏)기본-파생 클래스 간함수명 동일, 가상 함수 아님 또는 매개변수 상이

추상 클래스

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

가상 함수 선언 끝에 = 0을 붙이면 순수 가상 함수가 되며, 이를 하나 이상 포함하는 클래스를 추상 클래스라 한다. 추상 클래스는 인스턴스화할 수 없으며, 파생 클래스가 모든 순수 가상 함수를 재정의해야만 비로소 객체 생성이 가능하다.

class AbstractDevice {
public:
    virtual void initialize() = 0;
};

// 추상 클래스를 상속했지만 순수 가상 함수를 재정의하지 않아 인스턴스화 불가
class Phone : public AbstractDevice {
    // initialize() 재정의 누락
};

int main() {
    // AbstractDevice device;  // 에러: 추상 클래스는 인스턴스화 불가
    // Phone phone;            // 에러: 순수 가상 함수 미구현
    return 0;
}
class AbstractDevice {
public:
    virtual void initialize() = 0;
};

class Phone : public AbstractDevice {
public:
    virtual void initialize() override {
        std::cout << "Phone 초기화" << std::endl;
    }
};

int main() {
    Phone device;
    device.initialize();
    return 0;
}

인터페이스 상속 vs 구현 상속

일반 함수의 상속은 구현 자체를 물려받는 '구현 상속'이다. 반면 가상 함수의 상속은 함수의 인터페이스(선언부)를 물려받는 '인터페이스 상속'으로, 파생 클래스에서 재정의하여 다형성을 달성하는 것이 목적이다. 따라서 다형성이 필요 없다면 함수를 가상 함수로 만들지 않아야 한다.

가상 함수 테이블 (Virtual Table)

다음 클래스의 크기를 생각해보자:

class Engine {
public:
    virtual void ignite() {
        std::cout << "Engine::ignite()" << std::endl;
    }
private:
    int _power = 1;
};

int main() {
    Engine e;
    std::cout << sizeof(e) << std::endl;  // 32비트: 8 bytes
    return 0;
}

_power 멤버(4 bytes) 외에 추가로 4 bytes가 더해져 8 bytes가 된다. 이 추가 공간은 객체 앞에 배치되는 가상 함수 테이블 포인터(__vfptr)이다. 여기서 v는 virtual, f는 function을 의미한다. 가상 함수를 가진 클래스는 최소한 하나의 vtable 포인터를 가지며, 가상 함수들의 주소가 vtable에 저장된다.

파생 클래스의 vtable 생성 과정

기본 클래스에 가상 함수를 추가하고 파생 클래스에서 하나를 재정의하는 구조를 살펴보자:

class Engine {
public:
    virtual void ignite() {
        std::cout << "Engine::ignite()" << std::endl;
    }
    virtual void shutdown() {
        std::cout << "Engine::shutdown()" << std::endl;
    }
    void diagnose() {
        std::cout << "Engine::diagnose()" << std::endl;
    }
private:
    int _power = 1;
};

class TurboEngine : public Engine {
public:
    virtual void ignite() override {
        std::cout << "TurboEngine::ignite()" << std::endl;
    }
private:
    int _boost = 2;
};

분석 결과:

  1. 파생 클래스 객체 TurboEngine도 자체 vtable 포인터를 가진다. 객체는 상속받은 기본 클래스 멤버 부분(여기에 vtable 포인터 포함)과 자신의 멤버로 구성된다.
  2. 기본 클래스와 파생 클래스의 vtable은 서로 다르다. ignite()는 재정의되었으므로 파생 클래스 vtable에는 TurboEngine::ignite의 주소가 들어간다. 이것이 '덮어쓰기(override)'라는 용어의 원리적 의미다.
  3. shutdown()은 상속 후에도 가상 함수이므로 vtable에 포함되지만, diagnose()는 일반 함수이므로 vtable에 들어가지 않는다.
  4. vtable은 본질적으로 가상 함수 포인터들의 배열이며, 일반적으로 끝에 nullptr가 배치된다.

파생 클래스 vtable 생성 과정 요약:

  1. 기본 클래스 vtable의 내용을 파생 클래스 vtable로 복사
  2. 파생 클래스에서 재정의한 가상 함수가 있으면 해당 슬롯을 파생 클래스 함수 주소로 교체
  3. 파생 클래스에 새로 추가된 가상 함수를 선언 순서대로 vtable 끝에 추가

가상 함수와 vtable의 저장 위치

흔히 하는 오해 두 가지를 바로잡자:

  • "가상 함수가 vtable에 존재한다" — 틀린 표현. vtable에는 가상 함수의 포인터만 있다. 가상 함수 본문은 일반 함수와 마찬가지로 코드 영역(code segment)에 존재한다.
  • "vtable이 객체 내부에 있다" — 틀린 표현. 객체 내부에는 vtable 포인터만 있다. vtable 자체는 별도 메모리 영역에 존재하며, Visual Studio 환경에서 검증해보면 코드 영역에 배치된다.

태그: C++ Polymorphism virtual function vtable Abstract Class

9월 7일 21:03에 게시됨