본문 바로가기

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

순수 가상 함수로 파생 클래스의 구현 계약을 정의하고 직접 생성할 수 없는 추상 클래스로 인터페이스를 설계합니다.

앞 절에서 기반 클래스 참조·포인터와 가상 함수로 런타임 다형성을 구현하는 방법을 학습했습니다.

특히 기반 클래스 포인터를 통해 파생 클래스의 재정의된 함수를 호출하는 동적 바인딩의 강력함을 알아보았습니다.

이번 절에서는 가상 함수의 특별한 형태인 순수 가상 함수(Pure Virtual Function)와 이를 포함하는 추상 클래스(Abstract Class)에 대해 더 깊이 있게 다루겠습니다.

이들은 객체 지향 설계에서 추상화(Abstraction)를 구현하고, 파생 클래스에게 특정 기능의 구현을 강제하는 데 매우 중요한 역할을 합니다.

먼저 추상 클래스가 “직접 생성할 수 없음”을 넘어 어떤 호출 계약을 만드는지 큰 흐름으로 보겠습니다.

C++ · ABSTRACT INTERFACE

Shape는 계약을, 구체 도형은 넓이 계산을 제공한다

추상 클래스는 직접 생성할 수 없지만 참조와 포인터의 안정된 경계가 된다. 구체 클래스는 모든 순수 가상 함수의 final overrider와 안전한 소멸 계약을 제공한다.

하나의 Shape 계약을 따로 완성하는 두 도형원 desktop과 같은3클래스2public상속간선이고 Circle→Shape와Rectangle→Shape이다. 추상 Shape의 calculateArea const순수함수,displayInfo const,virtual소멸자와각도형의비순수최종재정의를유지한다. Circle에서Rectangle로순차이동하지않으며시그니처완성이입력유효성이나정확한계산을자동검증한다는뜻도아니다.Shape추상 계약displayInfo() constcalculateArea()const = 0virtual ~Shape()CirclecalculateArea()const overrideradius로 계산RectanglecalculateArea()const overridewidth × height각 구체 타입이 Shape를 상속Circle과 Rectangle 사이 간선 없음
순수 가상 함수는 concrete 타입이 완성할 호출 계약이다 추상 Shape 계약을 Circle과 Rectangle이 구현하고 virtual destructor로 다형적 수명을 보장하는 UML 클래스 다이어그램. «ABSTRACT»Shape+ displayInfo() const+ calculateArea() const = 0+ ~Shape(): virtualCircle+ calculateArea() const overrideradius로 구현Rectangle+ calculateArea() const overridewidth × heightpublic is-apublic is-a

Shape · 추상 계약

calculateArea() const = 0의 비순수 최종 재정의가 필요합니다. 일반 가상 함수 displayInfo()와 가상 소멸자 ~Shape()도 제공합니다.

Circle → Shape

calculateArea() const override에서 radius로 원의 넓이를 계산합니다.

Rectangle → Shape

calculateArea() const override에서 width × height를 계산합니다.

순수 가상 함수도 필요하면 클래스 밖 정의를 가질 수 있다. ‘순수’는 final overrider 의무를 뜻한다.


순수 가상 함수 (Pure Virtual Functions)

때로는 기반 클래스에서 특정 멤버 함수의 구현을 제공하는 것이 의미가 없거나 불가능한 경우가 있습니다.

예를 들어 Shape 클래스에서 calculateArea() 함수를 정의한다고 할 때, 일반적인 Shape의 넓이를 계산하는 것은 불가능합니다.

오직 Circle, Rectangle, Triangle과 같은 구체적인 도형만이 넓이를 계산할 수 있습니다.

이러한 경우, 기반 클래스는 이런 함수가 있어야 해!라는 선언만 하고, 실제 구현은 파생 클래스에게 맡기도록 강제할 수 있습니다.

이때 사용하는 것이 순수 가상 함수입니다.

순수 가상 함수 정의 방법
  • 함수 선언 끝에 = 0을 붙입니다.
  • 클래스 안의 순수 지정자 = 0에는 함수 본문을 함께 쓸 수 없습니다. 필요하면 클래스 밖에 별도의 정의를 제공할 수 있지만, 그 정의만으로 추상성이 사라지지는 않습니다.
순수 가상 함수 선언 형식
virtual 반환타입 함수이름(매개변수) = 0;
순수 가상 함수를 포함하는 추상 클래스 Shape
#include <iostream>
#include <string>

class Shape {
public:
    // 일반 가상 함수 (선택적 구현)
    virtual void displayInfo() const {
        std::cout << "이것은 일반적인 도형입니다." << std::endl;
    }

    // 순수 가상 함수: 객체를 생성할 구체 클래스에는 비순수 최종 재정의 함수가 필요합니다.
    virtual double calculateArea() const = 0; // 도형의 넓이는 도형마다 다르므로 구현을 강제

    // 가상 소멸자 (Shape*로 파생 객체를 delete하는 계약)
    virtual ~Shape() {
        std::cout << "Shape 소멸자 호출.\n";
    }
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}

    void displayInfo() const override {
        std::cout << "이것은 원입니다." << std::endl;
    }

    double calculateArea() const override { // 순수 가상 함수 구현
        return 3.14159 * radius * radius;
    }
    ~Circle() override { std::cout << "Circle 소멸자 호출.\n"; }
};

class Rectangle : public Shape {
private:
    double width;
    double height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}

    void displayInfo() const override {
        std::cout << "이것은 사각형입니다." << std::endl;
    }

    double calculateArea() const override { // 순수 가상 함수 구현
        return width * height;
    }
    ~Rectangle() override { std::cout << "Rectangle 소멸자 호출.\n"; }
};

int main() {
    // Shape s; // 컴파일 오류! 순수 가상 함수가 있는 추상 클래스는 객체를 직접 생성할 수 없습니다.

    Circle c(5.0);
    Rectangle r(4.0, 6.0);

    // 다형성 활용 (기반 클래스 포인터)
    Shape* s1 = &c;
    Shape* s2 = &r;

    s1->displayInfo();     // 출력: 이것은 원입니다.
    std::cout << "원의 넓이: " << s1->calculateArea() << std::endl; // 계산값은 약 78.54이며 출력 자릿수는 스트림 정밀도에 따름

    s2->displayInfo();     // 출력: 이것은 사각형입니다.
    std::cout << "사각형의 넓이: " << s2->calculateArea() << std::endl; // 출력: 사각형의 넓이: 24

    // 동적 할당된 객체
    Shape* s3 = new Circle(7.0);
    std::cout << "새로운 원의 넓이: " << s3->calculateArea() << std::endl;
    delete s3; // Circle 소멸자 -> Shape 소멸자 호출 (가상 소멸자 덕분)

    return 0;
}

추상 클래스 (Abstract Classes)

순수 가상 함수의 최종 재정의가 하나 이상 순수로 남아 있는 클래스를 추상 클래스(Abstract Class)라고 합니다.

추상 클래스의 특징
  • 객체를 직접 생성할 수 없습니다. (Shape s;와 같은 문법이 허용되지 않습니다.)
  • 다른 클래스가 상속받을 기반을 제공하는 목적으로 존재합니다.
  • 객체를 생성할 구체 클래스에는 모든 순수 가상 함수의 비순수 최종 재정의 함수가 있어야 합니다. 중간 파생 클래스는 추상 상태로 남아도 됩니다.
    • 순수 가상 함수의 최종 재정의가 하나라도 순수로 남아 있으면, 그 파생 클래스도 추상 클래스이므로 객체를 직접 만들 수 없습니다. 앞선 기반 클래스에서 비순수 재정의를 상속받았다면 같은 함수를 다시 구현할 필요는 없습니다.

앞의 도형 예제는 양수 크기를 사용합니다. Circle 생성자는 반지름의 음수 여부를 검사하지 않으므로 “구체 클래스를 만들 수 있다”와 “도형으로 유효하다”를 구분해야 합니다. calculateArea가 double을 돌려주는 구현을 제공했는지는 컴파일러가 확인하지만, 필요한 도메인 조건은 개발자가 정합니다.

스스로 확인하기 도형 예제의 크기만 Circle(2.0), Rectangle(3.0, 4.0)으로 바꾸면 각 기반 포인터의 calculateArea 결과는 얼마일까요? 별도로 Circle의 calculateArea 재정의 전체를 제거하면 Circle c를 만들 수 있을까요?

값 계산과 추상성의 풀이

원의 계산값은 3.14159 × 2 × 2 = 12.56636이며 기본 스트림 출력에서는 자릿수에 따라 약 12.5664로 보입니다. 사각형은 3 × 4 = 12입니다. Circle의 넓이 재정의를 제거한 별도 변형에서는 순수 가상 함수가 남아 Circle도 추상 클래스가 되므로 객체 생성이 컴파일되지 않습니다. 출력 반올림과 구현 누락은 다른 문제입니다.

  • 추상 클래스는 일반 멤버 변수, 일반 멤버 함수, 가상 함수(순수 가상 함수가 아닌)도 가질 수 있습니다.
  • 가상 소멸자: 추상 클래스에도 가상 소멸자를 선언하는 것이 매우 중요합니다. (지난 장에서 강조) 이는 앞 절처럼 기반 클래스 포인터로 동적 파생 객체를 delete하는 계약에서 올바른 소멸자 체인을 호출하도록 합니다.

특히 기반 타입 포인터나 참조로 객체 수명을 다룰 가능성이 있다면, 소멸자도 추상 클래스의 계약에 포함해서 판단해야 합니다.

왜 추상 클래스를 사용하는가? (설계 관점)
  • 인터페이스 정의: 추상 클래스는 클래스 계층 구조의 최상단에서 공통적인 인터페이스(계약)를 정의하는 데 사용됩니다. 즉, 이런 기능을 제공해야 해!라고 명세하는 역할을 합니다.
  • 구현 강제: 객체를 생성할 구체 클래스가 필요한 함수 구현을 완성하도록 합니다. 같은 함수 시그니처를 제공하게 하지만 계산의 정확성이나 입력 검증까지 자동으로 보장하지는 않습니다.
  • 불완전한 개념 표현: 현실에서 추상적인 개념(예: 일반적인 도형)은 그 자체로 객체를 만들기 어렵습니다. 추상 클래스는 이러한 불완전하거나 추상적인 개념을 모델링하는 데 적합합니다.

추상 클래스는 “객체를 만들 수 없는 클래스”라는 문법 설명에서 끝나지 않습니다.

어떤 함수는 계약으로 고정하고, 어떤 구현은 공통 코드로 제공하며, 소멸과 소유권은 어떻게 다룰지까지 함께 설계해야 안정적인 기반 타입이 됩니다.


추상 클래스와 인터페이스 (Interface)

모든 멤버 함수가 순수 가상 함수에 가깝고 상태를 거의 두지 않는 추상 클래스는, C++에서 인터페이스 역할을 맡습니다.

추상 클래스가 모든 멤버 함수를 순수 가상 함수로만 정의하고, 멤버 변수를 하나도 가지지 않는다면, 이는 사실상 자바(Java)나 C#의 인터페이스(Interface)와 유사한 역할을 합니다.

즉, 객체를 만들 구체 클래스에는 이 함수들의 비순수 최종 재정의가 있어야 합니다. 이 구현을 직접 제공하거나 앞선 기반 클래스에서 상속받아 호출 계약을 완성합니다.

C++에서는 interface 키워드가 따로 존재하지 않기 때문에, 모든 함수를 순수 가상 함수로 정의하고 멤버 변수를 최소화하는 추상 클래스가 인터페이스 역할을 수행합니다.

인터페이스 역할을 하는 추상 클래스 ILogger
#include <iostream>
#include <string>

// 인터페이스 역할을 하는 추상 클래스
class ILogger { // 'I' 접두사는 인터페이스임을 나타내는 관례
public:
    // 순수 가상 함수: 모든 로거는 메시지를 기록하는 기능을 반드시 가져야 함
    virtual void log(const std::string& message) = 0;

    // 순수 가상 함수: 모든 로거는 에러 메시지를 기록하는 기능을 가져야 함
    virtual void logError(const std::string& errorMessage) = 0;

    // 가상 소멸자: 파생 클래스의 소멸자 호출 보장
    virtual ~ILogger() {
        std::cout << "ILogger 소멸자 호출.\n";
    }
};

// 파일 로거의 인터페이스 예시: 여기서는 콘솔 출력으로 대체
class FileLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "[FILE_LOG]: " << message << std::endl;
        // 예시용 콘솔 출력; 실제 파일 쓰기는 구현하지 않음
    }

    void logError(const std::string& errorMessage) override {
        std::cerr << "[FILE_ERROR]: " << errorMessage << std::endl;
        // 예시용 표준 오류 출력; 실제 파일 쓰기는 구현하지 않음
    }
    ~FileLogger() override { std::cout << "FileLogger 소멸자 호출.\n"; }
};

// 콘솔에 로그를 기록하는 클래스
class ConsoleLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "[CONSOLE_LOG]: " << message << std::endl;
    }

    void logError(const std::string& errorMessage) override {
        std::cerr << "[CONSOLE_ERROR]: " << errorMessage << std::endl;
    }
    ~ConsoleLogger() override { std::cout << "ConsoleLogger 소멸자 호출.\n"; }
};

// 로그 기능을 사용하는 함수
void processData(ILogger* logger) {
    logger->log("데이터 처리 시작.");
    // ... 데이터 처리 로직 ...
    logger->logError("데이터 처리 중 오류 발생!");
    logger->log("데이터 처리 완료.");
}

int main() {
    FileLogger fileLog;
    ConsoleLogger consoleLog;

    std::cout << "--- 파일 로거 사용 ---\n";
    processData(&fileLog); // FileLogger 객체를 ILogger 포인터로 전달

    std::cout << "\n--- 콘솔 로거 사용 ---\n";
    processData(&consoleLog); // ConsoleLogger 객체를 ILogger 포인터로 전달

    // 동적 할당 예시
    ILogger* currentLogger = nullptr;
    if (true) { // 어떤 조건에 따라 로거 선택
        currentLogger = new FileLogger();
    } else {
        currentLogger = new ConsoleLogger();
    }
    std::cout << "\n--- 동적 할당 로거 사용 ---\n";
    currentLogger->log("동적 로거 메시지.");
    delete currentLogger; // FileLogger 소멸자 -> ILogger 소멸자

    return 0;
}

이처럼 추상 클래스는 프로그램의 확장성과 유연성을 크게 높여줍니다.

새로운 로깅 방식(예: 데이터베이스 로거, 네트워크 로거)이 필요하더라도 ILogger 인터페이스를 구현하기만 하면, processData 함수를 변경할 필요 없이 새로운 로거를 사용할 수 있습니다.

현재 processData는 유효한 ILogger 객체의 주소를 받는다는 전제가 있습니다. main은 살아 있는 fileLog·consoleLog의 주소를 넘기므로 이 조건을 지킵니다. nullptr를 넘겨도 안전하게 처리해 주는 구현은 아닙니다. FileLogger라는 이름이어도 이 예제는 실제 파일을 생성하지 않고, logError도 예외를 던지거나 처리를 중단하지 않습니다. 따라서 오류 메시지 뒤에 완료 메시지 호출이 이어집니다. cout과 cerr를 합쳐 보는 표시 순서는 실행 환경에 영향을 받을 수 있으므로 함수의 호출 순서와 화면의 두 출력 스트림을 구분하세요.

예시 ILogger를 상속한 새 타입에서 log만 구현하고 logError를 남겨 두면, 그 타입은 계속 추상 클래스입니다. 모든 함수에 override를 붙였다는 형식만 볼 것이 아니라, 두 순수 가상 함수에 대응하는 구현이 모두 있는지 확인해야 합니다. 구체 클래스 생성 조건은 표준 초안의 추상 클래스 규칙에서 확인할 수 있습니다.

추상 클래스는 구현을 공유하기보다 호출자가 의존할 최소 계약을 고정할 때 가장 효과적입니다.

순수 가상 함수는 구체 클래스가 완성할 호출 계약을 남기고, 추상 클래스는 그 계약을 통해 호출 코드와 구현을 분리합니다.