가상 함수 (Virtual Function)
한눈에 보기
부모 포인터로 자식 객체의 함수를 호출하는 다형성을 vtable과 vptr 구조로 설명하고, 가상 소멸자가 필수인 이유, 순수 가상 함수와 추상 클래스, override 키워드까지 다룬다.
최종 업데이트 2026.09.17
가상 함수(Virtual Function)는 부모 클래스 포인터나 참조로 자식 객체를 다룰 때, 실행 시점의 실제 객체 타입에 맞는 함수가 호출되도록 하는 C++의 다형성(Polymorphism) 메커니즘이다. 컴파일러는 가상 함수 테이블(Virtual Function Table, vtable)과 각 객체에 숨겨진 vptr 포인터를 이용해 이 동적 바인딩(Dynamic Binding)을 구현한다. 상속 계층을 다루는 코드라면 가상 소멸자(Virtual Destructor)와 override 키워드까지 함께 이해해야 한다.
정적 바인딩과 동적 바인딩
일반 멤버 함수 호출은 컴파일 시점에 포인터의 선언 타입을 기준으로 호출 대상이 결정된다. 이것이 정적 바인딩(Static Binding)이다. 반면 virtual로 선언된 함수는 실행 시점에 객체의 실제 타입을 보고 호출 대상을 고른다. 부모 타입 하나로 여러 자식을 담아 두고 같은 인터페이스로 서로 다른 동작을 실행하는 것이 다형성의 핵심이며, 가상 함수가 없으면 부모 포인터로는 항상 부모의 함수만 불린다.
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual double area() const { return 0.0; }
virtual ~Shape() = default; /* 가상 소멸자 */
};
class Circle : public Shape {
double r;
public:
Circle(double r) : r(r) {}
double area() const override { return 3.141592 * r * r; }
};
class Rect : public Shape {
double w, h;
public:
Rect(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rect>(3.0, 4.0));
for (const auto &s : shapes) {
std::cout << s->area() << '\n'; /* 실제 타입의 area가 불린다 */
}
return 0;
}
vtable과 vptr: 메모리 관점의 동작
가상 함수를 하나라도 가진 클래스마다 컴파일러는 가상 함수들의 주소를 담은 vtable을 하나 만들어 읽기 전용 영역에 둔다. 그리고 그 클래스의 모든 객체에는 자기 클래스의 vtable을 가리키는 숨은 포인터 vptr가 추가된다. 자식 클래스가 가상 함수를 재정의(Override)하면 자식의 vtable에서 해당 항목이 자식 함수 주소로 교체된다. 부모 포인터로 가상 함수를 호출하면, 객체의 vptr를 따라 vtable을 찾고 정해진 인덱스의 함수 주소를 읽어 호출하는 두 번의 간접 참조가 일어난다. 객체가 Circle이면 vptr가 Circle의 vtable을 가리키므로 자연스럽게 Circle::area가 선택되는 것이다.
이 구조의 비용도 알아 두어야 한다. 객체마다 vptr만큼(64비트에서 8바이트) 크기가 커지고, 호출마다 간접 참조가 추가되며, 호출 대상이 실행 시점에 정해지므로 컴파일러의 인라인(Inline) 최적화가 어려워진다. 대부분의 애플리케이션에서는 무시할 수준이지만, 성능이 극도로 중요한 내부 루프에서는 고려 대상이 된다.
가상 소멸자가 필수인 이유
부모 포인터로 자식 객체를 delete할 때 부모의 소멸자가 virtual이 아니면, 정적 바인딩으로 부모 소멸자만 호출되고 자식 소멸자는 불리지 않는다. 자식이 소유한 자원이 해제되지 않아 메모리 누수(Memory Leak)가 생기며, 표준상 정의되지 않은 동작(Undefined Behavior)이다. 따라서 다형적으로 사용될 부모 클래스는 반드시 소멸자를 virtual로 선언해야 한다. 위 예시의 virtual ~Shape() = default가 그 역할을 한다. 반대로 상속용이 아닌 클래스는 굳이 가상 소멸자를 둘 필요가 없고, final 키워드로 상속 자체를 막을 수도 있다.
순수 가상 함수와 추상 클래스, override
함수 선언 뒤에 = 0을 붙이면 구현이 없는 순수 가상 함수(Pure Virtual Function)가 되고, 이를 하나라도 가진 클래스는 객체를 만들 수 없는 추상 클래스(Abstract Class)가 된다. 자식은 순수 가상 함수를 모두 구현해야 인스턴스화할 수 있으므로, 추상 클래스는 구현을 강제하는 인터페이스(Interface) 역할을 한다. C++11의 override 키워드는 부모의 가상 함수를 재정의하겠다는 의도를 명시하며, 시그니처를 잘못 써서 재정의가 아니라 새 함수 정의가 되어 버리는 실수를 컴파일 오류로 잡아 준다. 모던 C++(Modern C++)에서는 재정의하는 모든 함수에 override를 붙이는 것이 표준적인 습관이다. 한 가지 함정으로, 생성자와 소멸자 안에서 가상 함수를 호출하면 아직 자식 부분이 만들어지기 전이거나 이미 파괴된 뒤라서 동적 바인딩이 기대대로 동작하지 않고 현재 클래스의 버전이 불린다는 점도 기억해 두어야 한다.
| 구분 | 일반 함수 | 가상 함수 | 순수 가상 함수 |
|---|---|---|---|
| 바인딩 시점 | 컴파일 시점 | 실행 시점 | 실행 시점 |
| 본문 | 필수 | 필수 | 없음(= 0) |
| 클래스 성격 | 일반 | 일반 | 포함 시 추상 클래스 |
| 자식의 재정의 | 가려짐(Hiding) | 선택 | 필수 |
면접에서 이렇게 물어본다
- 가상 함수는 어떤 문제를 해결하며, vtable과 vptr로 어떻게 구현되는가?
- 부모 클래스의 소멸자를 virtual로 선언하지 않으면 어떤 문제가 생기는가?
- 순수 가상 함수와 추상 클래스는 무엇이고 언제 사용하는가?
- 가상 함수 호출의 비용은 무엇이며, 일반 함수 호출과 어떤 차이가 있는가?
- override 키워드가 잡아 주는 실수는 무엇인지 예를 들어 설명해 보라.
C / C++에서 같이 읽기
- C 언어 컴파일 과정 (Compile Process)C 소스 코드가 전처리, 컴파일, 어셈블, 링킹 네 단계를 거쳐 실행 파일이 되는 과정을 단계별 산출물, 목적 파일의 섹션 구조, 정적 링킹과 동적 링킹의 차이와 함께 정리한다.
- C 동적 할당 (malloc, free)실행 중에 힙 메모리를 확보하고 반납하는 C의 동적 할당을 malloc, calloc, realloc, free의 동작과 할당기의 내부 구조, 메모리 누수와 댕글링 포인터 같은 대표 오류 중심으로 설명한다.
- 포인터 (Pointer)다른 변수의 메모리 주소를 저장하는 포인터의 개념과 역참조, 포인터 산술, 배열과의 관계, 이중 포인터와 함수 포인터, 그리고 널 포인터와 댕글링 포인터 같은 오류 상황까지 정리한다.
- 구조체 메모리 크기와 정렬, 패딩 (Struct Alignment)구조체 크기가 멤버 합보다 커지는 이유인 메모리 정렬과 패딩의 규칙을 오프셋 계산 예시로 설명하고, 멤버 순서에 따른 크기 차이와 #pragma pack 사용 시 주의점을 다룬다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기