본문 바로가기
Programming II/C++

[Effective C++] Chapter6 상속, 그리고 객체 지향 설계

by 김 원 2025. 3. 26.

- 상속은 단일 상속과 다중 상속이 가능하고, 상속 관계 하나하나가 public, protected, private의 성질을 가질 수 있습니다. 그뿐 아니라 여기에 가상 상속과 비가상 상속이 얹힐 수도 있다.

- 멤버 함수? 가상 함수? 비가상 함수? 순수 가상 함수? 그리고 C++가 지원하는 다른 기능들과 이들의 상호관계.

- 기본 매개변수는 가상 함수와 어떻게 맞물려 돌아갈까?

- 상속은 C++의 이름 탐색 규칙에 어떤 영향을 줄까요?

- 설계 시의 선택사항은 어떤 식으로 바뀔까요?

- 어떤 클래스의 동작 원리를 외부에서 수정할 수 있어야 한다고 결정했을 때, 과연 가상 함수가 최선의 방법일까요?

 

- public 상속은 반드시 is-a 관계를 뜻해야 하며, 이 외의 의미를 붙이려고 하면 난처해진다.

- 가상 함수의 의미는 인터페이스가 상속되어야 한다인 반면, 비가상 함수의 의미는 인터페이스와 구현이 둘 다 상속되어야한다. 

항목 32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 232(7)

- public 상속은 "is-a(...는 ...의 일종이다)"를 의미한다.

   - 클래스 D를 클래스 B로부터 Public 상속을 통해 파생시켰다면, D 타입으로 만들어진 모든 객체는 또한 B 타입의 객체이지만, 그 반대는 되지 않는다. 다시 말해 B는 D보다 더 일반적인 개념을 나타내며, D는 B보다 더 특수한 개념을 나타낸다고 알리는 것이다. 그러니까 B 타입의 객체가 쓰일 수 있는 곳에는 D 타입의 객체도 마찬가지로 쓰일 수 있다고 단정한다. D타입의 모든 객체는 B타입의 객체도 된다. 반면 D타입이 필요한 부분에 B타입의 객체를 쓰는 것은 불가능하다. 모든 D는 B의 일종이지만(D is a B), B는 D의 일종이 아니기 때문이다.

   - Public 상속에서만 통한다. 설명한대로 C++가 동작하려면 상속 관계를 가져야 한다. private 상속은 의미 자체가 완전히 다르고, protected 상속은 의미가 아리아리하다.

 

- 새는 날 수 있다. 펭귄은 새다. 펭귄은 날 수 있는가? 이런 문제가 발생할 수 있다. 새 > 나는 새/ 새 > 펭귄 

- 그러나 나는 기능이 필요없는 경우는 구분하지 않고 새 > 펭귄으로 사용하는 것이 좋다. 이는 모든 소프트웨어에 이상적인 설계같은 것은 없다는 사실을 반증할 수 있다.

 

- public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속이다.

 

- is-a 관계를 제외한 다른 관계

   - has-a(...는 ...를 가짐) -> 항목 38

   - is-implemented-in-terms-of (...는 ...를 써서 구현됨) -> 항목39

-* public 상속의 의미는 "is-a(...는 ...의 일종)"입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 한다. 왜냐하면 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문이다.

항목 33: 상속된 이름을 숨기는 일은 피하자 239(7)

- 상속이란 이름을 달고 이번 항목을 시작했지만 진짜 관계가 있는 것은 유효범위(scope)이다.

   - 안쪽 유효범위에 있는 이름이 바깥쪽 유효범위에 있는 이름을 가리기(덮기) 때문이다. 타입이 달라도 이름을 가린다.

 

- 기본 클래스에 속해 있는 것을 파생 클래스 멤버 함수 안에서 참조하는 문장이 있으면 컴파일러는 이 참조 대상을 바로 찾아낼 수 있다. 기본 클래스에 선언된 것은 파생 클래스가 모두 물려받기 때문이다. 파생 클래스의 유효범위가 기본 클래스의 유효범위 안에 중첩되어 있다.

   - 상속된 클래스의 함수에서 특정 함수 호출문을 만났을 때, 컴파일러는 해당 이름이 붙은 것의 선언문이 들어 있는 유효범위를 탐색하는 방법을 쓴다. 지역 유효 범위 -> 해당 함수 밖 클래스의 유효 범위 -> 부모 클래스 유효 범위 -> 네임스페이스 -> ... 전역 유효범위

 

- 이름 가리기는 기본 클래스와 파생 클래스에 있는 (이름이 같은) 함수들이 받아들이는 매개변수 타입이 다르거나 말거나 거리낌 없다.

- 함수들이 가상 함수인지 비가상 함수인지의 여부에도 상관없이 이름이 가려진다.

 

- 가려진 이름은 using 선언을 서서 끄집어낼 수 있다.

   - 어떤 기본 클래스로부터 상속을 받으려고 하는데, 오버로드된 함수가 그 클래스에 들어 있고 이 함수들 중 몇 개만 재정의(오버라이드)하고 싶다면, 각 이름에 대해 using 선언을 붙여 주어야 한다.

   - 어떤 파생 클래스가 기본 클래스로부터 public 상속으로 만들어진 것일 경우, 기본 클래스의 public 영역에 있는 이름들은 파생 클래스에서도 public 영역에 들어 있어야 한다.

 

-  private 상속이 이루어졌다고 가상하면 using 선언으로 해결할 수 없다. 해당 선언을 내리면 그 이름에 해당되는 것들이 모두 파생 클래스로 내려가 버린다. 그러므로 다른 기법이 필요하게 되었는데 이는 간단한 전달 함수를 만들어 놓는 것이다.

   - 기본 클래스의 이름을 파생 클래스의 유효범위에 끌어와 쓰고 싶은데, using 선언을 아예 지원하지 못하는(물론 틀린 거죠)암울한 시대의 컴파일러를 사용하고 있다면 이 인라인 전달 함수를 써서 우회적으로 해결할 수 있다.

-* 파생 클래스의 이름은 기본 클래스의 이름을 가린다. public 상속에서는 이런 이름 가림 현상을 바람직하지 않다.
-* 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있다.

항목 34: 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 246(10)

- 상속이라는 개념에는 인터페이스 상속과 함수 구현의 상속으로 나뉜다. 차이는 함수 선언 및 함수 정의의 차이와 맥을 같이 한다.

- 멤버 함수 인터페이스는 항상 상속되게 되어 있다.

   - public 상속의 의미는 is-a이므로 기본 클래스에 해당하는 것들은 모두 파생 클래스에도 해당되어야 한다. 따라서 어떤 클래스에서 동작하는 함수는 그 클래스의 파생 클래스에서도 동작해야 맞다.

 

- 순수 가상 함수

   - 어떤 순수 가상 함수를 물려받은 구체 클래스가 해당 순수 가상 함수를 다시 선언해야 한다.

   - 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다

   -* 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다.

   - 순수 가상 함수에도 정의를 제공할 수 있다. 구현이 붙은 순수 가상 함수를 호출하려면 반드시 클래스 이름을 한정자로 붙여주어야 한다.

 

- 단순(비순수) 가상 함수

   - 파생 클래스로 하여금 함수의 인터페이스를 상속하게 한다는 점은 똑같지만, 파생 클래스 쪽에서 오버라이드할 수 있는 함수 구현부도 제공한다는 점이 다르다.

   -* 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것이다.

 

   - 단순 가상 함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하도록 내버려 두는 것은 위험할 수 있다. 

      - 기본 동작을 원한다고 명시적으로 밝히지 않아도 해당 동작을 물려받는 데 아무런 걸림돌이 없다.

      - 가상 함수의 인터페이스와 그 가상 함수의 기본 구현을 잇는 연결 관계를 끊어 버리는 것으로 해결할 수 있다.

         - 순수 가상 함수로 바꾸고 구현부 자체는 별도의 함수로 만든다. 별도의 함수는 비가상 함수인데 이는 파생 클래스 쪽에서 재정의해서는 안 되기 때문이다.

      -  순수 가상 함수가 구체 파생 클래스에서 재선언되어야 한다는 사실을 할용하되, 자체적으로 순수 가상 함수의 구현을 구비해 두는 방법도 있다. 선언부는 함수의 인터페이스를 지정하고, 정의부는 이 함수의 기본 동작(명시적으로 원할 경우에만 사용 가능한)을 지정한다. 해당 방법은 함수 양쪽에 각기 다른 보호 수준을 부여할 수 있는 융통성을 날아갔다.

 

- 비가상 함수

   - 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않았다는 뜻이다. 실제로 비가상 멤버 함수는 클래스 파생에 상관없이 변하지 않는 동작을 지정하는데 쓰인다.

   -* 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현을 물려받게 하는 것이다.

 

- 클래스 설계에서 발견되는 실수

   - 1. 모든 멤버 함수를 비가상 함수로 선언하는 것이다. 이렇게 하면 파생 클래스를 만들더라도 기본 클래스의 동작을 특별하게 만들만한 여지가 없어지게 된다. 특히 비가상 소멸자가 문젯거리가 될 수 있다. 기본 클래스로 쓰이는 클래스는 대부분 가상 함수를 갖고 있다.

   - 2. 모든 멤버 함수를 가상 함수로 선언하는 것이다. 분명히 파생 클래스에서 재정의가 안 되어야 하는 함수도 분명히 있을 것이다.

 

-* 인터페이스 상속은 구현 상속과 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.
-* 순수 가상 함수는 인터페이스 상속만을 허용합니다.
-* 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.
-* 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.

항목 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자 256(11)

+ 비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

   - 사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 방법으로, 비가상 함수 인터페이스(non-virtual-interface: NVI) 관용구라고 알려져 있다. 이 관용구는 템플릿 메서드라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것이다.

   - NVI 관용구의 이점은 필자가 위의 코드에 주석문으로 써둔 "사전 동작" 및 "사후 동작"에 전부 다 들어 있다.

      - 주석문이 가리키는 부분은 실제 동작을 수행하는 가상 함수를 호출하기 전과 호출한 후에 호출될 코드들이 들어갈 자리이다. 다시 말해, 가상 함수가 호출되기 전에 어떤 상태를 구성하고 가상 함수가 호출된 후에 그 상태를 없애는 작업이 랩퍼를 통해 공간적으로 보장된다는 뜻이다.

   - NVI 관용구에서는 파생 클래스의 가상 함수 재정의를 허용하기 때문에, 어떤 기능을 어떻게 구현할지를 조정하는 권한은 파생 클래스가 갖게 되지만, 함수를 언제 호출할지를 결정하는 것은 기본 클래스만의 고유 권한이다.

   - NVI 관용구에서 가상 함수는 엄격하게 private 멤버일 필요가 없습니다. 어떤 클래스 계통의 경우엔, 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수를 호출할 것을 예상하고 설계된 것도 있는데 이런 경우에 적법한 함수 호출이 되려면 그 가상 함수가 private 멤버가 아니라 protected 멤버이어야 합니다.

   - 간혹 가상 함수가 심지어 public 멤버이어야 할 때도 있지만(다형성 기본 클래스의 소멸자가 그 예이다.) 여기까지 오면 사실 NVI 관용구를 적용하는 의미가 없다.

 

+ 함수 포인터로 구현한 전략 패턴

- 캐릭터의 체력치를 계산하는 작업은 캐릭터의 타입과 별개로 놓는 편이 맞을 것이다. 체력치 계산이 구태여 어떤 캐릭터의 일부일 필요가 없다. 한 예로 각 캐릭터의 생성자에 체력치 계산용 함수의 포인터를 넘기게 만들고, 이 함수를 호출해서 실제 계산을 수행하도록 하면 된다.

-클래스 계통의 멤버 함수가 아니라는 점은 체력치 계산되는 대상 객체의 비공개 데이터는 이 함수로 접근할 수 없다.

 

+ tr1::function으로 구현한 전략 패턴

- tr1::function 계열의 객체는 함수호출성 객체(함수 포인터, 함수 객체 혹은 멤버 함수 포인터)를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그너처와 호환되는 시그너처를 갖고 있다.

- 함수 포인터로 구현한 전략 패턴과 다른건 tr1::function 객체, 좀 더 일반화된 함수 포인터를 물게 된다는 것이다. 이는 융통성을 열어준 것이 된다.

- 호환되는 시그너타처란 std::tr1::function (현대 C++에서는 std::function) 객체가 저장할 수 있는 함수, 함수 객체, 또는 멤버 함수의 타입이 해당 function 객체의 시그너처와 일치하거나 변환 가능한 경우를 의미.

- 멤버 함수 포인터는 직접적으로 호환되지 않지만, std::bind 등을 사용하면 가능

 

+ "고전적인" 전략 패턴

- 체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력치 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.

 

+ 지금까지 공부한 것들에 대한 요약

- 비가상 인터페이스 관용구(NVI 관용구)를 사용한다. 

   - 공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸는 호출하는, 템플릿 메서드 패턴의 한 형태이다.

- 가상 함수를 함수 포인터 데이터 멤버로 대체한다.

   - 군더더기 없이 전략 패턴의 핵심만을 보여주는 형태이다.

- 가상 함수를 trl::function 데이터 멤버로 대체하여, 호환되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 만든다.

   - 전략 패턴의 한 형태이다.

- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다.

   - 전략 패턴의 전통적인 구현 형태이다.

 

-* 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예이다.
-* 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
-* trl::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그너처와 호환되는 모든 함수호출성 개체를 지원한다.

항목 36: 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물! 267(3)

- 비가상 함수는 정적 바인딩으로 묶인다. B에 대한 포인터 타입으로 선언되었을 때는 pB는 B에 대한 포인터 타입으로 선언되었기 때문에, pB를 통해 호출되는 비가상 함수는 항상 B클래스에 정의되어 있을 것이라고 결정해버린다.

- 가상 함수의 경우엔 동적 바인딩으로 묶인다. 가상 함수였다면 진짜로 가리키는 대상의 타입의 객체의 함수가 호출된다.

 

- 어떤 상황에서도 상속받은 비가상 함수를 재정의하는 것은 절대 금물이다.

-* 상속받은 비가상 함수를 재정의하는 일을 절대로 하지 말자.

항목 37: 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자 270(4)

- C++에서 상속받을 수 있는 함수의 종류는 가상 함수와 비가상 함수뿐이다. 비가상 함수는 언제라도 재정의해서는 안 되는 함수이다. 따라서 기본 매개변수 값을 가진 가상 함수를 상속하는 경우로 좁힐 수 있다.

   - 가상 함수는 동적으로 바인딩되지만, 기본 매개변수 값은 정적으로 바인딩된다는 것이다.

 

- 정적 바인딩은 선행 바인딩이란 이름으로도 알려져있고 동적 바인딩은 지연 바인딩이란 이름으로도 알려져있다.

- 객체의 정적 타입은 프로그램 소스 안에 여러분이 놓는 선언문을 통해 그 객체가 갖는 타입이다.

- 객체의 동적 타입은 현재 그 객체가 진짜로 무엇이냐에 따라 결정되는 타입이다. 즉 이 객체가 어떻게 동작할 것이냐를 가리키는 타입이다. 동적 타입은 이름에서 풍기는 느낌 그대로 프로그램이 실행되는 도중에 바뀔 수 있다.

 

- 가상 함수는 동적으로 바인딩된다. 가상 함수의 호출이 일어난 객체의 동적 타입에 따라 어떤 가상 함수가 호출될지가 결정된다는 뜻이다.

- 가상 함수는 동적으로 바인딩되어 있지만 기본 매개변수는 정적으로 바인딩되어 있다. 즉 파생 클래스에 정의된 가상 함수를 호출하면서 기본 클래스에 정의된 기본 매개변수 값을 사용해버릴 수 있다는 이야기이다.

-* 상속받은 기본 매개변수 값은 절대로 재정의해서는 안 된다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수는 동적으로 바인딩되기 때문이다.

 

항목 38: "has-a(...는...를 가짐)" 혹은 "is-implemented-in-terms-of(...는...를 써서 구현됨)"를 모형화할 때는 객체 합성을 사용하자 275(4)

- 합성이란 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 그 타입들 사이의 관계를 일컫는다. 레이어링, 포함, 통합, 내장 등으로도 알려져 있다.

- 객체 합성은 has-a(...는 ...를 가짐)을 뜻할 수 있고 is-implemented-in-terms-of(...는 ...를 써서 구현됨)을 뜻할 수 있다.

   - 객체 합성이 응용 영역의 객체들 사이에서 일어나면 has-a 관계이다.

   - 구현 영역에서 일어나면 그 객체 합성의 의미는 is-implemented-in-terms-of 관계를 나타낸다.

-* 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다르다.
-* 응용 영역에서 객체 합성의 의미는 has-a(...는 ...를 가짐)이다. 구현 영역에서는 is-implemented-in-terms-of(...는 ...를 써서 구현됨)의 의미를 갖는다.

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

- private 상속은 is-a를 뜻하지 않는다. 

- private 상속 시 컴파일러는 일반적으로 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다.

   - 기본 클래스로부터 물려받은 멤버는 파생 클래스에서 모조리 private 멤버가 된다. 기본 클래스에서 원래 protected 멤버였거나 public 멤버였어도 이에 해당된다. 

   - private 상속의 의미는  is-implemented-in-terms-of 관계 이다.

   - private 상속의 의미는 구현만 물려받을 수 있다. 인터페이스는 국물도 없다라는 뜻이다.

   - private 상속은 소프트웨어 설계 도중에는 아무런 의미도 갖지 않으며, 단지 소프트웨어 구현 중에만 의미를 가진다.

 

- is-implemented-in-terms-of(...는 ...를 써서 구현됨) 관계인 private 상속과 객체 합성 중에는 할 수 있으면 객체 합성을 사용하고, 꼭 해야 하면 private 상속을 사용하자.

   - 비공개 멤버를 접근할 때 혹은 가상 함수를 재정의할 경우가 주로 이 그림에 속한다. 또는 is-a 관계가 아닐 때.

 

- private 상속 대신에 public 상속에 객체 합성 조합이 더 좋은 이유 2가지.

   - 1. 파생은 가능하게 하되, 파생 클래스에서 재정의할 수 없도록 설계 차원에서 막고 싶을 때 유용하다.

   - 2. 컴파일 의존성을 최소화하고 싶을 때 좋다. 헤더 파일 추가하지 않고 간단 선언하는 것만으로도 의존성을 피할 수 있다.

 

- 객체 합성보다 private 상속을 선호할 수 밖에 없는 경우는 데이터가 전혀 없는 클래스를 사용할 때가 아니면 볼 수 없다. 데이터가 없는 클래스란 비정적 데이터 멤버가 없는 클래스를 일컫는다. 따라서 가상 함수도 하나도 없어야하고 가상 기본 클래스도 없어야 한다. 이런 공백 클래스는 개념적으로 차지하는 메모리 공간이 없는 게 맞다. 그러나 C++에는 독립 구조의 객체는 반드시 크기가 0을 넘어야 한다는 금기사항 같은 것이 정해져 내려오고 있다. 따라서 컴파일러는 공백 객체에 char 한 개를 끼워 넣는 식으로 처리하기 때문에 크기 값은 1로 나온다.

   - 하지만 바이트 정렬이 필요하다고 판단되면 컴파일러는 바이트 패딩 과정을 추가할 수도 있어서 객체의 크기는 char 하나의 크기를 넘게 될 수도 있다.

 

- 객체 크기가 0이면 안 된다는 제약은 파생 클래스 객체의 기본 클래스 부분에는 적용되지 않는다. 이때의 기본 클래스 부분은 독립구조 객체, 다시 말해 홀로서기를 한 객체가 아니기 때문이다.

 

- 공백 기본 클래스 최적화(empty base optimization: EBO)란?

공백 기본 클래스 최적화(EBO)는 C++에서 빈(멤버 변수가 없는) 기본 클래스를 상속할 때, 불필요한 메모리 낭비를 방지하는 최적화 기법이다.

배경

  • C++에서 객체는 최소한 1바이트 이상이어야 한다.
    → 객체가 크기가 0이면 포인터로 식별할 수 없기 때문.
  • 빈 클래스(struct A {})도 객체로 생성되면 크기가 1바이트 이상이 된다.

문제점

  • 만약 빈 클래스를 상속하면, 기본적으로 파생 클래스 객체는 부모 클래스의 크기(1바이트 이상)만큼 메모리를 차지한다.
  • 하지만 빈 클래스는 실제 데이터가 없으므로, 굳이 메모리를 할당할 필요가 없다.

EBO의 동작 방식

- 컴파일러는 빈 기본 클래스의 메모리를 차지하지 않도록 최적화(EBO)할 수 있다.
- 즉, 빈 기본 클래스의 메모리를 없애고, 파생 클래스의 다른 멤버와 겹쳐서 배치할 수 있다.

- EBO는 일반적으로 단일 상속하에서만 적용된다.

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

항목 40: 다중 상속(MI)은 심사숙고해서 사용하자 286(9)

- 단일 상속이 좋다면 다중 상속은 더 좋을 것이 분명하다는 입장과 단일 상속은 좋지만 다중 상속은 골칫거리밖에 안 된다는 입장이 있다.

- 다중 상속하면 둘 이상의 기본 클래스로부터 똑같은 이름을 물려받을 가능성이 생겨버린다. 즉 다중 상속 때문에도 모호성이 생긴다. 공개 범위가 달라도 모호성이 생긴다. 컴파일러는 최적 일치 함수를 찾은 후에 비로소 함수의 접근가능성을 접근하기 때문이다. 해당 모호성을 해소하려면 호출할 기본 클래스의 함수를 손수 지정해주어야 한다.

 

- 가상 기본 클래스 C++에서 다이아몬드 상속 문제(Diamond Problem)를 해결하기 위한 방법 중 하나다. 가상 기본 클래스를 사용하면 중복된 기본 클래스 객체를 하나로 공유할 수 있다. 가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속을 사용하게 만드는 것이다.

   - 가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않는 것보다 일반적으로 크기가 더 크다.

   - 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 비가상 기본 클래스의 데이터 멤버에 접근하는 속도보다 느리다. 즉 가상 상속은 비싸다.

   - 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한데다가 직관성도 더 떨어진다.

   - 대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생기게 된다.

      - 1. 초기화가 필요한 가상 기본 클래스로부터 클래스가 파생된 경우, 이 파생 클래스는 가상 기본 클래스와의 거리에 상관없이 가상 기본 클래스의 존재를 염두에 두고 있어야 한다.

      - 2. 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 클래스의 초기화를 떠맡아야 한다.

 

- 구태여 쓸 필요가 없으면 가상 기본 클래스를 사용하지 말자. 비가상 상속을 기본으로 삼아라.

- 가상 기본 클래스를 정말 쓰지 않으면 안 될 상황이라면, 가상 기본 클래스에는 데이터를 넣지 않는 쪽으로 최대한 신경을 쓰자. 데이터만 들어가지 않으면 가상 기본 클래스의 초기화 규칙만 생각하면 떠오르는 고뇌와 심난함에서 해방될 수 있다.

 

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