[C++ Basic] 다중 상속이란?

Fuji ㅣ 2022. 6. 13. 12:51

다중 상속(Multiple inheritance; MI)은 어떠한 클래스가 하나 이상의 상위클래스에 대해서 상속 받는걸 의미한다. 이번 포스팅에서는 다중 상속이 가지는 특징을 간단한 예제 코드를 통해 정리하고 다중 상속이 지니는 문제점과 그에 대한 해결책을 포스팅하려고 한다.

 

 


목차

  • 다중 상속의 특징
  • 다중 상속의 문제점
  • 가상 기초 클래스(virtual base class)
  • 가상 기초 클래스는 왜 virtual 키워드를 사용하게 되었을까?
  • 가상 기초 클래스의 문법 작성 방식
  • 가상 기초 클래스와 가상이 아닌 기초 클래스의 다중 상속

 


 

다중 상속의 특징

다중 상속은 단일 상속과 마찬가지로 is-a 관계를 구축할 때 사용된다. 여러 기초클래스들을 하나의 파생클래스에 상속하거나 하나의 기초클래스에 대해 상속 받은 여러 파생클래스를 다시 하나의 클래스에 봉합해서 사용하는 것을 다중 상속이라고 한다. 하지만 다중 상속은 큰 단점이 존재하는데, 바로 사용하기가 어렵고 복잡하다는 것이다. 대표적인 예로 여러 기초 클래스에 대해 상속받은 파생 클래스에서 상속된 멤버 함수의 이름이 같고 기능이 다르다면 문법의 모호함이 발생할 수 있다. 이런 문제들을 해결하기 위해서 단일 상속과는 다른 방식으로 문법을 작성해야 하므로 사용하기 매우 까다롭다. 복잡하고 모호한 이러한 문제들로 인해 많은 C++ 프로그래머들 사이에서 다중 상속의 사용에 대해 강하게 반대하는 입장이 많다. 하지만 방식의 차이일 뿐 정답은 없다고 보기 때문에 적절한 테크닉으로 상황에 맞게 절제하여 사용한다면 단일 상속과는 별개로 다른 이점을 취할 수도 있기 때문에 만약 사용한다면 주의 깊게 사용해야한다.

 


 

다중 상속의 문제점

다중 상속을 사용함에 있어 단일 상속에서 찾아볼 수 없는 문제점이 있다.

 

우선 하나의 기초클래스에 대해서 상속받는 두 가지의 파생클래스가 있다고 가정해보자.

 

// 기초 클래스 예시
class base
{
  // 생략
}

// base 클래스로부터 상속된 Acl 파생클래스 
class Acl : public base
{
private:
   int num;
public:
   Acl() : base(), num(0) {}
   ~Acl() {}
}

// base 클래스로부터 상속된 Acl 파생클래스 
class Bcl : public base
{
private:
   int num;
public:
   Bcl() : base(), num(0) {}
   ~Bcl() {}
}

 

그리고 위의 Acl, Bcl 파생클래스에 대해서 다중 상속받는 Ccl 클래스를 정의해보자.

 

class Ccl : public Acl, Public Bcl
{
   private:
   int num;
public:
   Ccl() : Acl(1), Bcl(2), num(0) {}
   ~Ccl() {}
}

 

여기서 다중 상속으로 인해 발생되는 문제점은 기초 클래스 포인터에 대입하는 업캐스팅 과정에서 발생하게 된다. 일반적으로 기초클래스의 포인터는 상속된 파생클래스에 대해서는 모두 가리킬 수 있다. 하지만 다중 상속된 Ccl 클래스를 기초클래스 base 클래스 포인터로 가르키면 모호한 상황이 발생하게 된다. 이유는 Acl 클래스와 Bcl 클래스를 초기화하려면 base 클래스를 이니셜라이징을 통해 생성자 초기화를 해주어야 하는데, Acl, Bcl 파생클래스에 대해서 다중 상속받은 Ccl 같은 경우 Acl, Bcl이 갖고 있는 Base 클래스 대한 객체의 주소가 두 개가 되어버리기 때문이다. 

 

Acl acl;   // Acl 클래스 초기화
Bcl bcl;   // Bcl 클래스 초기화
Ccl ccl;   // Ccl 클래스 초기화

Base *ptr = &acl;   // 업캐스팅 가능
Base *ptr = &bcl;   // 업캐스팅 가능
Base *ptr = &ccl;   // 객체 주소의 모호함으로 인해 업캐스팅 불가능

 

기초 클래스의 포인터로 파생클래스를 가리킬 수 있게 만드는 내부적 원리는 간단하다. 해당 파생클래스가 초기화할 때 내부의 기초클래스 객체 또한 초기화시켜주게 되는데 그 기초클래스 객체의 주소를 가리키면서 동작하는 원리이다. 하지만 ccl 같은 경우에는 Acl에 대한 기초클래스 주소와 Bcl에 대한 기초클래스의 주소가 2개가 되기 때문에 어떤걸 가르켜야할 지 알 수가 없어서 모호함이 생기게 되는 것이다. 하지만 이 부분에 대한 해결을 다음과 같이 하면 어떨까?

 

Base *ptr = (Acl*) &ccl;   
Base *ptr = (bcl*) &ccl;

 

이렇게 강제 데이터 형변환을 이용해서 가르키게 만들 수는 있지만 기초 클래스 포인터들의 배열을 사용해서 다양한 종류의 객체를 참조하는 다형 기법을 어렵게 만들기 때문에 좋은 해결책이 될 순 없다. 하지만 이에 대한 해결책은 C++에서 제시해주고 있다. 그게 바로 가상 기초 클래스(virtual base class) 이다.

 

 


 

 

가상 기초 클래스(virtual base class)

C++은 다중 상속 기능을 추가함과 동시에 위에서 소개한 다중 상속에 대한 문제점을 해결하기 위해 가상 기초 클래스(virtual base class)를 추가하였다. 가상 기초 클래스(virtual base class)는 파생클래스가 많아져도 동일한 기초 클래스의 객체를 상속할 수 있게 한다. 사용 방법은 클래스 선언에 키워드 virtual을 사용해주면 된다. 

 

class Acl : virtual public Base { ... };
class Bcl : public virtual Base { ... };

 

virtual 키워드와 public 순서는 상관이 없다. 가상 기초 클래스를 사용하는 이유는 위에서 소개한 다중 상속의 문제점을 간단하게 해결 할 수 있기 때문이다. 가상 기초 클래스를 사용하게 되면 파생클래스들은 모두 하나의 기초클래스 주소를 갖게 되기 때문에 모호함이 발생하지 않게 된다. 그렇다는건 다형 기법을 다시 사용할 수 있다는 의미가 된다.

 

Base *ptr = &acl;   // 업캐스팅 가능
Base *ptr = &bcl;   // 업캐스팅 가능
Base *ptr = &ccl;   // 업캐스팅 가능

 

 


 

 

가상 기초 클래스는 왜 virtual 키워드를 사용하게 되었을까

 

  • 왜 virtual 키워드를 사용할까?
  • 왜 기초 클래스들을 가상으로 선언하는 식으로 다중 상속의 표준으로 삼지 않을까?

 

첫번째, C++은 보통 새로운 키워드에 대한 도입에 대해 매우 비판적이고 소극적인 자세를 취하기 때문에 virtual 함수와 큰 연결점이 없어도 virtual 키워드를 일종의 키워드 오버로딩처럼 사용하는걸 채택했다.

 

두번째, 보통 하나의 기초클래스에 대해 여러번의 복사본을 원하는 경우가 많고, 특히나 가상으로 선언하게 되면 프로그램이 내부적으로 추가적인 작업을 해야하기 때문에 표준으로 삼기 어렵다. 필요 없는 순간에도 그런 부담을 떠안을 필요는 없기 때문이다. 

 


 

가상 기초 클래스의 문법 작성 방식

가상 기초 클래스가 정상적으로 작동하게 하기 위해선 생성자의 작성 규칙을 몇 가지 조정할 필요가 있다. 

 

class A
{
    int a;
public:
    A(int n = 0) { a = n; }
    ...
};

class B : public A
{
    int b;
public:
    B(int m = 0, int n = 0) : A(n) { b = m; }
    ....
};

class C : public B
{
    int c;
public:
    C(int q = 0, int m = 0, int n = 0) : B(m, n) { c = q; }
    ....
};

 

위 코드는 평소 상속되는 과정을 간단하게 나타낸 코드이다. 여기서 C 클래스는 B 클래스로 부터 파생되었으므로 A 클래스를 초기화하기 위해서는 B 클래스를 초기화하는 식으로 간접적으로 생성자를 초기화해주게 된다. 하지만 가상 기초 클래스를 사용하게 될 경우 이 기능을 사용할 수 없게된다. 즉, C 클래스가 간접적으로 A 클래스를 초기화해줄 수 없게 된다는 의미이다. 결국 자동적으로 A클래스의 디폴트 생성자를 호출하게 된다. 만약 직접 A 클래스를 초기화해주어야 하는 상황이 필요하다면 다음과 같이 직접 생성자를 초기화해주면 된다.

 

class C : public B
{
    int c;
public:
    C(int q = 0, int m = 0, int n = 0) : A(n), B(m) { c = q; }
    ....
};

 

이렇게 하면 A클래스에 대한 생성자 호출을 디폴트 생성자를 사용하지 않고 프로그래머가 직접 해줄 수 있게 된다. 

 


 

가상 기초 클래스와 가상이 아닌 기초 클래스 다중 상속

만약 파생클래스가 가상 기초 클래스와 동시에 가상이 아닌 기초 클래스를 다중 상속받게 된다면 가상 기초 클래스에 대한 경로와 일반적인 기초 클래스의 경로를 별도로 종속 객체들을 가지게 된다.