C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
이 글의 핵심
가상 함수의 동적 디스패치, vtable·vptr 레이아웃, 가상 상속·다이아몬드 문제, 순수 가상·추상 클래스, 가상 소멸자 필요 조건, 프로덕션 다형성 패턴까지 한 번에 정리합니다.
가상 함수가 왜 필요할까? (실생활 비유)
커피숍 예시로 이해하기: 여러분이 커피숍 사장이라고 상상해보세요. “음료”라는 카테고리 안에 아메리카노, 라떼, 프라푸치노 등 다양한 종류가 있습니다. 손님이 주문할 때 “음료 하나 주세요”라고만 하면, 실제로는 그 손님이 주문한 구체적인 음료를 만들어야 합니다.
프로그래밍에서도 마찬가지입니다. “동물”이라는 기본 개념이 있으며, 개·고양이·새처럼 구체적인 동물들이 있습니다. speak() (울음소리 내기)라는 행동은 모든 동물이 할 수 있지만, 실제로 내는 소리는 동물마다 다릅니다. 이때 virtual 키워드를 쓰면, “동물” 포인터로 가리켜도 실제 동물의 울음소리가 나오게 할 수 있습니다.
가상 함수란?
가상 함수(virtual function)는 실행 시점(런타임)에 실제 객체의 타입을 보고 어떤 함수를 호출할지 결정하는 멤버 함수입니다. 쉽게 말해, “겉으로는 동물인데, 실제로는 개”인 경우, 개의 speak()를 호출해주는 것입니다.
첫 번째 예제: 동물 울음소리
// 기본 "동물" 클래스 - 모든 동물의 공통 특성
class Animal {
public:
// virtual 키워드: "이 함수는 나중에 다시 정의될 수 있어요"
virtual void speak() {
cout << "Animal sound" << endl; // 기본 동물 소리
}
virtual ~Animal() = default; // Animal*로 delete하므로 필수 (아래 "가상 소멸자" 참고)
};
// 개 클래스 - Animal을 상속받아 확장
class Dog : public Animal {
public:
// override 키워드: "부모의 speak()를 다시 정의합니다"
void speak() override {
cout << "Woof!" << endl; // 개는 멍멍!
}
};
// 고양이 클래스 - Animal을 상속받아 확장
class Cat : public Animal {
public:
void speak() override {
cout << "Meow!" << endl; // 고양이는 야옹!
}
};
int main() {
// 중요! Animal* 타입이지만 실제로는 Dog 객체를 가리킴
Animal* animal1 = new Dog();
// Animal* 타입이지만 실제로는 Cat 객체를 가리킴
Animal* animal2 = new Cat();
// virtual 덕분에 실제 객체(Dog, Cat)의 함수가 호출됨!
animal1->speak(); // 출력: Woof! (Dog의 speak 호출)
animal2->speak(); // 출력: Meow! (Cat의 speak 호출)
// 메모리 정리
delete animal1;
delete animal2;
}
핵심 포인트: Animal* 타입의 포인터지만, virtual 덕분에 실제 객체가 개인지 고양이인지를 런타임에 확인하고 올바른 speak()를 호출합니다.
virtual vs non-virtual: 차이가 뭘까?
핵심 차이: virtual이 없으면 포인터 타입만 보고 함수를 결정하며, virtual이 있으면 실제 객체의 타입을 보고 함수를 결정합니다.
비교 예제
class Base {
public:
// virtual이 없는 일반 함수
void nonVirtual() {
cout << "Base::nonVirtual" << endl;
}
// virtual이 있는 가상 함수
virtual void virtualFunc() {
cout << "Base::virtualFunc" << endl;
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
// 같은 이름의 함수를 만들었지만 virtual이 없음
void nonVirtual() {
cout << "Derived::nonVirtual" << endl;
}
// override로 부모의 가상 함수를 재정의
void virtualFunc() override {
cout << "Derived::virtualFunc" << endl;
}
};
int main() {
// Base* 타입 포인터지만 실제로는 Derived 객체
Base* ptr = new Derived();
// ❌ virtual이 없어서 Base 버전이 호출됨 (정적 바인딩)
ptr->nonVirtual(); // 출력: Base::nonVirtual
// ✅ virtual 덕분에 Derived 버전이 호출됨 (동적 바인딩)
ptr->virtualFunc(); // 출력: Derived::virtualFunc
delete ptr;
}
용어 설명:
- 정적 바인딩: 컴파일 시점에 “포인터 타입”만 보고 결정 (빠름)
- 동적 바인딩: 실행 시점에 “실제 객체 타입”을 확인하고 결정 (유연함)
초보자 팁: virtual 없이는 “겉모습”만 보며, virtual이 있으면 “실제 정체”를 보고 판단한다고 기억하세요!
Derived::nonVirtual처럼 비가상 함수를 같은 이름으로 다시 정의하는 것을 가리기(hiding)라고 하며, 거의 항상 실수입니다. 같은 객체라도 Derived*로 부르면 Derived 버전, Base*로 부르면 Base 버전이 호출되어 포인터 타입에 따라 동작이 달라지기 때문입니다. 가리기에는 더 교묘한 형태도 있습니다. 기저에 virtual void draw(int)와 virtual void draw(double) 두 오버로드가 있을 때 파생 클래스가 draw(int)만 오버라이드하면, Derived 객체로 d.draw(3.14)를 부르는 순간 기저의 draw(double)은 보이지 않게 되어 3.14가 int로 변환된 채 draw(int)가 호출됩니다. 파생 클래스에 using Base::draw;를 넣어 기저의 오버로드를 다시 보이게 해야 하며, GCC는 -Woverloaded-virtual로 이 상황을 경고해 줍니다.
순수 가상 함수: “구현은 너희가 해!”
순수 가상 함수(pure virtual function)는 구현을 강제하는 함수입니다. = 0을 붙이면 “이 함수는 반드시 자식 클래스에서 구현해야 해요”라는 의미입니다.
왜 필요할까?
예를 들어 “도형”이라는 개념은 추상적입니다. “도형의 넓이”는 삼각형이냐 사각형이냐에 따라 계산 방법이 완전히 다릅니다. 따라서 Shape 클래스에서는 “넓이를 계산하는 함수가 있어야 한다”는 규칙만 정하며, 실제 계산은 각 도형 클래스가 알아서 하도록 강제하는 것입니다.
// 추상 클래스: 직접 만들 수 없는 "개념"
class Shape {
public:
// = 0이 붙으면 "순수 가상 함수" - 구현 강제!
virtual double area() const = 0;
virtual void draw() const = 0;
// 가상 소멸자 (나중에 설명)
virtual ~Shape() = default;
};
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14159 * radius * radius;
}
void draw() const override {
cout << "Drawing Circle" << endl;
}
};
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
void draw() const override {
cout << "Drawing Rectangle" << endl;
}
};
int main() {
// ❌ 에러! Shape는 추상 클래스라 직접 만들 수 없음
// Shape shape;
// ✅ 하지만 Shape* 포인터는 OK! (실제로는 구체적인 도형 가리킴)
vector<unique_ptr<Shape>> shapes;
// 반지름 5인 원 추가
shapes.push_back(make_unique<Circle>(5.0));
// 가로 4, 세로 6인 직사각형 추가
shapes.push_back(make_unique<Rectangle>(4.0, 6.0));
// 모든 도형을 순회하면서
for (const auto& shape : shapes) {
shape->draw(); // 각 도형의 그리기 방법 호출
cout << "Area: " << shape->area() << endl; // 각 도형의 넓이 계산 호출
}
// 출력 예시:
// Drawing Circle
// Area: 78.5398
// Drawing Rectangle
// Area: 24
}
핵심 개념:
- 추상 클래스: 순수 가상 함수가 하나라도 있으면 직접 객체를 만들 수 없음
- 구체 클래스: 모든 순수 가상 함수를 구현한 클래스는 객체를 만들 수 있음
- 인터페이스 역할: “이런 기능이 있어야 해”라는 규약만 정의
순수 가상 함수와 추상 클래스 심화
virtual void f() = 0 형태의 순수 가상 함수가 하나라도 있으면 해당 클래스는 추상 클래스(abstract class)가 되어 직접 인스턴스화할 수 없습니다. 컴파일러는 모든 순수 가상 함수를 구체 클래스(concrete class)에서 구현했는지 검사합니다. 순수 가상 함수에도 본문을 함께 정의할 수 있는데(예: 인터페이스 기본 동작을 헤더와 분리할 때), 이 경우에도 추상성은 유지되며 파생 클래스에서 override로 호출하거나 재정의할 수 있습니다.
인터페이스 역할을 강조하려면 public 순수 가상만 두고 데이터 멤버를 최소화하는 편이 일반적입니다. 반면 부분 추상으로 공통 멤버나 비가상 멤버 함수를 기저에 두며, 변형이 필요한 지점만 순수 가상으로 남기는 설계도 흔합니다. 추상 기저는 std::unique_ptr<Interface>와 함께 쓰일 때 소유권 경계와 수명을 명확히 하기 좋습니다. 다만 추상 클래스라도 생성자는 존재할 수 있으며, 파생 클래스 생성자에서 기저 생성자를 호출하는 흐름은 그대로입니다.
실전 예시
예시 1: 파일 시스템
class FileSystemNode {
protected:
string name;
public:
FileSystemNode(const string& n) : name(n) {}
virtual ~FileSystemNode() = default;
virtual void print(int indent = 0) const = 0;
virtual size_t getSize() const = 0;
};
class File : public FileSystemNode {
private:
size_t size;
public:
File(const string& n, size_t s) : FileSystemNode(n), size(s) {}
void print(int indent = 0) const override {
cout << string(indent, ' ') << "- " << name
<< " (" << size << " bytes)" << endl;
}
size_t getSize() const override {
return size;
}
};
class Directory : public FileSystemNode {
private:
vector<unique_ptr<FileSystemNode>> children;
public:
Directory(const string& n) : FileSystemNode(n) {}
void add(unique_ptr<FileSystemNode> node) {
children.push_back(move(node));
}
void print(int indent = 0) const override {
cout << string(indent, ' ') << "+ " << name << "/" << endl;
for (const auto& child : children) {
child->print(indent + 2);
}
}
size_t getSize() const override {
size_t total = 0;
for (const auto& child : children) {
total += child->getSize();
}
return total;
}
};
int main() {
auto root = make_unique<Directory>("root");
auto docs = make_unique<Directory>("docs");
docs->add(make_unique<File>("readme.txt", 1024));
docs->add(make_unique<File>("guide.pdf", 5120));
root->add(move(docs));
root->add(make_unique<File>("main.cpp", 2048));
root->print();
cout << "Total size: " << root->getSize() << " bytes" << endl;
}
이 예제의 print(int indent = 0)에는 가상 함수와 기본 인자에 관한 함정이 숨어 있습니다. 기본 인자는 가상 디스패치를 따르지 않고, 호출하는 표현식의 정적 타입으로 정해집니다. 만약 File이 print(int indent = 4)처럼 다른 기본값으로 오버라이드했다면, FileSystemNode*로 node->print()를 부를 때는 File::print가 실행되지만 인자는 기저의 기본값 0이 들어갑니다. 코드를 읽으면 4를 기대하게 되므로 찾기 어려운 버그가 됩니다. 그래서 가상 함수에는 기본 인자를 두지 않거나, 두더라도 기저에만 두고 모든 오버라이드가 같은 값을 쓰게 하는 것이 관례이며, 위 NVI 패턴처럼 비가상 공개 함수가 기본값을 처리하게 하는 방법도 있습니다.
예시 2: 전략 패턴
class PaymentStrategy {
public:
virtual ~PaymentStrategy() = default;
virtual void pay(double amount) = 0;
};
class CreditCardPayment : public PaymentStrategy {
private:
string cardNumber;
public:
CreditCardPayment(const string& card) : cardNumber(card) {}
void pay(double amount) override {
cout << "카드 " << cardNumber << "로 "
<< amount << "원 결제" << endl;
}
};
class PayPalPayment : public PaymentStrategy {
private:
string email;
public:
PayPalPayment(const string& e) : email(e) {}
void pay(double amount) override {
cout << "PayPal " << email << "로 "
<< amount << "원 결제" << endl;
}
};
class ShoppingCart {
private:
unique_ptr<PaymentStrategy> paymentStrategy;
double total = 0;
public:
void setPaymentStrategy(unique_ptr<PaymentStrategy> strategy) {
paymentStrategy = move(strategy);
}
void addItem(double price) {
total += price;
}
void checkout() {
if (paymentStrategy) {
paymentStrategy->pay(total);
total = 0;
}
}
};
int main() {
ShoppingCart cart;
cart.addItem(10000);
cart.addItem(20000);
cart.setPaymentStrategy(make_unique<CreditCardPayment>("1234-5678"));
cart.checkout();
cart.addItem(15000);
cart.setPaymentStrategy(make_unique<PayPalPayment>("[email protected]"));
cart.checkout();
}
예시 3: 로거
class Logger {
public:
virtual ~Logger() = default;
virtual void log(const string& message) = 0;
};
class ConsoleLogger : public Logger {
public:
void log(const string& message) override {
cout << "[Console] " << message << endl;
}
};
class FileLogger : public Logger {
private:
string filename;
public:
FileLogger(const string& file) : filename(file) {}
void log(const string& message) override {
ofstream ofs(filename, ios::app);
ofs << "[File] " << message << endl;
}
};
class Application {
private:
unique_ptr<Logger> logger;
public:
void setLogger(unique_ptr<Logger> l) {
logger = move(l);
}
void run() {
if (logger) {
logger->log("Application started");
// ...
logger->log("Application finished");
}
}
};
int main() {
Application app;
app.setLogger(make_unique<ConsoleLogger>());
app.run();
app.setLogger(make_unique<FileLogger>("app.log"));
app.run();
}
vtable과 vptr: 가상 함수의 비밀 무기
“어떻게 런타임에 올바른 함수를 찾을까?”
C++는 vtable(가상 함수 테이블)과 vptr(가상 함수 테이블 포인터)이라는 메커니즘을 사용합니다.
쉬운 비유: 전화번호부
- vtable: 각 클래스마다 가지고 있는 “함수 주소 목록” (전화번호부)
- vptr: 객체 안에 숨어있는 “전화번호부를 가리키는 포인터”
객체 생성 시:
Dog 객체 → vptr → Dog용 vtable [speak=Dog::speak 주소]
Cat 객체 → vptr → Cat용 vtable [speak=Cat::speak 주소]
함수 호출 시:
animal->speak()
→ animal의 vptr 확인
→ vtable에서 speak 찾기
→ 해당 주소로 점프!
내부 동작 3단계
Animal* animal = new Dog();
animal->speak();
// 컴파일러가 하는 일 (개념적으로):
// 1단계: animal 객체의 vptr을 찾는다
// 2단계: vptr이 가리키는 vtable을 찾는다
// 3단계: vtable에서 speak() 함수 주소를 찾아 호출한다
메모리 추가 비용
가상 함수를 쓰면:
- 객체마다 vptr 포인터 하나 추가 (보통 8바이트)
- 함수 호출 시 간접 참조 한 번 더 (미세하게 느림)
초보자 팁: 대부분의 경우 이 비용은 무시할 만합니다. “성능 때문에 virtual을 안 써야 하나?” 고민하기보다는, 먼저 코드를 깔끔하게 만드는 게 중요합니다!
가상 호출의 실제 비용은 포인터를 한 번 더 따라가는 것 자체보다 컴파일러가 인라인할 수 없다는 데서 옵니다. 일반 함수는 호출 지점에 본문이 펼쳐져 상수 전파, 루프 벡터화 같은 후속 최적화가 가능하지만, 가상 함수는 어느 구현이 불릴지 컴파일 시점에 모르기 때문에 그 문이 닫힙니다. 그래서 getX() 같은 아주 작은 함수를 수백만 번 도는 루프 안에서 가상으로 부를 때 차이가 드러나고, 한 번 호출에 수 마이크로초 이상 걸리는 함수라면 차이가 측정되지도 않습니다. 컴파일러는 실제 타입을 증명할 수 있을 때 가상 호출을 일반 호출로 바꾸는 비가상화(devirtualization)를 합니다. 클래스나 함수에 final을 붙이면 “더 이상 오버라이드가 없다”는 정보를 주어 이 최적화를 돕습니다.
vptr이 객체에 8바이트를 더한다는 점도 작은 객체를 대량으로 다룰 때는 무시할 수 없습니다. 예를 들어 float 세 개(12바이트)짜리 점 구조체에 가상 함수를 하나 넣으면 정렬까지 합쳐 24바이트가 되어, 같은 캐시에 들어가는 점의 수가 절반으로 줄어듭니다. 또 vptr이 있는 클래스는 memcpy로 복사하거나 파일·네트워크에 그대로 쓸 수 있는 trivially copyable 타입이 아니게 됩니다.
다중 상속에서는 기저 클래스마다 별도의 vptr·vtable이 필요할 수 있으며, 두 번째 이후 기저로 포인터를 바꿀 때 this 포인터 조정이 일어납니다. 이때 썽크(thunk) 코드가 끼어 올바른 this로 가상 호출을 이어주기도 합니다. 따라서 다중 상속·가상 상속이 겹칠수록 레이아웃과 호출 비용이 커지며, 성능·단순성 측면에서 단일 상속 + 컴포지션이 우선 검토 대상이 됩니다.
// 개념적 주석: 실제 레이아웃은 ABI/컴파일러에 의존
class Base {
public:
virtual void func1() {}
virtual void func2() {}
virtual ~Base() = default;
};
// Base 인스턴스 ≈ [ vptr ] + [ 비트 필드·데이터 멤버 ... ]
// vptr → vtable[0]=&Base::func1, [1]=&Base::func2, [2]=destructor pair 등
가상 상속과 다이아몬드 문제
다중 상속에서 동일한 비가상 기저 클래스가 서로 다른 경로로 두 번 이상 들어오면, 파생 객체 안에 동일 기저의 서브객체가 복수 생깁니다. 이를 다이아몬드 구조라고 부릅니다. 예를 들어 A를 B, C가 각각 상속하고 D가 B와 C를 동시에 상속하면, D 안에 A가 두 벌 생길 수 있어 A의 멤버에 접근할 때 모호함이 생기거나, 논리적으로 하나여야 할 기저 상태가 두 개로 갈라집니다.
이를 막으려면 B, C가 A를 virtual 상속하도록 하면 됩니다. 그러면 D에는 A 서브객체가 하나만 존재합니다. 대신 구현체는 가상 베이스 오프셋을 해석하기 위한 vbptr 등 추가 메타데이터를 둘 수 있어, 객체 크기·접근 비용이 커집니다. 따라서 실무에서는 다이아몬드를 설계로 피할 수 있는지(인터페이스 분리, 컴포지션)를 먼저 검토하는 경우가 많습니다.
// 개념 예시: 가상 상속으로 기저 A 단일화
struct A {
int value = 1;
virtual ~A() = default;
virtual void f() {}
};
struct B : virtual A { /* ... */ };
struct C : virtual A { /* ... */ };
struct D : B, C {
// A는 D 안에서 한 번만 존재
};
가상 상속에는 잘 알려지지 않은 규칙이 하나 있습니다. 가상 기저 A의 생성자는 B나 C가 아니라 가장 파생된 클래스 D가 직접 호출합니다. 그래서 B의 생성자가 B() : A(42) {}처럼 A에 인자를 넘겨도, D 객체를 만들 때는 그 호출이 무시되고 D의 초기화 목록에 A가 없으면 A의 기본 생성자가 쓰입니다. A에 기본 생성자가 없으면 D에서 no matching function for call to 'A::A()' 에러가 나는데, D는 A를 직접 상속하지도 않았으므로 처음 보면 원인을 이해하기 어렵습니다. 가상 기저 클래스는 기본 생성자를 두거나 상태를 갖지 않는 인터페이스로 만드는 편이 이런 문제를 피하기 쉽습니다.
가상 소멸자: 메모리 누수를 막는 핵심!
문제 상황
class Base {
public:
~Base() { } // ❌ virtual이 없음!
};
class Derived : public Base {
private:
int* bigData; // 큰 데이터를 가리키는 포인터
public:
Derived() {
bigData = new int[10000]; // 메모리 할당
}
~Derived() {
delete[] bigData; // 메모리 해제
}
};
int main() {
Base* ptr = new Derived(); // Base 포인터지만 실제로는 Derived
delete ptr; // 💥 문제 발생!
// 표준상 미정의 동작. 대부분의 구현에서는
// Base 소멸자만 호출되고 Derived 소멸자는 호출 안 됨
// → bigData 메모리 해제 안 됨 = 메모리 누수!
}
“누수”는 가장 흔히 관찰되는 증상일 뿐이고, 표준은 이 delete를 미정의 동작으로 규정합니다. 다중 상속으로 Base가 Derived 객체의 맨 앞에 있지 않으면 delete가 잘못된 주소를 해제해 힙이 손상되기도 합니다. GCC와 Clang은 -Wall에서 deleting object of polymorphic class type 'Base' which has non-virtual destructor might cause undefined behavior 경고(-Wdelete-non-virtual-dtor)를 내므로, 이 경고를 에러로 격상해 두면 대부분 컴파일 단계에서 잡힙니다. std::shared_ptr<Base> p = std::make_shared<Derived>();는 생성 시점의 타입으로 삭제자를 기억하므로 가상 소멸자가 없어도 ~Derived()가 호출되지만, std::unique_ptr<Base>는 그렇지 않다는 차이도 알아 두면 좋습니다.
해결책: 가상 소멸자 사용
class Base {
public:
virtual ~Base() { // ✅ virtual 추가!
cout << "~Base()" << endl;
}
};
class Derived : public Base {
private:
int* data;
public:
Derived() : data(new int[100]) {}
~Derived() {
cout << "~Derived()" << endl;
delete[] data; // 이제 정상적으로 호출됨!
}
};
int main() {
Base* ptr = new Derived();
delete ptr;
// 출력:
// ~Derived() ← 먼저 파생 클래스 소멸자
// ~Base() ← 그 다음 기저 클래스 소멸자
}
초보자가 자주 하는 실수: “내 클래스는 상속받을 일이 없을 것 같아서 virtual 안 붙였어요” → 나중에 누군가 상속받으면 버그 발생!
황금 규칙: 다른 클래스가 상속받을 수 있는 클래스라면 무조건 가상 소멸자를 만드세요!
가상 소멸자가 호출되면 파생 → 기저 순으로 소멸 규칙이 적용되며, 가장 파생 클래스의 소멸자 본문이 먼저 실행된 뒤 기저것으로 진행됩니다. 가상 소멸자가 “항상” 필요한 것은 아닙니다. 기저 클래스를 다형적으로 삭제하지 않고, 문서/설계상 기저 포인터로 파생을 잡지 않는다면 비가상 소멸자를 유지할 수도 있습니다. 혹은 다형 인터페이스이지만 delete를 막고 싶다면 protected ~Base(); 같은 패턴으로 잘못된 스택/힙 삭제를 제한하기도 합니다.
class Base {
public:
virtual ~Base() {
cout << "~Base()" << endl;
}
};
class Derived : public Base {
private:
int* data;
public:
Derived() : data(new int[100]) {}
~Derived() {
cout << "~Derived()" << endl;
delete[] data;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // ~Derived() 후 ~Base() — 가상 소멸자 덕분에 안전
}
프로덕션 다형성 패턴
인터페이스 + 팩토리 + 스마트 포인터 조합은 서비스 코드에서 가장 흔한 형태입니다. 구현 타입은 컴파일 의존을 줄이기 위해 .cpp에 두며, 헤더에는 추상 기저와 std::unique_ptr<Interface> create...() 정도만 노출합니다. NVI(Non-Virtual Interface) 패턴은 public 비가상 멤버가 템플릿 메서드처럼 전후처리를 고정하며, private/protected 가상 함수로만 확장점을 열어 불변식(invariant)을 지키기 쉽게 합니다.
테스트에서는 동일 인터페이스에 목(mock) 구현을 꽂아 I/O·네트워크를 대체합니다. 성능이 중요한 경로에서는 final로 더 이상 오버라이드되지 않음을 알리거나, 컴파일 타임 다형성(CRTP 등)으로 가상 호출을 없애는 방안을 비교합니다. 핫패스는 프로파일로 확인한 뒤에만 최적화하는 것이 안전합니다. 광범위한 virtual 남발보다 경계가 명확한 소수 인터페이스가 유지보수에 유리한 경우가 많습니다.
// NVI 스케치: 공개 비가상 인터페이스 + protected 가상 구현 훅
class Engine {
public:
void run() { // 고정된 전후처리
on_before();
do_work(); // 파생에서만 오버라이드
on_after();
}
virtual ~Engine() = default;
protected:
virtual void do_work() = 0;
virtual void on_before() {}
virtual void on_after() {}
};
생성자·소멸자 안에서 가상 함수를 부르면
가상 함수는 객체가 완전히 만들어진 뒤에만 파생 클래스 버전으로 디스패치됩니다. 기저 클래스 생성자가 실행되는 동안에는 파생 부분이 아직 초기화되지 않았으므로, C++는 그 시점의 객체를 “기저 타입”으로 취급하고 vptr도 기저의 vtable을 가리키게 합니다. 소멸자에서도 파생 부분이 먼저 파괴된 뒤 기저 소멸자가 실행되므로 같은 규칙이 적용됩니다.
class Widget {
public:
Widget() { init(); } // ❌ 파생 클래스의 init()이 아니라 Widget::init() 호출
virtual ~Widget() = default;
virtual void init() { cout << "Widget::init\n"; }
};
class Button : public Widget {
string label_ = "OK";
public:
void init() override { cout << "Button::init " << label_ << "\n"; }
};
Button b; // 출력: Widget::init (Button::init이 아님)
이 동작은 Java나 C#과 반대라서, 그 언어들에 익숙한 사람이 C++에서 가장 자주 당하는 함정 중 하나입니다. 만약 Widget::init이 순수 가상(= 0)이었다면 결과는 더 나쁩니다. 생성자에서 직접 부르면 링크 에러가 나지만, 생성자가 다른 멤버 함수를 거쳐 간접적으로 부르면 컴파일은 되고 실행 중에 pure virtual method called와 terminate called without an active exception 메시지를 남기고 프로그램이 종료됩니다(MSVC에서는 R6025 pure virtual function call). 파생 클래스별 초기화가 필요하다면 생성 후 호출하는 팩토리 함수(auto b = make_widget<Button>(); b->init();)나 생성자 인자로 필요한 값을 넘기는 방식으로 설계를 바꿔야 합니다.
초보자가 자주 하는 실수 TOP 3
실수 1: 가상 소멸자를 안 만듦
// ❌ 가상 소멸자 없음
class Base {
public:
~Base() {
cout << "~Base()" << endl;
}
};
class Derived : public Base {
private:
int* data;
public:
Derived() : data(new int[100]) {}
~Derived() {
cout << "~Derived()" << endl;
delete[] data; // 호출 안 됨!
}
};
Base* ptr = new Derived();
delete ptr; // 메모리 누수
// ✅ 가상 소멸자
class Base {
public:
virtual ~Base() {
cout << "~Base()" << endl;
}
};
실수 2: override를 안 붙여서 오타 발견 못 함
class Base {
public:
virtual void calculate() {}
};
// ❌ 오타가 있는데 컴파일은 됨 (새 함수로 인식)
class Derived : public Base {
public:
void calcuate() {} // 오타! calculate가 아니라 calcuate
// 부모 함수를 재정의한 게 아니라 새 함수를 만든 것!
};
int main() {
Base* ptr = new Derived();
ptr->calculate(); // Base의 calculate가 호출됨 (의도와 다름!)
}
해결책: 항상 override 붙이기!
class Derived : public Base {
public:
void calcuate() override {}
// ✅ 컴파일 에러 발생!
// "부모에 calcuate라는 함수가 없어요!"
};
초보자 팁: override는 귀찮아도 무조건 붙이세요. 오타를 컴파일 단계에서 잡아줍니다!
실제 에러 메시지는 GCC 기준 error: 'void Derived::calcuate()' marked 'override', but does not override입니다. 오타보다 더 흔한 원인은 시그니처의 미묘한 차이입니다. 기저가 virtual void update() const인데 파생에서 const를 빠뜨리거나, 기저가 int를 받는데 파생이 long을 받으면 역시 새 함수가 됩니다. 라이브러리 업데이트로 기저 클래스의 가상 함수 시그니처가 바뀌었을 때, override가 붙어 있던 파생 클래스만 즉시 컴파일 에러로 알려 줍니다. Clang의 -Winconsistent-missing-override나 GCC의 -Wsuggest-override를 켜면 override를 빠뜨린 곳도 찾아 줍니다.
실수 3: 객체 슬라이싱 (Object Slicing)
가장 헷갈리는 버그!
class Base {
public:
virtual void print() {
cout << "I'm Base" << endl;
}
};
class Derived : public Base {
private:
int extraData = 42; // Derived만의 추가 데이터
public:
void print() override {
cout << "I'm Derived with " << extraData << endl;
}
};
// ❌ 슬라이싱 발생!
Derived d;
Base b = d; // Derived를 Base에 "복사"
// 이때 Derived의 extraData는 잘려나감!
b.print(); // 출력: I'm Base (Derived 부분이 사라짐)
// ✅ 올바른 방법: 포인터나 참조 사용
Base* ptr = &d;
ptr->print(); // 출력: I'm Derived with 42
왜 이런 일이?: Base b = d;는 복사입니다. Base 크기만큼만 복사되므로 Derived의 추가 부분은 잘려나갑니다.
초보자 팁: 다형성을 쓸 땐 항상 포인터(*)나 참조(&)를 사용하세요!
초보자 FAQ (자주 묻는 질문)
Q1: 가상 함수는 언제 써야 하나요?
A: 다음 상황에서 사용하세요:
- “종류가 여러 개”인 경우: 동물(개, 고양이, 새…), 도형(원, 사각형, 삼각형…)
- “같은 인터페이스, 다른 구현”: 모두
draw()가 있지만 그리는 방법은 다름 - 런타임에 타입이 결정: 사용자 입력에 따라 어떤 객체를 쓸지 결정
쉬운 판단 기준: “이 함수를 자식 클래스마다 다르게 구현해야 하나?” → Yes면 virtual!
Q2: virtual을 쓰면 얼마나 느려지나요?
A: 걱정하지 마세요!
- 추가 비용: 함수 호출 시 포인터 한 번 더 따라가기 (나노초 단위)와 인라인 불가
- 대부분의 프로그램에서는 체감되지 않음
- 느리다면 먼저 알고리즘과 메모리 접근 패턴을 프로파일러로 확인하고, 아주 작은 가상 함수가 핫 루프에서 수백만 번 호출되는 경우에만
final이나 템플릿으로 바꾸는 것을 검토
초보자 조언: 성능 걱정보다 코드를 이해하기 쉽게 만드는 게 먼저입니다!
Q3: override를 꼭 써야 하나요?
A: 필수는 아니지만 강력 추천!
// override 없이도 작동함 (C++11 이전 방식)
class Dog : public Animal {
void speak() { } // 작동은 함
};
// override 있으면 실수를 막아줌 (C++11 이후 권장)
class Dog : public Animal {
void speak() override { } // 오타가 있으면 컴파일 에러!
};
결론: 귀찮아도 override 붙이세요. 나중에 디버깅 시간을 매우 절약합니다!
Q4: 가상 소멸자는 언제 필요한가요?
A: 포인터로 삭제할 가능성이 1%라도 있으면 무조건 필요!
// 이런 코드가 있을 수 있다면
Base* ptr = new Derived();
delete ptr; // ← 이게 있으면 가상 소멸자 필수!
// 항상 스택에서만 쓴다면 가상 소멸자 불필요
void func() {
Derived d;
// ...
} // 자동 소멸
Q5: 순수 가상 함수(= 0)는 언제 써요?
A: “이 함수는 반드시 구현해!”라고 강제하고 싶을 때
class Shape {
virtual double area() = 0; // "넓이 계산은 필수야!"
};
// Circle에서 area()를 구현 안 하면 컴파일 에러!
사용 예: 인터페이스를 정의할 때 (Java의 interface와 비슷)
Q6: 가상 함수를 private으로 선언해도 오버라이드되나요?
A: 네. 접근 제어(public/private)와 가상 디스패치는 서로 독립적입니다. 파생 클래스는 기저의 private 가상 함수를 호출할 수는 없지만 오버라이드할 수는 있습니다. 앞의 NVI 패턴이 이 성질을 이용해, 공개된 비가상 함수만 외부에 노출하고 확장 지점인 가상 함수는 private(또는 protected)으로 숨깁니다. Scott Meyers의 “Effective C++” 항목 35와 Herb Sutter의 “Virtuality” 글이 이 설계를 자세히 다룹니다.
초보자를 위한 체크리스트
가상 함수를 쓸 때 이것만 기억하세요:
- 상속받을 수 있는 클래스라면 가상 소멸자 만들기
- 재정의하는 함수에는
override붙이기 - 다형성 쓸 때는 포인터(
*)나 참조(&) 사용하기 - 순수 가상 함수(
= 0)로 구현 강제하기 - 성능 걱정은 프로파일러로 확인한 후에 하기
같이 보면 좋은 글
- C++ 상속과 다형성
- C++ 객체 슬라이싱: 파생 부분이 잘려 나가는 네 가지 경우와 다형성 지키는 법
- C++ vtable과 vptr: 가상 함수 호출이 동작하는 방식과 메모리·호출 비용
- C++ 가상 소멸자
- C++ 클래스와 객체
- C++ CRTP 패턴