관리 메뉴

Kim's Programming

C++ - 클래스 상속(2/3) 본문

Programming/Cplusplus

C++ - 클래스 상속(2/3)

Programmer. 2015. 10. 12. 12:37

상속의 특성


C++상속의 특성


객체 지향이라는 똑같은 이론에 기반하더라도 각 언어별로 상속을 구현하는 방법과 수준에는 다소 차이가 있습니다. C++언어의 상속은 대체로 세 가지 정도로 특징을 요약할 수 있습니다.


    1. 하나의 기반 클래스로부터 여러 개의 클래스를 파생시킬 수 있습니다. 세포라는 기본적인  속 성과 호흡한다, 번식한다, 등의 동작을 가지는 생물로부터 동물을 파생시켜 움직인다는 동작을 추가할 수 있습니다. 동물은 생물의 모든 특성을 가지기 때문에 이런 파생이 가능합니다. 마찬가지로 생물로부터 식물이나 미생물도 파생 가능한데 둘 다 생물의 일종이기 떄문입니다.이렇게 되면 동물, 식물, 미생물은 공동의 조상인 생물로부터 물려받은 속성과 동작을 공유하게 됩니다. 물론 각 파생 클래스는 기반 클래스로부터 물려받은 속성 외에 각기 다른 속성들을 추가로 정의할 수 있습니다. 예를 들어 동물은 척추 유무, 심장의 구조, 이동한다, 먹는다가 추가될 것이고 식물은 떡잎의 개수, 광합성 등의 속성과 동작이 추가될 것입니다.

    2. 하나의 클래스로부터 파생될 수 있는 클래스의 개수에 제한이 없을 뿐만아니라 파생의 깊이에도 제한이 없을 뿐만 아니라 파생의 깊이에도 제한이 없습니다. 파생된 클래스로부터 새로운 클래스를 얼마든지 파생시킬 수 있습니다. 생물로부터 상속받은 동물은 포유류라는 새로운 클래스를 파생시킬 수 있으며 포유류는 또한 영장류의 부모가 될 수 있습니다. 각 파생 관계의 아래쪽으로 내려올수록 더 많은 속성과 동작이 정의될 것입니다. 파생관계의 위쪽에있는 클래스는 속성을 몇 개 가지지 않는 일반적인 사물을 표현하며 포괄하는 범위가 넓은 반면 아래쪽에 있는 클래스일수록 점점 더 특수하고 구체적인 사물을 표현합니다. 파생을 많이 할수록 더 많은 속성과 동작이 정의되므로 점점 특수해집니다.

      기반, 파생 클래스 또는 부모, 자식이라는 용어는 상대적인 개념입니다. 한 클래스가 상속관계의 중간에 있다면 이 클래스는 부모에 대해서는 자식이지만 또한 자신의 자식에 대해서는 부모가 됩니다. 상속관계의 위쪽에 있는 클래스를 선조 또는 조상이라고 하며 아래쪽에 있는 클래스를 후손이라고 표현하기도 합니다. 또 최상위 클래스는 루트라고합니다. 부모 자식 클래스의 관계를 IS A관계하고 하는데 이는 자식 클래스가 일종의 부모 클래스란 뜻입니다. 예를 들어 동물은 생물의 일종이며 포유류는 또한 동물의 일종리라 할 수 있습니다. IS A라는 용어는 영문에서의 is a 를 뜻하는데 "동물은 일종의 생물이다"를 영어로 animal is a creature라고 표현하기 떄문입니다. 하지만 역관계는 성립하지 않는데 모든 생물을 동물이라고 할 수 없기 떄문입니다.

    3. 비록 자주 사용되지는 않지만 C++은 두 개 이상의 클래스로부터 새로운 클래스를 파생시킬 수 있는데 이를 다중 상속이라고 합니다. 이때 파생되는 클래스는 기반 클래스들의 모든 속성을 물려받습니다. 사람이 부모의 속성을 동시에 물려받는 것과 마찬가지입니다. 다중 상속에 의해 여러 개의 클래스가 복잡한 상속 관계를 구성하는 크래프가 만들어지는데 이렇게 되면 상속 계층이 너무 복 잡해져서 잘 사용되지 않습니다. 이 외에도 상속에 대한 또 다른 제약으로 기본 타입으로부터 상속은 허가되지 않는다는 규칙이 있습니다. 즉, class MyInt : public int{ } 식으로 기본 타입인 int를 상속받아 새로운 클래스를 만들 수는 없다는 상식적인 이야기입니다. int같은 시스템 내장 타입은 클래스와 똑같이 취급되기는 하지만 실제로 클래스 선언문이 존재하는 것은 아니므로 기반 클래스로 사용할 수 없습니다. 다중 상속을 제외할 경우 클래스간의 계층 관계는 보통 트리형태로 그릴 수 있습니다.

이차 상속


상속의 깊이에 제한이 없어 파생된 클래스로부터 또 다른 클래스를 파생시킬 수 있다고 했습니다. 다음 소스는 Coord로부터 파생된 Point를 기반 클래스로하여 원을 표현할 수 있는 Circle 클래스를 파생시킵니다. 이름을 붙이자면 이차상속입니다.
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#define _USE_MATH_DEFINES
#include<iostream>
#include<math.h>
#include<conio.h>
#include<Windows.h>
using namespace std;
void Gotoxy(int x, int y)
{
    COORD Position = { x, y };
    SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), Position);
}
class Coord
{
protected:
    int x, y;
public:
    Coord(int ax, int ay){ x = ax; y = ay; }
    void GetXY(int &rx, int &ry){ 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*M_PI / 180)*Rad), int(y - cos(a*M_PI / 180)*Rad));
            _putch(ch);
        }
    }
    void Hide()
    {
        for (double a = 0; a < 360; a += 15)
        {
            Gotoxy(int(x + sin(a*M_PI / 180 * Rad)), int(y - cos(a*M_PI / 180)*Rad));
            _putch(' ');
        }
    }
};
void main()
{
    Point P(1010'@');
    P.Show();
    Circle C(4010'*'8);
    C.Show();
}
cs
Cirlce은 Point에 정의되어 있는 x,y,ch 멤버 변수와 Get(Set)XY, Show, Hide 멤버 함수를 상속받으며 여기에 원의 반지름을 지정하기 위한 Rad멤버 변수를 추가했습니다. 상속받은 멤버 중 Show, Hide는 다시 재정의하는데 점을 그리는 방법과 원을 그리는 방법이 다르기 때문에 코드를 다시 작성해야합니다. 상속받은 함수의 본체를 수정하는 것을 오버라이딩이라고 하는데 다음에 상세히 알아보겠습니다. Point는 Coord로 부터 x,y,Get(Set)XY 멤버를 상속받고 여기에 ch, Show, Hide 멤버를 추가했으며 Circle은 Point의 모든 멤버를 상속받은 후 Rad 멤버를 추가했습니다. 결국 Circle이 가진 x,y, Get(Set)XY는 애초의 기반 클래스인 Coor로 물려받은 것입니다. 이때 Coord는 Circle의 부모의 부모인 셈인데 사람으로 치면 할아버지쯤 됩니다.

파생 클래스로부터 상속을 계속 해 나가면 최종 클래스는 모든 기반 클래스(선조)의 멤버를 한꺼번에 상속받습니다. 이 상태에서 Circle로부터 또 다른 클래스를 파생시킬 수도 있는데 이 클래스도 Circle을 통해 Point와 Coor를 간접적으로 상속받는 셈입니다. Circle 클래스는 중심점 (x,y)와 원호를 그릴 문자 그리고 반지름 Rad를 속성으로 가지며 그리기, 숨기기 동작을 할 수 있으므로 원 객체를 표현할 수 있습니다. main에서는 (10,10)위치에 @점을 찍고 (40,10)위치에 * 문자로 반지름 8의 원을 그렸습니다. 결과는 다음과 같이 나옵니다.

왼쪽 @문자가 점이고 오른쪽에는 원같지 않지만 원이 그려져 있습니다. 점,원은 그래픽 객체인데 콘솔창에서 억지로 표현을 하다보니 세로로 길쭉한 타원이 나왔습니다. Circle의 Show함수에는 원을 그리는 코드가 작성되어 있는데 원을 그리는 알고리즘은 그다지 복잡하지는 않지만 상속이야기 중이니 넘어가겠습니다.


객체의 생성 및 파괴


상속받은 멤버는 파생 클래스에서 직접 초 기화할 수 없으며 기반 클래스에게 초기화를 부탁해야합니다. 파생 클래스는 기반 클래스의 모든 멤버를 상속받기는 하지만 이 멤버를 어떻게 초기화해야하는지 정확하게 알지 못합니다. 또한 상 속받은 멤버 중 일부는 private 엑세스 속성을 가질 수도 있으므로 파생 클래스가 이 멤버를 초기화할 권한이 없습니다. 자식에게조차 공개하지 않겠다고 숨겨놓은 것이므로 파생 클래스는 부모의 private멤버에  대해서 관심을 가질 필요도 건들일 수도 없습니다. 대신 기반 클래스의 public생성자를 호출하여 상속받은 멤버를 초기화해야합니다. 생성자는 항상 public이므로 누구나 호출할 수 있습니다. 상속받은 멤버의 의미와 초기화 방법에 대해서 가장 정확하게 알고 있는 주체는 이 멤버를 정의한 클래스이므로 기반 클래스의 생성자를 이용하는 것이 합리적입니다. 파생 클래스가 기반 클래스의 생성자를 호출할때는 초기화 리스트를 사용해야합니다. 위의 소스에서 main 함수에 있는 Circle C(40,10,'*',8);선언문이 어떤 순서로 이 객체를 초기화하는지 순서대로 따라가보겠습니다.


    1. main에서 Circle 객체 C를 생성할 때 Circle의 생성자가 호출됩니다. Circle(40,10,'*',8)이 호출되며 생성자로 원 객체 생성에 필요한 인수들이 전달됩니다.

    2. 생성자의 본체가 실행되기 전에 초기화 리스트가 먼저 실행됩니다. 초기화 리스트에서 기반 클래스인 Point 생성자를 호출하며 이 생성자로 ax,ay,ach인수를 전달합니다.

    3. Point의 생성자는 다시 자신의 초기화 리 스트에 있는 Coord의 생성자를 호출하며 이 생성자로 ax,ay인수를 전달합니다. 이런 식으로 파생 클래스는 항상 기반 클래스의 생성자를 통해 상속받은 멤버를 초기화해야합니다.

    4. Coor의 생성자에서 x,y 멤버를 인수로 전달된 ax,ay로 초기화합니다. 이떄는 단순 타입이므로 초기화 리스트를 쓰지 않아도 상관없습니다. ax, ay는 40,10으로 전달되었으므로 x,y는 (40,10)좌표를 가리키도록 초기화될 것입니다.

    5. Coord 생성자가 리턴되면 Point의 생성자 본체에서 ch 멤버에 인수로 전달된 ach의 값 '*'을 대입합니다. Point는 자신의 고유 멤버를 초기화한 후 리턴합니다.

    6. Circle 생성자는 초기화 리 스트를 통해 상속받은 멤버의 초기화를 마치고 본체에서 자신의 고유 멤버인 Rad을 aRad 인수로 초기화합니다. 따라서 Rad는 8이 될 것입니다. Circle 생성자가 자신의 모든 멤버를 초기화하고 main으로 리턴하면 객체의 C의 초기화가 완료됩니다.

초기화가 완료 된 후 main에서는 C.Show()를 호출하는데 이 함수는 C의 멤버값이 지정하는대로 (40,10)좌표에 * 문자로 반지름 8의 원을 출력할 것입니다. 파생 클래스의 초기화 과정이 다소 복잡한데 파생의 단계가 깊을수록 더 많은 과정을 거쳐야 할 것입니다.

개게 하나를 생성하는데 조상 클래스의 생성자들을 일일이 호출해야 하므로 객체 생성 속도가 굉장히 느릴 것처럼 보입니다. 복잡한 계층에서는 선조 클래스가 수십 개나 될 수도 있으므로 이런 걱정이 되는 것이 무리는 또 아닙니다. 그러나 생 성자들은 그 특성상 길이가 짧고 내부 정의하는 것이 보통이므로 대부분이 인 라인이며 함수 호출 부담이 없어 속도를 염려할 정도는 아닙니다. 위 소스에서 Circle C 선언문은 결국 x=ax;y=ay;ch=ach;Rad=aRad; 4개의 대입문만 실행할 뿐이며 이정도 대입문은 순식간에 처리할 수있습니다. 초기화 리트스를 통해 기반 클래스의 생성자를 연쇄적으로 호출하며 상속받지 않은 멤버는 자신이 직접 초기화합니다. 일반적으로 기반 클래스는 파생 클래스가 동작하기 위한 전제 조건이 되기 떄문에 파생 클래스의 멤버보다 상속받은 멤버가 먼저 초기화되어야 합니다. 가령 파생 클래스의 생성자 본체에서부터 상속 받은 멤버를 당장 참조할 수도 있으므로 생성자 본체보다도 기반 클래스의 초기화가 더 우 선입니다. 그래서 생성자 본체가 실행되기 전에 상속받은 멤버는 초기화되어야 하며 그러기 위해서는 초기화 리스트를 사용하는 방법밖에 없습니다. 파생 클래스의 생성자 본체에서 기반 클래스의 생성자를 직접적으로 호출할 수는 없는데 다음 코드를 보겠습니다.
1
2
3
4
5
Circle(int ax, int ay, char ach, int aRad)
{
    Point(ax.ay.ach);
    Rad = aRad;
}
cs
여기서 생성자 본체에 있는 Point(ax,ay,ach);호출문은 상속받은 멤버를 초기화하는 문장이 아니라 Point 클래스의 생성자를 호출하여 이름도 없는 임시 Point 객체를 생성하는 문장이 되어버립니다. 사실 이 코드는 Point, Coord가 디폴트 생성자를 정의하지 않아 컴파일되지도 않는데 컴파일되게 만든다 하더라도 Circle이 상속받은 x, y, ch 멤버는 전혀 초기화되지 않고 쓰레기값을 가지며 원은 제대로 글지지 않을 것입니다. 생성자 본체에서 상속받은 멤버를 직접 초기화하는 다음 코드를 보겠습니다.
1
2
3
4
5
Circle(int ax, int ay, char ach, int aRad)
{
    x=ax;y=ay;ch=ach;
    Rad=aRad;
}
cs
이렇게 하면 컴파일도 잘되고 원이 제대로 그려지기도 하지만 이 코드는 전혀 일반적이지 않습니다. 왜냐하면 대입은 초기화와 여러 가지 면에서 다르며 상속받은 멤버를 파생 클래스가 항상 마음대로 액세스 할 수 있는 것도 아니기 떄문입니다. 만약 Coord나 Point가 x, y, ch를 protected가 아닌 private로 정의하고 있다면 이 코드는 컴파일되지 않습니다. 부모의 입장에서 자식이 몰라도되는 멤버가 존재할 수 있으며 이 런 멤버는 상속은 되지만 자식이 초기화할 권한이 없고 그럴 필요도 없습니다. 파생 클래스의 초기화 리스트에서 기반 클래스의 생성자를 호출하지 않으면 이때는 기반 클래스의 디폴트 생성자가 호출되는데 만약 기반 클래스가 디폴트 생성자를 정의하지 않는다면 에러로 처리됩니다. 디폴트 생성자는 보통 아무 것도 하지 않거나 무난한 값으로 멤버를 초기화하므로 이렇게 되면 상속받은 멤버는 원하는 대로 초기화되지 않을 것입니다. 그래서 파생 클래스 생성자의 초기화 히스트에는 거의 예외없이 기반 클래스의 생성자 호출문이 오며 자신이 전달받은 인수의 일부를 기반 클래스의 생성자에게 전달합니다.
1
2
3
4
Derive(인수들) : Base(상속받은 인수들)
{
    본체 - 여기서 자신의 고유 멤버 초기화
}
cs
만약 기반 클래스에 여러 개의 생성자가 오버로딩되어 있다면 초기화 리스트의 인수 목록에 따라 호출될 생성자가 결정됩니다. 상속받은 멤버를 어떤 식으로 초기화할지를 파생 클래스의 초기화 리스트에서 선택할 수 있습니다. 파생 클래스가 이차 상속 된 경우라면 바로 위의 부모가 할아버지 생성자를 알아서 호출할 것입니다. 자신이 직접 할아버지 생성자를 호출할 수는 없으며 그럴 필요도 없습니다. Circle은 Point의 생성자만 호출하면 좌표와 문자를 초기화할 수 있습니다. Point가 Coord의 생성자를 호출하는가 아닌가는 Circle의 입장에서는 관심 대상이 아닙니다. 어쨋든 바로 위의 부모를 호출하여 x,y,ch만 제대로 초기화하면 그만입니다.

파생 클래스의 객체가 파괴될 떄는 생성자가 호출된 역순으로 파괴자가 호출됩니다. 먼저 자신의 파괴자가 호출되어 스스로의 멤버를 정리하며 상속 계층을 따라 부모의 파괴자가 연쇄적으로 호출되어 상속된 모든 멤버에 대한 정리작업을 합니다. 자식이 파괴되는 동안에도 부모의 멤버를 참조할 수 있어야 하므로 자식이 완전히 파괴될 때까지 부모는 온전히 살아있어야합니다. 그러나 부모가 파괴될 떄 자식의 멤버를 참조할 일은 전혀 없습니다. 즉 자 신의 부모에 종속적이지만 그 역은 성립하지 않으므로 자식이 먼저 파괴되는 것이 순서상 옳습니다. 위의 경우는 파괴자도 없고 파괴할 내용도 없으므로 이런 동작은 확인할 수는 없습니다.

객체의 생성 및 파괴


클래스가 파생될 때 기반 클래스로부터 대부분의 멤버를 상속받지만 일부 상속에서 제외되는 것들도 있습니다. 상속되지 않는 멤버는 다음과 같습니다.
    • 생성자와 파괴자
    • 대입 연산자
    • 정적 멤버 변수와 정적 멤버 함수
    • 프렌드 관계지정
이 멤버들이 상속에서 제외되는 이유는 기반 클래스만의 고유한 처리를 담당하기 때문입니다. 생성자와 파괴자, 대입연산자는 특정 클래스에 완전히 종속적이며 해당 클래스의 멤버에 대해서만 동작하기 떄문에 파생 클래스는 이 함수들을 직접 사용할 필요가 없습니다. 대신 초기화 리스트에서는 호출할 수는 있습니다. 생성될 때 자동으로 호출되어 상속된 멤버를 대신 초기화하며 객체가 일단 생성 완료되면 다시 호출할 필요가 없으므로 파생 클래스가 이 함수들을 가질 이유가 없습니다. 이런 특수한 몇 가지 멤버들을 제외하고는 기반 클래스의 모든 멤버가 파생 클래스로 무조건 상속됩니다. 원하는 멤버만 선택적으로 상속한다거나 특정 멤버를 상속받지 않는 방법은 없습니다. 부모가 가진 모든 속성과 동작을 상속받아야만 제대로 된 자식이라고 할 수 있습니다. 만약 파생 클래스에서 특정 멤버를 전혀 사용하지 않는다면 일단 상속 받은 후 사용하지 않고 무시해버리면 됩니다.

파생 클래스는 기반 클래스의 모든 멤버 변수와 멤버 함수를 상속받으므로 기반 클래스의 속성과 동작을 그대로 물려받습니다. 그런데 만약 상속 받은 멤버와 똑같은 이름으로 똑같은 멤버를 다시 선언하면 어떻게 될까요? 다음 소스로 테스트해보겠습니다.

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
#include<iostream>
 
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(12);
    printf("d.m = %d\n", d.m);
    d.f();
}
cs





'Programming > Cplusplus' 카테고리의 다른 글

C++에서의 다양한 Casting 방법들  (0) 2018.02.01
네임스페이스(namespace)의 이용  (0) 2016.03.07
C++ - 클래스 상속(1/3)  (0) 2015.09.22
C++ - 연산자 오버로딩(3/3)  (0) 2015.09.21
C++ - 연산자 오버로딩(2/3)  (0) 2015.09.20