추상 클래스와 순수 가상 함수
순수 가상 함수로 파생 클래스의 구현 계약을 정의하고 직접 생성할 수 없는 추상 클래스로 인터페이스를 설계합니다.
앞 글의 Shape는 draw()에 기본 구현을 두었지만, 넓이를 계산하는 함수라면 사정이 다릅니다. 원이나 사각형의 넓이는 계산할 수 있어도, 모양이 정해지지 않은 도형 하나의 넓이에는 의미 있는 값이 없습니다. 이럴 때 기반 클래스는 "이런 함수가 있어야 한다"는 것만 정하고, 실제 구현은 파생 클래스에 맡길 수 있습니다.
구현 없이 선언만 두는 가상 함수를 순수 가상 함수(pure virtual function)라고 하고, 그런 함수가 남아 있어 객체를 직접 만들 수 없는 클래스를 추상 클래스(abstract class)라고 합니다. 이 글은 두 개념의 문법과, 추상 클래스로 호출하는 코드와 구현을 나누는 인터페이스 설계를 다룹니다.
C++ · ABSTRACT INTERFACE
Shape는 계약을, 구체 도형은 넓이 계산을 제공한다
추상 클래스는 직접 생성할 수 없지만 참조와 포인터의 안정된 경계가 된다. 구체 클래스는 모든 순수 가상 함수의 final overrider와 안전한 소멸 계약을 제공한다.
Shape · 추상 계약
calculateArea() const = 0의 비순수 최종 재정의가 필요합니다. 일반 가상 함수 displayInfo()와 가상 소멸자 ~Shape()도 제공합니다.
Circle → Shape
calculateArea() const override에서 radius로 원의 넓이를 계산합니다.
Rectangle → Shape
calculateArea() const override에서 width × height를 계산합니다.
순수 가상 함수도 필요하면 클래스 밖 정의를 가질 수 있다. ‘순수’는 final overrider 의무를 뜻한다.
순수 가상 함수
가상 함수 선언 끝에 = 0을 붙이면 순수 가상 함수가 됩니다. 기반 클래스는 이 함수의 구현을 정하지 않고 선언만 두며, 파생 클래스가 재정의해야 그 클래스의 객체를 만들 수 있습니다. 아래 코드에서 calculateArea는 순수 가상 함수이고 displayInfo는 기본 구현이 있는 보통의 가상 함수이므로, Rectangle이 둘 가운데 무엇만 재정의했는지를 함께 보세요.
#include <iostream>
#include <memory>
#include <numbers>
#include <vector>
class Shape {
public:
virtual void displayInfo() const { std::cout << "a shape\n"; }
virtual double calculateArea() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
private:
double radius;
public:
explicit Circle(double r) : radius(r) {}
void displayInfo() const override { std::cout << "a circle\n"; }
double calculateArea() const override {
return std::numbers::pi * radius * radius;
}
};
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double calculateArea() const override { return width * height; }
};
int main() {
// Shape s; // 컴파일 오류: 추상 클래스는 객체를 만들 수 없음
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rectangle>(4.0, 6.0));
for (const auto& shape : shapes) {
shape->displayInfo();
std::cout << "area " << shape->calculateArea() << '\n';
}
}a circle
area 78.5398
a shape
area 24위 그림처럼 Shape는 calculateArea()를 순수 가상 함수로 두어 구현을 정하지 않고, Circle과 Rectangle이 각자 그 함수를 재정의해 구현을 채웁니다. displayInfo()는 보통의 가상 함수여서, 재정의하지 않은 Rectangle은 Shape의 구현을 물려받아 a shape를 출력합니다. std::numbers::pi는 C++20의 원주율 상수이며, 넓이는 기본 출력 형식에 따라 유효 숫자 여섯 자리로 보입니다.
순수 가상 함수의 = 0은 클래스 안에서 본문과 함께 쓸 수 없지만, 필요하면 클래스 밖에 따로 정의를 둘 수는 있습니다. 정의를 두어도 추상 클래스라는 점은 바뀌지 않으며, 파생 클래스가 Shape::calculateArea()처럼 이름을 붙여 부를 때만 쓰입니다. 소멸자를 virtual ~Shape() = 0;처럼 순수 가상으로 만들 수도 있는데, 이때는 파생 클래스의 소멸 과정에서 반드시 호출되므로 클래스 밖에 정의가 있어야 합니다.
더 깊이: 순수 가상 함수가 실제로 불리면
정상적인 코드에서는 구현이 없는 순수 가상 함수가 불릴 일이 없지만, 생성 중이거나 소멸 중인 객체에서는 생길 수 있습니다. 다형성과 가상 함수에서 본 것처럼 기반 클래스의 생성자와 소멸자가 실행되는 동안 가상 호출은 기반 클래스의 함수로 정해지므로, 그동안 다른 멤버 함수를 거쳐 순수 가상 함수를 부르면 구현이 없는 함수를 부르게 됩니다. 표준은 이것을 정의되지 않은 동작으로 둡니다.
구현은 이런 호출이 조용히 엉뚱한 곳으로 가지 않게 대비해 둡니다. Itanium C++ ABI는 순수 가상 함수의 vtable 자리에 __cxa_pure_virtual을 넣을 수 있게 하며, 이 함수의 동작은 정하지 않지만 오류 메시지와 함께 프로그램을 끝낼 것으로 기대한다고 적습니다. MSVC의 런타임은 같은 상황에서 pure virtual function call이라는 런타임 오류 R6025로 프로그램을 끝내며, Microsoft 문서는 생성 중인 기반 클래스에서 얻은 포인터를 파생 클래스의 포인터로 바꾸어 쓴 경우를 원인으로 듭니다. 이런 오류를 만나면 생성자와 소멸자 안에서 시작된 가상 호출부터 찾아봅니다.
추상 클래스
순수 가상 함수가 하나라도 재정의되지 않고 남아 있는 클래스는 추상 클래스이며, 그 타입의 객체를 직접 만들 수 없습니다. 그래서 Shape s;는 컴파일 오류이지만, Shape*나 const Shape&처럼 포인터와 참조로는 쓸 수 있고, 이것이 추상 클래스의 쓰임입니다. 앞 글에서 본 것처럼 기반 포인터로 객체를 정리하므로 가상 소멸자도 함께 둡니다.
객체를 만들 수 있는 구체 클래스(concrete class)가 되려면, 모든 순수 가상 함수에 대해 구현이 있는 최종 재정의(final overrider)가 있어야 합니다. 중간의 파생 클래스가 구현하지 않으면 그 클래스도 추상 클래스로 남고, 그 아래의 클래스가 구현하면 됩니다. 앞의 기반 클래스에서 이미 구현한 함수를 물려받았다면 다시 구현할 필요는 없습니다.
컴파일러가 확인하는 것은 구현이 있는지와 서명이 맞는지까지입니다. Circle의 생성자는 반지름이 음수인지 검사하지 않으므로, Circle(-1.0)도 만들어지고 넓이는 양수로 계산됩니다. 구체 클래스를 만들 수 있다는 것과 도형으로서 올바르다는 것은 다른 문제이며, 값의 조건은 생성자와 소멸자에서 본 것처럼 생성자에서 따로 검사해야 합니다.
인터페이스로 쓰는 추상 클래스
C++에는 interface 키워드가 없지만, 멤버 변수 없이 순수 가상 함수와 가상 소멸자만 가진 추상 클래스가 인터페이스(interface)의 역할을 합니다. 호출하는 코드는 이 인터페이스에만 의존하고, 실제 구현은 그것을 상속한 클래스들이 제공합니다.
#include <iostream>
#include <string>
#include <vector>
class ILogger {
public:
virtual void log(const std::string& message) = 0;
virtual void logError(const std::string& message) = 0;
virtual ~ILogger() = default;
};
class ConsoleLogger : public ILogger {
public:
void log(const std::string& message) override {
std::cout << "[log] " << message << '\n';
}
void logError(const std::string& message) override {
std::cout << "[error] " << message << '\n';
}
};
class MemoryLogger : public ILogger {
public:
std::vector<std::string> lines;
void log(const std::string& message) override {
lines.push_back(message);
}
void logError(const std::string& message) override {
lines.push_back("ERROR " + message);
}
};
void processData(ILogger& logger) {
logger.log("start");
logger.logError("bad record");
logger.log("done");
}
int main() {
ConsoleLogger console;
processData(console);
MemoryLogger memory;
processData(memory);
std::cout << memory.lines.size() << ' ' << memory.lines[1] << '\n';
}[log] start
[error] bad record
[log] done
3 ERROR bad recordprocessData는 ILogger&만 알고, 받은 객체가 화면에 출력하는지 메모리에 저장하는지는 모릅니다. 그래서 파일이나 네트워크로 기록하는 새 로거가 필요해도, ILogger를 구현한 클래스를 만들기만 하면 processData를 고치지 않고 쓸 수 있습니다. 매개변수를 포인터가 아니라 참조로 받았으므로 nullptr을 넘길 수 없고, 반드시 있는 로거를 받는다는 것이 서명에 드러납니다.
MemoryLogger처럼 실제로 화면에 아무것도 출력하지 않는 구현은 테스트에서 특히 쓸모가 있습니다. processData가 어떤 메시지를 남겼는지 lines로 바로 확인할 수 있기 때문입니다. logError도 예외를 던지거나 처리를 멈추지 않으므로, 오류 메시지 뒤에 done이 이어서 기록됩니다.
정리
- 순수 가상 함수는 선언 끝에
= 0을 붙인 가상 함수이며, 구현을 파생 클래스에 맡깁니다. - 순수 가상 함수가 재정의되지 않고 남아 있는 클래스는 추상 클래스이며, 객체를 직접 만들 수 없고 포인터와 참조로 씁니다.
- 구체 클래스가 되려면 모든 순수 가상 함수에 구현이 있는 최종 재정의가 있어야 하며, 중간 클래스는 추상으로 남아도 됩니다.
- 순수 가상 소멸자를 쓰면 클래스 밖에 그 정의도 두어야 합니다.
- 순수 가상 함수와 가상 소멸자만 가진 추상 클래스는 인터페이스 역할을 하며, 호출하는 코드와 구현을 나눕니다.
- 컴파일러는 구현이 있는지만 확인하며, 값이 올바른지는 클래스가 따로 검사해야 합니다.
연습 문제
1. 크기 바꾸기와 구현 지우기
pure_virtual.cpp에서 도형을 Circle(2.0)과 Rectangle(3.0, 4.0)으로 바꾸면 두 넓이는 무엇으로 출력되나요? 따로, Circle의 calculateArea 재정의를 지우면 어떻게 되나요?
해설 보기
원의 넓이는 π × 2 × 2 ≈ 12.566이어서 유효 숫자 여섯 자리로 12.5664, 사각형은 3 × 4 = 12로 12가 출력됩니다. Circle의 재정의를 지우면 calculateArea가 순수로 남아 Circle도 추상 클래스가 되므로, std::make_unique<Circle>(5.0)이 컴파일 오류가 납니다.
2. 한 함수만 구현한 로거
ILogger를 상속하고 log만 구현한 PartialLogger를 만들어 PartialLogger p;로 객체를 만들면 어떻게 되나요?
해설 보기
logError가 순수 가상 함수로 남아 있으므로 PartialLogger도 추상 클래스이고, 객체를 만드는 줄에서 컴파일 오류가 납니다. 모든 함수에 override를 붙였는지가 아니라, 두 순수 가상 함수가 모두 구현되었는지를 확인해야 합니다.
3. 중간 클래스
class Polygon : public Shape { };와 class Square : public Polygon { double side = 1.0; public: double calculateArea() const override { return side * side; } };가 있을 때, Polygon과 Square의 객체를 각각 만들 수 있나요?
해설 보기
Polygon은 calculateArea를 구현하지 않았으므로 여전히 추상 클래스여서 객체를 만들 수 없습니다. Square는 그 함수를 구현했고 displayInfo는 Shape에서 물려받으므로 구체 클래스가 되어 객체를 만들 수 있으며, 넓이는 1입니다. 중간 클래스가 추상으로 남아 있어도 그 아래에서 구현을 채우면 됩니다.
다음 글 다중 상속과 가상 상속에서는 한 클래스가 여러 기반 클래스를 상속할 때의 모호함과, 공통 기반을 하나로 합치는 가상 상속을 다룹니다.