본문 바로가기

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

항목 40: 다중 상속은 심사숙고해서 사용하자

C++에서 다중 상속(multiple inheritance: MI) 관한 견해를 살펴보면, 크게 가지 진영으로 나뉜다.

 

  1. 단일 상속(single inheritance: SI) 좋다면 다중 상속은 좋을 것이다.
  2. 단일 상속은 좋지만 다중 상속은 골칫거리밖에 안된다.

 

일단 가지 견해가 어떤 이야기인지 살펴보자.

 


 

'다중 상속'하면 바로 떠올라야 하는 사실 하나는, 이상의 기본 클래스로부터 똑같은 이름(이를테면 함수, typedef ) 물려받을 가능성이 생긴다는 점이다. 다중 상속으로 인해 모호성 생긴다는 것이다.

 

 

 

 

여기서 보면, checkOut() 호출부에서 모호성이 발생하고 있는데, 사실 checkOut() 중에서 파생 클래스가 접근할 있는 함수가 정해져 있다.(ElectrionGadget::checkOut() private 맴버라 파생 클래스에서 접근 불가능하다.)

 

이것은 중복된 함수 호출 하나를 골라내는 C++의 규칙을 따른 결과이다. 어떤 함수가 접근 가능한 함수인지를 알아보기 전에, C++ 컴파일러는 규칙을 써서 주어진 호출에 대해 최적으로 일치하는(best-match) 함수인지를 먼저 확인한다.

=> 최적 일치 함수를 찾은 비로소 함수의 접근 가능성을 점검한다.

 

지금의 경우 checkOut() C++ 규칙에 의한 일치도가 서로 같기 때문에, 최적 일치 함수가 결정되지 않았다. 그래서 ElectronicGadget::checkOut() 접근 가능성이 점검되는 순서조차 오지 않고 모호성 에러를 내는 것이다.

 

모호성을 해소할려면, 호출할 기본 클래스의 함수를 우리가 손수 지정해 줘야 한다.

 

 


 

다중 상속의 의미는 그냥 " 이상의 클래스로부터 상속을 받는것" 이다. 하지만 MI 상위 단계의 기본 클래스를 여러 개가 갖는 클래스 계통에서 종종 눈에 띈다. 특히 소위 "죽음의 MI 마름모꼴(deadly MI diamond)" 계통 구조가 나올 있다.

 

 

 

이런 계통 구조를 혹시라도 쓰게 된다면,

"기본 클래스의 데이터 맴버가 경로 개수만큼 중복 생성되는 아닌가?"

 라는 생각을 하게 된다.

 

예를 들어, File 클래스 안에 fileName 이라는 데이터 맴버 하나 들어 있다고 가정하면, IOFile 클래스에는 필드가 개가 들어 있을까? 가지 답이 나올 있다.

 

  1. InputFile OutputFile로부터 사본을 하나씩 물려받게 되니까 결과적으로 fileName 데이터 맴버가 2개야여 한다.
  2. IOFile 객체는 파일 이름이 하나만 있는게 맞으니까, 기본 클래스로부터 fileName 동시에 물려받더라도 fileName 중복되면 안된다.

 

기본은 1번이지만, 2번도 지원한다. 만약 2번을 원하는 것이였다면, 해당 데이터 맴버를 가진 클래스(File) 가상 기본 클래스(virtual base class) 만드는 것으로 해결 가능하다. 자세히 말하면, 가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속(virtual inheritance) 사용하게 만드는 것이다.

 

 

 

사실 표준 C++라이브러리가 이런 모양의 MI 상속 계통을 하나 갖고 있다. 클래스 템플릿이라는 점이 예외이다.

 

basic_ios, basic_istream, basic_ostream, basic_iostream 이다. 위의 File, InputFile, OutFile, IOFile 자리에 하나씩 들어가면 된다.

 


 

정확한 동작의 관점에서 보면, public 상속은 항상 가상 상속이여야 한다. 하지만 정확성 외에 다른 측면도 같이 생각해야 한다. 사실, 상속되는 데이터 맴버의 중복생성을 막는 데는 우리 눈에는 보이지 않는 컴파일러의 숨은 꼼수가 필요하다. 그리고 꼼수 때문에, 가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않은 것보다 일반적으로 크기가 크다. 게다가, 가상 기본 클래스의 데이터 맴버에 접근하는 속도도 비가상 기본 클래스의 데이터 맴버에 접근하는 속도보다 느리다. 세부적인 크기, 속도 차이는 컴파일러마다 다르지만, 가상 상속은 비싸다는 사실은 같다.

 

그리고 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡하고 직관성도 떨어진다. 대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생긴다. 이때 들어가는 가상 기본 클래스의 초기화 규칙은 다음과 같다.

 

  1. 초기화가 필요한 가상 기본 클래스로부터 클래스가 파생된 경우, 파생 클래스는 가상 기본 클래스와의 거리에 상관없이 가상 기본 클래스의 존재를 염두에 두고 있어야 한다.
  2. 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 파생 클래스는 가상 기본 클래스의 초기화를 떠맡아야 한다.

 

 

 정리하자면

 

  • 파생 클래스 객체의 크기
  • 기본 클래스 객체의 맴버에 대한 접근 속도
  • 기본 클래스 객체의 초기화 비용

 

따라서 가상 기본 클래스(가상 상속) 다음과 같은 규칙에 따라 쓰자.

 

  1. 구태여 필요가 없다면 가상 기본 클래스를 사용하지 말자.(비가상 상속을 기본으로 삼자)

 

  1. 가상 기본 클래스를 무조건 써야 하는 상황이라면, 가상 기본 클래스에는 데이터를 넣지 않는 쪽으로 신경을 쓰자. 데이터만 들어가지 않으면 가상 기본 클래스의 초기화 규칙(대입도 포함) 복잡성에서 해방될 있다.

 

참고로, C++ 가상 기본 클래스와 비교되 개념이 JAVA .NET Interface 인데, 언어에서는 언어적으로 데이터를 아예 갖지 못하도록 설정되어 있다.

 


 

C++ 인터페이스 클래스를 써서 사람을 모형화 해보자.(항목 31 참고)

 

 

 

 

IPerson을 쓰려면 분명히 Iperson 포인터 또는 참조자를 통해 프로그래밍을 해야 것이다.(추상 클래스는 인스턴스를 만들 없다.) 조작이 가능한 IPerson 객채(정확히는 IPerson 동작 원리를 그대로 있는 객체) 생성하기 위해, IPerson 사용자는 팩토리 함수(항목 31 참고) 사용하여 IPerson 구체 파생 클래스를 인스턴스로 만든다.

 

 

 

 

 

makePerson() 반환할 포인터로 가르킬 객체를 새로 만들려면, IPerson 객체는 만들지 못하니 IPerson 상속받는 어떤 구체 클래스 객체를 만들어 이를 IPerson으로 캐스팅하는 형태일 것이다.

 

구제 클래스 이름을 CPerson이라고 가정하자. CPerson IPerson으로부터 물려받은 순수 가상 함수에 대한 구현을 반드시 제공해야 한다. 이를 처음부터 손수 구현할 수도 있겠지만, 다른 코드를 재사용하는 편이 나을 있다.

 

이를테면, 예전에 준비해둔 데이트베이스 전담 클래스인 PersonInfo 보니 현재의 CPerson 필요한 핵심 기능을 갖고 있더라.. 라고 한번 가정을 해보자.

 

 

클래스를 살펴보다, 클래스에는 데이터베이스 필드를 다양한 서식으로 출력할 있는 기능을 갖고 있따는 사실을 알아냈다. 기능을 쓰면 필드값의 시작과 끝을 임의의 문자열로 구분하여 출력할 있다. 그리고 기본적으로 구분자는 대괄호([ ]) 정해져 있다.

 

예를 들어 "Ring-tailed Lemur" 라는 필드 값을 출력하면 "[Ring-tailed Lemur]" 출력되는 것이다.

 

PersonInfo 사용자가 전부 대괄호를 구분자로 쓰고 싶어하지는 않을 것이다. 그래서 사용자가 원하는 시작 구분자와 구분자를 설정할 있도록 valueDelimOpen() valueDelimClose() 가상 함수로 제공한다.(사용자 입맛대로 재정의할 있게)

 

예를 들면 다음과 같이 있다.

 

 

물론 Person::theName() 구현 형태가 상당히 구시대적인 스타일이긴 하다. 특히 정적 버퍼를 쓰는 부분은 여러 문제(버퍼 오버런, 스레딩 문제 등등) 일으킬 있다.(항목 21 참고) 일단 이런 문제들은 접어두고 다음 부분에만 집중해보자.

 

  • theName() valueDelimOpen() 호출해서 시작 구분자를 만든다.
  • name 자체를 만든다.
  • valueDelimClose() 호출하여 구분자를 만든다.

 

이때 valueDelimOpen() valueDelimClose() 가상 함수이기 때문에, theName 반환하는 결과는 PersonInfo에만 좌우되는 것이 아니라 PersonInfo로부터 파생된 클래스에도 좌우된다.

 

CPerson 구현하는 사람의 입장에서는 매우 좋은 소식이다. 이런 내용이 적힌 IPerson 문서를 읽다 보면 name theBirth 함수가 반환하는 겂에는 장식, 구분자가 붙으면 안된다는 사실을 것이기 때문이다. 어떤 사람(사람 객체) 이름이 Homer 라면 사람의 name 함수는 "[Homer]" 아닌 "Homer" 반환해야 한다는 이야기이다.

 

CPerson PersonInfo 사이를 잇는 관계고리는 별거 없다. PersonInfo 클래스는 CPerson을 구현하기 편하게 만들어주는 함수를 어찌저지 가지고 있다는 말고는 없다. 항목들에서 배운 내용을 써먹자면, is-implemented-in-terms-of 관계라는 뜻이다.

 

관계를 표현하는 방법 객체 합성(항목 38 참고), private 상속(항목 39 참고) 있다. 대부분의 경우 객체 합성을 선호하지만, 가상 함수를 재정의할려면 private 상속을 써야한다. 지금은 CPerson클래스에서 valueDelimOpen() valueDelimClose() 반드시 재정의해야 하기 때문에 CPerson PersonInfo로부터 private 상속을 받도록 만드는 것이 좋아보인다. 혹은 항목 39에서 나온 방봅으로, 객체 합성 + public 상속 조합 방법도 생각해볼 있다. 여기서는 private 상속을 이용해보자.

 

한편, CPerson클래스는 IPerson인터페이스도 함꼐 구현해야 하기 때문에, IPerson public 상속해야한다.

=> 인터페이스의 public 상속과 구현의 private 상속을 모두 할려면 다중 상속을 사용해야 한다.

 

 

UML 사용하여 설계를 나타내면 다음과 같다.

 

 


 

다중 상속을 그냥 객체 기향 기법 하나, 소프트웨어를 개발하는 쓰이는 도구 중 하나로 생각하자. 물론 단일 상속에 비해 사용하기 복잡하고, 이해하기도 복잡하기 때문에 MI 설계와 동일한 효과를 내는 SI 설계가 있다면 SI 쪽으로 가는 것이 맞다. 왠만한 경우 SI 가는 길이 있으니 걱정말자.

 

물론 가장 명료하고 유지보수성도 좋고 가장 적합한 방법이 MI 설계일 때도 존재한다. 이때다! 라고 확신이 들면 주저말고 MI 지르자. 중요한 것은 다중 상속을 혹시라도 성급하지 않았나 확인하는 습관을 들이자는 것이다.

 


 

  • 다중 상속은 단일 상속보다 확실히 복잡하다. 새로운 모호성 문제를 일으킬 아니라 가상 상속이 필요해질 수도 있다.
  • 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 대입 연산의 복잡도가 커진다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적이다.
  • 다중 상속을 적법하게 있는 경우가 있다. 여러 시나리오중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것이다.