[Effective C++]다중 상속은 심사숙고해서 사용하자

상속, 그리고 객체 지향 설계, 아홉 번째 이야기

Posted by SungBeom on December 28, 2019 · 16 mins read

다중 상속의 모호성 문제

C++에서 다중 상속(multiple inheritance: MI)의 사실 중 하나는, 둘 이상의 기본 클래스로부터 똑같은 이름(이를테면 함수, typedef 등)을 물려받을 가능성이 생겨 모호성이 발생한다는 점입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BorrowableItem {
public:
    void checkOut();
    ...
};
 
class ElectronicGadget {
private:
    bool checkOut() const;
    ...
};
 
class MP3Player:  // 여기서 다중 상속이 됩니다.
    public BorrowableItem,
    public ElectronicGadget
{ ... };
 
MP3Player mp;
 
mp.checkOut();    // 모호성 발생! 어느 checkOut인지 모호합니다.
cs

checkOut 함수를 호출하는 부분에서 모호성이 발생하고 있는데, 여기서 눈여겨둘 사실은 두 checkOut 함수들 중에서 파생 클래스가 접근할 수 있는 함수가 딱 결정되는 것이 분명한데도(BorrowableItem에서는 public 멤버이지만, ElectronicGadget에서는 private 멤버이죠) 모호성이 생긴다는 점입니다. 이것은 중복된 함수 호출 중 하나를 골라내는 C++의 규칙을 따른 결과입니다. 어떤 함수가 접근 가능한 함수인지를 알아보기 전에, C++ 컴파일러는 이 규칙을 써서 주어진 호출에 대해 최적으로 일치하는(best-match) 함수인지를 먼저 확인합니다. 다시 말해, 최적 일치 함수를 찾은 후에 비로소 함수의 접근가능성을 점검한다는 이야기입니다. 지금의 경우 두 checkOut 함수는 C++ 규칙에 의한 일치도가 서로 같기 때문에, 최적 일치 함수가 결정되지 않습니다. 그렇기 때문에 ElectronicGadget::checkOut 함수의 접근가능성이 점검되는 순서조차 오지 않는 것입니다.

mp.BorrowableItem::checkOut();
작금의 모호성을 해소하려면, 위와 같이 호출할 기본 클래스의 함수를 손수 지정해 주어야 합니다.
ElectronicGadget::checkOut 함수를 호출해 봤자 함수 모호성 에러가 나올 자리에 private 멤버 함수를 호출을 알리는 컴파일 에러가 나올 것입니다.

일반 상속 vs 가상 상속

다중 상속의 의미는 그냥 '둘 이상의 클래스로부터 상속을 받는 것'일 뿐이지만, 이 MI는 상위 단계의 기본 클래스를 여러 개 갖는 클래스 계통에서 심심치 않게 눈에 띕니다. 이런 구조의 계통에서는 소위 "죽음의 MI 마름모꼴(deadly MI diamond)"이라고 알려진 좋지 않은 모양이 나올 수 있습니다.

1
2
3
4
5
6
class File { ... };
 
class InputFile: public File { ... };
class OutputFile: public File { ... };
 
class IOFile: public InputFile, public OutputFile { ... };
cs

이렇게 기본 클래스와 파생 클래스 사이의 경로가 두 개 이상이 되는 상속 계통을 혹시라도 쓰게 되면, 기본 클래스의 데이터 멤버가 경로의 개수만큼 중복 생성됩니다. 예를 들어, File 클래스 안에 fileName이라는 데이터 멤버가 하나 들어 있다고 생각해 보면, IOFile 클래스에는 이 필드가 몇 개가 들어 있어야 할까요? C++은 기본적으로 데이터 멤버를 중복생성하는 쪽으로 데이터 멤버가 두 개가 들어있게 됩니다. 하지만 만약 데이터 멤버의 중복생성을 원한 것이 아니었다면, 해당 데이터 멤버를 가진 클래스를 가상 기본 클래스(virtual base class)로 만드는 것으로 데이터 멤버가 하나만 들어있게 할 수도 있습니다. 더 자세히 말씀드리면, 가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속(virtual inheritance)을 사용하게 만드는 것입니다.

1
2
3
4
5
6
class File { ... };
 
class InputFile: virtual public File { ... };
class OutputFile: virtual public File { ... };
 
class IOFile: public InputFile, public OutputFile { ... };
cs

사실 표준 C++ 라이브러리가 이런 모양의 MI 상속 계통을 하나 갖고 있습니다. 클래스가 아니라 클래스 템플릿인데, 위의 File, InputFile, OutputFile, IOFile 자리에 각각 basic_ios, basic_istream, basic_ostream, basic_iostream이 들어가면 됩니다.

정확한 동작의 관점에서 보면, public 상속은 반드시 항상 가상 상속이어야 하는 것이 맞습니다. 그런데 정확성 외에 다른 측면도 같이 생각해야 합니다. 가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않는 것보다 일반적으로 크기가 더 큽니다. 게다가, 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 비가상 기본 클래스의 데이터 멤버에 접근하는 속도보다 느립니다. 세부적인 크기와 속도 차이는 컴파일러마다 다르지만, 공통적으로 가상 상속은 분명히 비쌉니다.

게다가 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한데다가 직관성도 떨어집니다. 대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생기게 됩니다. 이때 들어가는 초기화 규칙은, 다음과 같습니다.
첫째, 초기화가 필요한 가상 기본 클래스로부터 클래스가 파생된 경우, 이 파생 클래스는 가상 기본 클래스와의 거리에 상관없이 가상 기본 클래스의 존재를 염두에 두고 있어야 합니다.
둘째, 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 클래스(역시 거리에 상관없이)의 초기화를 떠맡아야 합니다.

가상 기본 클래스(그러니까 가상 상속)에 대해 드릴 수 있는 조언은 다음과 같습니다.
첫째, 구태여 쓸 필요가 없으면 가상 기본 클래스를 사용하지 말고, 비가상 상속을 기본으로 삼으세요.
둘째, 가상 기본 클래스를 정말 쓰지 않으면 안 될 상황이라면, 가상 기본 클래스에는 데이터를 넣지 않는 쪽으로 최대한 신경을 쓰세요.

인터페이스 클래스로부터 public 상속 + 구현을 돕는 클래스로부터 private 상속

이번에는 C++ 인터페이스 클래스를 써서 사람을 모형화해 보도록 합시다.

1
2
3
4
5
6
7
class IPerson {
public:
    virtual ~IPerson();
 
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
};
cs

IPerson을 쓰려면 분명히 IPerson 포인터 및 참조자를 통해 프로그래밍을 해야 할 것입니다. 추상 클래스를 인스턴스로 만들 수는 없으니까요. 조작이 가능한 IPerson 객체(정확히 말하면 IPerson의 동작 원리를 그대로 쓸 수 있는 객체)를 생성하기 위해, IPerson의 사용자는 팩토리 함수를 사용해서 IPerson의 구체 파생 클래스를 인스턴스로 만듭니다.

1
2
3
4
5
6
7
8
9
10
// 유일한 데이터베이스 ID로부터 IPerson 객체를 만들어내는 팩토리 함수입니다.
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
 
// 사용자로부터 데이터베이스 ID를 얻어내는 함수
DatabaseID askUserDatabaseID();
 
DatabaseID id(askUserDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id));  // IPerson 인터페이스를 지원하는 객체를 하나 만들고
                                                   // pp로 가리키게 합니다. 이후에는 *pp 조작을 위해
                                                   // Iperson 멤버 함수를 사용합니다.
cs

makePerson 함수가 자신이 반환할 포인터로 가리킬 객체를 새로 만들기 위해서는, 분명히 makePerson 함수가 인스턴스로 만들 수 있는 구체 클래스가 IPerson으로부터 파생되어 있어야 할 것입니다. 이 클래스의 이름이 CPerson이라고 가정합시다. 구체 클래스가 원래 그렇듯, CPerson은 IPerson으로부터 물려받은 순수 가상 함수에 대한 구현을 제공해야 하겠지요.

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
    ...
};
cs

PersonInfo 클래스를 이리저리 살피다가, 이 클래스에는 데이터베이스 필드를 다양한 서식으로 출력할 수 있는 기능을 갖고 있다는 사실을 알아냈습니다. 이 기능을 쓰면 각 필드값의 시작과 끝을 임의의 문자열로 구분하여 출력할 수 있습니다. 기본적으로, 출력용 필드값의 시작과 끝에 붙는 구분자가 대괄호([])로 미리 정해져 있습니다. 그러니까 어떤 필드값이 "Ring-tailed Lemur"이라면 '['가 시작(oepn) 구분자, ']'가 끝(close) 구분자로 쓰이면서 '[Ring-tailed Lemur]'와 같이 서식화되는 것입니다. 따라서 사용자가 원하는 시작 구분자와 끝 구분자를 파생 클래스에서 지정할 수 있도록 valueDelimOpen 함수와 valueDelimClose 함수를 가상 함수로 마련해 둡니다. 그리고 PersonInfo 클래스의 다른 멤버 함수들은 이 가상 함수를 통해 자신들이 사용하는 필드 값에 적절한 구분자를 붙이도록 구현되는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const char * PersonInfo::valueDelimOpen() const
{
    return "[";
}
 
const char * PersonInfo::valueDelimClose() const
{
    return "]";
}
 
const char * PersonInfo::theName() const
{
    // 반환 값을 위한 버퍼를 예약해 둡니다. 이 버퍼는
    // 정적 메모리이기 때문에, 자동으로 0으로 초기화됩니다.
    static char value[Max_Formatted_Field_Value_Length];
 
    // 시작 구분자를 value에 씁니다.
    std::strcpy(value, valueDelimOpen());
    value에 들어 있는 문자열에 이 객체의 name 필드를 덧붙입니다.
    // 끝 구분자를 value에 추가합니다.
    std::strcat(value, valueDelimClose());
    return value;
}
cs

theName은 valueDelimOpen을 호출해서 시작 구분자를 만들고, name 값 자체를 만든 다음, valueDelimClose를 호출하도록 구현됐습니다. 이때 valueDelimOpen 및 valueDelimClose는 가상 함수이기 때문에, theName이 반환하는 결과는 PersonInfo에만 좌우되는 것이 아니라 PersonInfo로부터 파생된 클래스에도 좌우됩니다. CPerson을 구현하는 사람의 입장에서 볼 때 이점은 아주 반가운 소식인데, IPerson의 문서를 읽다 보면 name과 birthDate 함수가 반환하는 값에는 장식, 즉 구분자가 붙으면 안 된다는 사실을 알게 될 것이기 때문입니다. 어떤 사람(사람 객체)의 이름이 Homer라면 그 사람의 name 함수는 "[Homer]"가 아닌 "Homer"를 반환해야 한다는 이야기입니다.

PersonInfo 클래스는 CPerson을 구현하기 편하게 만들어 주는 함수를 갖고 있기 때문에, 둘의 관계는 is-implemented-in-terms-of 관계입니다. 이 관계를 표현하는 방법은 객체 합성과 private 상속인데, 현재는 CPerson 클래스에서는 valueDelimOpen 및 valueDelimClose를 반드시 재정의해야 하므로, 단순한 객체 합성으로는 목적 달성이 불가능합니다. 물론 객체 합성과 public 상속을 조합하는 방법도 있지만, 여기서는 private 상속을 이용해 보겠습니다. 한편, CPerson 클래스는 IPerson 인터페이스도 함께 구현하지 않으면 안 되기 때문에, 이를 위해서는 public 상속이 필요합니다. 이렇게 해 놓고 보니 다중 상속을 의미 있게 써먹는 예가 하나 나옵니다. 바로 지금처럼 인터페이스의 public 상속과 구현의 private 상속을 조합하는 것 말입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 * valueDelimOpen() const;
    virtual const char * valueDelimClose() const;
    ...
};
 
class CPerson: public IPerson, private PersonInfo {      // MI가 쓰였습니다.
public:
    explicit CPerson(DatabaseID pid): PersonInfo(pid) {}
 
    virtual std::string name() const                     // IPerson 클래스의 순수 가상 함수에
    { return PersonInfo::theName(); }                    // 대한 파생 클래스의 구현을 제공합니다.
 
    virtual std::string birthDate() const
    { return PersonInfo::theBirthDate(); }
private:                                                 // 구분자에 관련된 가상 함수들도
    const char * valueDelimOpen() const { return ""; }   // 상속되므로 이 함수들에 대한
    const char * valueDelimClose() const { return ""; }  // 재정의 버전을 만듭니다.
};
cs

이번 예제를 보면, MI도 경우에 따라서는 상당히 쓸 만하고 나름대로 의미가 있습니다. 다중 상속은 대단한 것이 아니라, 그냥 객체 지향 기법으로 소프트웨어를 개발하는 데 쓰이는 도구 중 하나로 보시면 됩니다. 단일 상속과 비교해서 사용하기에도 좀더 복잡하고 이해하기에도 좀더 복잡하다는 것은 사실이므로, MI 설계와 동등한 효과를 내는 단일 상속(single inheritance: SI) 설계를 뽑을 수 있다면 SI 쪽으로 가는 것이 확실히 좋습니다.


정리

다중 상속은 단일 상속보다 확실히 복잡합니다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있습니다.
가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적입니다.
다중 상속을 적법하게 쓸 수 있는 경우가 있습니다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.