39: private 상속은 심사숙고해서 구사하자
class Person {};
class Student : private Person {};
void eat(const Person& p);
Person p;
Student s;
eat(p);
eat(s); // 에러! Student는 Person의 일종이 아니다.
Person의 파생 클래스 Student를 public이 아닌 private으로 상속하였다. private 상속은 분명이 is-a를 뜻하지 않는다.
우선 private 상속을 쓰면 어떻게 되는지 알아보자. 클래스 사이의 상속 관계가 private이면 컴파일러는 일반적으로 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다. 그리고 기본 클래스로부터 물려받은 멤버는 파생 클래스에서 모조리 private 멤버가 된다. 기본 클래스에서 원래 protected, public 멤버였어도 말이다.
private 상속의 의미는 is-implemented-in-terms-of이다. B클래스로부터 private 상속을 통해 D클래스를 파생시킨 것은, B 클래스에서 쓸 수 있는 기능들 몇 개를 활용할 목적으로 한 행동이지, B 타입과 D타입의 객체 사이에 어떤 개념적 관계가 있어서 한 행동이 아니라는 것이다. private 상속은 그 자체로 구현 기법 중 하나다.
- private 상속의 의미는 구현만 물려받을 수 있다. 인터페이스는 국물도 없다.
private 상속은 소프트웨어 설계(design) 도중에는 아무런 의미도 갖지 않으며, 단지 소프트웨어 구현(implementation) 중에만 의미를 가질 뿐이다.
그렇다면 객체 합성과 private 상속 중 어떤걸 골라야 할까? 할 수 있으면 객체 합성을 사용하고, 꼭 해야 하면 private 상속을 사용하자. '꼭 해야 하는' 때는 비공개 멤버를 접근할 때 혹은 가상 함수를 재정의할 경우가 주로 속한다. 그리고 공간 문제로 얽히는 만약의 경우도 있다.
하나의 예를 보자. Widget 클래스의 각 멤버 함수가 일정 시간동안 얼마나 호출되는지를 알고 싶다. 이를 위해 기존의 Timer 클래스를 사용할 것이다.
class Timer {
public:
explicit Timer(int tickFrequency);
virtual void onTick() const; // 일정 시간 마다 호출
};
Timer는 일정 시간 마다 가상 함수를 호출하고 있다. 이 가상 함수를 재정의해서 Widget의 현재 상태를 점검하면 되는 것이다. 이렇게 하려면 Widget 클래스에서 Timer의 가상 함수를 재정의할 수 있어야 하므로, Widget 클래스는 어쨌든 Timer에서 상속을 받아야 한다. 하지만 지금 public 상속은 맞지 않다. 게다가, Widget 객체의 사용자가 onTick 함수를 호출해선 안 된다. 이 함수는 개념적으로 Widget 인터페이스의 일부로 볼 수 없기 때문이다.
class Widget : private Timer {
private:
virtual void onTick() const;
};
이렇게 보면 흠잡을 곳 없는 설계이다. 하지만 대신에 객체 합성을 쓰기로 마음먹었다면 충분히 그렇게 해도 되는 상황이다. Widget 안에 private 중첩 클래스로 선언해 놓고, 이 클래스에서 onTick을 재정의한 다음, 이 타입의 객체 하나를 Widget 안에 데이터 멤버로 넣는 것이다.
class Widget : private Timer {
private:
// 중첩 클래스
class WidgetTimer : private Timer {
public:
virtual void onTick() const;
};
WidgetTimer timer;
};
private 상속과 비교하면 상당히 복잡한 구조이다. 현실적으로는 private 상속 대신에 public 상속에 객체 합성 조합이 더 자주 쓰인다. 다음의 두 가지 이유 때문이다.
첫째, Widget 클래스를 설계하는데 있어서 파생은 가능하게 하되, 파생 클래스에서 onTick을 재정의할 수 없도록 설계 차원에서 막고 싶을 때 유용하다. 만약에 Widget을 Timer로부터 상속시킨 구조라면 이런 게 안 된다. private 상속을 해도 재정의는 가능하기 때문이다. 하지만 위처럼 하면 Widget의 파생 클래스는 아무리 용을 써도 WidgetTimer에 접근할 수 없다.
둘째, Widget의 컴파일 의존성을 최소화하고 싶을 때 좋다. 지금의 설계에서 WidgetTimer의 정의를 Widget으로부터 빼내고, Widget이 WidgetTimer 객체에 대한 포인터만 갖도록 만들어 두면, 컴파일 의존성을 피할 수 있다.
공간 최적화 측면에서 private 상속을 사용하는 만약의 경우가 있다. 데이터가 전혀 없는 클래스를 사용할 때이다. 데이터가 없는 클래스란 비정적 데이터 멤버가 없는 클래스이다. 가상 함수도 없어야 하고, 가상 기본 클래스도 없어야 한다. 이런 공백 클래스는 개념적으로 차지하는 메모리 공간이 없는게 맞다. 하지만 C++에는 "독립 구조(freestanding)의 객체는 반드시 크기가 0을 넘어야 한다"라는 요상한 금기사항이 있다.
class Empty {};
class HoldAnInt {
private:
int x;
Empty e;
};
놀랍게도 sizeof(HoldAnInt) > sizeof(int)가 되는 괴현상을 목도하게 된다. 대부분의 컴파일러에서 sizeof(Empty)의 값은 1로 나온다. 위의 제약을 지키기 위해, 컴파일러는 이런 공백 객체에 char 한 개를 끼워넣는 식으로 처리하기 때문이다. 위의 금기사항은 '독립구조'만 해당된다. 파생 클래스 객체의 기본 클래스 부분에는 적용되지 않는다. Empty 타입의 객체를 데이터 멤버로 두지 말고 Empty로부터 상속시켜 보면,
class HoldAnInt : private Empty {
private:
int x;
};
sizeof(HoldAnInt) == sizeof(int)가 된다. 이 공간 절약 기법은 공백 기본 클래스 최적화(empty base optimization: EBO)라고 알려져 있다. 메모리 공간에 무던히 신경쓰는 사용자를 상대하는 라이브러리 개발자라면 알아두는 게 좋다. 이와 더불어 EBO는 일반적으로 단일 상속하에서만 적용된다.
섣불리 private 상속을 쓸 필요가 없다. 모든 대안을 고민한 후에, 주어진 상황에서 두 클래스 사이의 관계를 나타낼 가장 좋은 방법이 private 상속이라는 결론이 나면 쓰자.
이것만은 잊지 말자!
- private 상속의 의미는 is-implemented-in-terms-of(...는...를 써서 구현됨)이다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있다.
- 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화 (EBO)를 확성화시킬 수 있다. 이 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 한다.