C++ 고급 문법 (22) — 추상 클래스(Abstract Class)

도형마다 넓이를 계산하는 방법은 다르지만, “도형”이라는 객체 자체는 넓이를 정할 방법이 없다. 그렇다고 Shape::area()가 0을 반환하게 두면 파생 클래스가 재정의를 빠뜨려도 조용히 0이 나온다. 순수 가상 함수(pure virtual function) = 0을 선언한 추상 클래스(abstract class)는 직접 객체를 만들 수 없고, 파생 클래스가 모든 순수 가상 함수를 구현해야만 구체 클래스가 된다. 이 글에서는 g++ 13.3으로 추상 클래스의 규칙과 순수 가상 함수의 기본 구현, 생성자에서의 순수 가상 호출, 템플릿 메서드 패턴을 확인한다. 데이터 멤버 없이 순수 가상 함수만 모은 인터페이스는 다음 편에서 다룬다.

이 시리즈의 다른 글

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

#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
“기본값”을 반환하는 일반 가상 함수로 대체재정의 누락이 조용히 통과필수 동작은 순수 가상으로

마무리

  • 순수 가상 함수가 하나라도 남아 있으면 추상 클래스이고 객체를 만들 수 없다.
  • 순수 가상 함수도 본문을 가질 수 있으며, 파생 클래스가 명시적으로 호출해 기본 동작으로 쓴다.
  • 생성자에서 간접적으로 순수 가상 함수를 부르면 실행 중 종료되므로 초기화 단계를 분리한다.

참고

답글 남기기