[C++] 코드의 재활용(다중 상속)
C++가 추구하는 중요한 목표 중의 하나가 코드의 재활용성을 높이는 것이다.
한 가지 테크닉은, 다른 클래스에 속하는 객체를 클래스 멤버로 사용하는 것이다. 이것을 우리는 컨테이먼트(containment), 컴퓨지션(composition) 또는 레이어링(layering)이라 부른다. 또 한가지 테크닉은, private 상속이나 protexted 상속을 사용하는 것이다. 컨테인먼트, private 상속, protected 상속은 일반적으로 has - a 관계를 나타낸다. has - a 관계는 새로운 클래스가 다른 클래스의 객체를 포함하는 관계이다.
Student 클래스 설계
C++에서 has-a 관계를 모델링하는 일반적인 테크닉은, 컴포지션(컨테인먼트)을 사용하는 것이다. 즉, 다른 클래스의 객체들이 멤버로 가지는(내포하는) 클래스를 만드는 것이다. 예를 들면, 다음과 같은 선언으로 Student 클래스를 시작할 수 있다.
class Student
{
private:
string name;
valarray<double> scores;
};
private 상속
public 상속에서는, 기초 클래스의 public 메서드가 파생 클래스의 public 메서드가 된다. 다시 말해서, 파생 클래스는 기초 클래스의 인터페이스를 상속한다. 이것이 is-a 관계의 역할이다. 그러나 private 상속에서는, 기초 클래스의 public 메서드가 파생 클래스의 private 메서드가 된다. 즉, 파생 클래스가 기초 클래스의 인터페이스를 상속하지 않는다. 내포된 객체에서 살펴보았듯이, 상속이 안 되는 것은 has-a 관계의 역할이다.
컴터인먼트는, 객체를 이름이 있는 멤머 객체로 클래스에 추가한다.반면에 private상속은, 객체를 이름이 없는 상속된 객체로 클래스에 추가한다. 이 책에서는 상속에 의해 추가된 객체나, 컨테인먼트에 의해 추가된 객체를 모두 종속 객체(subobject)라고 부른다.
private 상속은 컨테인먼트와 동일한 기능을 제공한다. 즉, 구현은 획득하지만 인터페이스는 획득하지 않는다. 그러므로 그것 역시 has-a 관계를 구현하는 데 사용할 수 있다.
Student 클래스 예재(새 버전)
private 상속을 사용하려면, 클래스를 정의할 때 public 대신에 private를 키워드로 사용해야 한다.(디폴트는 private이다. 그래서 접근 제한자를 생략하면 private 상속이 된다.). Student 클래스는 두 개의 클래스로부터 상속해야 한다. 그러므로 선언에 그 두 개의 클래스를 모두 적어야 한다.
class Student : private std::dtring, private std::valarray<double>
{
public:
...
}
기초 클래스가 하나 이상일 경우에 그것을 다중 상속(multiple inheritance; MI)이라고 부른다. 일반적으로 MI를 사용하면, 부가적인 문법 규칙을 통해 해결해야 하는, 몇 가지 문제가 발생한다.
기초 클래스 성분의 초기화
멤버 객체 대신에 암시적으로 상속된 성분을 사용하려면, 더 이상 name과 scores를 사용하여 그 객체들을 나타낼 수 없기 때문에 public 상속에 사용했던 테크닉을 다시 사용해야 한다. 예를 들어 생성자들을 고려해 보자. 컨테인먼트는 다음과 같은 생성자를 사용한다.
Student(const char * str, const double * pd, int n)
: name(str), score(pd, n) {} // 컨테인먼트를 위해 객체 이름을 사용한다.
새 버전은 상속된 클래스들을 위해 멤버 초기자 리스트 문법을 사용해야 한다. 그것은 멤버 이름 대신 클래스 이름을 사용하여 생성자들을 식별한다.
Student(const char * str, const double * pd, int n)
: std::string(str), ArrayDb(pd, n) {} // 상속을 위해 클래스 이름을 사용한다.
컨테인먼트와 private 상속
컨테인 먼트와 private 상속 둘 중 어느 하나를 사용하여 has-a 관계를 모델링할 수 있다면, 어느 것을 사용해야 할까? 대부분의 C++ 프로그래머는 컨테이먼트를 선호한다.
첫째 이유는, 컨테인먼트가 사용하기 쉽기 때문이다. 클래스 선언을 들여다보면 내포된 클래스를 나타내는 객체가 명시적으로 이름이 지정되어 있고, 프로그램에서 이들 객체를 이름을 사용하여 참조할 수 있다. 상속을 사용하는 것은 그 관계를 조금 추상적으로 만든다.
둘째 이유는, 하나 이상의 기초 클래스로부터 클래스를 상속할 때, 상속이 문제를 일으킬 수 있기 때문이다. 즉, 별개의 키초 클래스들이 하나의 조상 클래스들을 공유하고 있을 때의 문제, 또는 별개의 기초 클래스들이 같은 이름을 가진 메서드를 사용하고 있을 때의 문제과 같은 것들을 해결해야 하는 일이 생길 수 있다.
protected 상속
protected 상속은 private 상속의 변종이다. protected 상속은 기초 클래스를 나열할 때 키워드 protected를 사용한다.
class Student : protected std::string, protected std::valarray<double>{ ... };
protected 상속에서는, 기초 클래스의 public 멤버와 protected 멤버가 파생 클래스의 protected 멤버가 된다.
public 상속, private 상속, protected 상속을 요약하면 다음 표와 같다.
특성 | public 상속 | protected 상속 | private 상속 |
public 멤버 | 파생 클래스의 public 멤버 | 파생 클래스의 protected 멤버 | 파생 클래스의 private 멤버 |
protected 멤버 | 파생 클래스의 protected 멤버 | 파생 클래스의 protected 멤버 | 파생 클래스의 private 멤버 |
private 멤버 | 기초 클래스 인터페이스를 통해서만 접근할 수 있다. | 기초 클래스 인터페이스를 통해서만 접근할 수 있다. | 기초 클래스 인터페이스를 통해서만 접근할 수 있다. |
암시적 업캐스팅 | 가능하다 | 파생 클래스 안에서만 가능하다 | 불가능하다. |
여기서 암시적 업캐스팅(implicit upcasting)이라는 말은, 명시적 데이터형을 변환을 사용하지 않아도 기초 클래스 포인터나 참조가 파생 클래스 객체를 참조할 수 있다는 것을 뜻한다.
using을 사용하여 접근 사디 정의하기
protected 파생이나 private 파생을 사용할 때, 기초 클래스의 public 멤버들은 protected 멤버 또는 private 멤버가 된다. 어떤 특정 기초 클래스 메서드를 파생 클래스에서 public으로 사용할 수 있게 하고 싶다고 가정하자.
한 가지 해결책은, 기초 클래스 메서드를 사용하는 파생 클래스 메서드를 정의하는 것이다. 예를 들어, Student 클래스가 valarray 클래스의 sum() 메서드를 사용할 수 있게 하고 싶다고 가정하자, 그렇다면 클래스 선언에 sum() 메서드를 선언하고, 그 메서드를 다음과 같이 정의할 수 있다.
double Stuendt::sum() const
{
return std::valarry<double>::sum();
}
그러면 이제 Student 객체는 Student::sum() 을 호출할 수 있다. 이것은 다시 내포된 valarray 객체에 valarray<double>::sum() 메서드를 적용한다.
하나의 함수 호출을 다른 함수 안에 넣을 수 있는 다른 방도가 있다. using 선언을 사용하여, 파생 클래스에서 사용할 특정 기초 클래스 멤버를 지정하는 것이다. 예를 들어, valarray 클래스의 min()과 max() 메서드를 Student 클래스와 함께 사용할 수 있게 하고 싶다고 가정하자. 이런 경우에, studenti.h의 public 부분에 using 선언을 추가할 수 있다.
class Student: private std::string, private std::valarray<double>
{
...
public:
using std::valarray<double>::min;
using std::valarray<double>::max;
>>>
};
이 using 선언은 valarray<double>::min()과 valarray<double>::max() 메서드를 마치 public의 Student 메서드인 것처럼 사용할 수 있게 만든다.
또 한 가지 방법은, private으로 파생된 클래스에 기초 클래스 메서드들을 다시 선언하는 것이다. 즉, 다음과 같이 파생 클래스의 public 부분에 그 메서드 이름을 넣는 구식 방법이 있다.
class Student : private std::string, private std::valarray<double>
{
public:
std::valarray<double>::operater[]; // public으로 이름만 다시 선언한다.
...
}
다중 상속
다중 상속(multiple inberitance; MI)은 직계 인접한 기초 클래스를 하나 이상 가지는 클래스를 서술한다. 단일 상속과 마찬가지로, public 다중 상속은 is-a 관계를 나타내야 한다.
다중 상속은 프로그래머에게 새로운 문제를 안겨 줄 수 있다. 별개의 두 기초 클래스로부터 이름은 같지만 서로 다른 메서드들을 상속하는 문제와, 둘 이상의 서로 관련된 인접 기초 클래스들로부터 어떤 클래스의 다중 인스턴스를 상속하는 문제이다.
다중 상속이 가능한 상황을 만들려면, 몇 개의 클래스가 필요하다. 이 예제를 위해, 우리는 Worker라는 추상화 기초 클래스를 정의하고, 그 Worker로 부터 Waiter 클래스와 Singer 클래스를 파생시킬 필요가 있다. 그러고 나서 다중 상속을 사용하여 Waiter 클래스와 Singer 클래스로 쿠터 SingIngWaiter 클래스를 파생시킬 수 있다.
사원 수는 몇 명 인가?
Singer와 Waiter로 부터 SingingWaiter를 public으로 파생시키면서 시작한다고 가정하자.
class SingingWaiter : public Singer, public Waiter {...};
Singer 클래스와 Waiter 클래스는 둘 다 Worker 성분을 하나씩 상속하기 때문에, SingingWaiter는 두 개의 Worker 성분을 갖게 된다.
이미 눈치 챘겠지만, 이것은 문제를 일으킨다. 예를 들어, 일반적으로 파생 클래스 객체의 주소를 기초 클래스 포인터에 대입할 수 있다. 그러나 지금은 그것이 모호해진다.
SingingWaiter ed;
Worker * pw = &ed; // 모호하다.
일반적으로, 이와 같은 대입은 기초 클래스 포인터를 파생 객체 안에 있는 기초를 클래스 객체의 주소로 설정한다. 그러나 여기서 ed는 두 개의 Worker 객체를 내포하고 있다. 그래서 선택할 수 있는 주소가 두 개 이다. 강제 데이터형 변환을 사용하여 어느 객체를 말하는지 지정할 수 있을 것이다.
Worker * pw1 = (Waiter *) &ed; // Waiter에 있는 Worker
Worker * pw2 = (Singer *) &ed; // Singer에 있는 Worker
이것은 기초 클래스 포인터들의 배열을 사용하여 다양한 종류의 객체를 참조하는 기법(다형; polymorphism)을 어렵게 만든다.
Worker 객체의 복사본을 두 개 가지는 것은 다른 문제도 일으킨다. 그러나 뮈니뭐니 해도 진짜 문제는 "Worker 객체의 복사본이 과연 두 개씩이나 필요할까?"이다.
다른 사원과 마찬가지로, 가수 겸 웨이터(singing waiter)도 하나의 사원 이름과 하나의 사원 번호를 가져야 한다. C++는 다중 상속 기능을 추가하면서, 이것을 가능하게 만드는 가상 기초 클래스(virtual base class)를 추가하였다.
가상 기초 클래스
가상 기초 클래스는, 하나의 공통 조상을 공유하는 여러 개의 기초 클래스로부터 공통 조상의 유일 객체를 상속하는 방식으로 객체를 파생시키는 것을 허용한다. 이 예제에서는 클래스 선언 키워드 virtual을 사용하여 Worker를 Singer와 Waiter의 가상 기초 클래스로 만든다.(virtual과 public이 나타나는 순서는 아무래도 좋다.)
class Singer : virtual public Worker {...};
class Waiter : public virtual Worker {...};
이렇게 하면 SingingWaiter를 전처럼 정의할 수 있다.
이제 SingingWaiter 객체는 Worker 객체의 복사본을 하나만 내포할 것이다. 본질적으로 이것은, 상속된 Singer 객체와 Waiter 객체가 복사본을 각각 하나씩 갖는 대신에 하나의 Worker 객체를 공유한다.
SingingWaiter는 하난의 Worker 종속 객체를 내포하기 때문에, 이제 다시 다형(polymorphism)을 사용할 수 있다.
새로운 생성자 규칙
가상 기초 클래스의 사용은 클래스 생성자에 새로운 접근 방색일 요구한다. 가상이 아닌 기초 클래스에서는, 직계의 인접한 기초 클래스들의 생성자들만이 멤버 초기자 리스트에 생성자로 나타날 수 있다. 이들 생성자들은 그들의 기초 클래스에 정보를 전달할 수 있따. 예를 들어, 다음과 같은 생성자 계열이 있다고 가정하자.
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 클래스로부터 파생된 생성자에 의해서만 포출할 수 있다. B 생성자는 A 클래스로부터 파생된 생성자에 의해서만 호출할 수 있다. 여기서 C 생성자는 q 값을 상요하고, m과 n의 값을 B생성자에 전달한다. B 생성자는 m 값을 사용하고, n 의 값을 A 생성자에 전달한다.
이와 같은 정보의 자동 전달은, Worker가 가상 기초 클래스일 때에는 동작하지 않는다. 예를 들면, 다중 상속의 예를 위해 다음과 같은 생성자가 있다고 가정해 보자.
SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
: Waiter(wk,p), Singer(wk,v) {} // 결함이 있다.
여기에서 문제는, 정보의 자동 전달에 의해 wk가 서로 다은 두 경로(Waiter와 Singer)를 거쳐 Worker 객체에 전달된다는 것이다. 이와 같은 잠재적 충돌을 피하기 위해, 기초 클래스가 가상일 경우에 C++는 중간 클래스를 통해 기초 클래스에 자동으로 정보를 전달하는 기능을 정지시킨다. 그러므로 앞의 생성자는 중간 클래스 멤버인 panache와 voice는 초기화하지만, wk 맴버변수에 있는 정보는 Waiter 종속 객체에 전달되지 않는다. 그러나 컴파일러는 파생 객체들을 생성하기 전에 기초 객체성분을 생성해야 한다. 이 경우에, 디폴트 Worker 생성자를 사용할 것이다.
가상 기초 클래스를 위해 디폴트 생성자가 아닌 것을 사용하기를 원한다면, 적절한 기초 생성자를 명시적으로 호출해야 한다. 그래서 생성자는 다음과 같다.
SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
: Waiter(wk), Waiter(wk,p), Singer(wk,v) {}
이 코드는 Worker(const Worker &) 생성자를 명시적으로 호출한다. 이와 같은 사용법은 가상 기초 클래스에 대해 적절하고도 필수적이다. 그것은 가상이 아닌 기초 클래스에 대해서는 부적절하다.
어느 메서드를 사용하는가?
클래스 생성자 규칙에 변화를 주는 것 외에도, 다중 상속을 사용하려면 코딩을 약간 고쳐야 한다. Show() 메서드를 SingingWaiter 클래스에 확장하는 문제를 생각해 보자, SingingWaiter 객체는 새로운 데이터 멤버를 가지지 않으므로, 그 클래스는 상속받은 메서드들만 사용할 수 있다고 잘못 생각할 수 있다. 이것은 첫 번째 문제를 발생시킨다. Show() 메서드의 새 버전을 생략하고, SingingWaiter 객체를 사용하여 상속받은 Show() 메서드를 호출한다고 가정해 보자.
SingingWaiter newhire("Madonna", 2005, 6, soprano);
newhire.Show(); // 모호하다.
단일 상속에는, Show()를 다시 정의하지 않으면 가장 가까운 조상의 정의가 사용된다. 이 경우에는, 직계의 인접한 조상이 Show() 함수를 각각 하나씩 가지고 있다. 이것이 이 호출을 모호하게 만든다.
사용 범위 결정 연산자를 사용하면 어느 것을 말하는지 확실하게 할수 있다. 그러나 더 나은 방법은 SingingWaiter에 대해 Show()를 다시 정의하고, 그것으로 사용할 Show() 버전을 지정하는 것이다. 예를 들어, SingingWaiter 객체가 Singer 버전을 사용하게 하려면 다음과 같이 할 수 있다.
void SingingWaiter::Show()
{
Singer::Show();
}
파생 메서드가 기초 메서드를 호출하게 하는 이 방법은, 단일 상속에는 잘 동작한다. 그러나 이와 같은 점층적 접근 방식은 SingerWaiter에 대해서는 실패한다. 위와 같은 메서드는 Waiter 성분을 무시하기 때문에 실패한다. Waiter 버전도 함께 호출함으로 써 그것을 해결할 수는 있다. 그러나 이것은, Singer::Show()와 Worker::Show() 호출하기 때문에, 사원 이름과 사원 번호를 두번 표시하게 만든다.
어떻게 하면 우리가 이 문제를 해결할 수 있을까? 한 가지 방법은 점층적 접근 방식 대신 모듈 접근 방식을 사용하는 것이다.
void Worker::Data() const
{
cout << "사원 이름: " <<endl;
cout << "번호 이름: " <<endl;
}
void Waiter::Data() const
{
cout << "웨이터 등급: " <<endl;
}
void Singer::Data() const
{
cout << "목소리 유형: " <<endl;
}
void SingingWaiter::Data() const
{
Singer::Data();
Waiter::Data();
}
void SingingWaiter::Data() const
{
Singer::Data();
Waiter::Data();
}
void SingingWaiter::Data() const
{
Worker::Data();
Data();
}
가상 기초 클래스와 가상이 아닌 기초 클래스의 혼합
기초 클래스를 하나의 이상 경로로 상속하는 파생 클래스의 경우를 다시 생각해 보자. 기초 클래스가 가상이면, 파생 클래스는 그 기초 클래스의 종속 객체를 하나만 내포한다. 기초 클래스가 가상이 아니라면 파생 클래스는 여러 개의 종속 객체를 내포한다. 그런데 가상 기초 클래스와 가상이 아닌 기초 클래스가 섞여 있다면 어떻게 될까?
가상 기초 클래스와 비교 우위
가상 기초 클래스의 사용은 C++가 모호함을 해결하는 방법을 변경한다. 가상이 아닌 기초 클래스에서 규칙은 단순하다.
어떤 클래스가 서로 다른 클래스로부터 이름이 같은 멤버를 둘 이상 상속한다면, 클래스 이름으로 자격을 부여하지 않고 그 이름을 사용하는 것은 모호하다. 그러나 가상 기초 클래스가 개입된다면, 그러한 이름 사용은 모호할 수도 있고 그렇지 않을 수도 있다. 이러한 경우에, 하나의 이름이 다른 이름들보다 비교 우위(dominance)를 가진다면, 제한자가 없어도 모호함 없이 사용될 수 있다.
한 멤버 이름이 다른 이름들보다 비교 우위를 갖게 하려면 어떻게 하면 될까? 파생 클래스에 있는 이름은 직접이든 간접이든 간에 조상 클래스에 있는 동안 이름보다 비교 우위를 가진다. 예를 들어, 다음과 같은 정의들이 있다고 가정해 보자.
class B
{
public:
short q();
...
};
class C : virtual public B
{
public:
long q();
int omg();
...
};
class D : public C
{
...
};
class E : virtual public B
{
private:
int omg();
...
};
class F : public D, public E
{
...
};
여기서 c가 B로부터 파생되었기 때문에, 클래스 C에 있는 q() 정의가 클래스 B에 있는 q() 정의보다 비교 우위를 가진다. 그러므로 F에 있는 메서드들은 C::q()를 그냥 q()로 나타낼 수 있다. 반면에, C와 E는 서로가 상대 클래스에 대해 기초가 아니므로, 하나의 omg() 정의가 다른 omg() 정의보다 비교 우위를 가지지 않는다. 그러므로 F에서 클래스 이름으로 제한하지 않고 omg()를 사용하려고 시도하면 상황이 모호해진다.
가상 모호성 규칙은 접근 규칙은 고려하지 않는다. 즉, E::omg()가 private이라서 클래스 F에서 직접 접근할 수 없더라도, omg()의 사용은 모호해진다. 마찬가지로 C::q() 가 private이였다 할지라도, C::q()가 D::q() 보다 비교 우위를 가진다.
그러한 경우에 우리는 클래스 F에서 B::q() 를 호출할 수 있다. 그러나 클래스 F에서, 클래스 이름으로 제한하지 않은 q()의 사용은, 접근할 수 없는 C::q()를 참조할 것이다.