public 상속은 “파생 클래스는 기반 클래스의 일종이다(is-a)”를 뜻해서, 파생 객체를 기반 참조로 넘길 수 있고 기반의 public 인터페이스가 그대로 노출된다. private 상속은 의미가 다르다. 기반 클래스의 구현을 가져다 쓰지만 외부에는 그 사실을 숨기는 “~를 써서 구현된다(is-implemented-in-terms-of)” 관계다. 대부분은 멤버로 포함(composition)하는 편이 낫지만, 기반의 가상 함수를 재정의하거나 protected 멤버에 접근해야 할 때, 빈 클래스의 크기를 없앨 때는 private 상속이 필요하다. 이 글에서는 g++ 13.3으로 상속 접근 지정자별 차이와 private 상속이 쓰이는 경우를 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (15) — new 연산자(new Expression)
- C++ 고급 문법 (16) — protected 생성자(protected Constructor)
- C++ 고급 문법 (18) — 업캐스팅(Upcasting)
- C++ 고급 문법 (19) — 함수 바인딩(Function Binding)
상속 접근 지정자
| 기반 멤버 | public 상속 | protected 상속 | private 상속 |
|---|---|---|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 접근 불가 | 접근 불가 | 접근 불가 |
| 외부에서 기반으로 변환 | 가능 | 불가 | 불가 |
| 의미 | is-a | 파생의 파생까지만 공개 | is-implemented-in-terms-of |
private 상속의 기본 동작
class Engine {
public:
void start() {}
void set_rpm(int) {}
};
class Car : private Engine { // Car는 Engine을 써서 구현된다 (Car is-not-a Engine)
public:
void drive() { start(); set_rpm(2000); }
};
void tune(Engine&) {}
int main()
{
Car c;
c.drive();
c.set_rpm(9000); // Engine 인터페이스가 숨겨졌다
tune(c); // Car -> Engine& 변환 불가
}$ g++ -std=c++20 -Wall -c basic_err.cpp 2>&1 | grep -E 'error'
basic_err.cpp:18:14: error: ‘void Engine::set_rpm(int)’ is inaccessible within this context
basic_err.cpp:18:14: error: ‘Engine’ is not an accessible base of ‘Car’
basic_err.cpp:19:9: error: ‘Engine’ is an inaccessible base of ‘Car’
클래스 안에서는 Engine의 멤버를 자유롭게 쓰지만, 밖에서는 Engine의 멤버도 Engine& 변환도 막힌다.
using으로 골라서 공개
#include <cstdio>
#include <vector>
// "Stack은 vector를 써서 구현된다" (is-implemented-in-terms-of)
class Stack : private std::vector<int> {
public:
void push(int v) { push_back(v); } // 기반 멤버는 내부에서 자유롭게 사용
int pop() { int v = back(); pop_back(); return v; }
using std::vector<int>::size; // 골라서 다시 공개
using std::vector<int>::empty;
};
int main()
{
Stack s;
s.push(1); s.push(2); s.push(3);
int top = s.pop();
std::printf("pop=%d size=%zu\n", top, s.size());
}$ g++ -std=c++20 -Wall -o b basic.cpp
$ ./b
pop=3 size=2
insert, operator[] 같은 vector 연산은 숨기고 스택 연산과 size/empty만 노출했다.
protected 상속과의 차이
private 상속한 기반은 그 클래스 안에서만 보이고, protected 상속한 기반은 파생의 파생에서도 보인다.
struct Base { void hello() const {} };
struct ViaPrivate : private Base {};
struct FromPrivate : ViaPrivate {
void call() const { hello(); }
};$ g++ -std=c++20 -Wall -c protected_inh_err.cpp 2>&1 | grep error
protected_inh_err.cpp:4:25: error: ‘void Base::hello() const’ is private within this context
protected_inh_err.cpp:4:30: error: ‘void Base::hello() const’ is private within this context
protected_inh_err.cpp:4:30: error: ‘Base’ is not an accessible base of ‘const FromPrivate’
#include <cstdio>
struct Base { void hello() const { std::puts("Base::hello"); } };
struct ViaProtected : protected Base {};
struct FromProtected : ViaProtected {
void call() const { hello(); } // 파생의 파생에서도 보인다
};
int main() { FromProtected{}.call(); }$ g++ -std=c++20 -Wall -o pi protected_inh.cpp
$ ./pi
Base::hello
private 상속이 필요한 경우
가상 함수 재정의와 protected 멤버 접근
#include <cstdio>
// 라이브러리 클래스: 파생 클래스가 on_timeout()을 재정의하도록 설계됨
class Timer {
public:
virtual ~Timer() = default;
void fire() { on_timeout(); }
protected:
virtual void on_timeout() = 0;
int ticks() const { return 42; } // 파생 클래스용 protected 멤버
};
// Session은 Timer가 아니지만 Timer의 가상 함수를 재정의해야 한다
class Session : private Timer {
public:
void poll() { fire(); } // 외부에는 Timer 인터페이스를 노출하지 않는다
private:
void on_timeout() override { std::printf("session timeout after %d ticks\n", ticks()); }
};
int main()
{
Session s;
s.poll();
}$ g++ -std=c++20 -Wall -o ov override.cpp
$ ./ov
session timeout after 42 ticks
멤버로 포함한 Timer의 가상 함수는 재정의할 수 없고 protected 멤버에도 접근할 수 없다. private 상속은 둘 다 가능하면서 Session이 Timer로 취급되는 것은 막는다.
빈 기반 클래스 최적화
#include <cstdio>
struct Less { bool operator()(int a, int b) const { return a < b; } }; // 빈 클래스
struct ByMember { // 멤버로 포함
Less cmp;
int* data;
};
struct ByPrivateBase : private Less { // private 상속: 빈 기반 클래스 최적화(EBO)
int* data;
};
struct ByNoUniqueAddress { // C++20 대안
[[no_unique_address]] Less cmp;
int* data;
};
int main()
{
std::printf("sizeof(Less) = %zu\n", sizeof(Less));
std::printf("sizeof(ByMember) = %zu\n", sizeof(ByMember));
std::printf("sizeof(ByPrivateBase) = %zu\n", sizeof(ByPrivateBase));
std::printf("sizeof(ByNoUniqueAddress) = %zu\n", sizeof(ByNoUniqueAddress));
}$ g++ -std=c++20 -Wall -o e ebo.cpp
$ ./e
sizeof(Less) = 1
sizeof(ByMember) = 16
sizeof(ByPrivateBase) = 8
sizeof(ByNoUniqueAddress) = 8
빈 클래스도 멤버가 되면 최소 1바이트를 차지하고, 정렬 때문에 8바이트가 더 붙는다. 기반 클래스로 두면 크기 0으로 합쳐진다(EBO, Empty Base Optimization). C++20에서는 [[no_unique_address]] 멤버로 같은 효과를 얻을 수 있어 이 용도의 private 상속은 줄었다.
| 요구 사항 | 포함(composition) | private 상속 |
|---|---|---|
| 구현 재사용 | 가능 | 가능 |
| 가상 함수 재정의 | 불가 | 가능 |
| protected 멤버 접근 | 불가 | 가능 |
| 같은 타입 여러 개 보유 | 가능 | 불가 |
| 빈 클래스 크기 제거 | C++20 [[no_unique_address]] | EBO |
| 결합도 | 낮음 | 높음 (기반 변경 영향) |
기본 상속 접근
struct Base { void hello() {} };
class A : Base {}; // class의 기본 상속은 private
struct B : Base {}; // struct의 기본 상속은 public
int main()
{
B{}.hello();
A{}.hello();
}$ g++ -std=c++20 -Wall -c default_access.cpp 2>&1 | grep error
default_access.cpp:9:14: error: ‘void Base::hello()’ is inaccessible within this context
default_access.cpp:9:14: error: ‘Base’ is not an accessible base of ‘A’
class는 접근 지정자를 생략하면 private 상속이 된다. 의도치 않은 private 상속을 피하려면 항상 public/private을 명시한다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
class D : B처럼 접근 지정자 생략 | 의도치 않은 private 상속 | 항상 명시 |
| 구현 재사용만 필요한데 private 상속 | 기반 변경이 파생에 그대로 전파 | 포함을 먼저 고려 |
| 표준 컨테이너를 public 상속 | 가상 소멸자가 없어 기반 포인터 delete 시 미정의 | private 상속 또는 포함 |
| private 상속한 클래스를 기반 타입으로 받는 함수 | 호출 불가 | 필요한 경우 public 상속(is-a)인지 다시 검토 |
마무리
- private 상속은 구현만 가져오고 is-a 관계는 외부에 숨긴다.
- 가상 함수 재정의나 protected 멤버 접근이 필요할 때 포함 대신 쓴다.
- 빈 클래스 크기 제거는 C++20
[[no_unique_address]]로도 가능하다.