본문 바로가기

다중 상속과 가상 상속

다중 상속에서 기반 객체가 중복되는 다이아몬드 문제를 재현하고 가상 상속과 생성자 규칙으로 해결합니다.

C++에서는 한 클래스가 여러 기반 클래스를 동시에 상속할 수 있으며, 이를 다중 상속(multiple inheritance)이라고 합니다. 인쇄도 하고 스캔도 하는 복합기를 Printer와 Scanner를 함께 상속해 만드는 식입니다. 하지만 두 기반 클래스가 다시 같은 클래스를 상속하고 있으면, 그 공통 기반이 객체 안에 두 번 들어가는 문제가 생깁니다.

이 글은 다중 상속의 문법, 공통 기반이 중복되는 다이아몬드 문제, 공통 기반을 하나로 합치는 가상 상속(virtual inheritance)과 그 생성 규칙, 그리고 다중 상속을 쓸 때의 기준을 다룹니다.

C++ · VIRTUAL INHERITANCE

가상 상속은 Device 두 부분을 하나로 공유한다

같은 기반 클래스가 두 경로로 들어올 때 non-virtual은 두 부분 객체, virtual base는 공유 부분 객체 하나를 만든다.

Device가 중복되는 구조와 공유되는 구조원 desktop의 일반5클래스4간선 및 가상4클래스4간선 총9노드8간선이다. 각각Copier에서두직접기반으로,각기반에서자신의Device또는공유Device로향한다. 가상구조는Printer와Scanner모두virtual publicDevice이며 Device한부분을같은완전한Copier내에서공유한다. 그림의일반화방향은생성호출방향이아니다.일반 상속 · Device 2개Device부분1Device부분2Printerpublic DeviceScannerpublic DeviceCopierDevice 2개가상 상속 · Device 1개Device공유 기반PrintervirtualpublicDeviceScannervirtualpublicDeviceCopierDevice 1개빈 삼각형은 기반 관계생성 호출 순서는 별도 그림
diamond 상속은 Device 부분 객체 수를 바꾼다일반 다중 상속은 Copier 안에 Device 부분 두 개를 만들고 virtual inheritance는 공유 Device 하나를 만든다. Devicenon-virtual 기반Device두 번째 부분 객체Printerpublic DeviceScannerpublic DeviceCopierDevice 부분 2개Device공유 virtual basePrintervirtual public DeviceScannervirtual public DeviceCopierDevice 부분 1개
  1. non-virtual 경로

    Printer와 Scanner가 각자의 Device 부분을 가진다.

  2. Copier 결과

    Device 부분이 두 개여서 id 경로가 모호하다.

  3. virtual 경로

    두 중간 클래스가 같은 virtual Device를 참조한다.

  4. 공유 결과

    Copier 안에 Device 부분이 하나만 남는다.

virtual은 이름 충돌을 숨기는 문법이 아니라 가장 파생된 객체의 기반 부분 수를 바꾸는 모델링 선택이다.

다이아몬드 문제

Printer와 Scanner가 모두 Device를 상속하고, Copier가 그 둘을 상속하면 상속 관계가 마름모 모양이 됩니다. 이를 다이아몬드 상속이라고 합니다.

diamond.cpp
#include <iostream>

class Device {
public:
    int 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인지 모호함
    c.Printer::id = 1;
    c.Scanner::id = 2;
    std::cout << c.Printer::id << ' ' << c.Scanner::id << '\n';
}
1 2

위 그림의 왼쪽처럼 보통의 상속에서는 Printer 부분과 Scanner 부분이 각자 자기의 Device를 가지므로, Copier 객체 안에 Device가 두 개 있습니다. 그래서 c.id는 어느 쪽인지 정할 수 없어 컴파일 오류이고, c.Printer::id처럼 경로를 밝혀야 합니다. 두 id는 서로 다른 변수여서 따로 1과 2를 넣을 수 있는데, 복합기의 장치 번호가 둘이라는 것은 대개 의도한 설계가 아닙니다.

가상 상속

중간 클래스가 virtual public Device처럼 기반을 가상으로 상속하면, 그림의 오른쪽처럼 최종 객체 안에 공통 기반이 하나만 만들어지고 두 경로가 그것을 함께 씁니다. 이렇게 공유되는 기반을 가상 기반 클래스(virtual base class)라고 합니다.

virtual_inheritance.cpp
#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;
    std::cout << "id=" << c.id << '\n';
    c.id = 7;
    std::cout << "id=" << c.id << '\n';
}
Device ctor
Printer ctor
Scanner ctor
Copier ctor
id=42
id=7

Device가 하나뿐이므로 c.id는 모호하지 않고, Printer 부분과 Scanner 부분이 같은 id를 봅니다. virtual은 Copier가 아니라 Device를 상속하는 중간 클래스에 붙인다는 점에 주의합니다. 또 이 virtual은 가상 함수의 virtual과 같은 키워드지만, 쓰는 위치와 해결하는 문제가 전혀 다릅니다.

가상 기반은 가장 파생된 클래스가 초기화합니다

공유하는 Device는 하나인데, Printer와 Scanner가 각자 Device를 초기화하려 하면 누구의 값을 쓸지가 문제가 됩니다. 그래서 가상 기반 클래스는 실제로 만드는 객체의 타입인 가장 파생된 클래스(most derived class)가 초기화하며, 위에서는 Copier의 Device(42)가 그 값입니다. 출력에서 id=42가 나온 이유입니다.

가상 기반은 가장 파생된 생성자가 초기화한다

Copier가 Device(42)를 결정한 뒤 Printer·Scanner 선언 순서와 Copier 본문으로 진행하는 초기화 순서가 본문에 명시되어 있다.

C++ · INITIALIZATION ORDER

공유 Device의 초기값은 Copier가 정한다

중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초기값을 결정한다.

가상 기반 초기화의 네 역할과 메시지원 desktop의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다.CopierDevice기반들본문기반들: Printer · ScannerDevice(42) 초기화공유 기반 준비 완료직접 기반 부분 생성Copier 본문·완성위에서 아래로 시간 진행메시지 방향과 시간축 구분
가상 기반은 가장 파생된 생성자가 초기화한다 Copier 생성이 공유 Device를 먼저 초기화하고 Printer와 Scanner 부분을 만든 뒤 Copier 본문을 실행한다. Copier ctorMOST DERIVEDDeviceVIRTUAL BASEPrinter · ScannerDIRECT BASECopier bodyOBJECTDevice(42) 초기화공유 기반 준비 완료직접 기반 부분 생성가장 파생된 객체 완성
  1. Copier 시작

    가장 파생된 생성자가 Device(42)를 정한다.

  2. virtual base

    공유 Device가 먼저 생성된다.

  3. 직접 기반

    Printer와 Scanner 부분이 이어서 생성된다.

  4. 객체 완성

    멤버와 Copier 본문이 실행된다.

초기화는 virtual base → 직접 기반 → 멤버 → 가장 파생된 본문 순이고 소멸은 역순이다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석공유 Device의 초깃값은 Copier가 정한다
본문·주석중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초깃값을 결정한다.
관계·조건 설명원래 가로 그림의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device 42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 Device

최종 파생 Copier가 가상 기반의 초깃값을 정한다.

  • Device 값: 42
  • 초기화 책임: Copier
  • 가상 기반은 최종 파생 생성자가 한 번 초기화한다.
직접 기반

선언 순서대로 Printer와 Scanner를 구성한다.

  • 완료: Device → Printer → Scanner
  • 공유 Device: 하나
  • 직접 기반 초기화 순서는 기반 선언 순서를 따른다.
Copier 완성

Copier의 생성자 본문으로 진행한다.

  • 현재: Copier 본문
  • Device 값: 42
  • 기반 초기화가 끝나야 파생 생성자 본문에 진입한다.

가상 기반은 가장 파생된 생성자가 초기화한다

Copier가 Device(42)를 결정한 뒤 Printer·Scanner 선언 순서와 Copier 본문으로 진행하는 초기화 순서가 본문에 명시되어 있다.

C++ · INITIALIZATION ORDER

공유 Device의 초기값은 Copier가 정한다

중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초기값을 결정한다.

가상 기반 초기화의 네 역할과 메시지원 desktop의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다.CopierDevice기반들본문기반들: Printer · ScannerDevice(42) 초기화공유 기반 준비 완료직접 기반 부분 생성Copier 본문·완성위에서 아래로 시간 진행메시지 방향과 시간축 구분
가상 기반은 가장 파생된 생성자가 초기화한다 Copier 생성이 공유 Device를 먼저 초기화하고 Printer와 Scanner 부분을 만든 뒤 Copier 본문을 실행한다. Copier ctorMOST DERIVEDDeviceVIRTUAL BASEPrinter · ScannerDIRECT BASECopier bodyOBJECTDevice(42) 초기화공유 기반 준비 완료직접 기반 부분 생성가장 파생된 객체 완성
  1. Copier 시작

    가장 파생된 생성자가 Device(42)를 정한다.

  2. virtual base

    공유 Device가 먼저 생성된다.

  3. 직접 기반

    Printer와 Scanner 부분이 이어서 생성된다.

  4. 객체 완성

    멤버와 Copier 본문이 실행된다.

초기화는 virtual base → 직접 기반 → 멤버 → 가장 파생된 본문 순이고 소멸은 역순이다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석공유 Device의 초깃값은 Copier가 정한다
본문·주석중간 클래스의 초기화식보다 가장 파생된 Copier의 Device 인수가 공유 기반의 실제 초깃값을 결정한다.
관계·조건 설명원래 가로 그림의 네 역할과 네 메시지를 그대로 둔다. Copier 생성자에서 Device 42를 먼저 정하고 Device 준비 뒤 직접 기반 Printer/Scanner를 생성하고 Copier 본문으로 향한다. 기반들 묶음 안에서는 본문의 선언 순서인 Printer 다음 Scanner다. standalone Printer에는 이 Copier 인수를 적용하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 Device

최종 파생 Copier가 가상 기반의 초깃값을 정한다.

  • Device 값: 42
  • 초기화 책임: Copier
  • 가상 기반은 최종 파생 생성자가 한 번 초기화한다.
직접 기반

선언 순서대로 Printer와 Scanner를 구성한다.

  • 완료: Device → Printer → Scanner
  • 공유 Device: 하나
  • 직접 기반 초기화 순서는 기반 선언 순서를 따른다.
Copier 완성

Copier의 생성자 본문으로 진행한다.

  • 현재: Copier 본문
  • Device 값: 42
  • 기반 초기화가 끝나야 파생 생성자 본문에 진입한다.

그림처럼 Copier를 만들면 가상 기반인 Device가 가장 먼저 만들어지고, 그다음 직접 기반인 Printer와 Scanner가 선언 순서대로, 마지막으로 Copier의 본문이 실행됩니다. 이때 Printer나 Scanner의 초기화 목록에 Device(...)가 적혀 있어도 Copier의 일부로 만들어질 때는 무시됩니다. 반면 Printer p;처럼 Printer를 단독으로 만들면 그때는 Printer가 가장 파생된 클래스이므로 Printer가 Device를 초기화합니다.

가장 파생된 클래스가 가상 기반의 생성자를 지정하지 않으면 가상 기반의 기본 생성자가 쓰입니다. Device에 기본 생성자가 없다면, Copier뿐 아니라 그 아래의 모든 파생 클래스가 Device의 생성자를 직접 지정해야 합니다. 가상 상속을 쓰면 이처럼 초기화의 부담이 계층의 맨 아래까지 내려간다는 점도 함께 고려합니다.

더 깊이: 가상 기반을 찾아가는 방법과 그 대가

보통의 상속에서 기반 클래스 부분은 파생 클래스 객체 안의 정해진 위치에 있으므로, 컴파일러는 고정된 거리만큼 주소를 옮겨 기반 부분에 닿습니다. 가상 기반은 다릅니다. Printer 안에서 Device가 놓이는 위치는 Printer를 단독으로 만들 때와 Copier의 일부로 만들 때 서로 다르므로, 객체의 실제 타입을 알아야 정확한 위치를 알 수 있습니다. GCC와 Clang이 따르는 Itanium C++ ABI는 이 거리를 vtable 안의 가상 기반 거리(vbase offset) 항목에 두고, 가상 기반에 접근할 때 그 값을 써서 위치를 찾습니다.

이 구조 때문에 가상 기반에서 파생 클래스로 내려가는 변환에도 제한이 있습니다. static_cast는 컴파일할 때 정한 거리로 주소를 옮기는 변환이어서, Device*를 Printer*로 바꾸는 것처럼 가상 기반에서 내려가는 static_cast는 컴파일 오류입니다. 이런 변환이 꼭 필요하다면 실행 중에 객체의 실제 타입을 보고 위치를 계산하는 dynamic_cast를 쓰며, 이를 위해서는 기반 클래스에 가상 함수가 하나 이상 있어야 합니다. 가상 기반에 접근할 때는 이처럼 vtable을 한 번 더 거쳐야 할 수 있으므로, 가상 상속은 정말로 공유해야 하는 기반에만 씁니다.

다중 상속의 이름 충돌

다이아몬드가 아니어도, 두 기반 클래스에 같은 이름의 멤버가 있으면 다중 상속한 클래스에서 그 이름은 모호해집니다. 가상 상속은 같은 기반 객체가 중복되는 문제만 해결하므로, 서로 다른 두 클래스가 각자 선언한 같은 이름의 멤버는 해결하지 않습니다.

name_conflict.cpp
#include <iostream>

class Camera {
public:
    void start() const { std::cout << "camera start\n"; }
};

class Microphone {
public:
    void start() const { std::cout << "mic start\n"; }
};

class Recorder : public Camera, public Microphone {
public:
    void start() const {
        Camera::start();
        Microphone::start();
    }
};

int main() {
    Recorder r;
    r.start();
}
camera start
mic start

Recorder가 start()를 정의하지 않으면 r.start()는 두 기반의 start 가운데 어느 것인지 모호해 컴파일 오류가 납니다. 위처럼 파생 클래스에 같은 이름의 함수를 두고 두 기반의 함수를 Camera::start()처럼 이름을 붙여 부르면, Recorder에 맞는 동작을 직접 정할 수 있습니다.

다중 상속을 쓸 때의 기준

다중 상속이 가장 자연스러운 경우는 추상 클래스와 순수 가상 함수에서 본 인터페이스를 여러 개 구현할 때입니다. class Document : public IPrintable, public ISavable처럼 데이터가 없는 인터페이스를 여러 개 상속하면, 중복될 상태가 없어 다이아몬드 문제도 생기지 않습니다.

데이터를 가진 구체 클래스를 여러 개 상속하면, 중복된 상태와 이름 충돌, 가상 기반의 초기화 부담을 모두 관리해야 해서 설계가 급격히 복잡해집니다. 그럴 때는 상속의 개념과 구현에서 본 합성으로 바꾸어, Copier가 Printer와 Scanner를 상속하지 않고 멤버로 가지게 하는 편이 단순한 경우가 많습니다.

정리

  • 다중 상속은 한 클래스가 여러 기반 클래스를 상속하는 것이며, 두 기반이 같은 클래스를 상속하면 다이아몬드 구조가 됩니다.
  • 보통의 상속에서는 다이아몬드의 공통 기반이 객체 안에 두 번 들어가, 그 멤버를 이름만으로 쓰면 모호합니다.
  • 중간 클래스가 virtual public Base로 상속하면 공통 기반이 하나만 만들어져 공유됩니다.
  • 가상 기반 클래스는 가장 파생된 클래스가 초기화하고 가장 먼저 만들어지며, 중간 클래스의 초기화 목록은 무시됩니다.
  • 서로 다른 기반에 같은 이름이 있으면 가상 상속과 상관없이 모호하며, Base::name으로 밝히거나 파생 클래스에서 다시 정의합니다.
  • 다중 상속은 인터페이스를 여러 개 구현할 때 쓰고, 상태가 있는 클래스의 조합에는 합성을 먼저 생각합니다.

연습 문제

1. 가장 파생된 객체마다 다른 초기화

virtual_inheritance.cpp에서 Copier의 Device(42)를 Device(99)로 바꾸면 첫 id 출력은 무엇인가요? main에 Printer p;를 추가하고 p.id를 출력하면 무엇이 나오나요?

해설 보기

Copier가 가상 기반을 초기화하므로 첫 출력은 id=99입니다. Printer p는 Printer가 가장 파생된 클래스이고 Device의 인자를 지정하지 않았으므로 기본값 0이 쓰여 p.id는 0입니다. 두 객체는 서로 다른 객체이므로, 하나의 Copier 안에서 공유되는 Device와 p의 Device는 관계가 없습니다.

2. 경로를 밝힌 접근

virtual_inheritance.cpp의 main에서 c.Printer::id = 1;과 c.Scanner::id = 2;를 차례로 실행한 뒤 두 값을 출력하면 무엇이 나오나요? diamond.cpp의 결과와 왜 다른가요?

해설 보기

2 2가 출력됩니다. 가상 상속에서는 두 경로가 같은 Device를 가리키므로, 두 번째 대입이 같은 id를 2로 덮어씁니다. diamond.cpp에서는 Device가 둘이어서 각자 1과 2를 따로 가졌기 때문에 1 2가 나왔습니다.

3. 한쪽만 가상으로 상속하면

Printer는 virtual public Device로, Scanner는 보통의 public Device로 상속하면 Copier 안의 Device는 몇 개이고 c.id는 컴파일되나요?

해설 보기

Printer 쪽의 가상 기반 하나와 Scanner 쪽의 보통 기반 하나로 Device가 두 개가 됩니다. 그래서 c.id는 여전히 어느 쪽인지 모호해 컴파일 오류이며, 공통 기반을 하나로 합치려면 그 기반을 상속하는 모든 경로가 virtual이어야 합니다.

다음 글 Rule of Three, Five, Zero 실전에서는 자원을 직접 소유하는 클래스의 복사, 이동, 소멸을 실제 코드로 구현하고, 그런 코드를 쓰지 않아도 되는 설계와 비교합니다.