C++ 고급 문법 (17) — private 상속(private Inheritance)

public 상속은 “파생 클래스는 기반 클래스의 일종이다(is-a)”를 뜻해서, 파생 객체를 기반 참조로 넘길 수 있고 기반의 public 인터페이스가 그대로 노출된다. private 상속은 의미가 다르다. 기반 클래스의 구현을 가져다 쓰지만 외부에는 그 사실을 숨기는 “~를 써서 구현된다(is-implemented-in-terms-of)” 관계다. 대부분은 멤버로 포함(composition)하는 편이 낫지만, 기반의 가상 함수를 재정의하거나 protected 멤버에 접근해야 할 때, 빈 클래스의 크기를 없앨 때는 private 상속이 필요하다. 이 글에서는 g++ 13.3으로 상속 접근 지정자별 차이와 private 상속이 쓰이는 경우를 확인한다.

이 시리즈의 다른 글

상속 접근 지정자

기반 멤버public 상속protected 상속private 상속
publicpublicprotectedprivate
protectedprotectedprotectedprivate
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]]로도 가능하다.

참고

답글 남기기