icon
10장 : 객체 지향 프로그래밍 심화

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

지난 장에서 다형성의 개념과 이를 구현하는 핵심 요소인 가상 함수에 대해 학습했습니다.

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

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

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


순수 가상 함수 (Pure Virtual Functions)

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

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

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

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

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

순수 가상 함수 정의 방법

  • 함수 선언 끝에 = 0을 붙입니다.
  • 구현부를 가지지 않습니다.
순수 가상 함수 선언 형식
virtual 반환타입 함수이름(매개변수) = 0;
순수 가상 함수를 포함하는 추상 클래스 Shape
#include <iostream>
#include <string>

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

    // 순수 가상 함수: 모든 파생 클래스는 이 함수를 반드시 구현해야 합니다.
    virtual double calculateArea() const = 0; // 도형의 넓이는 도형마다 다르므로 구현을 강제

    // 가상 소멸자 (추상 클래스에도 반드시 필요)
    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.53975

    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;와 같은 문법이 허용되지 않습니다.)
  • 다른 클래스에게 상속받기 위한 목적으로만 존재합니다.
  • 파생 클래스가 추상 클래스의 모든 순수 가상 함수를 반드시 재정의하여 구현해야 합니다.
    • 만약 파생 클래스가 순수 가상 함수 중 하나라도 구현하지 않으면, 해당 파생 클래스 또한 추상 클래스가 되며, 그 자체로 객체를 생성할 수 없습니다.
  • 추상 클래스는 일반 멤버 변수, 일반 멤버 함수, 가상 함수(순수 가상 함수가 아닌)도 가질 수 있습니다.
  • 가상 소멸자: 추상 클래스에도 가상 소멸자를 선언하는 것이 매우 중요합니다. (지난 장에서 강조) 이는 기반 클래스 포인터로 파생 클래스 객체를 delete할 때 올바른 소멸자 체인이 호출되도록 보장합니다.

왜 추상 클래스를 사용하는가? (설계 관점)

  • 인터페이스 정의: 추상 클래스는 클래스 계층 구조의 최상단에서 공통적인 인터페이스(계약)를 정의하는 데 사용됩니다. 즉, "이런 기능을 제공해야 해!"라고 명세하는 역할을 합니다.
  • 구현 강제: 모든 파생 클래스가 특정 기능을 반드시 구현하도록 강제하여, 일관된 동작을 보장하고 설계의 의도를 명확히 합니다.
  • 불완전한 개념 표현: 현실에서 추상적인 개념(예: 일반적인 '도형')은 그 자체로 객체를 만들기 어렵습니다. 추상 클래스는 이러한 불완전하거나 추상적인 개념을 모델링하는 데 적합합니다.

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

추상 클래스가 모든 멤버 함수를 순수 가상 함수로만 정의하고, 멤버 변수를 하나도 가지지 않는다면, 이는 사실상 자바(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 함수를 변경할 필요 없이 새로운 로거를 사용할 수 있습니다.