도형마다 넓이를 계산하는 방법은 다르지만, “도형”이라는 객체 자체는 넓이를 정할 방법이 없다. 그렇다고 Shape::area()가 0을 반환하게 두면 파생 클래스가 재정의를 빠뜨려도 조용히 0이 나온다. 순수 가상 함수(pure virtual function) = 0을 선언한 추상 클래스(abstract class)는 직접 객체를 만들 수 없고, 파생 클래스가 모든 순수 가상 함수를 구현해야만 구체 클래스가 된다. 이 글에서는 g++ 13.3으로 추상 클래스의 규칙과 순수 가상 함수의 기본 구현, 생성자에서의 순수 가상 호출, 템플릿 메서드 패턴을 확인한다. 데이터 멤버 없이 순수 가상 함수만 모은 인터페이스는 다음 편에서 다룬다.
이 시리즈의 다른 글
- C++ 고급 문법 (20) — CRTP(Curiously Recurring Template Pattern)
- C++ 고급 문법 (21) — 가상 소멸자(Virtual Destructor)
- C++ 고급 문법 (23) — 인터페이스(Interface)
- C++ 고급 문법 (24) — RTTI(Run-Time Type Information)
순수 가상 함수와 추상 클래스
#include <cstdio>
#include <memory>
#include <vector>
class Shape { // 추상 클래스: 순수 가상 함수가 하나 이상
public:
explicit Shape(const char* n) : name_(n) {} // 데이터 멤버와 생성자를 가질 수 있다
virtual ~Shape() = default;
virtual double area() const = 0; // 순수 가상 함수
void print() const { std::printf("%-7s %.2f\n", name_, area()); } // 공통 구현
private:
const char* name_;
};
class Circle : public Shape {
double r_;
public:
explicit Circle(double r) : Shape("circle"), r_(r) {}
double area() const override { return 3.14159 * r_ * r_; }
};
class Rect : public Shape {
double w_, h_;
public:
Rect(double w, double h) : Shape("rect"), 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>(1));
shapes.push_back(std::make_unique<Rect>(2, 3));
for (const auto& s : shapes) s->print();
}$ g++ -std=c++20 -Wall -o b basic.cpp
$ ./b
circle 3.14
rect 6.00
추상 클래스도 데이터 멤버, 생성자, 일반 멤버 함수를 가질 수 있다. print()처럼 공통 동작은 추상 클래스에 두고 달라지는 부분만 순수 가상 함수로 남긴다.
구현을 빠뜨리면 파생 클래스도 추상
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
virtual double perimeter() const = 0;
};
struct Circle : Shape {
double area() const override { return 3.14; } // perimeter 미구현 -> Circle도 추상
};
int main()
{
Shape s;
Circle c;
}$ g++ -std=c++20 -Wall -c instantiate.cpp 2>&1 | grep -E 'error|note:.*(pure|virtual)'
instantiate.cpp:12:11: error: cannot declare variable ‘s’ to be of abstract type ‘Shape’
instantiate.cpp:1:8: note: because the following virtual functions are pure within ‘Shape’:
instantiate.cpp:3:20: note: ‘virtual double Shape::area() const’
instantiate.cpp:4:20: note: ‘virtual double Shape::perimeter() const’
instantiate.cpp:13:12: error: cannot declare variable ‘c’ to be of abstract type ‘Circle’
instantiate.cpp:6:8: note: because the following virtual functions are pure within ‘Circle’:
instantiate.cpp:4:20: note: ‘virtual double Shape::perimeter() const’
컴파일러가 아직 남아 있는 순수 가상 함수 목록을 알려준다. Circle은 perimeter()를 구현하지 않아 여전히 추상이다.
순수 가상 함수의 기본 구현
#include <cstdio>
class Logger {
public:
virtual ~Logger() = default;
virtual void write(const char* msg) = 0; // 반드시 재정의해야 하지만
};
void Logger::write(const char* msg) // 기본 구현도 제공할 수 있다
{
std::printf("[default] %s\n", msg);
}
class FileLogger : public Logger {
public:
void write(const char* msg) override
{
std::printf("[file] ");
Logger::write(msg); // 명시적으로 기본 구현 호출
}
};
int main()
{
FileLogger f;
Logger& l = f;
l.write("started");
}$ g++ -std=c++20 -Wall -o pd pure_def.cpp
$ ./pd
[file] [default] started
= 0은 “파생 클래스가 반드시 재정의해야 한다”는 뜻이지 “본문이 없다”는 뜻이 아니다. 본문을 클래스 밖에 정의해 두면 파생 클래스가 Logger::write()로 명시적으로 호출할 수 있다.
생성자에서 순수 가상 함수 호출
class Task {
public:
Task() { run(); } // 직접 호출
virtual ~Task() = default;
virtual void run() = 0;
};
class Job : public Task { public: void run() override {} };
int main() { Job j; }$ g++ -std=c++20 -Wall -o pcd purecall_direct.cpp 2>&1 | grep -E 'warning|undefined|error'
purecall_direct.cpp:3:17: warning: pure virtual ‘virtual void Task::run()’ called from constructor
purecall_direct.cpp:(.text._ZN4TaskC2Ev[_ZN4TaskC5Ev]+0x26): undefined reference to `Task::run()'
collect2: error: ld returned 1 exit status
직접 호출은 컴파일러가 경고하고, 본문이 없으면 링크 단계에서 막힌다. 다른 멤버 함수를 거쳐 간접적으로 호출하면 경고 없이 빌드된다.
#include <cstdio>
class Task {
public:
Task() { start(); } // 생성자에서 간접적으로 순수 가상 함수 호출
virtual ~Task() = default;
void start() { std::fputs("starting...\n", stderr); run(); }
virtual void run() = 0;
};
class Job : public Task {
public:
void run() override { std::fputs("Job::run\n", stderr); }
};
int main()
{
Job j;
}$ g++ -std=c++20 -Wall -o pc purecall.cpp
$ ./pc; echo "exit=$?"
starting...
pure virtual method called
terminate called without an active exception
bash: line 1: 9595 Aborted (core dumped) ./pc
exit=134
Task 생성자가 도는 동안 vptr은 Task의 vtable을 가리키고, 그 칸에는 __cxa_pure_virtual이 들어 있어 프로그램이 종료된다.
템플릿 메서드 패턴
#include <cstdio>
#include <type_traits>
// 템플릿 메서드: 처리 순서는 추상 클래스가 고정하고, 단계별 구현은 파생 클래스가 채운다
class Exporter {
public:
virtual ~Exporter() = default;
void run() // 비가상 공개 함수
{
open();
for (int i = 1; i <= 2; ++i) write_row(i);
close();
}
protected:
virtual void open() {} // 선택적 재정의 (기본 구현)
virtual void write_row(int i) = 0; // 필수 재정의
virtual void close() {}
};
class CsvExporter : public Exporter {
void open() override { std::puts("id,value"); }
void write_row(int i) override { std::printf("%d,%d\n", i, i * 10); }
};
class JsonExporter : public Exporter {
void open() override { std::puts("["); }
void write_row(int i) override { std::printf(" {\"id\": %d}\n", i); }
void close() override { std::puts("]"); }
};
int main()
{
CsvExporter{}.run();
JsonExporter{}.run();
std::printf("is_abstract<Exporter>=%d is_abstract<CsvExporter>=%d\n",
std::is_abstract_v<Exporter>, std::is_abstract_v<CsvExporter>);
}$ g++ -std=c++20 -Wall -o tm template_method.cpp
$ ./tm
id,value
1,10
2,20
[
{"id": 1}
{"id": 2}
]
is_abstract<Exporter>=1 is_abstract<CsvExporter>=0
처리 순서(run)는 비가상 함수로 고정하고, 반드시 구현할 단계는 순수 가상, 선택 단계는 기본 구현이 있는 가상 함수로 둔다. std::is_abstract로 추상 여부를 컴파일 시점에 확인할 수 있다.
| 선언 | 파생 클래스 재정의 | 기반 구현 |
|---|---|---|
void f(); (비가상) | 재정의 대상 아님 (이름 숨김만 가능) | 있음 |
virtual void f(); | 선택 | 있음 |
virtual void f() = 0; | 필수 | 없음 |
virtual void f() = 0; + 클래스 밖 정의 | 필수 | 있음 (Base::f()로 명시 호출) |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 순수 가상 함수 하나를 빠뜨림 | 파생 클래스도 추상이 되어 생성 지점에서 에러 | 에러의 note 목록 확인, override 사용 |
| 생성자·소멸자에서 간접적으로 순수 가상 호출 | pure virtual method called로 종료 | 생성 후 별도 초기화 단계에서 호출 |
| 추상 클래스에 가상 소멸자 누락 | 기반 포인터 delete 시 파생 소멸자 누락 | virtual ~Base() = default; |
| 추상 타입을 값으로 다룸 | 변수·컨테이너 원소로 쓸 수 없음 | 참조, 포인터, unique_ptr |
| “기본값”을 반환하는 일반 가상 함수로 대체 | 재정의 누락이 조용히 통과 | 필수 동작은 순수 가상으로 |
마무리
- 순수 가상 함수가 하나라도 남아 있으면 추상 클래스이고 객체를 만들 수 없다.
- 순수 가상 함수도 본문을 가질 수 있으며, 파생 클래스가 명시적으로 호출해 기본 동작으로 쓴다.
- 생성자에서 간접적으로 순수 가상 함수를 부르면 실행 중 종료되므로 초기화 단계를 분리한다.