40: 다중 상속은 심사숙고해서 사용하자
다중 상속(multiple inheritance: MI)하면 바로 머리에 들어와야 하는 사실 중 하나는, 둘 이상의 기본 클래스로부터 똑같은 이름(이를테면 함수, typedef 등)을 물려받을 가능성이 생겨 버린다. 모호성이 생긴다는 것이다.
class BorrowableItem {
public:
void checkOut();
};
class ElectronicGadget {
private:
bool checkOut() const;
};
class MP3Player : public BorrowableItem, public ElectronicGadget {};
MP3Player mp;
mp.checkOut(); // 모호성 발생!
위의 checkOut 함수는 C++ 규칙에 의한 일치도가 서로 같기 때문에, 최적 일치 함수가 결정되지 않는다. 작금의 모호성을 해소하려면, 호출할 기본 클래스의 함수를 손수 지정해 주어야 한다.
mp.BorrowableItem::checkOut();
MI는 상위 단계의 기본 클래스를 여러 개 갖는 클래스 계통에서 심심치 않게 눈에 띈다. 이런 구조의 계통에서는 소위 "죽음의 MI 마름모꼴(deadly MI diamond)"라고 알려진 좋지 않은 모양이 나올 수 있다.
class File {};
class InputFile : public File {};
class OutputFile : public File {};
class IOFile : public InputFile, public OutputFile {};
이렇게 기본 클래스와 파생 클래스 사이의 경로가 두 개 이상이 되는 상속 계통을 혹시라도 쓰게 되면, 기본 클래스의 데이터 멤버가 경로 개수만큼 중복 생성된다.
만약 데이터 멤버의 중복생성을 원한 것이 아니었다면, 해당 데이터 멤버를 가진 클래스를 가상 기본 클래스(virtual base class)로 만드는 것으로 해결할 수 있다. 더 자세히 말하면, 가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속(virtual inheritance)을 사용하게 만드는 것이다.
class File {};
class InputFile : virtual public File {};
class OutputFile : virtual public File {};
class IOFile : public InputFile, public OutputFile {};
하지만 가상 상속은 비싸다. 사실, 상속되는 데이터 멤버의 중복생성을 막는 데는 우리 눈에는 보이지 않는 컴파일러의 숨은 꼼수가 필요하다. 그 덕택에, 가상 상속을 사용하는 클래스로 만들어진 객체는 일반적으로 크기가 더 크다. 게다가, 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 비가상 기본 클래스의 데이터 멤버에 접근하는 속도보다 느리다.
그리고 다른 쪽으로 또 비용이 나간다. 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한데다가 직관성도 더 떨어진다. 초기화 규칙은 다음과 같다.
- 초기화가 필요한 가상 기본 클래스로부터 클래스가 파생된 경우, 이 파생 클래스는 가상 기본 클래스와의 거리에 상관없이 가상 기본 클래스의 존재를 염두에 두고 있어야 한다.
- 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 클래스의 초기화를 떠맡아야 한다.
가상 기본 클래스에 대한 조언은 첫째, 구태여 쓸 필요가 없으면 가상 기본 클래스를 사용하지 말자. 둘째, 가상 기본 클래스를 정말 쓰지 않으면 안 될 상황이라면, 가상 기본 클래스에는 데이터를 넣지 않는 쪽으로 최대한 신경을 쓰자.
다중 상속을 적법하게 쓸 수 있는 경우도 있다. 여러 시나리오 중 하나는 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것이다.
// 용도에 따라 구현될 가상 클래스
class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
class DatabaseID {}; // 아래에서 쓰인다.
// IPerson 인터페이스를 구현하는데 유용한 함수가 들어 있다.
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const;
virtual const char* theBirthDate() const;
virtual const char* valueDelimOpen() const;
virtual const char* valueDelimClose() const;
};
class CPerson : public IPerson, private PersonInfo { // MI 사용
public:
explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}
virtual std::string name() const {
return PersonInfo::theName();
}
virtual std::string birthDate() const {
return PersonInfo::theBirthDate();
}
private:
const char* valueDelimOpen() const {
return "";
}
const char* valueDelimClose() const {
return "";
}
};
Cperson은 IPerson을 public 상속받음과 동시에 IPerson을 구현하는데 유용하며 기존에 존재하고 있는 PersonInfo를 is-implementated-in-terms-of의 관계로 private 상속을 받았다. MI도 경우에 따라서는 상당히 쓸만하다.
다중 상속은 그냥 객체 지향 기법으로 소프트웨어를 개발하는 데 쓰이는 도구 중 하나로 보는게 좋다. 단일 상속과 비교해서 사용하기에도 좀더 복잡하고 이해하기에도 좀더 복잡하다는 것은 사실이므로 동등한 효과를 낸다면 SI 쪽으로 가는 것이 확실히 좋다. 물론, 가장 명료하고 유지보수성도 가장 좋으며 가장 적합한 방법이 MI일 경우도 진짜로 존재한다. 다중 상속을 쓸 때 혹시라도 성급하지는 않았나, 확인하고 또 확인하자.
이것만은 잊지 말자!
- 다중 상속은 단일 상속보다 확실히 복잡하다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있다.
- 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커진다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적이다.
- 다중 상속을 적법하게 쓸 수 있는 경우가 있다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것이다.