29-1. 상속
29-1-가.클래스 확장
상속은 캡슐화, 추상화와 함께 객체 지향 프로그래밍의 중요한 특징 중 하나이다. 캡슐화와 추상화는 객체가 온전한 부품이 될 수 있는 방법을 제공하는데 비해 상속은 클래스를 좀 더 쉽게 만들 수 있는 고수준의 재사용성을 확보하고 클래스간의 계층적인 관계를 구성함으로써 객체 지향의 또 다른 큰 특징인 다형성의 문법적 토대가 된다. 다형성은 다음 장의 주제이므로 이번 장에서는 상속에 관련된 것만 연구해 보자.
상속(Inheritance)의 사전적 의미는 자식이 부모가 가진 모든 것을 물려 받는 것을 의미하는데 OOP의 상속도 기본적인 의미는 동일하다. 이미 정의되어 있는 클래스의 모든 특성을 물려 받아 새로운 클래스를 작성하는 기법을 상속이라고 한다. 흔히 상속은 이미 만들어진 클래스를 재활용하기 위한 기법으로 소개되며 재활용이 상속의 가장 큰 장점이기는 하지만 상속에 의해 부차적으로 발생하는 효과도 있다. 상속을 하는 목적 또는 상속에 의한 효과는 다음 세가지로 간략하게 요약할 수 있다.
① 기존의 클래스를 재활용한다. 가장 기본적인 효과이다.
② 공통되는 부분을 상위 클래스에 통합하여 반복을 제거하고 유지, 보수를 편리하게 한다.
③ 공동의 조상을 가지는 계층을 만듬으로써 객체의 집합에 다형성을 부여한다.
상속의 이런 세 가지 목적을 모두 이해하고 100% 활용할 수 있다면 상속을 모두 정복했다고 할 수 있다. 두 번째, 세 번째 효과는 조금 어려우므로 우선 상대적으로 쉬운 재활용에 대한 문제부터 고찰해 보자.
앞 장에서 살펴보았다시피 클래스는 필요한 멤버를 모두 포함하고 적절히 멤버를 숨겨 자신을 방어함으로써 프로그램의 부품으로 사용된다. 그러나 한 번 만들어진 클래스가 언제까지고 어느 곳에서나 그대로 계속 사용될 수 있는 것은 아니다. 외부 세계의 요구가 끊임없이 변화하고 객체가 동작하는 환경이 각기 다르기 때문에 완성된 클래스에 기능을 추가하거나 변경해야 하는 경우는 아주 빈번하다.
클래스를 처음 디자인할 때부터 범용성과 이식성을 확보하기 위해 굉장히 많은 노력을 한다. 경험이 많은 개발자일수록 초기에 다양한 상황을 충분히 고려하여 클래스를 디자인할 것이며 이런 노력들이 확실히 효과가 있어서 잘 설계된 클래스는 훨씬 안정적이고 재사용될 수 있는 범위도 넓다. 그러나 아무리 경험이 많고 모든 것을 고려한다 하더라도 미래의 일까지 예측하는 것은 불가능하기 때문에 재사용을 위해 클래스를 수정해야 하는 상황을 근본적으로 피할 수는 없다.
사람의 이름과 나이를 표현할 수 있는 Person 클래스에 약간의 멤버를 더 추가하면 좀 더 기능이 복잡한 대상을 표현할 수 있다. Person 클래스에 월급, 근무 시간이라는 속성과 출근한다, 일한다 등의 동작을 추가하면 직원(Staff) 클래스가 될 것이고 계급, 보직 등의 속성과 훈련한다, 전투한다 등의 동작이 추가되면 군인(Soldier) 클래스가 될 것이다. 표현하고자 하는 대상이 복잡하고 구체적일수록 추가되어야 하는 속성과 동작의 개수는 많아질 것이다.
Person 클래스에 학번(StNum)이라는 속성과 공부한다(Study)는 동작을 추가해서 학생(Student)이라는 대상을 표현해야 한다고 해 보자. 물론 완전한 학생이 되기 위해서는 이 외에도 전공, 학년, 성적 등의 속성들과 수업을 듣는다, 시험친다, 미팅한다, 땡땡이 친다 등의 다양한 동작들이 더 필요하겠지만 간단한 예를 위해 학번과 공부한다만 필요하다고 하자. 학생의 본분은 누가 뭐라고 해도 공부하는 것이다.
일단 기존 클래스를 원하는대로 마음대로 뜯어 고치는 방법을 생각할 수 있다. Person이라는 이름을 Student로 바꾸고 int StNum; 이라는 멤버 변수와 Study라는 멤버 함수를 추가한다. 물론 클래스의 이름이 바뀌었으므로 클래스와 같은 이름을 사용하는 생성자, 파괴자의 이름은 반드시 바꿔야 한다. OutPerson도 OutStudent로 바꾸는 것이 좋겠지만 일단 이름은 그대로 두고 학번을 출력하는 코드만 추가하자.
이렇게 되면 과연 Person이 Student가 되며 새로 만든 Student로 학생이라는 실제 대상을 잘 표현할 수 있다. 그러나 Person을 Student로 바꿔 버렸기 때문에 기존에 존재하던 Person 클래스는 사라져 버리며 이미 이 클래스를 사용하고 있는 중이라면 이것은 확실히 문제가 된다. Student를 만드는데는 성공했지만 기존의 클래스가 파괴되어 버렸으므로 이것은 변경일 뿐이지 재활용이라고 볼 수는 없다. 원본을 유지한 채로 새로운 클래스를 만들려면 기존 클래스를 복사하여 사본을 만든 후에 사본을 뜯어 고쳐야 한다.
이렇게 하면 새로운 클래스가 만들어지면서 기존 클래스도 온전히 유지된다. 이 방법이 객체 지향 이전의 전통적인 재활용 방법(Copy & Paste & Edit)이다. 사실 이 방법은 코드를 짤 때나 문서 작업을 할 때와 같은 일상 생활에서도 여러 가지 용도로 익숙하게 활용되어온 방법이다. 특히 친구 숙제를 베껴 쓸 때 많이 활용되는데 일단 그대로 가져온 후 안 베낀 것처럼 어투나 순서만 조금 바꾸는 수법을 많이 쓴다. 이때 특히 이름과 학번을 고치는 걸 잊어서는 안된다.
이미 익숙한 방법이고 방법상으로 문제는 없지만 새로 만든 Student에 기존의 Person에 있던 멤버의 선언문이 그대로 반복되어야 한다는 점이 낭비이다. 만약 100개의 멤버를 가진 클래스에 2개의 멤버를 더 추가해야 한다면 코드의 반복이 심해질 것이고 전체적으로 좋은 구조를 만들 수 없다. 이럴 때 상속을 사용한다.
상속을 할 때 원본 클래스가 어떤 것이라는 것을 밝히고 이 외에 더 필요한 멤버를 추가로 선언한다. 그러면 컴파일러는 원본 클래스의 모든 멤버에 대한 선언문을 가져오고 추가로 선언한 멤버도 클래스 안에 같이 포함시킨다. 전통적인 방법에 비해 복사해서 붙여 넣고 기존 멤버에 대한 선언문을 가져오는 동작을 컴파일러가 대신한다는 점이 다르다. 물론 컴파일러가 진짜 소스를 뜯어 고치는 것은 아니고 컴파일중의 중간 단계에서 이 작업을 할 것이다. 개발자는 상속된 클래스에 원하는 추가 멤버만 더 선언하면 된다.
기존 클래스의 재활용만을 목적으로 한다면 사실 복사한 후 뜯어 고치는 전통적인 방법과 상속을 하는 방법과 근본적인 차이점이 없다. 그러나 코드의 유지, 보수 측면에서는 엄청난 차이가 있는데 원본을 변경해야 할 때 복사한 경우는 양쪽을 다 직접 고쳐야 하지만 상속의 경우는 원본 클래스만 고치면 상속받은 클래스까지 한꺼번에 같이 수정되어 편리하며 불일치의 위험도 없다. 예를 들어 멤버 변수의 이름을 바꾼다거나 멤버 함수의 원형을 바꾼다고 할 때 원본의 멤버만 수정하면 된다. 다음 그림을 보자.
A, B 멤버를 가진 Parent 클래스로부터 C멤버를 추가하여 Child 클래스를 만들었다고 해 보자. 이 상태에서 A 멤버의 이름을 Alpha로 변경하고 싶을 때 복사해서 수정한 경우는 원본과 사본 두 군데를 고쳐야 하지만 상속을 받은 경우는 Parent의 A만 고치면 상속받는 Child는 더 이상 손 데지 않아도 된다. 여러 단계로 재사용될 경우 이런 장점이 더욱 부각되는데 상속 단계가 5단계만 넘어도 엄청난 차이가 발생한다.
상속이라는 개념은 사실 어려운 것도 아니고 이미 우리는 알게 모르게 상속이라는 개념을 많이 활용해 왔다. C에서 기능의 단위는 함수인데 필요한 모든 함수들이 다 제공되는 것은 아니므로 원하는 기능을 추가하여 새로운 함수를 만들어 사용해야 한다. 예를 들어 문자열을 출력한 후 1초간 대기하는 함수가 필요하다면 다음과 같이 작성한다.
void putsdelay(const char *message)
{
puts(message);
delay(1000);
}
putsdelay 함수는 인수로 전달된 message 문자열을 puts 함수로 출력한 후 delay를 호출하여 1초간 시간을 지연시키는데 원래 puts 함수의 기능을 상속받아 대기하는 기능을 추가했다고 볼 수 있다. 원본 함수인 puts를 뜯어 고친 것이 아니라 이 함수의 기능을 빌려 좀 더 구체적인 동작을 하는 특수한 함수를 정의한 것이다. malloc은 할당 후 초기화를 하지 않는데 원한다면 상속받아서 memset을 추가하면 할당 직후에 원하는 값으로 초기화하는 allocandinit 따위의 함수를 만들 수도 있다.
이런 것이 개념적인 함수의 상속이며 기존 함수를 호출함으로써 간단하게 구현한다. 이런 식으로 기존 함수를 한 번 감싸서 원래 동작에 약간의 처리를 추가하는 함수를 래퍼(Wrapper) 함수라고 하는데 원본 함수의 기능이 바뀌면 래퍼 함수의 기능도 덩달아 바뀐다. 클래스의 상속도 이와 비슷하다고 생각하면 C++의 상속 개념을 대충 이해할 수 있을 것이다. 물론 어디까지나 비유이므로 정확하게 같다고는 할 수 없겠지만 말이다.
29-1-나.상속의 예
상속에 대한 문법적인 이론만 계속 나열해서는 이해하기 쉽지 않으므로 일단은 구체적인 실제 예를 보도록 하자. 다음 예제는 비록 극단적으로 간단하기는 하지만 상속을 통해 클래스를 재활용하는 기본적인 방법을 보여 준다.
예 제 : InheritPoint
|
#include <Turboc.h>
class Coord
{
protected:
int x,y;
public:
Coord(int ax, int ay) { x=ax;y=ay; }
void GetXY(int &rx, int &ry) const { rx=x;ry=y; }
void SetXY(int ax, int ay) { x=ax;y=ay; }
};
class Point : public Coord
{
protected:
char ch;
public:
Point(int ax, int ay, char ach) : Coord(ax,ay) { ch=ach; }
void Show() {
gotoxy(x,y);putch(ch);
}
void Hide() {
gotoxy(x,y);putch(' ');
}
};
void main()
{
Point P(10,10,'@');
P.Show();
}
두 개의 클래스를 정의하고 있는데 Coord 클래스는 화면상의 좌표 하나를 표현한다. 좌표는 위치만을 가지며 보이는 실체가 아니므로 크기나 모양, 색상 따위의 개념이 없다. 그래서 Coord 클래스에는 순수하게 위치만 표현할 수 있는 x, y만 멤버 변수로 선언되어 있다. 그리고 x, y를 액세스하는 Get(Set)XY 멤버 함수와 생성자가 정의되어 있다.
두 번째 클래스인 Point는 점을 표현하는데 눈에 보이는 점을 그리기 위해서는 좌표 외에도 실제로 화면에 출력할 때 어떤 문자로 출력할 것인지에 대한 정보가 필요하다. 그래픽 환경이라면 점의 색상이 필요하겠지만 실습 환경이 콘솔이므로 특정 문자를 출력함으로써 점을 대신 표현하기로 한다. 이 특정 문자를 ch 멤버로 지정한다. 이 외에 점을 관리하는 Show, Hide 멤버 함수가 정의되어 있는데 점은 화면에 보일 수도 있고 숨을 수도 있으므로 이 두 동작을 처리하는 멤버 함수가 필요하다. 만약 이런 특성을 가지는 Point 클래스를 단독으로 정의한다면 아마도 다음과 같은 모양이 될 것이다. 지금까지 실습용으로 사용해왔던 Position과도 비슷하다.
class Point
{
protected:
int x,y;
char ch;
public:
Point(int ax, int ay, char ach) { x=ax;y=ay;ch=ach; }
void GetXY(int &rx, int &ry) const { rx=x;ry=y; }
void SetXY(int ax, int ay) { x=ax;y=ay; }
void Show() {
gotoxy(x,y);putch(ch);
}
void Hide() {
gotoxy(x,y);putch(' ');
}
};
이 선언문에서 보다시피 x, y와 Get(Set)XY 멤버 함수는 좌표를 표현하는 Coord 클래스에 이미 정의되어 있는 것들이다. 따라서 멤버를 새로 정의할 필요없이 Coord 클래스로부터 상속받으면 된다. 예제의 Point 클래스 선언문 뒤에 있는 : public Coord라는 선언이 바로 Coord로부터 상속을 받으라는 뜻이며 컴파일러는 이 선언에 의해 Point 클래스에 Coord가 가진 모든 멤버를 물려준다. Point는 Coord가 가진 좌표와 관련된 멤버는 그대로 사용하면서 여기에 점을 표시할 문자 ch 멤버와 자신을 보이거나 숨길 수 있는 Show, Hide 멤버 그리고 생성자만 추가하면 된다.
Point클래스가 Coord클래스로부터 상속을 받은 것이다. 클래스끼리 상속될 때 상위의 클래스를 기반 클래스(Base Class)라고 하며 상속을 받는 클래스를 파생 클래스(Derived Class)라고 한다. 이 경우 Coord 기반 클래스로부터 Point 클래스가 파생되었다고 표현한다. 기반, 파생이라는 용어 대신 부모, 자식이라는 용어를 대신 사용하기도 하고 상위 클래스(Super Class), 하위 클래스(Sub Class)라는 용어를 쓰기도 하는데 언어에 따라 사용하는 용어가 조금씩 다르다.
생성자, 파괴자 등의 특수한 몇 가지를 제외하고 파생 클래스는 기반 클래스의 모든 멤버를 상속받는다. Point는 좌표에 대한 정보인 x, y 멤버 변수와 이 멤버에 대한 액세스 함수인 Get(Set)XY 멤버 함수를 정의하고 있지 않지만 기반 클래스인 Coord로부터 상속받았으며 그래서 Point에는 x, y 멤버가 정의되어 있는 것과 마찬가지이다. Point의 멤버 함수인 Show, Hide에서 x, y 좌표를 참조하여 점을 찍거나 숨길 위치를 결정하는데 아무런 문제가 없는 것이다.
main에서는 Point형의 객체 P를 선언하되 (10,10) 좌표에 문자 '@'으로 점을 표현하도록 했다. P.Show 함수를 호출하면 (10,10) 좌표에 @ 문자 하나가 출력될 것이다. P는 상속에 의해 좌표에 대한 정보를 가질 수 있으며 이 좌표에 지정된 문자를 출력함으로써 자신의 존재를 나타낼 수 있는 완전한 객체인 것이다.
29-1-다.상속과 정보 은폐
클래스가 상속될 때 기반 클래스의 멤버에 대한 액세스 속성이 파생 클래스에게 어떻게 상속되는지 다음 예제를 통해 테스트해 보자. 이 예제를 컴파일하면 두 개의 에러 메시지가 출력될 것이다.
예 제 : InheritAccess
|
#include <Turboc.h>
class B
{
private:
int b_pri;
void b_fpri() { puts("기반 클래스의 private 함수"); }
protected:
int b_pro;
void b_fpro() { puts("기반 클래스의 protected 함수"); }
public:
int b_pub;
void b_fpub() { puts("기반 클래스의 public 함수"); }
};
class D : public B
{
private:
int d_pri;
void d_fpri() { puts("파생 클래스의 private 함수"); }
public:
void d_fpub() {
d_pri=0; // 자신의 모든 멤버 액세스 가능
d_fpri();
b_pri=1; // 에러 : 부모의 private 멤버는 액세스할 수 없음
b_fpri();
b_pro=2; // 부모의 protected 멤버는 액세스 가능
b_fpro();
b_pub=3; // 부모의 public 멤버는 액세스 가능
b_fpub();
}
};
void main()
{
D d;
d.d_fpub(); // 자신의 멤버 함수 호출
d.b_fpub(); // 부모의 public 멤버 함수 호출
}
기반 클래스인 B에는 private, protected, public 각각의 액세스 속성으로 멤버 변수와 멤버 함수를 모두 정의해 두었다. 테스트 예제이므로 멤버 이름은 소속과 액세스 지정을 포함하여 쉽게 구분할 수 있는 형식으로 작성했다. 예를 들어 b_pub는 기반 클래스의 퍼블릭 멤버 변수이고 b_fpri는 기반 클래스의 프라이비트 함수이다. B에서 D를 파생했을 때 파생 클래스인 D에서 기반 클래스의 각 멤버들을 액세스하면 어떻게 될까?
기반 클래스의 public 멤버는 공개되어 있으므로 파생 클래스뿐만 아니라 이 클래스의 외부에서도 얼마든지 액세스할 수 있다. D의 멤버 함수 d_fpub에서 b_pub와 b_fpub는 얼마든지 액세스할 수 있으며 main 함수에서도 이 멤버들은 참조 가능하다. 그러나 기반 클래스의 private 멤버는 숨겨져 있으므로 외부에서와 마찬가지로 파생 클래스에서 직접 액세스할 수 없다. 아무리 자식이라 하더라도 부모의 숨겨진 멤버를 건드리는 것은 허용되지 않는다. 그래서 D::d_fpub에서 B::b_pri를 참조한다거나 B::b_fpri 멤버 함수를 호출하는 문장은 에러로 처리된다. 이 두 줄을 주석으로 처리해야 예제가 컴파일될 것이다.
상속 관계에 있어서도 파생 클래스는 기반 클래스의 외부로 간주되어 엄격한 액세스 제한이 적용된다. 그런데 파생 클래스는 기반 클래스와 어느 정도 관련이 있기 때문에 때로는 파생 클래스에게 숨겨진 멤버에 대한 액세스를 허용해야 할 경우도 있다. 클래스 외부와는 달리 쌩판 남은 아닌 것이다. 이럴 때 사용하는 액세스 지정이 바로 protected이며 public과 private의 중간 정도에 해당한다. protected로 지정된 멤버는 클래스 외부에서는 참조할 수 없지만 파생 클래스에서는 참조할 수 있는 액세스 속성이다.
위 예제의 D::d_fpub에서 부모의 protected 멤버인 b_pro, b_fpro는 액세스 가능하다. 그러나 main에서 이 값을 참조하면 에러다. main에 d.b_pro=1234; 대입문을 작성해 보면 에러로 처리되는데 main은 명백한 클래스 외부이며 파생 클래스도 아니므로 이 멤버를 액세스할 수 없다. 액세스 지정자의 기능을 도표로 정리해 보면 다음과 같다.
액세스 지정자
|
클래스 외부
|
파생 클래스
|
설명
|
private
|
액세스 금지
|
액세스 금지
|
무조건 금지
|
protected
|
액세스 금지
|
액세스 허용
|
파생 클래스만 허용
|
public
|
액세스 허용
|
액세스 허용
|
무조건 허용
|
protected 액세스 속성은 상속 관계에 있지 않은 클래스나 외부에 대해 private와 같으며 파생 클래스에 대해서는 public과 같다. 외부에 대해서는 숨겨야 하지만 파생 클래스에서 액세스할 필요가 있는 멤버는 protected 액세스 속성을 지정한다. InheritPoint 예제에서 Coord의 x, y 멤버가 protected 액세스 속성으로 지정되어 있는데 외부에서 이 값을 함부로 건드리지 못하도록 보호해야 하지만 파생 클래스인 Point의 Show, Hide에서는 이 멤버들을 읽을 수 있어야 한다. 만약 Coord의 x, y를 private로 선언하여 파생 클래스에 대해서도 숨겨 버리면 이 예제는 컴파일되지 않을 것이다.
파생 클래스는 기반 클래스와 아주 밀접한 관계에 있음에도 불구하고 기반 클래스의 private 멤버를 참조하지 못한다는 것은 선뜻 이해하기 어려울 수도 있다. 쓰지도 못할 멤버를 왜 상속받아야 하는지 직관적으로 이해되지 않는다. 그러나 부모 클래스가 스스로의 정보 은폐를 위해 자식에게조차 멤버를 숨겨야 할 필요는 분명히 있으며 이렇게 해야 파생 클래스가 영향을 받지 않는다.
만약 부모의 private 멤버를 자식이 읽을 수 있다면 이는 정보 은폐를 완전히 포기하는 것과 마찬가지이다. 왜냐하면 클래스가 아무리 정보를 꼭꼭 감춰 놓아도 외부에서 상속만 받으면 모든 멤버를 마음대로 건드릴 수 있기 때문이다. private는 자식이 몰라도 되는 부분이며 마땅히 몰라야 하는 부분이다. 파생 클래스는 기반 클래스의 private 멤버를 직접 읽지는 못하지만 기반 클래스의 public, protected 함수를 통해 이 멤버를 여전히 사용할 수는 있다. 다음 예를 보자.
class B
{
private:
int b;
public: // 또는 protected
int Getb() { return b; }
void Setb(int ab) { b=ab; }
};
class D : public B
{
public:
void func() {
printf("기반 클래스의 b = %d\n", Getb());
}
};
D는 B의 private멤버인 b를 직접 참조할 수는 없지만 상속받은 Get(Set)b 멤버 함수를 통해 이 멤버값을 간접적으로 읽고 쓸 수는 있다. 클래스 외부에서 적용되는 규칙이 파생 클래스에 대해서도 그대로 적용됨을 알 수 있다. 단, 외부와는 달리 파생 클래스를 위해 protected라는 액세스 속성이 별도로 준비되어 있다는 점만 다르다. 일단 숨기되 차후에 상속될 가능성이 조금이라도 있다면 protected 액세스 속성을 지정하는 것이 좋다. 앞 장에서 만든 Str 클래스의 buf 멤버가 바로 이 속성으로 선언되어 있는데 이는 상속을 고려했기 때문이다.
29-1-라.상속 액세스 지정
파생 클래스를 정의하는 일반적인 문법, 즉 C++의 상속 구문은 다음과 같다.
클래스 선언문 다음에 : 이 오고 상속받을 기반 클래스의 이름이 온다. 그리고 : 과 기반 클래스 이름 사이에 상속 액세스 지정자라는 것이 위치하는데 이 지정자는 기반 클래스의 멤버들이 파생 클래스로 상속될 때 액세스 속성이 어떻게 변할 것인가를 지정한다. 멤버의 액세스 속성을 지정하는 public, protected, private와 똑같은 키워드를 사용하지만 의미는 다르다. 이 지정자에 따라 파생 클래스가 상속받는 멤버의 액세스 지정이 어떻게 변경되는지 도표로 정리해 보면 다음과 같다.
상속 액세스 지정자
|
기반 클래스의 액세스 속성
|
파생 클래스의 액세스 속성
|
public
|
public
|
public
|
private
|
액세스 불가능
| |
protected
|
protected
| |
private
|
public
|
private
|
private
|
액세스 불가능
| |
protected
|
private
| |
protected
|
public
|
protected
|
private
|
액세스 불가능
| |
protected
|
protected
|
먼저 기반 클래스의 private 멤버는 어떤 경우라도 파생 클래스에서 읽을 수 없다. 따라서 private 멤버는 상속은 되지만 파생 클래스에서는 직접 참조할 수 없으므로 액세스 속성이 아예 없다고 할 수 있다. 자신도 못 읽는 멤버에 대해 외부에서 이 멤버를 읽도록 허가하거나 금지하는 속성을 지정할 수는 없는 노릇이다. 기반 클래스의 public, protected 멤버는 상속 액세스 지정자에 따라 액세스 속성이 변경된다.
상속 액세스 지정자가 public이면 기반 클래스의 액세스 속성이 그대로 유지된다. 즉 부모의 protected 멤버는 상속된 후의 자식 클래스에서도 여전히 protected이며 부모의 public 멤버는 자식 클래스에서도 외부로 공개된다. public 상속은 부모로부터 상속받은 멤버의 액세스 속성에 아무런 변화도 없는 상속이다. 상속 액세스 지정자가 private, protected인 경우는 부모의 모든 멤버가 상속되면서 private, protected로 변경된다. 상속 액세스 지정자가 생략되면 디폴트인 private가 적용된다. 즉 다음 두 구문은 동일한 문장이다.
class D : B
class D : private B
클래스는 가급적이면 정보를 숨기려는 경향이 있기 때문인데 구조체의 경우는 생략시 public이 적용된다. 통상 상속이라 하면 public 상속을 의미하며 나머지 두 가지 상속 액세스 지정자는 아주 특수한 목적에 사용된다. 이 두 가지 경우에 대해서는 후술하므로 당분간은 public 상속에 대해서만 고려하도록 하자. 상속에 대한 구문을 정리했으므로 이제 InheritPoint 예제에 사용된 실제 상속 구문을 분석해보자.
Point는 Coord의 모든 멤버를 상속받되 상속받은 멤버의 액세스 속성은 그대로 유지하도록 했다. 그래서 Coord의 x, y는 Point에서도 protected이며 Get(Set)XY는 Point에서도 public이다.
29-2.상속의 특성
29-2-가.C++ 상속의 특성
객체 지향이라는 똑같은 이론에 기반하더라도 각 언어별로 상속을 구현하는 방법과 수준에는 다소 차이가 있다. C++언어의 상속은 대체로 세 가지 정도로 특징을 요약할 수 있다.
하나의 기반 클래스로부터 여러 개의 클래스를 파생시킬 수 있다. 세포라는 기본적인 속성과 호흡한다, 번식한다 등의 동작을 가지는 생물로부터 동물을 파생시켜 움직인다는 동작을 추가할 수 있다. 동물은 생물의 모든 특성을 가지기 때문에 이런 파생이 가능하다. 마찬가지로 생물로부터 식물이나 미생물도 파생 가능한데 둘 다 생물의 일종이 틀림없기 때문이다.
이렇게 되면 동물, 식물, 미생물은 공동의 조상인 생물로부터 물려받은 속성과 동작을 공유하게 된다. 물론 각 파생 클래스는 기반 클래스로부터 물려받은 속성 외에 각기 다른 속성들을 추가로 정의할 수 있다. 예를 들어 동물은 척추 유무, 심장의 구조, 이동한다, 먹는다가 추가될 것이고 식물은 떡잎의 개수, 광합성한다 등의 속성과 동작이 추가될 것이다.
하나의 클래스로부터 파생될 수 있는 클래스의 개수에 제한이 없을 뿐만 아니라 파생의 깊이에도 제한이 없다. 파생된 클래스로부터 새로운 클래스를 얼마든지 파생시킬 수 있다. 생물로부터 상속받은 동물은 포유류라는 새로운 클래스를 파생시킬 수 있으며 포유류는 또한 영장류의 부모가 될 수 있다.
각 파생 관계의 아래쪽으로 내려올수록 더 많은 속성과 동작이 정의될 것이다. 파생 관계의 위쪽에 있는 클래스는 속성을 몇 개 가지지 않는 일반적인 사물을 표현하며 포괄하는 범위가 넓은 반면 아래쪽에 있는 클래스일수록 점점 더 특수하고 구체적인 사물을 표현한다. 파생을 많이 할수록 더 많은 속성과 동작이 정의되므로 점점 특수해진다.
기반, 파생 클래스 또는 부모, 자식이라는 용어는 상대적인 개념이다. 한 클래스가 상속 관계의 중간에 있다면 이 클래스는 부모에 대해서는 자식이지만 또한 자신의 자식에 대해서는 부모가 된다. 위 예에서 포유류는 동물에 대해서는 자식이지만 영장류에 대해서는 부모라고 할 수 있다. 상속 관계의 위쪽에 있는 클래스를 선조 또는 조상이라고 하며 아래쪽에 있는 클래스를 후손이라고 표현하기도 한다. 또한 최상위의 클래스를 루트라고 한다.
부모와 자식 클래스의 관계를 IS A 관계라고 하는데 이는 자식 클래스가 일종의 부모 클래스라는 뜻이다. 예를 들어 동물은 생물의 일종이며 포유류는 또한 동물의 일종이라고 할 수 있다. IS A라는 용어는 영문의 is a를 의미하는데 "동물은 일종의 생물이다."를 영어로 animal is a creature라고 표현하기 때문이다. 그러나 역관계는 성립하지 않는데 동물은 생물이지만 생물이라고 해서 다 동물이라고 할 수는 없다.
비록 자주 사용되지는 않지만 C++은 두 개 이상의 클래스로부터 새로운 클래스를 파생시킬 수 있는데 이를 다중 상속이라고 한다. 이때 파생되는 클래스는 기반 클래스들의 모든 속성을 물려받는다. 사람이 엄마와 아빠의 속성을 동시에 물려받는 것과 마찬가지이다. 다중 상속에 의해 여러 개의 클래스가 복잡한 상속 관계를 구성하는 그래프가 만들어지는데 이렇게 되면 상속 계층이 너무 복잡해져서 잘 사용되지 않는다.
이 외에도 상속에 대한 또 다른 제약으로 기본 타입으로부터의 상속은 허가되지 않는다는 규칙이 있다. 즉, class MyInt : public int { } 식으로 기본 타입인 int를 상속받아 새로운 클래스를 만들 수는 없다는 너무 너무 상식적인 얘기이다. int 같은 시스템 내장 타입은 클래스와 똑같이 취급되기는 하지만 실제로 클래스 선언문이 존재하는 것은 아니므로 기반 클래스로 사용할 수 없다. 다중 상속을 제외할 경우 클래스간의 계층 관계는 보통 트리 형태로 그릴 수 있다.
이렇게 그려진 클래스간의 관계 일람표를 클래스 계층도(Class Hierarchy Chart)라고 하는데 상용 라이브러리의 클래스 계층도는 이보다 훨씬 더 복잡하다. MFC의 클래스 계층도에는 수백개의 클래스들이 그려져 있다. 여러분들도 다음에 MFC를 공부하게 되면 원하든 원치않든 클래스 계층도를 외우게 될 것이다.
29-2-나.이차 상속
상속의 깊이에 제한이 없어 파생된 클래스로부터 또 다른 클래스를 파생시킬 수 있다고 했다. 다음 예제는 Coord로부터 파생된 Point를 기반 클래스로 하여 원을 표현할 수 있는 Circle 클래스를 파생시킨다. 이름을 붙이자면 이차 상속이라고 할 수 있다.
예 제 : InheritCircle
|
#include <Turboc.h>
#include <math.h>
class Coord
{
protected:
int x,y;
public:
Coord(int ax, int ay) { x=ax;y=ay; }
void GetXY(int &rx, int &ry) const { rx=x;ry=y; }
void SetXY(int ax, int ay) { x=ax;y=ay; }
};
class Point : public Coord
{
protected:
char ch;
public:
Point(int ax, int ay, char ach) : Coord(ax,ay) { ch=ach; }
void Show() {
gotoxy(x,y);putch(ch);
}
void Hide() {
gotoxy(x,y);putch(' ');
}
};
class Circle : public Point
{
protected:
int Rad;
public:
Circle(int ax, int ay, char ach, int aRad) : Point(ax,ay,ach) { Rad=aRad; }
void Show() {
for (double a=0;a<360;a+=15) {
gotoxy(int(x+sin(a*3.14/180)*Rad),int(y-cos(a*3.14/180)*Rad));
putch(ch);
}
}
void Hide() {
for (double a=0;a<360;a+=15) {
gotoxy(int(x+sin(a*3.14/180)*Rad),int(y-cos(a*3.14/180)*Rad));
putch(' ');
}
}
};
void main()
{
Point P(10,10,'@');
P.Show();
Circle C(40,10,'*',8);
C.Show();
}
Circle은 Point에 정의되어 있는 x, y, ch 멤버 변수와 Get(Set)XY, Show, Hide 멤버 함수를 상속받으며 여기에 원의 반지름을 지정하기 위한 Rad 멤버 변수를 추가했다. 상속받은 멤버 중 Show, Hide는 다시 재정의하는데 점을 그리는 방법과 원을 그리는 방법이 다르기 때문에 코드를 다시 작성해야 한다. 상속받은 함수의 본체를 수정하는 것을 오버라이딩이라고 하는데 다음 항에서 따로 상세히 알아보도록 하자. Circle 클래스의 멤버 구성은 다음과 같다.
Point는 Coord로부터 x, y, Get(Set)XY 멤버를 상속받고 여기에 ch, Show, Hide 멤버를 추가했으며 Circle은 Point의 모든 멤버를 상속받은 후 Rad 멤버를 추가했다. 결국 Circle이 가진 x, y, Get(Set)XY는 애초의 기반 클래스인 Coord로 물려받은 것이다. 이때 Coord는 Circle의 부모의 부모인 셈인데 인간 관계로 표현하자면 할아버지라고 할 수 있겠다.
파생 클래스로부터 상속을 계속 해 나가면 최종 클래스는 모든 기반 클래스(선조)의 멤버를 한꺼번에 상속받는다. 이 상태에서 Circle로부터 또 다른 클래스를 파생시킬 수도 있는데 이 클래스도 Circle을 통해 Point와 Coord를 간접적으로 상속받는 셈이다. Circle 클래스는 중심점 (x, y)와 원호를 그릴 문자, 그리고 반지름 Rad를 속성으로 가지며 그리기, 숨기기 동작을 할 수 있으므로 원 객체를 표현할 수 있다. main에서는 (10,10) 위치에 @ 점을 찍고 (40, 10) 위치에 * 문자로 반지름 8의 원을 그렸다. 실행 결과는 다음과 같다.
왼쪽의 @ 문자가 점이고 오른쪽에는 별로 예쁘지는 않지만 원이 그려져 있다. 점, 원은 그래픽 객체인데 콘솔창에서 억지로 표현을 하다 보니 모양이 좋지 않고 원도 세로로 길쭉한 타원이다. Circle의 Show 함수에는 원을 그리는 코드가 작성되어 있는데 원을 그리는 알고리즘은 그다지 복잡하지는 않지만 지금 다루는 주제가 아니므로 일단 무시하도록 하자. 이왕이면 그래픽 환경에서 예쁘게 출력해 보고 싶으나 그래픽 환경은 문법을 배우기에는 적합하지 않으므로 부득이하게 이런 방법을 쓸 수밖에 없다.
29-2-다.객체의 생성 및 파괴
상속받은 멤버는 파생 클래스에서 직접 초기화할 수 없으며 기반 클래스에게 초기화를 부탁해야 한다. 파생 클래스는 기반 클래스의 모든 멤버를 상속받기는 하지만 이 멤버를 어떻게 초기화해야 하는지는 정확하게 알지 못한다. 또한 상속받은 멤버 중 일부는 private 액세스 속성을 가질 수도 있으므로 파생 클래스가 이 멤버를 초기화할 권한이 없다. 자식에게조차 공개하지 않겠다고 숨겨놓은 것이므로 파생 클래스는 부모의 private 멤버에 대해서는 관심을 가질 필요도 없고 건드릴 수도 없다. 대신 기반 클래스의 public 생성자를 호출하여 상속받은 멤버를 초기화해야 한다. 생성자는 항상 public이므로 누구나 호출할 수 있다.
상속받은 멤버의 의미와 초기화 방법에 대해서 가장 정확하게 알고 있는 주체는 이 멤버를 정의한 클래스이므로 기반 클래스의 생성자를 이용하는 것이 합리적이다. 파생 클래스가 기반 클래스의 생성자를 호출할 때는 초기화 리스트를 사용해야 한다. InheritCircle 예제의 main 함수에 있는 Circle C(40,10,'*',8); 선언문이 어떤 순서로 이 객체를 초기화하는지 순서대로 따라가 보자.
① main에서 Circle 객체 C를 생성할 때 Circle의 생성자가 호출된다. Circle(40,10,'*',8)이 호출되며 생성자로 원 객체 생성에 필요한 인수들이 전달될 것이다.
② 생성자의 본체가 실행되기 전에 초기화 리스트가 먼저 실행된다. 초기화 리스트에서 기반 클래스인 Point의 생성자를 호출하며 이 생성자로 ax, ay, ach 인수를 전달한다.
③ Point의 생성자는 다시 자신의 초기화 리스트에 있는 Coord의 생성자를 호출하며 이 생성자로 ax, ay 인수를 전달한다. 이런 식으로 파생 클래스는 항상 기반 클래스의 생성자를 통해 상속받은 멤버를 초기화해야 한다.
④ Coord의 생성자에서 x, y 멤버를 인수로 전달된 ax, ay로 초기화한다. 이때는 단순 타입이므로 초기화 리스트를 쓰지 않아도 상관없다. ax, ay는 40, 10으로 전달되었으므로 x, y는 (40, 10) 좌표를 가리키도록 초기화될 것이다.
⑤ Coord의 생성자가 리턴되면 Point의 생성자 본체에서 ch 멤버에 인수로 전달된 ach의 값 '*'를 대입한다. Point는 자신의 고유 멤버를 초기화한 후 리턴한다.
⑥ Circle 생성자는 초기화 리스트를 통해 상속받은 멤버의 초기화를 마치고 본체에서 자신의 고유 멤버인 Rad을 aRad 인수로 초기화한다. Rad는 8이 될 것이다. Circle 생성자가 자신의 모든 멤버를 초기화하고 main으로 리턴하면 객체 C의 초기화가 완료된다.
초기화가 완료된 후 main에서는 C.Show()를 호출하는데 이 함수는 C의 멤버값이 지정하는대로 (40, 10) 좌표에 * 문자로 반지름 8의 원을 출력할 것이다. 파생 클래스의 초기화 과정이 다소 복잡한데 파생의 단계가 깊을수록 더 많은 과정을 거쳐야 할 것이다. 그림으로 이 과정을 그려 보면 다음과 같다.
과연 이대로 생성자가 호출되는지 확인해 보려면 각 생성자에 중단점을 설정해 놓고 디버거를 돌려보면 알 수 있다. 또는 더 간단하게 시각적으로 확인해 보려면 각 생성자에 puts 호출문을 삽입해 놓고 출력 결과를 보면 된다. 예제 곳곳에 puts 호출을 삽입해 보자.
Coord(int ax, int ay) { puts("Coord 생성자");x=ax;y=ay; }
Point(int ax, int ay, char ach) : Coord(ax,ay) { puts("Point 생성자");ch=ach; }
Circle(int ax, int ay, char ach, int aRad) : Point(ax,ay,ach) { puts("Circle 생성자");Rad=aRad; }
void main()
{
puts("==== Point 생성전 ====");
Point P(10,10,'@');
puts("==== Circle 생성전 ====");
Circle C(40,10,'*',8);
P.Show();
C.Show();
}
각 클래스의 생성자와 main 함수에 실행 순서 확인을 위한 문자열 메시지 출력문을 삽입해 놓았다. 실행 결과는 다음과 같은데 각 객체 선언시마다 선조 클래스의 생성자들이 순서대로 호출된다는 것을 분명히 확인해 볼 수 있다.
==== Point 생성전 ====
Coord 생성자
Point 생성자
==== Circle 생성전 ====
Coord 생성자
Point 생성자
Circle 생성자
객체 하나를 생성하는데 조상 클래스의 생성자들을 일일이 호출해야 하므로 객체 생성 속도가 굉장히 느릴 것처럼 보인다. 복잡한 계층에서는 선조 클래스가 수십 개나 될 수도 있으므로 이런 걱정이 되는 것도 무리는 아니다. 그러나 생성자들은 그 특성상 길이가 짧고 내부 정의하는 것이 보통이므로 대부분이 인라인이며 함수 호출 부담이 없어 속도를 염려할 정도는 아니다. 위 예제의 Circle C 선언문은 결국 x=ax; y=ay; ch=ach; Rad=aRad; 4개의 대입문만 실행할 뿐이며 이정도 대입문은 순식간에 처리할 수 있다.
초기화 리스트를 통해 기반 클래스의 생성자를 연쇄적으로 호출하며 상속받지 않은 멤버는 자신이 직접 초기화한다. 일반적으로 기반 클래스는 파생 클래스가 동작하기 위한 전제 조건이 되기 때문에 파생 클래스의 멤버보다 상속받은 멤버가 먼저 초기화되어야 한다. 가령 파생 클래스의 생성자 본체에서부터 상속받은 멤버를 당장 참조할 수도 있으므로 생성자 본체보다도 기반 클래스의 초기화가 더 우선이다.
그래서 생성자 본체가 실행되기 전에 상속받은 멤버는 초기화되어야 하며 그러기 위해서는 초기화 리스트를 사용하는 방법밖에 없다. 파생 클래스의 생성자 본체에서 기반 클래스의 생성자를 직접적으로 호출할 수는 없는데 가령 다음 코드를 보자.
Circle(int ax, int ay, char ach, int aRad) {
Point(ax,ay,ach);
Rad=aRad;
}
여기서 생성자 본체에 있는 Point(ax,ay,ach); 호출문은 상속받은 멤버를 초기화하는 문장이 아니라 Point 클래스의 생성자를 호출하여 이름도 없는 임시 Point 객체를 생성하는 문장이 되어 버린다. 사실 이 코드는 Point, Coord가 디폴트 생성자를 정의하지 않아 컴파일되지도 않는데 컴파일되게 만든다 하더라도 Circle이 상속받은 x, y, ch 멤버는 전혀 초기화되지 않고 쓰레기값을 가지며 원은 제대로 그려지지 않을 것이다. 생성자 본체에서 상속받은 멤버를 직접 초기화하는 다음 코드를 보자.
Circle(int ax, int ay, char ach, int aRad) {
x=ax;y=ay;ch=ach;
Rad=aRad;
}
이렇게 하면 컴파일도 잘되고 원이 제대로 그려지기도 하지만 이 코드는 전혀 일반적이지 않다. 왜냐하면 대입은 초기화와 여러 가지 면에서 다르며 상속받은 멤버를 파생 클래스가 항상 마음대로 액세스할 수 있는 것도 아니기 때문이다. 만약 Coord나 Point가 x, y, ch를 protected가 아닌 private로 정의하고 있다면 이 코드는 컴파일되지 않는다. 부모의 입장에서 자식이 몰라도 되는 멤버가 존재할 수 있으며 이런 멤버는 상속은 되지만 자식이 초기화할 권한이 없고 그럴 필요도 없다.
파생 클래스의 초기화 리스트에서 기반 클래스의 생성자를 호출하지 않으면 이때는 기반 클래스의 디폴트 생성자가 호출되는데 만약 기반 클래스가 디폴트 생성자를 정의하지 않는다면 에러로 처리된다. 디폴트 생성자는 보통 아무 것도 하지 않거나 무난한 값으로 멤버를 초기화하므로 이렇게 되면 상속받은 멤버는 원하는대로 초기화되지 않을 것이다. 그래서 파생 클래스 생성자의 초기화 리스트에는 거의 예외없이 기반 클래스의 생성자 호출문이 오며 자신이 전달받은 인수의 일부를 기반 클래스의 생성자에게 전달한다.
Derived(인수들) : Base(상속받은 인수들) {
본체 - 여기서 자신의 고유 멤버 초기화
}
만약 기반 클래스에 여러 개의 생성자가 오버로딩되어 있다면 초기화 리스트의 인수 목록에 따라 호출될 생성자가 결정된다. 상속받은 멤버를 어떤 식으로 초기화할지를 파생 클래스의 초기화 리스트에서 선택할 수 있다. 파생 클래스가 이차 상속된 경우라면 바로 위의 부모가 할아버지 생성자를 알아서 호출할 것이다. 자신이 직접 할아버지 생성자를 호출할 수는 없으며 그럴 필요도 없다. Circle은 Point의 생성자만 호출하면 좌표와 문자를 초기화할 수 있다. Point가 Coord의 생성자를 호출하는가 아닌가는 Circle의 입장에서는 관심 대상이 아니다. 어쨌든 바로 위의 부모를 호출하여 x, y, ch만 제대로 초기화하면 그만이다.
파생 클래스의 객체가 파괴될 때는 생성자가 호출된 역순으로 파괴자가 호출된다. 먼저 자신의 파괴자가 호출되어 스스로의 멤버를 정리하며 상속 계층을 따라 부모의 파괴자가 연쇄적으로 호출되어 상속된 모든 멤버에 대한 정리 작업을 한다. 자식이 파괴되는 동안에도 부모의 멤버를 참조할 수 있어야 하므로 자식이 완전히 파괴될 때까지 부모는 온전히 살아 있어야 한다. 그러나 부모가 파괴될 때 자식의 멤버를 참조할 일은 전혀 없다. 즉 자식은 부모에 종속적이지만 그 역은 성립하지 않으므로 자식이 먼저 파괴되는 것이 순서상 옳다. 위 예제의 경우 파괴자가 없고 파괴할 내용도 없으므로 이런 동작을 확인할 수는 없다.
29-2-라.멤버 함수 재정의
클래스가 파생될 때 기반 클래스로부터 대부분의 멤버를 상속받지만 일부 상속에서 제외되는 것들도 있다. 상속되지 않는 멤버는 다음과 같다.
생성자와 파괴자
대입 연산자
정적 멤버 변수와 정적 멤버 함수
프렌드 관계 지정
이 멤버들이 상속에서 제외되는 이유는 기반 클래스만의 고유한 처리를 담당하기 때문이다. 생성자와 파괴자, 대입 연산자는 특정 클래스에 완전히 종속적이며 해당 클래스의 멤버에 대해서만 동작하기 때문에 파생 클래스는 이 함수들을 직접 사용할 필요가 없다. 대신 초기화 리스트에서 호출할 수는 있다. 생성될 때 자동으로 호출되어 상속된 멤버를 대신 초기화하며 객체가 일단 생성 완료되면 다시 호출할 필요가 없으므로 파생 클래스가 이 함수들을 가지고 있어야 할 이유가 전혀 없다.
이런 특수한 몇 가지 멤버를 제외하고는 기반 클래스의 모든 멤버가 파생 클래스로 무조건 상속된다. 원하는 멤버만 선택적으로 상속한다거나 특정 멤버를 상속받지 않는 방법은 없다. 부모가 가진 모든 속성과 동작을 상속받아야만 제대로 된 자식이라고 할 수 있다. 만약 파생 클래스에서 특정 멤버를 전혀 사용하지 않는다면 일단 상속받은 후 사용하지 않고 무시해 버리면 된다.
파생 클래스는 기반 클래스의 모든 멤버 변수와 멤버 함수를 상속받으므로 기반 클래스의 속성과 동작을 그대로 물려 받는다. 그런데 만약 상속 받은 멤버와 똑같은 이름으로 똑같은 멤버를 다시 선언하면 어떻게 될까? 어떤 현상이 일어나는지 다음 예제로 테스트해보자.
예 제 : MemberOverride
|
#include <Turboc.h>
class B
{
public:
int m;
B(int am) { m=am; }
void f() { puts("Base function"); }
};
class D : public B
{
public:
int m;
D(int dm,int bm) : B(bm) { m=dm; }
void f() { puts("Derived function"); }
};
void main()
{
D d(1,2);
printf("d.m = %d\n",d.m);
d.f();
}
기반 클래스 B에는 정수형 멤버 m과 함수 f가 정의되어 있다. B로부터 새로운 클래스 D를 파생시키면 m과 f는 파생 클래스로 상속될 것이다. 그런데 D에서 똑같은 이름과 원형으로 m과 f를 다시 정의했다. 이 상태에서 D 클래스의 객체 d를 선언하고 d.m을 읽어보고 d.f를 호출하면 과연 어떤 멤버가 참조될까? 직접 실행해 보자.
d.m = 1
Derived function
이 결과에서 알 수 있듯이 상속받은 멤버와 새로 정의한 멤버의 이름이 중복될 경우 자신의 멤버가 우선적으로 참조된다는 것을 알 수 있다. B로부터 상속받은 D는 부모의 모든 멤버를 그대로 물려받으며 또 자신의 멤버를 추가로 정의했으므로 다음과 같은 모양을 가질 것이다.
d 객체에는 이름이 같은 m과 f가 각각 두 개씩 존재하는 셈인데 이 상태에서 m과 f를 참조하면 이는 객체 자신의 멤버를 의미한다. 이 상황은 전역변수와 지역변수의 이름이 중복되었을 때와 유사하며 규칙에 따라 지역변수가 우선권을 가지듯이 객체에서는 상속받은 멤버보다 자신의 멤버가 우선권을 가진다. 그래서 이름이 중복된 상속받은 멤버는 자식이 새로 정의한 멤버에 의해 가려진다. 만약 부모의 멤버를 참조하고 싶다면 멤버 이름앞에 범위 연산자와 부모 클래스의 이름을 적는다. main 함수의 테스트 코드를 다음과 같이 수정해 보자.
void main()
{
D d(1,2);
printf("d.m = %d\n",d.B::m);
d.B::f();
}
d.B::m 이라는 표현은 d객체의 멤버 중 B로부터 상속받은 멤버 m을 의미한다. B::d.m이 아님을 주의하도록 하자. 멤버 변수의 경우는 굳이 부모의 멤버와 같은 이름을 사용할 필요가 없으며 바람직하지도 않다. 비록 컴파일러는 자식의 멤버에 우선권을 주고 원할 경우 범위 연산자로 부모의 멤버를 액세스할 수 있도록 함으로써 모호함을 해결하고 있지만 사람이 보기에는 여전히 혼란스럽다. 부모의 멤버를 쓰고 싶지 않다면 단순히 무시해 버리면 그만이지 굳이 같은 이름의 멤버를 선언해서 가릴 필요까지는 없는 것이다.
그러나 멤버 함수의 경우는 부모의 멤버 함수가 제공하는 동작이 파생 클래스와 맞지 않을 때 재정의할 필요가 있으며 이런 경우는 아주 빈번하다. 그 예는 멀리서 찾을 것도 없이 바로 앞에서 만들었던 InheritCircle 예제에서 볼 수 있다. 점과 원은 기하학적인 정의가 다르므로 그리는 방법도 완전히 다르다. 점을 그리는 Show 함수의 코드를 원을 그릴 때 재사용할 수 없으며 그래서 Circle 클래스는 Point 클래스로부터 상속받은 Show, Hide 함수를 원 객체에 맞게 완전히 다시 작성한 것이다. 부모로부터 상속받은 멤버 함수를 다시 작성하는 것을 재정의라고 하는데 원어로는 오버라이딩(Overriding)이라고 한다.
파생 클래스는 기반 클래스의 모든 멤버를 반드시 그대로 사용해야 할 의무가 없다. 자신의 목적에 맞지 않으면 언제든지 같은 이름으로 재정의할 수 있다. Circle 클래스의 Show, Hide 함수처럼 완전히 다시 작성할 수도 있고 상속 받은 함수에 원하는 동작을 약간 더 추가하거나 변경하는 것도 가능하다. 원을 그리는 Show 함수에 중심점도 같이 출력하도록 하고 싶다면 다음과 같이 Show 함수를 재정의한다.
void Show() {
Point::Show();
for (double a=0;a<360;a+=15) {
gotoxy(int(x+sin(a*3.14/180)*Rad),int(y-cos(a*3.14/180)*Rad));
putch(ch);
}
}
Circle::Show 함수의 본체에서 상속받은 Point::Show 함수를 먼저 호출하여 중심점을 찍고 여기에 추가로 원주까지 그린 것이다. 함수를 재정의한다고 해서 부모의 함수가 상속되지 않는 것은 아니므로 범위 연산자만 사용하면 가려진 부모의 함수를 언제든지 호출할 수 있다. 그래서 부모의 함수를 먼저 부른 후 추가 동작을 하거나 아니면 내가 하고 싶은 일은 먼저 한 후에 부모의 함수를 부를 수도 있다.
void D::func() {
B::func();
// 하고 싶은 일
}
|
void D::func() {
// 하고 싶은 일
B::func();
}
|
또는 하고 싶은 일을 하는 중간에라도 부모의 함수를 언제든지 호출할 수 있고 완전히 동작이 다르다면 호출하지 않을 수도 있다. 재정의된 함수의 본체에서 상속받은 함수를 언제 호출할 것인가는 경우에 따라 다른데 Circle::Show의 경우는 언제 호출하든지 상관없지만 어떤 경우는 순서를 잘 결정해야 원하는 결과가 나오기도 한다. 다음 예제는 기반 클래스의 멤버를 재정의하는 간단한 예이다.
예 제 : InheritStudent
|
#include <Turboc.h>
class Human
{
protected:
char Name[16];
public:
Human(char *aName) { strcpy(Name,aName); }
void Intro() { printf("이름:%s",Name); }
void Think() { puts("오늘 점심은 뭘 먹을까?"); }
};
class Student : public Human
{
private:
int StNum;
public:
Student(char *aName,int aStNum) : Human(aName) { StNum=aStNum; }
void Intro() { Human::Intro();printf(",학번:%d",StNum); }
void Think() { puts("이번 기말 고사 잘 쳐야 할텐데 ^_^"); }
void Study() { puts("하늘 천 따지 검을 현 누를 황..."); }
};
void main()
{
Student K("김상형",9506299);
K.Intro();puts("");
K.Think();
K.Study();
}
이름이라는 속성과 소개한다, 생각한다는 동작을 가지는 Human이라는 클래스에 학번과 공부한다는 동작을 추가하여 Student라는 클래스를 파생시켰다. 학생은 일종의 사람이므로 전형적인 IS A 관계라고 할 수 있다. 사람은 자기 소개를 할 수 있는데 이 예제의 Intro 멤버 함수는 자신의 이름을 화면으로 출력한다. 또한 사람은 생각하는 동물이므로 Think라는 동작을 할 수 있으며 누구나 오늘 점심꺼리를 걱정한다.
학생이라는 존재는 사람의 모든 특성을 상속받으므로 사람이 하는 짓은 모두 할 수 있다. 그러나 학생은 역시 단순한 사람보다는 좀 더 기능이 많고 구체적이다. 자기 소개를 할 때 이름뿐만 아니라 자신의 학번도 소개할 수 있으므로 Student는 Human으로부터 상속받은 Intro 함수를 그대로 사용하지 않으며 재정의한다. 부모 클래스에 있는 Intro 함수를 활용하기 위해 부모의 Intro를 먼저 호출하여 이름을 출력하고 다음으로 자신의 학번을 출력한다. 재정의한 함수가 부모의 가려진 함수를 호출한 것인데 코드의 재사용과 반복 제거의 의미가 있다. 여기서 Human::을 빼 먹으면 무한 재귀 호출이 되어 버리므로 주의하자.
학생은 생각하는 동작도 좀 더 고차원적이므로 Human의 Think를 완전히 재정의하였다. 이렇게 되면 Human의 Think는 재정의된 Think에 의해 가려진다. Intro, Think외에 Study라는 멤버 함수도 추가로 선언했다. main 함수에서는 Student 객체 K를 선언 및 초기화하고 이 객체의 멤버 함수들을 모두 호출해 보았다.
이름:김상형,학번:9506299
이번 기말 고사 잘 쳐야 할텐데 ^_^
하늘 천 따지 검을 현 누를 황...
보다시피 Human의 멤버 함수가 아닌 재정의된 멤버 함수들이 호출된다. 이상으로 파생 클래스에서 멤버를 재정의할 수 있다는 것을 살펴 봤는데 상기 예제들은 아직 완전하지 않다. 이 예제들이 부모의 멤버 함수를 안전하게 재정의하려면 가상 함수의 개념이 필요한데 이 주제에 대해서는 다음 장에서 상세하게 알아보도록 하자.
29-3.다중 상속
29-3-가.두 개의 기반 클래스
다중 상속(Multiple Inheritance)이란 두 개 이상의 기반 클래스로부터 새로운 클래스를 상속하는 것이다. 복잡도에 비해 실용성이 떨어지는 문법이므로 처음부터 너무 깊이 공부할 필요는 없는 주제이다. 실제 사물의 예를 들자면 다음과 같은 것들이 다중 상속된 좋은 예이다.
▶ 핸드폰, 카메라 : 카메라 폰
▶ 프린터, 스캐너, 팩스 : 복합기
핸드폰은 들고 다니며 전화를 걸거나 받는 기계이며 버튼, 안테나, 액정, 밧데리 등을 가지고 통화하는 것이 기본적인 기능이다. 카메라는 사진을 찍는 기계이며 렌즈, 메모리 등의 부품을 가진다. 이 둘의 특성과 기능을 결합하면 카메라 폰이라는 새로운 기계가 만들어진다. 이때 카메라 폰은 핸드폰과 카메라의 모든 속성과 동작을 상속받으므로 통화하는 기능과 사진을 찍는 기능을 동시에 가진다. 여기에 MP3 기능까지 더하면 MP3 카메라 폰이 될 수도 있고 TV 기능까지 겸할 수도 있다.
복합기의 경우 세 개의 사물을 결합하는 예인데 프린터와 스캐너 그리고 팩스의 기능을 하나로 통합해서 만든 물건이다. 이런 다중 상속의 예는 실생활에서도 흔히 발견할 수 있으며 비용 절감과 편의성 향상면에서 아주 긍정적이라 할 수 있다. 소프트웨어의 세계에서도 실생활의 통합과 유사한 일들이 벌어지는데 이것이 바로 다중 상속이다.
이미 만들어진 복수 개의 클래스들을 다중 상속하여 두 클래스의 기능을 모두 가지는 새로운 클래스를 쉽게 만들 수 있으며 게다가 더 필요한 기능을 추가하는 것도 가능하다. 다음 예는 다중 상속의 가장 직관적인 예제로 날짜를 표현하는 Date와 시간을 표현하는 Time 클래스를 다중 상속하여 한 시점을 표현할 수 있는 Now라는 클래스를 파생시키는 것이다.
예 제 : MultiInherit
|
#include <Turboc.h>
class Date
{
protected:
int year,month,day;
public:
Date(int y,int m,int d) { year=y;month=m;day=d; }
void OutDate() { printf("%d/%d/%d",year,month,day); }
};
class Time
{
protected:
int hour,min,sec;
public:
Time(int h,int m,int s) { hour=h;min=m;sec=s; }
void OutTime() { printf("%d:%d:%d",hour,min,sec); }
};
class Now : public Date, public Time
{
private:
bool bEngMessage;
int milisec;
public:
Now(int y,int m,int d,int h,int min,int s,int ms,bool b=FALSE)
: Date(y,m,d), Time(h,min,s) { milisec=ms; bEngMessage=b; }
void OutNow() {
printf(bEngMessage ? "Now is ":"지금은 ");
OutDate();
putch(' ');
OutTime();
printf(".%d",milisec);
puts(bEngMessage ? ".":" 입니다.");
}
};
void main()
{
Now N(2005,1,2,12,30,58,99);
N.OutNow();
}
실행 결과는 다음과 같다.
지금은 2005/1/2 12:30:58.99 입니다.
Date에는 날짜와 관련된 속성과 멤버 함수들이 이미 정의되어 있고 이 정보를 관리하는 다양한 멤버 함수들도 작성되어 있을 것이다(비록 이 예제의 Date는 그렇지 않지만). 마찬가지로 Time에는 시간과 관련된 모든 정보와 기능이 캡슐화되어 있을 것이다. 정확한 한 시점을 표현하는 Now 클래스는 처음부터 다시 만들 필요없이 이미 만들어져 있는 두 클래스의 기능을 상속받기만 하면 된다.
게다가 더 필요한 멤버를 추가로 정의할 수도 있는데 위 예제의 Now에는 메시지를 출력할 언어를 지정하는 bEngMessage와 1/100초 단위의 좀 더 정밀한 시간까지 표현하기 위한 milisec 멤버를 더 정의하고 있다. 그리고 자신이 표현하는 정보를 출력하기 위해 OutNow라는 멤버 함수를 제공하며 이 함수에서 상속받은 OutDate, OutTime을 적절하게 잘 부려먹기도 한다. Now가 상속되는 모양을 그림으로 그려보면 다음과 같다.
다중 상속을 받을 때는 클래스 선언문의 : 다음에 기반 클래스의 목록을 콤마로 구분하여 적는다. 이때 각 기반 클래스의 상속 액세스 지정은 서로 다를 수 있으므로 개별적으로 지정해야 하며 만약 한쪽을 생략하여 class Now : public Date, Time으로 적으면 디폴트 상속 액세스 지정자인 private가 적용된다.
단일 상속의 경우와 마찬가지로 상속받은 멤버의 초기화는 기반 클래스의 생성자가 대신 하는데 클래스 선언문에 나타난 기반 클래스 순서대로 생성자가 호출된다. Now 클래스의 경우 Date, Time 순으로 다중 상속되었으므로 Date의 생성자가 먼저 호출되고 다음으로 Time의 생성자가 호출될 것이다. 생성자의 초기화 리스트에 나타난 순서대로가 아니라 클래스 선언문의 기반 클래스 지정 순서대로임을 유의하자.
29-3-나.다중 상속의 문제점
앞 항의 Time과 Date로부터 Now를 다중 상속하는 예제는 Time과 Date가 완전히 독립된 클래스이기 때문에 아무런 문제가 없으며 이렇게 만들어진 Now 클래스도 아주 정상적으로 잘 동작한다. 그러나 다중 상속은 상속 경로가 여럿이기 때문에 복잡한 문제를 야기하는 경우가 많다. 일단 다음 선언문을 보자.
class B : public A, public A
{
....
};
A라는 하나의 클래스로부터 다중 상속받아 B를 파생시키는데 이 코드는 에러로 처리된다. 한 클래스로부터 두 번 상속을 받는 것은 금지되어 있는데 왜냐하면 이렇게 파생된 클래스에는 똑같은 이름의 멤버들이 두 개씩 존재하게 되므로 멤버 이름간에 충돌이 생기기 때문이다. A에 m이라는 멤버가 있을 때 b.m이 어느 쪽의 A로부터 물려받은 m인지 애매하며 b.A::m 식으로 범위 연산자를 써도 별 도움이 안된다. 문법적인 이유를 따지지 않더라도 이런 중복 상속이 문제가 될 것임은 직감적으로 이해할 수 있다. 그러나 간접적으로 한 클래스를 두 번 상속하는 것은 가능한데 다음 예제를 보자.
예 제 : VirtualBase1
|
#include <Turboc.h>
class A
{
protected:
int a;
public:
A(int aa) { a=aa; }
};
class B : public A
{
protected:
int b;
public:
B(int aa, int ab) : A(aa) { b=ab; }
};
class C : public A
{
protected:
int c;
public:
C(int aa,int ac) : A(aa) { c=ac; }
};
class D : public B, public C
{
protected:
int d;
public:
D(int aa, int ab, int ac, int ad) : B(aa,ab), C(aa,ac) { d=ad; }
void fD() {
b=1;
c=2;
a=3; // 여기서 문제 발생
}
};
void main()
{
D d(1,2,3,4);
}
4개의 클래스가 계층을 구성하고 있는데 클래스간의 상속 관계를 그림으로 그려 보면 다음과 같다. 여러 가지 복잡한 문제를 일으키기 때문에 이런 클래스 계층도를 공포의 다이아몬드(또는 마름모) 계층도라고 부른다.
D의 부모인 B, C는 모두 A를 공동의 조상으로 가지고 있으며 그래서 D는 간접적으로 A를 두 번 상속받는다. B와 C가 모두 A로부터 상속받은 멤버 변수 a를 가지고 있고 D는 B와 C로부터 상속을 받았으므로 결국 D에는 a라는 이름의 멤버 변수가 두 개 존재하는 것이다. 이 두 변수가 똑같은 의미를 가진다면 나머지 하나는 불필요하므로 기억 장소가 쓸데없이 낭비되고 객체가 비대해지는 문제가 있다. 카메라 폰은 카메라의 기능과 핸드폰의 기능을 모두 상속받아야 하되 그렇다고 해서 밧데리를 두 개나 가질 필요는 없지 않은가?
그 보다 심각한 문제는 D의 객체에서 a 멤버를 칭할 때 어떤 멤버를 칭하는지 알 수 없는 모호함이 발생한다는 것이다. 상속받은 수준이 같기 때문에 지역, 전역처럼 우선 순위를 매길 수도 없다. 위 예제를 컴파일해 보면 fD 멤버 함수의 세 번째 줄의 a=3; 문장에서 a가 모호하다는 에러 메시지가 출력될 것이다. 똑같은 이름의 멤버가 둘씩이나 있으니 컴파일러가 헷갈려하는 것이 당연하다. 이 점은 멤버 함수에 대해서도 마찬가지이다. B에 f 함수가 있고 C에 같은 이름의 f 함수가 있을 때 D의 객체에서 f를 호출하면 도대체 어느쪽을 호출하라는 것인지 애매해진다.
만약 각 경로를 통해 상속받은 똑같은 이름의 a가 비록 이름은 같더라도 서로 다른 의미를 가진다면 D에서 B::a, C::a 식으로 소속 기반 클래스를 명시함으로써 두 변수를 구분할 수는 있다. 일일이 누구로부터 상속받은 멤버인지를 밝혀야 한다는 점이 무척 번거롭기는 하지만 어쨌든 모호함을 피할 방법은 있는 셈이다.
다중 상속받은 중복된 멤버가 같은 이름으로 각기 다른 의미를 가지는 경우는 그리 흔하지 않지만 어쨌든 가능은 하다. 다음 예는 좀 더 복잡한 다중 상속의 예로 중복된 멤버가 서로 다른 의미로 사용되는 경우를 억지로 만들어 본 것이다. 최대한 간단하게 만들려고 했지만 다중 상속이라는 상황 자체가 복잡하기 때문에 길이가 좀 길다.
예 제 : VirtualBase2
|
#include <Turboc.h>
#include <math.h>
class Coord
{
protected:
int x,y;
public:
Coord(int ax, int ay) { x=ax;y=ay; }
void GetXY(int &rx, int &ry) const { rx=x;ry=y; }
void SetXY(int ax, int ay) { x=ax;y=ay; }
};
class Point : public Coord
{
protected:
char ch;
public:
Point(int ax, int ay, char ach) : Coord(ax,ay) { ch=ach; }
void Show() {
gotoxy(x,y);putch(ch);
}
void Hide() {
gotoxy(x,y);putch(' ');
}
};
class Circle : public Point
{
protected:
int Rad;
public:
Circle(int ax, int ay, char ach, int aRad) : Point(ax,ay,ach) { Rad=aRad; }
void Show() {
for (double a=0;a<360;a+=15) {
gotoxy(int(x+sin(a*3.14/180)*Rad),int(y-cos(a*3.14/180)*Rad));
putch(ch);
}
}
void Hide() {
for (double a=0;a<360;a+=15) {
gotoxy(int(x+sin(a*3.14/180)*Rad),int(y-cos(a*3.14/180)*Rad));
putch(' ');
}
}
};
class Message : public Coord
{
private:
char Mes[128];
public:
Message(int ax, int ay, char *M) : Coord(ax,ay) {
strcpy(Mes,M);
}
void Show() {
gotoxy(x-strlen(Mes)/2,y);
puts(Mes);
}
};
class CirMessage : public Circle, public Message
{
public:
CirMessage(int ax, int ay, char ach, int aRad, int mx, int my, char *M)
: Circle(ax,ay,ach,aRad), Message(mx,my,M) { }
public:
void Show() {
Circle::Show();
Message::Show();
}
};
void main()
{
CirMessage CM(10,10,'.',8,40,15,"테스트");
CM.Show();
}
Coord, Point, Circle은 지금까지의 예제에서 계속 사용해왔던 것들이며 Coord로부터 파생된 Message 클래스가 추가되었는데 x, y 위치에 문자열을 중앙 정렬하여 출력하는 기능을 가진다. 이 상태에서 원과 메시지를 동시에 표현하는 CirMessage 클래스를 정의하고자 한다면 이미 만들어진 Circle과 Message 클래스로부터 다중 상속을 받으면 된다. 전체 클래스 계층도는 다음과 같다.
Circle과 Message는 공동의 조상 Coord로부터 파생되었으므로 이 두 클래스를 다중 상속받는 CirMessage에는 x, y 멤버가 두 개씩 존재하게 된다. 하지만 이 예제의 경우는 비록 멤버가 중복되기는 했지만 CirMessage에서 이 멤버들을 직접 사용하지는 않으므로 문법적으로 별 문제가 없으며 또한 중복된 멤버의 의미도 다르다. 실행 결과는 다음과 같다.
main에서 생성한 CM객체의 초기값에 따라 원은 (10,10) 중심에 반지름 8로 그려졌고 메시지는 (40,15)에 출력되었다. CirMessage의 두 좌표 (x, y) 중 Circle로부터 상속받은 좌표는 원의 중심점을 지정하며 Message로부터 상속받은 좌표는 메시지를 출력할 좌표를 지정한다. 중복된 멤버의 의미가 확실히 다르며 원과 메시지가 각각 다른 위치에 출력될 수 있다.
그렇다면 이것이 과연 CirMessage 클래스를 만든 개발자의 의도와 일치하는 것일까 생각해 보자. 아마 그렇지 않을 것이다. 이 클래스는 메시지를 출력하고 그 주변에 적당한 크기의 원을 그림으로써 장식하는 기능을 가지는 것으로 작성되었을 것이다. 메시지와 원이 따로 놀아야 한다면 통합의 의미가 없는 셈이며 애초에 이런 걸 원했다면 다중 상속같은 복잡한 문법을 쓸 필요없이 두 개의 객체를 따로 만들면 된다. 물론 객체의 초기값에서 원의 좌표와 메시지의 좌표를 일치시키면 이런 객체를 그릴 수는 있다.
CirMessage CM(40,10,'.',8,40,10,"테스트");
main의 코드를 이렇게 수정하면 (40,10)에 메시지가 출력되고 그 주변에 원이 예쁘게 그려질 것이다. 하지만 이런 초기화 방법은 중복된 초기값을 주어야 한다는 것과 사용자에게 똑같은 값을 두 번 지정하도록 강요한다는 점에서 "최소 의사 표시" 원칙에 어긋난다. 두 멤버의 의미가 같다면 초기값을 한 번만 지정하는 것이 마땅하다.
29-3-다.가상 기반 클래스
앞 항에서 다중 상속으로 인한 문제점들에 대해 알아 봤는데 요약하자면 한 클래스를 간접적으로 두 번 상속받을 경우 이 클래스의 멤버가 중복되어 메모리가 낭비되고 어떤 멤버를 칭하는지 알 수 없는 모호함이 발생한다는 것이다. 이 문제를 해결하려면 한 클래스를 두 번 상속받더라도 이 클래스의 멤버들은 한 번만 상속하도록 하면 된다.
이런 클래스를 가상 기반 클래스(Virtual Base Class)라고 하는데 이렇게 지정된 클래스는 간접적으로 두 번 상속되더라도 결과 클래스에는 자신의 멤버를 한 번만 상속시킨다. 가상 기반 클래스를 지정할 때는 상속문의 기반 클래스앞에 virtual이라는 키워드를 쓴다. virtual 키워드와 상속 액세스 지정자의 순서는 무관하되 virtual을 먼저 지정하는 것이 보통이다. VirtualBase1 예제를 다음과 같이 수정해 보자.
class B : virtual public A
....
class C : virtual public A
....
class D : public B, public C
{
protected:
int d;
public:
D(int aa, int ab, int ac, int ad) : A(aa), B(aa,ab), C(aa,ac) { d=ad; }
B와 C를 A로부터 파생시킬 때 A를 가상 기반 클래스로 지정했다. 양쪽 다 virtual 상속을 해야 하며 한쪽만 virtual 상속하면 효과가 없다. 이 상태에서 B와 C로부터 다중 상속을 받으면 A의 멤버가 한 번만 나타나며 결과 클래스인 D에 a 멤버는 하나만 존재한다. 이렇게 수정한 후 컴파일하면 모호함이 제거되었으므로 D의 멤버 함수에서 a를 바로 참조해도 아무런 문제가 없다.
가상 기반 클래스로부터 상속받는 중간 단계 클래스의 생성자가 자식의 생성자로부터 호출될 때는 가상 기반 클래스의 생성자를 호출하지 않는다. 위 예에서 D 생성자의 초기화 리스트에서 B(aa,ab)를 부르는데 이때 B의 생성자가 A의 생성자를 호출하지 않는다는 얘기다. 왜냐하면 중간 클래스는 가상 기반 클래스의 멤버를 직접 소유하지 않으며 최종 클래스가 이 멤버를 어떤 용도로 사용하는지도 알 수 없기 때문이다.
또한 두 경로를 통해 가상 기반 클래스의 생성자가 각각 호출되면 서로 값을 덮어쓰는 충돌도 발생한다. D의 생성자 초기화 리스트에서 B(aa,ab)와 C(aa,ab)를 각각 호출하는데 이 두 생성자가 서로 A의 생성자를 호출한다고 해 보자. B(1,2), C(3,4)로 호출했다면 이 경우 a는 과연 어떤 값으로 초기화되어야 하는지 아주 애매해진다. 그래서 중간 단계의 클래스들은 가상 기반 클래스의 멤버에 대한 초기화를 최종 클래스에게 맡기는 것이다.
가상 기반 클래스로부터 상속받은 멤버는 최종 클래스가 직접 초기화해야 한다. 그래서 D의 생성자에서 A(aa) 호출문이 필요한 것이다. a 멤버는 B와 C를 통해 상속받기는 했지만 최종 클래스인 D의 것이므로 자신이 직접 초기화해야 한다. 만약 D의 생성자가 A의 생성자를 호출하지 않으면 이때는 A의 디폴트 생성자가 호출되며 A가 디폴트 생성자를 정의하지 않는다면 에러로 처리된다.
파생 클래스는 바로 위의 기반 클래스 생성자만 호출할 수 있으며 할아버지 생성자를 호출할 수 없다는 규칙이 있다. 그러나 다중 상속의 경우는 이 규칙의 예외가 인정되어 D가 A의 생성자를 바로 호출할 수 있다. 보다시피 다중 상속은 정교한 문법에 예외를 두어야 할 정도로 복잡한 문제를 야기시킨다.
다음은 VirtualBase2 예제를 수정해 보자. CirMessage 클래스가 상속받는 x, y를 하나로 통합하여 이 객체를 생성할 때 하나의 좌표로 원의 중심과 메시지의 출력 위치를 같이 지정하도록 해 보자. 다음과 같이 생성자만 수정해도 일단 목적을 이룰 수는 있다.
CirMessage(int ax, int ay, char ach, int aRad, char *M)
: Circle(ax,ay,ach,aRad), Message(ax,ay,M) { }
생성자로 ax, ay 인수를 하나만 전달받아 Circle의 생성자와 Message의 생성자에 똑같은 값을 전달하면 중복된 멤버가 같은 값을 가지므로 원의 중심점과 메시지의 출력 위치가 같아질 것이다. 그러나 아직 x, y 멤버는 여전히 두 벌이 있고 이 중복된 멤버가 메모리를 차지하며 언젠가는 잠재적인 문제를 일으킬 것이다. CirMessage가 Coord를 한 번만 상속받기 위해 Coord가 가상 기반 클래스가 되어야 한다. 소스를 다음과 같이 수정해 보자.
class Point : virtual public Coord
....
class Circle : public Point
{
protected:
int Rad;
public:
Circle(int ax, int ay, char ach, int aRad) : Coord(ax,ay),Point(ax,ay,ach) { Rad=aRad; }
....
class Message : virtual public Coord
....
class CirMessage : public Circle, public Message
{
public:
CirMessage(int ax, int ay, char ach, int aRad, char *M)
: Coord(ax,ay),Circle(ax,ay,ach,aRad), Message(ax,ay,M) { }
Coord를 직접 상속받는 Point, Message 클래스의 선언문에 virtual 키워드를 삽입하고 간접적으로 상속받는 Circle, CirMessage 클래스의 생성자에서는 Coord의 생성자를 명시적으로 호출해야 한다. 이렇게 하면 CirMessage는 Coord를 한 번만 상속받게 되며 이 클래스의 멤버 함수는 별다른 지정없이 x, y 멤버를 바로 액세스할 수 있다.
가상 상속을 받으면 중복된 멤버가 한 번밖에 나타나지 않으므로 객체의 크기는 줄어들어야 한다. 하지만 실제로는 중복된 멤버의 관리를 위해 숨겨진 포인터가 추가되기 때문에 반드시 그렇다고 할 수는 없다. 중복 멤버의 관리 방법은 컴파일러마다 다른데 비주얼 C++의 경우 가상 기반 클래스 하나에 대해 4바이트씩 더 추가되며 중복된 멤버의 크기가 4바이트 이상일 때만 객체 크기가 줄어든다. 가상 기반 클래스가 문자열이나 대규모 배열을 가질 경우 객체 크기는 극적으로 작아질 것이다.
29-3-라.다중 상속의 효용성
다중 상속은 여러 개의 기반 클래스가 가진 속성들을 모두 상속받는다는 점에서 굉장히 강력하고 편리한 상속 방법이다. 이미 잘 동작하는 클래스들을 다중 상속해서 원하는 새로운 클래스를 만들어 낼 수 있다는 것은 참으로 멋지고 환상적인 기능이다. 다중 상속을 사용하면 복잡한 문제를 아주 쉽게 풀 수 있는 경우도 있고 객체의 재활용성도 높아진다.
그러나 모든 기능에는 역기능이 있는데 다중 상속의 경우는 부작용이 상당히 많다. 앞에서 이미 살펴 봤다시피 한 클래스를 간접적으로 두 번 상속할 수 있어 멤버가 중복될 수도 있으며 이런 문제를 해결하기 위해 별도의 예외 문법을 만들 필요도 있다. 또한 가상 기반 클래스가 아닌 부모로부터 다중 상속할 경우 부모 클래스 타입의 포인터가 자식 객체를 가리킬 수 없어 다형성에 방해가 되는 문제도 있는데 이 문제에 대해서는 다음에 연구해 보도록 하자.
삼중, 사중의 다중 상속도 얼마든지 가능한데 이렇게 여러 개의 클래스를 동시에 사용하면 클래스 계층이 너무 복잡해지며 코드를 이해하고 관리하기도 어려워질 것이다. ATL이라는 COM 라이브러리는 20중 다중 상속을 하기도 하는데 이 경우는 인터페이스 상속이라 별다른 부작용은 없지만 초보자가 이 코드를 이해하는 것은 거의 불가능에 가까울 정도다. 게다가 가상 기반 클래스에 사용되는 virtual이라는 키워드도 심히 부적절하게 선택되어 있는데 차라리 unique나 onlyone 등의 키워드를 썼더라면 더 이해하기 쉬웠을 것이다.
이런 복잡성과 부작용에 비해 다중 상속의 실용성은 그다지 높지 않다. 다중 상속이 아니더라도 둘 이상의 클래스를 재활용할 수 있는 다른 문법들이 존재하며 다중 상속이 아니면 문제를 풀 수 없는 경우는 거의 없다. 그래서 자바같은 최신의 언어는 다중 상속을 아예 지원하지도 않으며 그러면서도 필요한 모든 코드는 다 작성할 수 있다. 심지어 MFC 같은 C++ 라이브러리조차도 다중 상속을 아예 사용하지 않으며 구조 자체가 다중 상속을 허용하지 않는다.
어떤 사람은 다중 상속이 너무 복잡하고 C++ 문법을 난잡하게 만드는데 비해 효용성은 크게 떨어지므로 아예 C++ 표준에서 빼 버려야 한다고 주장하기도 한다. 이 주장은 과연 설득력이 있기는 하지만 이미 다중 상속을 잘 사용하고 있는 프로젝트들이 있고 인터페이스 다중 상속같은 어려운 기법도 나름대로 실용성이 있으므로 현실적으로 이렇게 될 확률은 거의 없다. 아무튼 이런 과격한 주장이 제기될 정도로 다중 상속의 효용성은 형편없다.
C++이 만들어지던 시기는 언어들이 경쟁적으로 기능을 늘려가던 때였으므로 스트로스트룹은 다른 언어들보다 더 기능이 많은 언어를 디자인하고자 했을 것이며 이런 과욕의 결과로 만들어진 것이 바로 다중 상속이다. 자바나 C#같은 최신 언어들은 다중 상속을 지원하지 않으며 이 점이 광고 문구로 활용될 정도로 다중 상속은 효용에 비해 골치가 아프다. 결론적으로 다중 상속은 지원은 되지만 대부분의 사람들이 쓰기를 꺼려하는 문법의 사생아라고 할 수 있다.
C++이 언어 차원에서 다중 상속을 지원하기 때문에 이 책에서도 다중 상속을 다루고는 있는데 만약 이 절의 내용이 잘 이해가 가지 않는다면 일단은 무시해 버리는 방법도 나쁘지 않다. 다중 상속의 정의를 대충 알아 두고 아무튼 복잡하고 말썽이 많은 놈이라는 것 정도만 알고 넘어가도 될 것 같다. 설사 다중 상속을 잘 이해했더라도 가급적이면 프로젝트에서 사용하지 않는 것이 현명하다. 만약 상사가 "너 다중 상속 좀 할 줄 아니?" 라고 물으면 "그딴걸 왜 써요?"하고 자신있게 얘기하면 별 말하지 않을 것이다.
29-4.클래스 재활용
29-4-가.포함
상속은 이미 만들어진 클래스를 재활용하는 객체 지향적인 기법의 하나이다. 상속을 받으면 기반 클래스에 이미 정의된 속성과 동작을 그대로 재사용할 수 있어 클래스를 만드는 시간과 노력을 절감할 수 있다. 이전의 절차식 프로그래밍 기법에서는 찾아볼 수 없는 기발하고 멋진 방법이기는 하다. 그러나 상속만이 클래스를 재활용하는 유일한 기법은 아니다.
상속외에도 전통적인 포함 방법을 사용할 수 있다. 포함(Containment)이란 재활용하고 싶은 클래스의 객체를 멤버 변수로 선언하는 방법이다. 클래스에 포함되는 멤버의 타입에는 제한이 없으므로 다른 클래스의 객체도 당연히 멤버가 될 수 있다. C에서 구조체가 다른 구조체를 포함할 수 있는 것과 개념적으로 동일하며 사실 별로 특별한 기법도 아니다. 다음 예제는 포함 기법으로 Date 클래스를 재활용하는 것을 보여 준다.
예 제 : MemObject
|
#include <Turboc.h>
class Date
{
protected:
int year,month,day;
public:
Date(int y,int m,int d) { year=y;month=m;day=d; }
void OutDate() { printf("%d/%d/%d",year,month,day); }
};
class Product
{
private:
char Name[64];
char Company[32];
Date ValidTo;
int Price;
public:
Product(char *aN, char *aC, int y,int m,int d, int aP) : ValidTo(y,m,d) {
strcpy(Name,aN);
strcpy(Company,aC);
Price=aP;
}
void OutProduct() {
printf("이름:%s\n",Name);
printf("제조사:%s\n",Company);
printf("유효기간:");
ValidTo.OutDate();
puts("");
printf("가격:%d\n",Price);
}
};
void main()
{
Product S("새우깡","농심",2009,8,15,900);
S.OutProduct();
}
두 개의 클래스가 선언되어 있는데 Date는 지금까지 계속 봐 왔던 친숙한 날짜 클래스이다. Product는 제품 하나에 대한 정보를 표현하는데 제품의 이름, 제조사, 유통기한, 가격 등을 멤버로 가지고 있다. 이름과 제조사는 문자열이고 가격은 정수이므로 이미 익숙한 char [ ]이나 int형으로 선언할 수 있지만 날짜는 년, 월, 일의 요소로 구성되는 다소 복잡한 정보이므로 단순 타입으로는 선언할 수 없다. 그렇다고 해서 Product가 year, month, day멤버를 직접 선언하고 관리하는 것도 무척 번거롭다.
그래서 이미 만들어져 있는 Date 클래스를 재활용하기 위해 Date의 객체 ValidTo를 멤버로 선언했다. Date안에는 날짜와 관련된 모든 속성과 기능이 캡슐화되어 있으므로 Date 타입의 객체를 멤버로 선언하기만 하면 이 객체를 사용해 손쉽게 유효기간을 표현 및 관리할 수 있다. Product의 멤버 함수 OutProduct는 ValidTo 객체의 OutDate 멤버 함수를 호출하여 유효기간을 출력한다. 실행 결과는 다음과 같다.
이름:새우깡
제조사:농심
유효기간:2009/8/15
가격:900
객체는 생성자 본체가 실행되기 전에 상속받은 모든 멤버와 포함된 객체를 완전히 초기화해야 한다. 그래서 포함된 객체는 반드시 초기화 리스트에서, 즉 생성자 본체 이전에 초기화해야 한다. 이때 초기화 리스트에는 클래스 이름이 아닌 초기화하고자 하는 객체의 멤버 이름을 사용한다. 위 예제에서는 Product 생성자의 초기화 리스트에서 ValidTo(y,m,d)를 호출하여 ValidTo 멤버 객체를 초기화하고 있다. Date 클래스를 초기화하는 것이 아니라 Product에 포함된 ValidTo 객체를 초기화하는 것이므로 Date(y,m,d)로 적어서는 안된다.
만약 포함된 객체에 대한 초기식이 초기화 리스트에서 발견되지 않으면 이때는 디폴트 생성자가 호출된다. Product 생성자의 초기화 리스트에서 ValidTo 초기식을 빼 버리면 Date의 디폴트 생성자가 호출되는데 이 클래스는 디폴트 생성자를 정의하고 있지 않으므로 에러로 처리될 것이다. 포함된 객체를 어떻게 초기화할지 결정할 수 없기 때문이다. Date에 빈 디폴트 생성자를 추가하고 Product의 생성자를 다음과 같이 수정하면 어떻게 될까?
Product(char *aN, char *aC, int y,int m,int d, int aP) {
ValidTo=Date(y,m,d);
strcpy(Name,aN);
strcpy(Company,aC);
Price=aP;
}
초기화 리스트에 ValidTo 초기식을 빼고 대신 생성자 본체에 대입문을 작성했다. 이렇게 하면 에러없이 컴파일되고 Product 객체가 정상적으로 초기화되기는 하지만 초기화 과정은 상당히 달라진다. Date의 디폴트 생성자에 의해 ValidTo가 일단 쓰레기값으로 초기화된 후 Product의 생성자 본체에서 다시 Date(int, int, int) 생성자를 호출하여 임시 객체를 생성하고 이 임시 객체가 ValidTo 객체로 대입되며 이 과정에서 대입 연산자가 실행될 것이다.
두 개의 생성자가 차례대로 호출되는 이중 생성 과정을 거치며 대입 연산자까지 호출된다. Date는 아주 작은 클래스라 별 부담이 없지만 대형 클래스는 이 차이를 무시할 수 없다. 디폴트 초기화, 임시 객체 생성, 대입 연산 중의 깊은 복사, 임시 객체 파괴까지 엄청나게 긴 과정을 거쳐야 초기화가 완료된다. 이런 복잡한 과정이 싫으면 문법의 정상적인 권고대로 초기화 리스트에서 포함 객체를 초기화하는 것이 좋다.
Product가 Date를 포함하고 있는 이런 관계를 HAS A 관계라고 하는데 일종의 소유 관계이며 상속 관계를 표현하는 IS A와는 의미가 다르다. 제품이 유효기간 표현을 위해 날짜를 소유(Product has a Date)하는 것이지 제품이 일종의 날짜(Product is a Date)인 것은 아니다. 두 클래스의 관계가 IS A 관계일 때는 주로 public 상속을 사용하고 HAS A 관계일 때는 포함 기법이 적합하다. 그러나 모든 클래스의 관계가 이처럼 명확하게 구분되는 것은 아니므로 절대적인 재활용 법칙이라고 하기는 어렵다.
29-4-나.private 상속
Product가 Date 타입의 객체 ValidTo를 포함할 때 액세스 속성은 마음대로 지정할 수 있다. ValidTo를 외부에 공개하고 싶으면 public으로 선언하고 숨기고 싶다면 private로 선언하면 된다. MemObject 예제의 경우 ValidTo는 private 속성을 가지므로 Product의 외부에서는 이 멤버를 참조할 수 없다. main 함수에서 S.ValidTo.OutDate()를 호출할 수 없도록 정보가 은폐되어 있으며 유효기간은 제품의 고유 정보이므로 숨기는 것이 논리상 합당하다.
물론 공개하고자 한다면 언제든지 public으로 액세스 지정만 바꾸면 된다. 또한 외부에 대해서는 숨기되 파생 클래스는 직접 참조할 수 있도록 허가하고 싶다면 private와 public의 중간쯤 되는 protected라는 액세스 속성을 줄 수도 있다. 이처럼 포함 관계에서는 포함하는 클래스가 포함되는 객체의 액세스 속성을 임의로 결정한다. 단, 포함되는 객체가 private로 선언해 놓은 멤버는 원칙적으로 자신외에는 누구도 읽을 수 없다.
그렇다면 상속의 경우는 어떨까? 상속받은 멤버에 대해서도 클래스가 임의로 액세스 지정을 변경할 수 있을까? 파생 클래스는 자신이 직접 정의한 것이든 상속받은 것이든 결과적으로 자신의 소유가 된 멤버에 대해 원하는대로 정보 은폐를 할 수 있어야 하며 C++은 이런 방법을 제공하는데 이것이 바로 상속 액세스 지정자이다. 클래스 선언문의 기반 클래스앞에 붙는 public, private, protected가 바로 이것들이며 상속된 멤버의 액세스 속성에 영향을 미친다.
지금까지 연구해 본 상속은 public 상속이었으며 public 상속은 기반 클래스의 액세스 속성이 파생 클래스에서 그대로 유지되는 상속이다. 즉, 기반 클래스가 public으로 공개해 놓은 멤버는 상속된 후에도 여전히 public이다. 여기서 나머지 두 상속에 대해 연구해 보자. private 상속은 부모의 public, protected 멤버를 private로 바꾸어 버리며 그래서 파생 클래스에서는 이 멤버를 액세스할 수 있지만 외부에서는 상속받은 멤버를 참조할 수 없다. 심지어 이 클래스로부터 이차 파생되는 자식에게도 공개되지 않는다. private 상속은 포함과 유사한 효과가 있으며 HAS A 관계를 구현하는 또 다른 방법이다. 앞 항에서 포함 기법으로 만들었던 MemObject 예제를 private 상속으로 다시 만들어 보자.
예 제 : PrivateInherit
|
#include <Turboc.h>
class Date
{
protected:
int year,month,day;
public:
Date(int y,int m,int d) { year=y;month=m;day=d; }
void OutDate() { printf("%d/%d/%d",year,month,day); }
};
class Product : private Date
{
private:
char Name[64];
char Company[32];
int Price;
public:
Product(char *aN, char *aC, int y,int m,int d, int aP) : Date(y,m,d) {
strcpy(Name,aN);
strcpy(Company,aC);
Price=aP;
}
void OutProduct() {
printf("이름:%s\n",Name);
printf("제조사:%s\n",Company);
printf("유효기간:");
OutDate();
puts("");
printf("가격:%d\n",Price);
}
};
void main()
{
Product S("새우깡","농심",2009,8,15,900);
S.OutProduct();
}
ValidTo 멤버를 빼고 Date로부터 private 상속했으며 초기화 리스트에서는 Date 클래스의 생성자를 호출하여 상속받은 멤버를 초기화했다. 이렇게 되면 Product는 Date의 모든 멤버를 상속받으며 상속받은 멤버로 제품의 유효기간을 표현할 수 있다. 포함은 클래스 타입의 객체를 멤버로 선언하는데 비해 private 상속은 기반 클래스로부터 필요한 멤버를 상속받는 기법이다. 실행 결과는 MemObject 예제와 동일하다.
포함과 private 상속
포함과 private 상속은 둘 다 기존의 클래스를 재활용하는 기법이라는 면에서는 공통적이고 HAS A 관계를 표현하는 목적도 동일하지만 차이점도 상당히 많다. 가장 큰 차이점은 한 클래스에서 같은 타입의 객체 복수 개를 동시에 재활용할 수 있는가 하는 점이다. Product 클래스에 유효기간뿐만 아니라 제조일자나 입고날짜까지 포함시키고 싶다면 Date 타입의 객체를 각각 다른 이름으로 원하는만큼 포함하기만 하면 된다.
Date ManuFact; // 이건 제조 일자
Date ValidTo; // 요건 유효기간
멤버의 개수에 제한이 없으므로 얼마든지 많은 Date 객체를 포함할 수 있다. 그렇다면 private 상속의 경우는 어떨까? 같은 기반 클래스를 두 번 상속할 수 없다는 규칙이 있으므로 이 방법으로는 복수 개의 정보를 한 클래스에 포함할 수 없다. 즉 다음과 같은 다중 상속문은 냉정하게 에러로 처리된다.
class Product : private Date, private Date // 앞은 제조일자, 뒤는 유효기간. 그러나 에러
만약 이런 문장을 허용한다면 Product에는 year, month, day가 두 벌씩 존재하며 어떤 멤버가 유효기간을 표현하고 어떤 멤버가 제조일자를 표현하는지 구분되지 않는 모호함이 있다. 반면 포함되는 객체에는 분명한 이름이 지정되므로 모호함이 없으며 따라서 동시에 여러 개의 객체를 원하는만큼 포함시킬 수 있다. 다중 상속을 사용하면 다른 타입의 객체를 동시에 재활용할 수는 있지만 같은 타입의 객체를 동시에 재활용할 수는 없다. 설사 다중 상속으로 다른 타입의 객체들을 동시에 재활용할 수 있다고는 해도 앞에서 봤다시피 다중 상속은 여러 가지로 골치아픈 부작용이 많다.
포함된 객체에 이름이 있어 모호함이 없다는 것은 포함된 객체의 기능을 필요로 할 때 반드시 객체 이름과 함께 멤버를 사용해야 한다는 뜻이기도 하다. 같은 타입의 객체를 두 개 이상 포함할 수 있기 때문에 누구의 어떤 기능을 원하는지 항상 밝혀야 하는 것이다. Product에 유효기간과 제조일자가 모두 필요하다면 다음과 같이 선언한다.
Product 객체안에 두 개의 Date 객체가 각각의 이름으로 포함되어 있다. 유효기간을 출력하고 싶으면 ValidTo.OutDate() 함수를 호출해야 하고 제조일자를 출력하고 싶다면 ManuFact.OutDate()를 호출한다. 이 외에도 얼마든지 많은 Date 객체를 포함할 수 있으며 다른 Date 객체의 날짜를 출력하고 싶으면 해당 객체의 이름으로부터 OutDate()를 호출하면 된다.
포함과 private 상속의 또 다른 차이점은 protected 멤버에 대한 액세스 허가 여부이다. 포함의 경우 포함된 객체의 public 액세스 속성을 가지는 것만 직접 참조할 수 있다. Product가 아무리 Date의 객체를 포함한다 하더라도 Product는 분명히 Date의 외부이기 때문이다. 포함된 객체의 private 멤버는 물론 액세스할 수 없으며 protected 멤버도 액세스할 수 없다. 반면 private상속의 경우는 protected 멤버를 파생 클래스가 액세스할 수 있으므로 포함보다는 좀 더 긴밀한 관계라고 할 수 있다.
인터페이스 상속과 구현 상속
이번에는 포함 또는 private 상속과 public 상속은 어떤 점이 다른지 연구해 보자. 포함이나 private 상속은 둘 다 객체의 구현만 재사용할 뿐이며 인터페이스는 상속받지 않는데 비해 public 상속은 구현뿐만 아니라 인터페이스까지도 같이 상속한다는 점이 다르다. 구현 상속이란 객체의 구체적인 동작만 재사용할 수 있고 인터페이스는 물려받지 않는 상속이며 좀 더 구체적으로 얘기하자면 멤버 함수를 호출할 수는 있지만 스스로 멤버 함수를 가지지는 않는 상속이다.
MemObject 예제의 Product는 포함한 객체 ValidTo의 OutDate를 호출하여 날짜를 출력하는 기능을 재사용할 수는 있지만 Product 클래스가 OutDate를 멤버로 가지는 것은 아니다. 그래서 외부에서 OutDate()를 호출할 수는 없는데 MemObject의 끝에 S.OutDate(); 호출문을 넣어 보면 이를 확인할 수 있다. 대신 S.ValidTo.OutDate();로 호출할 수는 있는데 이렇게 되려면 S 객체가 ValidTo를 public으로 선언해야 하고 Date의 OutDate도 public이어야 한다. 어쨌든 S가 OutDate 인터페이스 함수를 가지는 것은 아니다.
private 상속이 포함과 유사하다고 하는 가장 큰 이유는 구현만 상속할 뿐 인터페이스를 상속하지 않기 때문이다. private 상속은 기반 클래스의 모든 멤버를 상속과 동시에 private 속성으로 바꾸어 버린다. 그래서 Product의 내부에서는 상속받은 멤버 함수 OutDate를 호출할 수 있지만 외부에 대해서는 이 멤버 함수가 숨겨지므로 Product는 이 인터페이스를 가지지 않는 것과 같아진다. 외부뿐만 아니라 Product로부터 파생되는 클래스에 대해서도 마찬가지로 OutDate는 알려지지 않는다. PrivateInherit 예제의 main에서 S.OutDate()를 호출하면 역시 에러 처리될 것이다.
Date 객체를 포함하는 Product자체에는 OutDate라는 인터페이스가 존재하지 않는다. Date 클래스로부터 private 상속받은 Product에는 OutDate라는 함수가 존재하기는 하지만 외부에서 호출할 수 없도록 숨겨지므로 공개된 인터페이스는 없는 셈이다. 인터페이스란 클래스의 공개된 기능을 의미하는데 private 상속은 부모의 공개된 기능을 상속과 동시에 안으로 숨겨 버리므로 인터페이스를 상속하지 않는 것과 같다. 두 경우 모두 포함 또는 상속을 통해 날짜를 출력하는 기능만 사용할 수 있을 뿐이다. 함수는 물려받지 않고 코드만 물려받는 이런 상속을 구현 상속이라고 한다.
반면 public 상속의 경우는 기반 클래스의 멤버를 물려받아 완전한 자기 것으로 만드는 것이므로 기반 클래스에 정의되어 있는 멤버 함수를 직접 호출할 수 있다. Coord를 상속받은 Point에는 GetXY 인터페이스가 존재하며 Human을 상속받은 Student는 Think, Intro 인터페이스를 가진다. 파생 클래스는 기반 클래스의 인터페이스를 그대로 물려 받아 외부로 공개하며 후손 클래스에게 물려줄 수도 있다. public 상속은 포함과 달리 구현과 인터페이스를 동시에 물려받는 것이다. PrivateInherit 예제에서 상속 액세스 지정을 public으로 바꾸고 main의 끝에서 S.OutDate()를 호출하면 별 문제가 없다. 기반 클래스의 public 멤버가 상속된 후에도 여전히 public이기 때문이다.
이것이 HAS A와 IS A를 구분하는 중요한 기준이다. 그래서 클래스를 재활용해야 할 때 두 클래스의 관계를 잘 판단해 보고 IS A 관계에 가까우면 public 상속을 하는 것이 좋고 HAS A 관계이면 포함시키거나 private 상속하는 것이 더 좋다. 물론 어디까지나 권장 사항일 뿐 절대적인 규칙은 아니다. 사실 Coord와 Point, 그리고 Point와 Circle의 관계도 HAS A 관계로 표현할 수 있다. 점은 좌표를 가지고 원은 점을 가진다고도 표현할 수 있기 때문이다. 그러나 여러 모로 따져 볼 때 Coord - Point - Circle 관계는 IS A쪽에 더 가깝고 다형성을 필요로 하므로 public 상속을 하는 것이 합리적이다.
정리하자면 private 상속과 public 상속은 상속받은 인터페이스가 외부로 공개되는가 아닌가의 차이점이 있다. 만약 PrivateInherit 예제의 클래스 선언문에서 상속 액세스 지정을 public으로 바꾸면 Product는 Date의 구현뿐만 아니라 인터페이스까지 상속받아 외부로 공개되는 OutDate인터페이스를 가지게 되며 이렇게 되면 Product와 Date는 IS A관계가 된다. 하지만 제품이 일종의 날짜인 것은 아니므로 자연스럽지 못하다. 포함과 두 가지 상속 유형을 그림으로 비교해 보자.
세 경우 모두 결과적으로 캡슐화되는 정보의 목록은 동일하지만 이 멤버들이 어디서 왔는지와 외부에 대한 인터페이스 공개 여부가 다르다. 클래스의 단순한 재사용만을 목적으로 한다면 포함이나 private 상속 중 하나를 쓸 수 있되 일반적으로 private 상속보다는 포함이 훨씬 더 쉽고 직관적이며 이미 익숙한 방법이기도 하다. 여러 가지 C++ 기법에 따른 상속의 종류를 도표로 요약해 보면 다음과 같다.
기법
|
private 상속, 포함
|
public 상속
|
순수 가상 함수
|
인터페이스 상속
|
X
|
O
|
O
|
구현 상속
|
O
|
O
|
X
|
private 상속은 기반 클래스의 구현만을 상속받으며 public 상속은 인터페이스와 구현을 동시에 상속받는다. 구현 상속은 단순히 어떤 객체를 재사용하기 위한 기법에 불과하다. 그러나 인터페이스 상속은 클래스간의 계층 관계를 이룸으로써 다형성을 구현할 수 있다는 점에서 단순한 재활용 이상의 의미를 가진다. 일단 상속받은 후 일부 함수의 동작을 재정의할 수도 있고 객체 타입에 따라 다른 동작을 하도록 만들 수도 있다. 객체 지향의 진정한 매력은 바로 다형성인데 이를 위한 전제 조건이 바로 public 상속이다.
또한 인터페이스, 즉 멤버 함수의 목록만 상속받고 구현은 전혀 상속받지 않는 순수 가상 함수라는 방법도 있다. 구현을 상속받지 않으면 언어에 상관없이 객체의 기능을 구현할 수 있다는 이점이 생기는데 이런 이론에 의해 만들어진 기술이 바로 COM이며 COM 기반 위에 ActiveX, DirectX같은 기술이 성립된다. 다형성과 순수 가상 함수, 추상 클래스 등은 다음 장에서 상세하게 연구해 볼 것이다.
protected 상속
protected 상속은 public 상속과 private 상속의 중간쯤 되는 기법인데 파생 클래스를 다시 파생시킬 때 독특한 특징을 보인다. 2차 파생된 클래스가 애초의 기반 클래스에 접근할 수 있다는 점에서 private 상속과는 다르며 기반 클래스의 멤버들을 외부에서 접근할 수 없다는 점에서 public 상속과도 다르다.
기반 클래스의 멤버에 대한 액세스 권한을 직계 후손들에게만 주고 싶을 때 protected 상속을 사용하는데 현실적인 실용성은 높지 않다.
29-4-다.중첩 클래스
중첩 클래스란 클래스 선언문안에 다른 클래스가 선언되는 형태이다. 특정 클래스를 구현하기 위한 보조 클래스가 필요한데 보조 클래스는 오직 이 클래스 내부에서만 사용하며 외부에는 전혀 알릴 필요가 없다면 이때 클래스를 중첩시킨다. 클래스에 캡슐화되는 것은 흔히 멤버 변수, 멤버 함수 정도이지만 타입도 포함될 수 있다. 클래스가 타입이므로 다른 클래스에 포함될 수 있는 것은 당연하며 열거형이나 typedef로 정의한 타입도 물론 가능하다. 다음 예제를 보자.
예 제 : NestClass
|
#include <Turboc.h>
class Outer
{
private:
class Inner
{
private:
int memA;
public:
Inner(int a) : memA(a) { }
int GetA() { return memA; }
} obj;
public:
Outer(int a) : obj(a) { }
void OutOuter() { printf("멤버값 = %d\n",obj.GetA()); }
};
void main()
{
Outer O(345);
// Inner I(678); // 에러
O.OutOuter();
}
Outer 클래스 선언문안에 Inner 클래스 선언문이 있고 Inner 클래스형의 객체 obj를 멤버로 포함하고 있다. Inner 클래스는 Outer 클래스 내부에서만 사용하므로 외부로는 전혀 알려지지 않는다. 그래서 main에서는 Inner 타입의 객체를 선언할 수 없다. 즉 Inner는 Outer 클래스에 대해 지역적인 클래스라고 할 수 있다. 만약 Inner를 외부에도 알리고 싶다면 public: 영역에서 선언하면 된다. 이 경우 외부에서는 Outer::Inner I(1234); 식으로 이 타입이 소속되어 있는 클래스를 지정해야 한다.
클래스가 하는 일이 굉장히 복잡해서 도우미 클래스를 만들어야 할 필요가 있다거나 내부적으로만 사용해야하는 타입이 있다면 이런 식으로 클래스 선언을 중첩시킨다. 다음 클래스는 이중 연결 리스트를 표현하는데 연결 리스트는 저장되는 데이터와 양방향의 링크로 구성된 노드의 집합이다. 노드들은 클래스 내부에서 완벽하게 관리할 수 있으므로 외부에서 굳이 알 필요가 없으므로 LinkedList 클래스 안에 선언했다.
class LinkedList
{
private:
struct Node {
int data;
Node *prev,*next;
};
Node *head,*tail;
public:
LinkedList();
~LinkedList();
Insert(Node *p,int a);
Delete(Node *p);
int GetData(Node *p);
};
외부에서는 Insert, Delete 등의 공개된 인터페이스 함수로 정수값을 넣거나 빼기만 할 뿐이고 모든 내부적인 자료 관리는 클래스에 완벽하게 캡슐화되어 있다. 물론 외부에서도 Node를 꼭 알아야 할 필요가 있다면 이 클래스를 퍼블릭 영역에 선언할 수도 있다. Node는 LinkedList에서만 필요하며 외부에 따로 정의해 봐야 아무짝에도 쓸모가 없으므로 지역 타입이 되는 것이 옳다. LinkedList가 Node를 완전히 포함하고 있으면 이 클래스는 자체적으로 동작할 수 있는 모든 것을 가지므로 재사용성과 범용성이 증가한다.
클래스 선언 자체가 지역적이라는 것과 클래스가 멤버 함수를 포함할 수 있다는 점을 활용하면 C/C++ 언어가 지원하지 않는 지역 함수를 만드는 것도 가능하다. 원래 C의 함수는 상호 평등한 관계에 있어 함수끼리 종속되지 않는다. 그러나 클래스가 개입되면 함수끼리 종속시킬 수도 있고 파스칼처럼 지역 함수를 사용할 수도 있다. C++이 언어 차원에서 지역 함수를 지원하는 것은 아니지만 지역 타입을 허용하며 클래스라는 타입이 함수를 가질 수 있으므로 지역 함수가 가능해진 것이다. 다음 예제를 보자.
예 제 : LocalFunc
|
#include <Turboc.h>
void func()
{
struct dummy {
static void localfunc() {
puts("저는 func 안에서만 정의됩니다.");
}
};
dummy::localfunc();
}
void main()
{
func();
// localfunc(); // 에러
}
func 함수 내부에서 dummy라는 이름으로 지역 클래스를 선언하고 이 클래스의 정적 함수 localfunc를 인라인으로 정의하고 있다. dummy를 struct로 선언했는데 class로 선언하고 public:을 붙이는 것과 같다. 정적으로 선언했으므로 호출할 때는 반드시 클래스 이름과 함께 사용해야 한다. 지역 클래스의 함수는 static이어야 하는데 그래야 객체없이 클래스명으로 바로 호출 가능하다. 만약 정적이 아니라면 이 함수를 호출하기 위해 dummy형 객체를 하나 선언해야 하므로 불편해진다.
localfunc는 func 함수 내부에서 정의한 지역 클래스 소속의 정적 멤버 함수이므로 이 함수는 func 외부로는 알려지지 않는다. 따라서 main 함수에서는 이 함수를 호출할 수 없다. 사실 main에게는 localfunc 뿐만 아니라 dummy라는 지역 클래스 자체가 알려지지 않는다.
이 예제는 C/C++로도 지역 함수를 만들 수 있다는 것을 보여줄 뿐 실용적 가치는 별로 없다. 게다가 이렇게 만든 함수는 인라인만 가능하다는 점에서 복잡한 동작을 처리하기에는 한계가 있다. 아주 긴 함수가 쓰는 작은 유틸리티 함수 집합을 이런 식으로 정의해 놓으면 배포하기 편리하다는 정도의 활용 방안이 있지만 문법을 변칙적으로 쓰면 이런 것도 가능하다는 흥미거리 정도밖에 안되는 것 같다.
29-4-라.상속의 방향성
클래스간의 상속 방향은 당연히 부모로부터 자식으로 내려가는 것이다. 부모가 자식에게 멤버를 상속시켜주는 것이므로 당연하다고 생각되겠지만 실제로 프로그램을 작성할 때는 부모보다 자식이 먼저 만들어지는 경우가 더 많다. 처음부터 부모의 멤버 목록을 완벽하게 작성해 놓고 자식 클래스를 파생시켜가면서 클래스 계층을 만드는 경우보다 자식들을 만들다보니 공통의 부모가 필요해진다는 것이다. 즉 상향식(Bottom Up)인데 사람의 사고는 특수한 것을 만들고 이 특수한 것으로부터 일반성을 추출해 내는 쪽에 더 익숙해 있다.
실제 프로젝트 예를 통해 이런 통합의 과정을 설명해 보자. 문방구 관리 프로그램을 작성하고 있는데 이 프로그램은 문방구에서 파는 모든 제품을 객체로 표현하고자 한다. 그러자면 문방구에서 판매하는 상품의 특성을 파악하여 추상화해야 하는데 이 과정을 업무 분석이라고 한다. 분석 결과 다음과 같은 각종 클래스를 만들었다고 하자. 각 클래스에는 표현하고자 하는 제품에 대한 상세한 정보와 동작들이 정의되어 있을텐데 여기서는 속성만 살펴보자.
이렇게 클래스들을 만들어 놓고 보니 제품마다 중복되는 멤버들이 많다는 것을 알 수 있다. 모든 제품에 모델명과 제조사, 가격에 대한 정보가 필요하므로 이 멤버들을 개별 클래스에 일일이 두는 것보다는 이 멤버들만 가지는 부모 클래스를 만들고 이 부모로부터 상속을 받도록 한다. 이 공통의 부모는 문방구에서 파는 제품의 일반적인 특성을 표현하므로 "문구류"라는 이름을 붙이는 것이 적절할 것 같다.
또한 연필과 볼펜은 길이라는 공통 속성이 있으므로 이 속성을 가지는 "필기구"라는 부모 클래스를 만들고 이로부터 파생시켜 연필과 볼펜을 정의하면 된다. 이런 통폐합에 의해 완성된 클래스 계층도는 다음과 같은 모양을 가질 것이다.
처음 그림보다 좀 복잡해 보이기는 하지만 이렇게 상속 계층을 만들어 두면 코드를 관리하기가 훨씬 더 쉬워진다. 문방구의 판매 정책에 변화가 생긴다면 제품을 관리하는 방법이 변경될 것이며 이 변경에 의해 모든 제품이 영향을 받게 된다. 예를 들어 불황으로 인해 가격 파괴 대잔치를 해야 한다면 모든 제품에 할인율이라는 새로운 속성이 추가되어야 할 것이다.
클래스가 계층을 이루지 않고 개별적으로 존재할 때 이 작업은 무척 힘든, 그러면서도 엄청난 시간을 소모하는 잡일이 될 것이다. 하지만 클래스 계층이 잘 만들어져 있으면 루트 클래스인 문구류에만 할인율 멤버를 추가하면 된다. 모든 제품은 문구류로부터 파생되므로 문구류가 바뀌면 모든 클래스들이 이 변경의 영향을 받는 것이다. 볼펜과 연필만 할인한다면 필기구에만 할인율을 추가하면 될 것이다. 새로운 멤버의 추가, 삭제뿐만 아니라 동작이 변경될 때도 변경 대상이 되는 가장 위의 부모만 수정하면 된다. 또한 계층이 생기면 다형성의 혜택도 받을 수 있다.
상속이란 기반 클래스로부터 파생 클래스를 정의하는 기술이지만 현실의 개발 절차는 거꾸로인 경우가 많고 그것이 사람의 생리에 훨씬 더 가깝다. 개별 클래스를 만들다 보면 공통 속성이 발견되고 이 속성들을 가지는 별도의 클래스를 만든 후 파생시키는 것이다. 시행 착오없이 처음부터 한 번에 완벽한 클래스 계층도를 디자인할 수 있다면 좋을 것이고 실제로 이런 설계 작업을 도와주는 툴들도 있지만 이것은 무척 어려운 일이다. 필요한 클래스를 만들어 가면서 통폐합을 반복하다 보면 점점 좋은 모양이 나오게 된다. 업무 분석이 잘 되어 있고 경험이 풍부하면 디자인 작업이 빠르고 정확해지며 디자인이 깔끔하면 개발도 효율적으로 진행된다.
댓글 없음:
댓글 쓰기