본문 바로가기

Effective C++/6. 상속, 그리고 객체 지향 설계

항목 39: private 상속은 심사숙고해서 구사하자

C++ public 상속을 is-a 관계로 나타낸다.(항목 32 참고) Student Person으로부터 public 상속으로 파생된 형태의 클래스 계통이 주어졌다고 가정하면, 함수 호출을 성공시키기 위해 컴파일러가 Student Person으로 암시적 변환을 수행하는 예제를 통해 is-a 관계를 설명했었다. 예제를 private 상속으로 살짝 바꿔보자.

 

 

private 상속은 분명 is-a 관계를 뜻하지 않는다. 그럼 뭘까?

 

일단 private 상속의 동작 규칙부터 알아보자.

 

private 상속의 동작 규칙

  1. 파생 클래스 객체(Student) 기본 클래스(Person)으로 변환하지 않는다.
  2. 기본 클래스로부터 물려받은 맴버는 파생 클래스에서 모조리 private 맴버가 된다. 기본 클래스에서 원래 public이나 protected 였던 맴버들이 모두 private 맴버가 된다.

 

private 상속의 의미

  • private 상속 = is-implemented-in-terms-of 관계

 

Base 클래스로부터 private 상을 통해 Derived 클래스를 파생시키는 것은, B 클래스에서 있는 기능 개를 활용할 목적으로 행동이지, Base Derived 객체 사이에 어떤 개념적 관계가 있어서 행동이 아니다. private 상속은 자체로 구현 기법 하나이다.(private 상속 규칙 2번의 이유가 이것이다.)

 

항목 34에서 소개한 용어로 이야기하면, private 상속의 의미는 '구현만 물려받을 있다. 인터페이스는 물려받을 수 없다.' 라는 뜻이다. Derived Base로부터 private 상속을 받으면, 이것은 그냥 Derived 객체가 Base 객체를 써서 구현되는 거라고 생각하면 된다. private 상속은 소프트웨어 설계(design) 도중에는 아무런 의미가 없고, 단지 소프트웨어 구현(implementation) 중에만 의미를 갖는다.

 

항목 38에서 객체 합성도 is-implemented-in-terms-of 관계가 있다고 했는데, 객체 합성과 private 상속의 차이점은 뭘까? 둘을 고르는 기준이 뭘까?

 

  • 비공개 맴버를 접근할 , 혹은 가상 함수를 재정의할 : private 상속
  • 굳이 내용을 필요가 없을 : 객체 합성

 

private 상속이 필요하다! 라고 하면 private 상속을 하고, private 상속이 필요 없으면 객체 합성을 하면 된다.

 


 

Widget 객체를 사용하는 응용프로그램을 만들고 있다고 가정해보자. 근데 만들다보니 어떻게 Widget 객체가 사용되는지 알아야 하는 상황에 놓였다. Widget 맴버 함수의 호출 횟수 같은 것들도 알아야 하고, 실행 시간이 지남에 따라 호출 비용이 어떻게 변하는지도 알아야 한다. 또는 실행 단계가 딱딱 구분되는 프로그램이라면 단계에 따라 보여주는 프로파일(profile) 양상도 알아야 있다. 이를테면, 컴파일러가 소스 코드를 파싱하는 단계에서 사용된 함수들과 최적화 코드 생성 단계가 진행되는 동안에 사용된 함수들이 상당 부분 다를 있다.

 

그래서 맴버 함수가 번이나 호출되는지를 추적하기 위해 Widget 클래스를 직접 손보기로 한다. 여기에 더해 Widget 객체의 값과 더불어 다른 유용한 자료들도 넣을 있을 것이다. 작업을 위해 타이머를 하나 설치하고, 예전에

 

이를 위해 외부 유틸리티 툴킷에서 코드를 하나 가져왔다.

 

 

Timer 객체는 반복적으로 시간을 경과시킬 주기(tickPrequency) 정할 있고, 주기가 지날때마다 가상 함수(onTick()) 호출하도록 되어 있다. 가상 함수를 재정의해서, Widget 객체의 현재 상태를 점검하면 된다.

 

이렇게 할려면 Widget 클래스에서 Timer::onTick() 재정의해야 하므로, Widget 클래스는 어쨌 Timer 상속받아야 한다. 하지만 지금 상황에서 public 상속은 맞지 않다. Widget Timer 일종이 아니기 때문이다.

 

게다가, Widget 객체의 사용자는 Widget 객체를 통해 onTick() 호출해선 안된다. 왜냐하면 함수는 개념적으로 Widget 인터페이스의 일부로 없기 때문이다.onTick() 호출을 Widget 인터페이스에서 하게 내버려두는 것은 결국 Widget 인터페이스를 잘못 사용하게 쉬워지게 만드는 것과 같다.이는 항목 18 "제대로 사용하기엔 쉽게, 잘못 사용하기에는 어렵게 만들라" 라는 항목 18 조언을 어기는 행동이다.

=> public 상속은 틀린 선택이다.

 

그러므로 private 상속을 해야 한다.

 

 

 

private 상속을 하였기 때문에, Timer public 맴버인 onTick() Widget private 맴버가 되었다. 그렇다고 함수를 public 인터페이스로 빼놓으면 안된다. 분명 사용자는 ' 함수는 호출할 있구나' 라고 오해할 것이고, 바로 항목 18 위반하는 것이다.

 


 

여기까지 보면 흠잡을 없는 설계이다. 하지만 지금 private 상속을 필요가 있느냐라는 점은 생각해 볼만 하다. => 대신 객체 합성을 써도 되는 상황이기 때문이다.

 

Timer로부터 public 상속을 받은 클래스를 Widget 객체 안에 private 중첩 클래스로 선언해 놓고, 클래스에서 onTick() 재정의한 다음, 타입의 객체 하나를 Widget 안에 데이터 맴버로서 넣는 것이다.

 

 

private 상속 설계보다 다시 복잡하다. 이런 예제를 보여주는 것은

 

  • '하나의 설계 문제에 대한 접근 방법이 하나만 있는 것은 아니다!'
  • '여러 가지 방법을 실제로 고민하는 습관을 들이는 것이 좋다!'

 

라는 주장을 상기시키기 위함이다. 현실적으로도 private 상속 보다는 public 상속 + 객체 합성 조합이 자주 쓰이긴 한다. 다음과 같은 가지 좋은 때문이다.

 

  1. Widget 클래스를 설계하는 있어서 파생은 가능하게 하되, 파생 클래스에서 onTick() 재정의할 없도록 설계 차원에서 막고 싶을 유용하다.

 

만약 Widget Timer로부터 상속시킨 구조라면 이런게 안된다. 심지어 상속을 private으로 해도 안된다.(어쨌든 privat e 상속도 상속이고, 파생클래스에서는 물려받은 가상 함수를 재정의할 있기 때문이다.)

하지만 위처럼 Timer로부터 상속을 받은 WidgetTimer Widget 클래스의 private 영역에 있으면, Widget 파생 클래스는 아무리 용을 써도 WidgetTimer 접근할 없다.(자바의 final 메서드, C# sealed 메서드와 동일하다.)

 

  1. Widget 컴파일 의존성을 최소화 있다.

 

Widget Timer에서 파생된 상태라면, Widget 컴파일될 Timer 정의도 끌어와야 하기 때문에 Widget 정의부에 Timer.h 같은 헤더를 #include 해야 있다.

 

반면, 지금의 설계에서는 WidgetTimer 정의를 Widget에서 빼내고 Widget Widget 객체에 대한 포인터만 갖도록 만들어 놓으면, WidgetTimer 클래스를 전방 선언만 해놓아도 컴파일 의존성을 슬쩍 피할 있다. Timer 관한 어떤 것도 필요없기 때문이다. (항목 31 참고)

 


 

위에서 private 상속을 해야할 때를 말했었다.

 

  • 비공개 맴버를 접근할 , 혹은 가상 함수를 재정의할 : private 상속
  • 굳이 내용을 필요가 없을 : 객체 합성

 

근데 여기서 private 상속을 해야할 때가 가지 있는데, 바로 공간 최적화 얽힌 경우이다.

 

경우는 거의 안일어나지만, 진짜 가끔 일어날 수도 있다. 데이터가 전혀 없는 클래스(비정적 데이터 맴버가 없는 경우) 사용할 때이다. 그러니까 가상 함수도 하나도 없어야 하고(vptr 없어야 하기 때문), 가상 기본 클래스도 없어야 한다.(가상 기본 클래스도 오버헤드를 일으킬 있다. 항목 40 참고)

 

이런 공백 클래스(empty class) 개념적으로 차지하는 메모리 공간이 없어야 한다. 하지만 기술적인 문제 때문에 C++에서는 "독립 구조(freestanding) 객체는 반드시 크기가 0 넘어야 한다" 라는 금지사항이 있다.

 

 

 

실제로 이런 괴현상이 목격된다. Empty 타입의 데이터 맴버가 메모리를 요구하게 되는 것이다. 크기가 0 독립 구조의 객체가 생기는 것을 금지하는 C++ 제약때문에 1byte 혹은 바이트 정렬(byte alignment, 항목 50 참고) 때문에 4byte 슬그머니 넣는 것이다.

 

여기서 여겨볼 만한 것이 "독립 구조" 라는 말이다. C++ 제약은 파생 클래스 객체의 기본 클래스 부분에는 적용되지 않는다. 이때의 기본 클래스는 독립구조 객체, 홀로 있을 있는 객체가 아니기 때문이다. 그래서 Empty 타입의 객체를 데이터 맴버로 두지말고 상속을 시켜보면

 

 

 

Empyt 객체의 크기가 0 되는 것을 확인할 있다.

 

공간 절약 기법은 공백 기본 클래스 최적화(empty base optimization: EBO) 라고 알려져 있다. 만약 메모리 공간에 신경을 많이 써야 한다면 기법을 알아둬야 한다.

 

이와 더불어 알면 좋은게 있는데, EBO 단일 상속하에서만 적용된다는 점이다. 기본 클래스를 이상 갖는 파생 클래스는 EBO 적용될 없다.

 

실무적으로 따지면, "공백" 클래스는 진짜로 것은 아니다. 비정적 데이터 맴버는 가지고 있지 않지만, typedef 혹은 enum, 정적 데이터 맴버, 비가상 함수를 갖는 경우가 비일비재 하다. STL에서는 방금 말한 성격의 맴버(대게 typedef) 포함하고 있는, 기술적으로 공백 처리된 클래스가 많이 있다. unary_function binary_function 대표적인 예인데, 이들은 사용자 정의 함수 객체를 만들 상속 시킬 기본 클래스로 자주 사용되는 클래스이다.요즘은 EBO 구현이 보편화되어 있어서, 이런 상속은 아무리 자주 되더라도 파생 클래스의 크기를 증가시키는 일이 없긴 하다.

 

이런 극히 일부의 예말고 다시 일반적인 상황으로 돌아오면, 대부분의 상속은 is-a 관계를 나타내고, 이는 public 상속이다. private 상속을 곳이 별로 없다는 소리이다. is-implemented-in-terms-of 관계는 객체 합성과 private 상속이 나타낼 있지만, 이해하기엔 객체 합성이 훨씬 낫다. 그래서 있으면 객체 합성을 사용하는 것이 좋다.

 


 

private 상속이 적법한 설계 전략일 가능성이 가장 높은 경우가 있긴 하다. 아무리 봐줘도 is-a 관계로는 이어지지 않을 같은 클래스를 사용해야 하는데, 사이에서 한쪽 클래스가 다른 클래스의 protected 맴버에 접근해야 하거나 다른 클래스의 가상 함수를 재정의해야 때이다.

 

그렇다고 private 상속일 필요는 없다는 것을 배웠다. public 상속 + 객체 합성을 섞으면, 설계 복잡도가 조금 올라가는 대신 원하는 동작을 얻을 있다. "private 상속을 심사숙고해서 구사하자" 의미는 섣불리 이것을 필요가 없다는 생각을 갖고 모든 대안을 고민한 , 주어진 상황에서 클래스 사이의 관계를 나타낼 가장 좋은 방법이 private 상속이라는 결론이 나면 쓰라는 이다.

 


 

  • private 상속의 의미는 is-inplemented-in-terms-of(... ... 써서 구현됨)이다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 맴버에 접근해야 경우 혹은 상속받은 가상 함수를 재정의해야 경우에는 private 상속이 나름대로 의미가 있다.
  • 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO) 활성화시킬 있다. 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 매력적인 특징이 있다.