다중 상속과 가상 상속
다중 상속에서 기반 객체가 중복되는 다이아몬드 문제를 재현하고 가상 상속과 생성자 규칙으로 해결합니다.
이전 절 ‘상속의 개념과 구현’에서 다중 상속을 간단히 언급했습니다.
이번 절에서는 다중 상속에서 가장 중요한 이슈인 다이아몬드 상속 문제와, 이를 해결하는 가상 상속(virtual inheritance)을 실제 코드로 다룹니다.
C++ · VIRTUAL INHERITANCE
가상 상속은 Device 두 부분을 하나로 공유한다
같은 기반 클래스가 두 경로로 들어올 때 non-virtual은 두 부분 객체, virtual base는 공유 부분 객체 하나를 만든다.
- non-virtual 경로
Printer와 Scanner가 각자의 Device 부분을 가진다.
- Copier 결과
Device 부분이 두 개여서 id 경로가 모호하다.
- virtual 경로
두 중간 클래스가 같은 virtual Device를 참조한다.
- 공유 결과
Copier 안에 Device 부분이 하나만 남는다.
virtual은 이름 충돌을 숨기는 문법이 아니라 가장 파생된 객체의 기반 부분 수를 바꾸는 모델링 선택이다.
다이아몬드 상속 문제란?
다음과 같은 상속 구조를 생각해 봅시다.
Device를Printer,Scanner가 상속Copier가Printer,Scanner를 모두 상속
이 구조는 상속 그래프가 마름모(다이아몬드) 형태를 이루며, Device가 중복으로 포함될 수 있습니다.
#include <iostream>
class Device {
public:
int id;
Device() : id(100) {}
};
class Printer : public Device {};
class Scanner : public Device {};
class Copier : public Printer, public Scanner {};
int main() {
Copier c;
// c.id = 10; // 오류: Device::id가 두 경로로 존재하여 모호
}Copier 안에 Device가 두 개 생기므로, 어떤 id를 의미하는지 컴파일러가 판단할 수 없습니다.
가상 상속으로 문제 해결하기
기반 클래스 쪽 상속을 virtual로 선언하면, 최종 파생 클래스에서 공유된 하나의 기반 클래스 인스턴스를 사용하게 됩니다.
#include <iostream>
class Device {
public:
int id;
Device(int v = 0) : id(v) {
std::cout << "Device ctor\n";
}
};
class Printer : virtual public Device {
public:
Printer() { std::cout << "Printer ctor\n"; }
};
class Scanner : virtual public Device {
public:
Scanner() { std::cout << "Scanner ctor\n"; }
};
class Copier : public Printer, public Scanner {
public:
Copier() : Device(42) { // 가상 기반 클래스는 최종 파생 클래스에서 초기화
std::cout << "Copier ctor\n";
}
};
int main() {
Copier c;
c.id = 7; // 모호성 없음
std::cout << "id=" << c.id << "\n";
}핵심은 Printer, Scanner에서 virtual public Device를 쓰는 것입니다.
예시
위 코드의 메시지는 Device ctor, Printer ctor, Scanner ctor, Copier ctor, id=7 순서입니다. 공유 Device가 만들어질 때 id는 42입니다. 이후 main의 c.id = 7이 같은 하나의 id를 변경하므로 마지막 출력은 42가 아니라 7입니다. 상속 구조 그림의 화살표는 기반 관계를 나타내며 생성 실행 순서 자체를 뜻하지 않습니다.
생성자 호출 순서와 초기화 규칙
가상 상속에서는 생성자 초기화 규칙이 일반 상속과 다릅니다.
- 가상 기반 클래스(
Device)는 최종 파생 클래스(Copier)가 초기화합니다. - 중간 클래스가 Copier의 기반 부분으로 생성될 때는 그 중간 생성자의 가상 기반 초기화식
Device(...)는 실행되지 않습니다. 반면 Printer 자체를 가장 파생된 객체로 만들 때는 Printer가 Device 초기화를 담당합니다.
즉, 가상 기반 클래스 초기화 책임은 최종 파생 클래스에 있다고 기억하면 됩니다.
스스로 확인하기
원 예제를 복사한 별도 변형에서 Copier의 Device(42)를 Device(99)로 바꾸고, main의 c.id = 7 한 줄만 제거하세요. 마지막 id는 얼마일까요? 이어 같은 main에서 별도 Printer p를 만들면 p.id는 얼마일까요? 두 객체의 공유가 서로 다른 완전한 객체까지 이어진다고 가정하지 마세요.
가장 파생된 객체별 초기화 풀이
Copier c의 id는 99입니다. 이 Copier 객체 안의 두 가상 경로가 같은 Device 부분을 공유합니다. 별도 Printer p에서는 Printer가 가장 파생된 타입이고 명시한 Device 인수가 없으므로 Device의 기본 인수 0을 사용합니다. p의 id는 0이며 c와 저장 상태를 공유하지 않습니다.
두 중간 경로 중 Scanner만 일반 public Device로 되돌리는 별도 실험에서는 Device 부분이 다시 두 개입니다. 하나는 Printer 쪽 virtual 부분, 하나는 Scanner 쪽 non-virtual 부분이므로 원래의 c.id처럼 경로를 지정하지 않은 접근은 모호합니다. 가상 함수의 virtual과 가상 상속의 virtual도 쓰는 위치와 해결하는 문제가 다릅니다. 기반 부분 객체 규칙과 가상 기반 초기화 규칙을 함께 확인할 수 있습니다.
언제 다중 상속을 써야 할까?
다중 상속은 가능하지만 설계 복잡도를 크게 높입니다.
여러 인터페이스 성격의 추상 클래스를 동시에 구현해야 하는 경우.
상태(데이터 멤버)를 가진 구체 클래스들을 다중 상속할 때.
상속 대신 합성(Composition)으로 기능을 조합하는 것이 더 단순한 경우가 많습니다.
실제 설계에서는 “다중 상속을 쓸 수 있는가”보다 “중복 상태와 초기화 책임을 통제할 수 있는가”를 먼저 확인해야 합니다.
다이아몬드 구조, virtual public, 최종 파생 클래스 초기화, 합성 대안을 함께 놓고 판단하면 실수를 줄일 수 있습니다.
체크 항목 중 하나라도 애매하다면 다중 상속을 유지하기보다 인터페이스 분리나 합성으로 책임을 나누는 쪽이 더 읽기 쉬운 설계가 됩니다.
가상 상속을 선택했다면 마지막으로 초기화 책임을 확인해야 합니다.
다이아몬드 구조에서는 중간 클래스가 아니라 최종 파생 클래스가 공유 기반 클래스의 생성자를 호출하므로, 객체 생성 순서를 설계도처럼 추적하는 습관이 중요합니다.
가상 기반은 가장 파생된 생성자가 초기화한다
Copier가 Device(42)를 결정한 뒤 Printer·Scanner 선언 순서와 Copier 본문으로 진행하는 초기화 순서가 본문에 명시되어 있다.
C++ · INITIALIZATION ORDER
공유 Device의 초기값은 Copier가 정한다
중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초기값을 결정한다.
- Copier 시작
가장 파생된 생성자가 Device(42)를 정한다.
- virtual base
공유 Device가 먼저 생성된다.
- 직접 기반
Printer와 Scanner 부분이 이어서 생성된다.
- 객체 완성
멤버와 Copier 본문이 실행된다.
초기화는 virtual base → 직접 기반 → 멤버 → 가장 파생된 본문 순이고 소멸은 역순이다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 공유 Device의 초깃값은 Copier가 정한다 |
| 본문·주석 | 중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초깃값을 결정한다. |
| 관계·조건 설명 | 원래 가로 그림의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device 42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공유 Device 최종 파생 Copier가 가상 기반의 초깃값을 정한다. |
|
|
| 직접 기반 선언 순서대로 Printer와 Scanner를 구성한다. |
|
|
| Copier 완성 Copier의 생성자 본문으로 진행한다. |
|
|
가상 기반은 가장 파생된 생성자가 초기화한다
Copier가 Device(42)를 결정한 뒤 Printer·Scanner 선언 순서와 Copier 본문으로 진행하는 초기화 순서가 본문에 명시되어 있다.
C++ · INITIALIZATION ORDER
공유 Device의 초기값은 Copier가 정한다
중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초기값을 결정한다.
- Copier 시작
가장 파생된 생성자가 Device(42)를 정한다.
- virtual base
공유 Device가 먼저 생성된다.
- 직접 기반
Printer와 Scanner 부분이 이어서 생성된다.
- 객체 완성
멤버와 Copier 본문이 실행된다.
초기화는 virtual base → 직접 기반 → 멤버 → 가장 파생된 본문 순이고 소멸은 역순이다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 공유 Device의 초깃값은 Copier가 정한다 |
| 본문·주석 | 중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초깃값을 결정한다. |
| 관계·조건 설명 | 원래 가로 그림의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device 42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공유 Device 최종 파생 Copier가 가상 기반의 초깃값을 정한다. |
|
|
| 직접 기반 선언 순서대로 Printer와 Scanner를 구성한다. |
|
|
| Copier 완성 Copier의 생성자 본문으로 진행한다. |
|
|
다중 상속 적용 기준 정리
- 다중 상속은 다이아몬드 문제를 유발할 수 있다.
virtual inheritance는 공유 기반 클래스 하나만 유지해 해당 기반 부분 객체의 중복을 제거한다. 서로 다른 기반 클래스가 별도로 선언한 같은 이름의 멤버까지 해결하지는 않는다.- 가상 기반 클래스 초기화는 최종 파생 클래스가 담당한다.
- 설계 복잡도가 높아지므로, 필요성과 대안을 함께 검토해야 한다.
다중 상속을 유지할지 고민될 때는 공유 기반 클래스, 가상 상속, 생성자 책임, 합성 대안을 함께 비교하면 판단이 쉬워집니다.