[C++] 다중 상속과 상속의 주의사항

chooha·2026년 1월 5일

C++

목록 보기
15/23

📚 C++ 다중 상속과 상속의 주의사항

🔸 Multiple Inheritance (다중 상속)

다중 상속이란?

  • 하나의 클래스가 여러 부모 클래스를 동시에 상속받는 것
  • C++에서는 가능하지만 Java, C#에서는 금지됨 (인터페이스만 다중 구현 가능)

기본 문법

class Lion : public Animal
{
public:
    void roar() { std::cout << "Roar!" << std::endl; }
};

class Tiger : public Animal
{
public:
    void growl() { std::cout << "Growl!" << std::endl; }
};

class Liger : public Lion, public Tiger  // 다중 상속
{
public:
    void speak() { std::cout << "Liger sound" << std::endl; }
};

🔸 Diamond Problem (다이아몬드 문제)

문제 상황

class Animal
{
public:
    Animal() { std::cout << "Animal constructor" << std::endl; }
    virtual ~Animal() { std::cout << "Animal destructor" << std::endl; }
    void breathe() { std::cout << "Breathing" << std::endl; }
protected:
    int age;
};

class Lion : public Animal
{
public:
    Lion() { std::cout << "Lion constructor" << std::endl; }
};

class Tiger : public Animal
{
public:
    Tiger() { std::cout << "Tiger constructor" << std::endl; }
};

class Liger : public Lion, public Tiger
{
public:
    Liger() { std::cout << "Liger constructor" << std::endl; }
};

int main()
{
    Liger liger;
    // 출력:
    // Animal constructor  ← Lion을 위한 Animal
    // Lion constructor
    // Animal constructor  ← Tiger를 위한 Animal (중복!)
    // Tiger constructor
    // Liger constructor
    
    // liger.breathe();  // ❌ 컴파일 에러! Lion의 breathe? Tiger의 breathe?
    // liger.age = 5;    // ❌ 컴파일 에러! 어느 Animal의 age?
    
    return 0;
}

문제점:
1. Animal 생성자가 2번 호출됨 (메모리 낭비)
2. Liger 객체 안에 Animal이 2개 존재 (모호성)
3. Animal의 멤버에 접근할 때 어느 것인지 불명확

메모리 구조 (일반 다중 상속)

Liger 객체:
┌────────────────┐
│ Lion 부분      │
│  - Animal     │  ← 첫 번째 Animal
│  - Lion 데이터 │
├─────────────────┤
│ Tiger 부분     │
│  - Animal     │  ← 두 번째 Animal (중복!)
│  - Tiger 데이터│
├─────────────────┤
│ Liger 데이터    │
└─────────────────┘

🔸 Virtual Inheritance (가상 상속)

해결 방법: Virtual Inheritance 사용

class Animal
{
public:
    Animal() { std::cout << "Animal constructor" << std::endl; }
    virtual ~Animal() { std::cout << "Animal destructor" << std::endl; }
    virtual void speak() = 0;
protected:
    double animalData;  // 8 bytes
};

class Lion : public virtual Animal  // ✅ virtual 키워드 추가
{
public:
    Lion() { std::cout << "Lion constructor" << std::endl; }
    void speak() override { std::cout << "Roar!" << std::endl; }
protected:
    double lionData;  // 8 bytes
};

class Tiger : public virtual Animal  // ✅ virtual 키워드 추가
{
public:
    Tiger() { std::cout << "Tiger constructor" << std::endl; }
    void speak() override { std::cout << "Growl!" << std::endl; }
protected:
    double tigerData;  // 8 bytes
};

class Liger : public Lion, public Tiger
{
public:
    Liger() { std::cout << "Liger constructor" << std::endl; }
    void speak() override { std::cout << "Liger sound" << std::endl; }
};

int main()
{
    Liger liger;
    // 출력:
    // Animal constructor  ← 단 1번만 호출됨! ✅
    // Lion constructor
    // Tiger constructor
    // Liger constructor
    
    liger.speak();     // ✅ OK - "Liger sound"
    // liger.animalData = 5.0;  // ✅ 모호성 해결! (protected라 직접 접근은 불가)
    
    return 0;
}

가상 상속의 효과

  • Animal 생성자가 단 1번만 호출
  • Liger 객체 안에 Animal1개만 존재
  • 멤버 접근 모호성 해결

🔸 메모리 레이아웃 비교

일반 상속 vs 가상 상속의 메모리 크기 변화

1. 일반 상속 (Lion만 있을 때)

class Lion : public Animal
{
protected:
    double lionData;  // 8 bytes
};

// sizeof(Lion) = 24 bytes
// [vptr: 8] [animalData: 8] [lionData: 8]

2. 가상 상속 (Lion만 있을 때)

class Lion : public virtual Animal
{
protected:
    double lionData;  // 8 bytes
};

// sizeof(Lion) = 32 bytes
// [Lion vptr: 8] [lionData: 8] [Animal vptr: 8] [animalData: 8]

메모리 구조 상세 비교

일반 상속 Lion (24 bytes):
┌─────────────┐
│ vptr (8B)  │ → VTable
├─────────────┤
│ animalData │
├─────────────┤
│ lionData   │
└─────────────┘

가상 상속 Lion (32 bytes):
┌──────────────┐
│ Lion vptr   │ → Lion VTable (offset 포함)
├──────────────┤
│ lionData    │
├──────────────┤
│ Animal vptr │ → Animal VTable
├──────────────┤
│ animalData  │
└──────────────┘

가상 상속이 더 큰 이유:

  • VTable이 2개 필요 (Lion용, Animal용)
  • Lion VTable에 offset 정보 추가 저장
  • Animal 데이터의 위치가 동적으로 결정됨

🔸 Thunk와 Offset

일반 상속에서의 함수 호출

Animal* polyLion = new Lion();
polyLion->speak();  // Lion의 speak 직접 호출
  • Lion의 데이터 위치가 고정되어 있어서 offset 계산 불필요
  • VTable에서 함수 주소를 바로 호출

가상 상속에서의 함수 호출

Animal* polyLion = new Lion();  // Animal 포인터로 가상 상속된 Lion 참조
polyLion->speak();

문제: Animal* 포인터는 객체의 Animal 부분을 가리킴. 하지만 가상 상속에서는 Lion 데이터가 Animal 데이터와 떨어져 있음!

해결: Thunk 사용

Lion VTable (가상 상속):
┌─────────────────────────┐
│ offset to Animal: +16 │ ← offset 정보
├─────────────────────────┤
│ &Lion::speak [thunk]  │ ← thunk 함수
└─────────────────────────┘

Thunk 함수:
1. this 포인터를 offset만큼 조정
2. 실제 Lion::speak() 호출

Thunk란?

  • 포인터를 조정(adjust)하는 작은 함수
  • this 포인터에 offset을 더해서 올바른 위치로 이동
  • 가상 상속에서만 필요 (일반 상속에서는 불필요)

포인터 타입에 따른 VTable 선택

Lion lion;

// Lion* 타입: offset 계산 불필요
Lion* lionPtr = &lion;
lionPtr->speak();  // thunk 없는 VTable 사용 (빠름)

// Animal* 타입: offset 계산 필요
Animal* animalPtr = &lion;
animalPtr->speak();  // thunk 있는 VTable 사용 (느림)

가상 상속의 성능 비용

  • VTable이 커짐 (offset 정보 + thunk 함수 포인터)
  • 간접 참조가 한 단계 더 추가됨
  • 메모리 오버헤드 증가

🔸 다중 상속 사용 지침

다중 상속을 피해야 하는 이유
1. 복잡성 증가: 코드 이해와 유지보수 어려움
2. 다이아몬드 문제: 가상 상속으로 해결 가능하지만 복잡도 증가
3. 성능 오버헤드: 가상 상속은 메모리와 성능 비용 발생
4. 모호성: 같은 이름의 멤버가 여러 부모에 있을 때 충돌

다중 상속이 허용되는 경우

// ✅ OK: 여러 인터페이스 구현
class ISerializable
{
public:
    virtual std::string serialize() = 0;
    virtual ~ISerializable() = default;
};

class ILoggable
{
public:
    virtual void log() = 0;
    virtual ~ILoggable() = default;
};

class User : public ISerializable, public ILoggable
{
    // 순수 가상 함수만 있는 인터페이스는 다중 상속 OK
};

권장 설계 패턴

  • 인터페이스 다중 구현: ✅ 안전
  • 구현 클래스 다중 상속: ❌ 위험
  • Composition over Inheritance: 상속 대신 포함 관계 사용
// ❌ 나쁜 설계: 구현 클래스 다중 상속
class Liger : public Lion, public Tiger { };

// ✅ 좋은 설계: Composition 사용
class Liger : public Animal
{
    Lion lionBehavior;   // Lion을 포함
    Tiger tigerBehavior; // Tiger를 포함
};

🔸 Object Slicing (객체 잘림 현상)

Object Slicing이란?

  • 자식 객체를 부모 타입 값(value) 으로 복사할 때 자식 부분이 잘려나가는 현상
  • 포인터나 참조에서는 발생하지 않음

문제 상황

class Animal
{
public:
    virtual void speak() { std::cout << "Animal sound" << std::endl; }
protected:
    double weight;
};

class Cat : public Animal
{
public:
    void speak() override { std::cout << "Meow~" << std::endl; }
private:
    std::string name;  // Cat만의 데이터
};

int main()
{
    Cat kitty;
    kitty.speak();  // "Meow~"
    
    // ✅ 참조: Object Slicing 없음
    Animal& animalRef = kitty;
    animalRef.speak();  // "Meow~" (가상 함수 정상 동작)
    
    // ❌ 값 복사: Object Slicing 발생!
    Animal animalObj = kitty;  // Cat의 name이 잘려나감!
    animalObj.speak();  // "Animal sound" (Cat의 speak가 아님!)
    
    // sizeof 확인
    std::cout << "sizeof(Cat): " << sizeof(Cat) << std::endl;      // 40 bytes
    std::cout << "sizeof(animalObj): " << sizeof(animalObj) << std::endl;  // 16 bytes
    // Cat의 name이 사라짐!
    
    return 0;
}

Object Slicing이 발생하는 상황

void feedAnimal(Animal animal)  // ❌ 값으로 받음
{
    animal.speak();  // 항상 "Animal sound"
}

Cat kitty;
feedAnimal(kitty);  // Object Slicing 발생!

메모리 구조

Cat 객체 (원본):

┌──────────┐
│ vptr    │
├──────────┤
│ weight  │
├──────────┤
│ name    │  ← Cat 고유 데이터
└──────────┘

Animal animalObj = kitty 복사 후:

🔸 Object Slicing 방지 방법

방법 1: Copy Constructor를 Protected로 선언

class Animal
{
public:
    virtual void speak() = 0;
    virtual ~Animal() = default;
    
protected:
    Animal() = default;
    Animal(const Animal&) = default;  // ✅ protected 복사 생성자
    Animal& operator=(const Animal&) = default;
};

int main()
{
    Cat kitty;
    Animal animalObj = kitty;  // ❌ 컴파일 에러! protected라 접근 불가
    return 0;
}

방법 2: Copy Constructor를 Delete

class Animal
{
public:
    virtual void speak() = 0;
    virtual ~Animal() = default;
    
    Animal(const Animal&) = delete;  // ✅ 복사 금지
    Animal& operator=(const Animal&) = delete;
    
protected:
    Animal() = default;
};

방법 3: Pure Abstract Class로 만들기 (가장 권장)

class IAnimal  // 인터페이스
{
public:
    virtual void speak() = 0;
    virtual ~IAnimal() = default;
    
protected:
    IAnimal() = default;
    IAnimal(const IAnimal&) = delete;
    IAnimal& operator=(const IAnimal&) = delete;
};

// 구체 클래스는 복사 가능
class Cat : public IAnimal
{
public:
    Cat() = default;
    Cat(const Cat&) = default;  // ✅ Cat끼리는 복사 가능
    void speak() override { std::cout << "Meow~" << std::endl; }
};

int main()
{
    Cat kitty1;
    Cat kitty2 = kitty1;  // ✅ OK - Cat끼리는 복사 가능
    
    IAnimal* ptr = &kitty1;
    // IAnimal obj = *ptr;  // ❌ 컴파일 에러! IAnimal은 추상 클래스
    
    return 0;
}

올바른 함수 파라미터 설계

// ❌ 나쁜 설계: 값으로 받음 (Object Slicing)
void feedAnimal(Animal animal) { }

// ✅ 좋은 설계: 참조로 받음
void feedAnimal(const Animal& animal) { }

// ✅ 좋은 설계: 포인터로 받음
void feedAnimal(Animal* animal) { }

🔸 상속에서의 Operator Overloading 문제

문제 상황

class Animal
{
public:
    virtual void speak() { std::cout << "Animal" << std::endl; }
    
    bool operator==(const Animal& other) const
    {
        return weight == other.weight;
    }
    
protected:
    double weight;
};

class Cat : public Animal
{
public:
    void speak() override { std::cout << "Meow~" << std::endl; }
    
    // operator==를 재정의하지 않음!
private:
    std::string name;
};

int main()
{
    Cat kitty1;
    Cat kitty2;
    
    if (kitty1 == kitty2)  // ❌ Animal::operator==가 호출됨
    {                       // name은 비교하지 않음!
        std::cout << "Same cats" << std::endl;
    }
    
    return 0;
}

문제점:

  • 자식 클래스에서 operator==를 재정의하지 않으면 부모의 연산자가 사용됨
  • 자식 클래스의 추가 멤버는 비교되지 않음

해결 방법: 자식 클래스에서 재정의

class Cat : public Animal
{
public:
    bool operator==(const Cat& other) const
    {
        // 부모의 멤버도 비교
        return Animal::operator==(other) && name == other.name;
    }
    
private:
    std::string name;
};

더 나은 해결: 가상 함수로 만들기

class Animal
{
public:
    virtual bool equals(const Animal& other) const
    {
        return weight == other.weight;
    }
    
protected:
    double weight;
};

class Cat : public Animal
{
public:
    bool equals(const Animal& other) const override
    {
        // dynamic_cast로 Cat인지 확인
        const Cat* catPtr = dynamic_cast<const Cat*>(&other);
        if (!catPtr) return false;
        
        return Animal::equals(other) && name == catPtr->name;
    }
    
private:
    std::string name;
};

🔸 Dynamic Cast와 RTTI

RTTI (Run-Time Type Information)

  • 런타임에 객체의 실제 타입을 확인하는 기능
  • dynamic_cast, typeid 연산자 사용
  • 성능 오버헤드와 설계 문제로 사용을 피하는 것이 좋음

Upcasting (업캐스팅): 항상 안전

Cat kitty;
Animal* ptr = &kitty;  // ✅ OK - 자식 → 부모 (안전)

Downcasting (다운캐스팅): 위험

Animal* animalPtr = getAnimal();  // 실제로 무엇인지 모름

// ❌ static_cast: 위험! 타입 체크 없음
Cat* catPtr = static_cast<Cat*>(animalPtr);
catPtr->meow();  // animalPtr이 실제로 Cat이 아니면 Undefined Behavior!

// ⚠️ dynamic_cast: 안전하지만 권장하지 않음
Cat* catPtr2 = dynamic_cast<Cat*>(animalPtr);
if (catPtr2)  // nullptr 체크 필요
{
    catPtr2->meow();  // ✅ 안전
}
else
{
    std::cout << "Not a Cat!" << std::endl;
}

dynamic_cast 동작 원리

Animal* ptr = &someObject;
Cat* catPtr = dynamic_cast<Cat*>(ptr);

// 내부 동작:
// 1. ptr이 가리키는 객체의 VTable 확인
// 2. VTable의 type_info 조회
// 3. 상속 계층 구조를 검사
// 4. Cat으로 안전하게 변환 가능하면 포인터 반환
// 5. 불가능하면 nullptr 반환

dynamic_cast를 피해야 하는 이유

  1. 성능 오버헤드

    • VTable 탐색과 타입 체크 비용
    • 상속 계층이 깊을수록 느려짐
  2. 설계 문제의 신호

    // ❌ 나쁜 설계: 타입 체크
    void process(Animal* animal)
    {
        if (Cat* cat = dynamic_cast<Cat*>(animal))
        {
            cat->meow();
        }
        else if (Dog* dog = dynamic_cast<Dog*>(animal))
        {
            dog->bark();
        }
        // 새로운 동물이 추가되면 이 코드를 수정해야 함!
    }
    
    // ✅ 좋은 설계: 가상 함수 사용
    void process(Animal* animal)
    {
        animal->speak();  // 각 클래스가 자신의 방식으로 speak 구현
    }
  3. Open-Closed Principle 위반

    • 새로운 타입 추가 시 기존 코드 수정 필요

올바른 대안: Virtual Function 사용

class Animal
{
public:
    virtual void makeSound() = 0;
    virtual void play() = 0;
    virtual ~Animal() = default;
};

class Cat : public Animal
{
public:
    void makeSound() override { std::cout << "Meow~" << std::endl; }
    void play() override { std::cout << "Playing with yarn" << std::endl; }
};

class Dog : public Animal
{
public:
    void makeSound() override { std::cout << "Woof!" << std::endl; }
    void play() override { std::cout << "Playing fetch" << std::endl; }
};

// ✅ 타입 체크 없이 처리
void interact(Animal* animal)
{
    animal->makeSound();
    animal->play();
    // 새로운 동물이 추가되어도 이 코드는 수정 불필요!
}

dynamic_cast가 허용되는 경우

  • 레거시 코드와의 상호작용
  • 외부 라이브러리의 제약
  • 정말로 타입 정보가 필요한 특수한 경우 (예: 직렬화, 리플렉션)

🔸 상속 설계 Best Practices

1. Base 클래스는 Pure Abstract Class로 만들기

// ✅ 권장: 인터페이스
class IAnimal
{
public:
    virtual void speak() = 0;
    virtual void eat() = 0;
    virtual ~IAnimal() = default;
    
protected:
    IAnimal() = default;
    IAnimal(const IAnimal&) = delete;
    IAnimal& operator=(const IAnimal&) = delete;
};

이유:

  • Object Slicing 방지
  • 다형성 강제
  • 명확한 인터페이스 정의

2. Composition over Inheritance

// ❌ 상속 남용
class Stack : public std::vector<int>
{
    // vector의 모든 기능이 노출됨 (insert, erase 등)
};

// ✅ Composition 사용
class Stack
{
public:
    void push(int value) { data.push_back(value); }
    int pop() { int val = data.back(); data.pop_back(); return val; }
    
private:
    std::vector<int> data;  // 포함 관계
};

3. 가상 소멸자 필수

class Base
{
public:
    virtual ~Base() = default;  // ✅ 필수!
};

4. override 키워드 사용

class Derived : public Base
{
public:
    void func() override;  // ✅ 오버라이드 의도 명확히
};

5. final 키워드로 상속 제한

class FinalClass final  // 더 이상 상속 불가
{
};

class Base
{
public:
    virtual void func() final;  // 이 함수는 더 이상 오버라이드 불가
};

6. 다중 상속은 인터페이스로만

// ✅ OK: 인터페이스 다중 구현
class Serializable : public ISerializable, public ILoggable { };

// ❌ 피하기: 구현 클래스 다중 상속
class Liger : public Lion, public Tiger { };

🔸 핵심 정리

다중 상속 관련

  • 다이아몬드 문제: 가상 상속으로 해결 가능하지만 복잡도와 성능 비용 증가
  • 가상 상속: offset과 thunk로 인한 메모리 및 성능 오버헤드
  • 권장: 인터페이스 다중 구현만 사용, 구현 클래스 다중 상속 피하기

Object Slicing

  • 원인: 자식 객체를 부모 타입 값으로 복사
  • 해결: 복사 생성자 protected/delete, Pure Abstract Class 사용
  • 함수 파라미터: 항상 참조나 포인터로 받기

Dynamic Cast와 RTTI

  • 피해야 하는 이유: 성능 오버헤드, 설계 문제의 신호
  • 대안: Virtual Function으로 다형성 구현
  • 허용되는 경우: 레거시 코드, 외부 라이브러리 제약

설계 원칙
1. Base 클래스는 Pure Abstract Class로
2. Composition over Inheritance
3. 가상 소멸자 필수
4. override/final 키워드 활용
5. 인터페이스 다중 구현만 허용


<참고 자료>

코드없는 프로그래밍
C++ Multiple Inheritance
C++ Virtual Inheritance

0개의 댓글