- 다중 상속은 단일 상속보다 복잡하다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수 있다.
- 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커진다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 실용적이다.
- 다중 상속을 적법하게 쓸 수 있는 경우가 있다. 여러 경우 중 하나는 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것이다.
다중 상속
다중 상속(Multiple inheritance: MI)을 하게 되면 둘 이상의 기본 클래스로부터 똑같은 이름을 물려받을 가능성이 생긴다.
즉, 다중 상속 때문에 모호성이 생길 수 있다.
class BorrowbleItem {
public:
void checkOut();
...
};
class ElectronicGadget {
private:
bool checkOut() const;
...
};
class MP3Player: public BorrowblaItem, public ElectronicGadget { ... };
MP3Player mp;
mp.checkOut(); // Error, 모호성 발생
checkOut 함수를 호출하는 부분에서 모호성이 발생한다.
checkOut 함수 중 파생 클래스가 접근할 수 있는 함수는 public으로 선언된 BorrowbleItem::checkOut 뿐이다.
그럼에도 모호성이 발생하는 이유는 C++의 규칙 때문이다.
C++에서는 함수의 접근 가능 여부를 확인하기 전에, 컴파일러에 의해 최적 일치 여부를 확인한다.
지금의 경우 두 checkOut 함수는 C++ 규칙에 의한 일치도가 같기 때문에, 최적 일치 함수가 결정되지 않는다.
모호성을 없애기 위해서는 함수를 지정해서 사용해야 한다.
mp.BorrowbleItem::checkOut();
다중 상속의 의미는 둘 이상의 클래스로부터 상속을 받는 것이지만, MI는 상위 단계의 기본 클래스를 여러 개 갖는 클래스 계통에서 자주 등장한다. 이를 deadly MI dimond라고 한다.
class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile { ... };
이렇게 기본 클래스와 파생 클래스 사이의 경로가 두 개 이상이 되는 상속 계통을 쓰게 되면, 기본 클래스의 데이터 멤버가 경로 개수만큼 중복 생성되게 된다.
예를 들어, File 클래스 안에 fileName이라는 데이터 멤버가 하나 들어 있다고 가정해 보자.
IOFile 클래스에는 기본 클래스로부터 사본을 하나씩 물려받게 되어 fileName 데이터 멤버가 두 개가 된다.
기본적으로는 데이터 멤버를 중복생성하게 되지만, 해당 데이터 멤버를 가진 클래스를 가상 기본 클래스로 만드는 것으로 해결할 수 있다.
가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속을 사용하게 만드는 것이다.
class File { ... };
class InputFile: virtual public File { ... };
class OutputFile: virtual public File { ... };
class IOFile: public InputFile, public OutputFile { ... };
가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않은 것보다 일반적으로 크기가 더 크다.
게다가, 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 비가상 기본 클래스의 데이터 멤버에 접근하는 속도보다 느리다. 즉, 가상 상속은 비용이 많이 든다.
또한, 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한 데다 직관성도 떨어진다.
대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생기게 된다. 이때 필요한 초기화 규칙은 다음과 같다.
- 초기화가 필요한 가상 기본 클래스로부터 클래스가 파생된 경우, 이 파생 클래스는 가상 기본 클래스와의 거리에 상관없이 가상 기본 클래스의 존재를 염두에 두고 있어야 한다.
- 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 클래스의 초기화를 맡아야 한다.
그렇기 때문에 가상 기본 클래스는 되도록이면 사용하지 않는 것이 좋다. 위에서 말한 것처럼 비용도 비싸고 초기화도 어렵다.
가상 기본 클래스를 꼭 사용해야 할 경우, 가상 기본 클래스에는 최대한 데이터를 넣지 않아야 한다.
데이터만 들어가지 않으면 가상 기본 클래스의 초기화 규칙만 생각하면 된다.
다중 상속 예시
class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
IPerson을 쓰려면 분명히 IPerson 포인터 및 참조자를 통해 프로그래밍을 해야 할 것이다.
추상 클래스를 인스턴스로 만들 수 없다. 조작이 가능한 IPerson 객체를 생성하기 위해, IPerson의 사용자는 팩토리 함수를 사용해서 IPerson의 구체 파생 클래스를 인스턴스로 만든다.
//유일한 데이터베이 ID로부터 IPerson 객체를 만드는 팩토리 함수
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
DatabaseID askUserForDatabaseID();
DatabaseID id(askUserForDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id));
meakePerson 함수가 인스턴스로 만들 수 있는 구체 클래스가 IPerson으로부터 파생되어 있어야 한다.
이 클래스의 이름이 CPerson이라고 가정하자.
CPerson은 IPerson으로부터 물려받은 순수 가상 함수에 대한 구현을 제공해야 한다.
그리고 CPerson에 필요한 기능을 갖고 있던 PersonInfo를 이용해서 구현한다고 가정해 보자.
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const;
virtual const char* theBirthDate() const;
...
private:
virtual const char* valueDelimOpen() const;
virtual const char* valueDelimClose() const;
...
};
PersonInfo 클래스는데이터베이스 필드를 다양한 서식으로 출력할 수 있는 기능을 갖고 있다.
각 필드값의 시작과 끝을 임의의 문자열로 구분하여 출력할 수 있다.
기본적으로, 출력용 필드값의 시작과 끝에 붙는 구분자는 대괄호로 정해져 있다.
PersonInfo의 사용자가 전부 대괄호를 구분자로 쓰고 싶어 하지 않을 수 있다. 사용자가 원하는 구분자를 파생 클래스에서 지정할 수 있도록 valueDelimOpen 함수와 valueDelimClose 함수를 가상 함수로 마련해 둔다.
그리고 PersonInfo 클래스의 다른 멤버 함수들은 이 가상 함수를 통해 자신들이 사용하는 필드 값에 적절한 구분자를 붙이도록 구현되는 것이다. PersonInfo::theName 함수를 예로 들면, 다음과 같다.
const char* PersonInfo::valueDelimOpen() const
{
return "[";
}
const char* PersonInfo::valueDelimClose() const
{
return "]";
}
const char* PersonInfo::theName() const
{
//반환값을 위한 버퍼
static char vlaue[Max_Formatted_Field_Value_Length];
std::strcpy(value, valueDelimOpen());
...
std::strcpy(value, valueDelimClose());
return value;
}
theName은 valueDelimOpen을 호출해서 구분자를 만들고, name 값을 만든 후 valueDelimClose를 호출하도록 구현됐다.
이때 valueDelimOpen 및 valueDelimClose는 가상 함수이기 때문에, theName이 반환하는 결과는 PersonInfo로부터 파생된 클래스에서도 영향을 줄 수 있다.
CPerson과 PersonInfo를 연결하는 것은 간단하다.
PersonInfo 클래스는 CPerson을 구현하기 편하게 만들어 주는 함수를 갖고 있다. 그렇기 때문에 객체 합성 혹은 private 상속을 이용하면 된다.
가상 함수를 재정의해야 하기 때문에 private 상속을 이용하면 된다.
CPerson 클래스는 IPerson 인터페이스도 함께 구현해야 하기 때문에 public 상속이 필요하다.
class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
class DatabaseID { ... };
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const;
virtual const char* theBirthDate() const;
virtual const char* valueDelimpOpne() const;
virtual const char* valueDelimClose() const;
...
};
class CPerson: public IPerson, private PersonInfo {
public:
explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}
virtual std::string name() const
{ return PersonInfo::theName(); }
virtual std::string birthDate() const
{ return PersonInfo::theBirthDate(); }
private:
virtual const char* vlaueDelimOpen() const { return ""; }
virtual const char* valueDelimClose() const { return ""; }
};
MI도 경우에 따라 유용하게 사용될 수 있다.
다중 상속은 객체 지향 기법으로 소프트웨어를 개발하는 데 쓰이는 도구 중 하나이다.
MI 설계와 동등한 효과를 내는 SI(단일 상속)을 찾을 수 있다면 SI를 사용하는 것이 좋다.
하지만, MI가 가장 좋은 방법인 경우도 있으니 꼼꼼하게 살핀 후 사용해야 한다.