책정리/혼자 연구하는 C,C++ 2

30장 다형성

GONII 2015. 3. 3. 16:14

30.1 가상 함수

가. 객체와 포인터

가상 함수란 클래스 타입의 포인터로 멤버 함수를 호출 할 때 동작하는 특별한 함수이다. 객체 지향의 중요한 특징인 다형성을 구현하는 문법적 기반이 바로 가상 함수이다.

Human h("aa");

Student s(""ss", 9090);

h = s // 가능

s = h // 에러

부모 클래스의 객체인 h가 자식 클래스의 객체인 s를 대입받는 것은 논리적으로 가능하다. 왜냐하면 h가 대입받을 모든 멤버가 s에 있기 때문이다. is a관계가 성립된다. s와 h에 동시에 존재하는 모든 멤버가 h로 대입되며 s에는 있지만 h에는 없는 멤버는 대입에서 제외된다.

h = s 대입에 의해 h는 s가 가지고 있는 이름 정보를 가지게 될것이다. 그러나 대입은 가능하지만 우변의 정보 중 일부가 좌변에 대입되면서 사라지는 슬라이스(Slice)문제가 발생하는 부작용이 있다.

반대로의 대입인 s = h대입은 명백한 에러로 처리된다. 둘 사이에 공통적으로 존재하는 멤버만 대입하는 방법을 쓸 수 있겠지만 s가 온전한 객체가 되지 못할 확률이 크다. 읿란적으로 자식 객체는 부모보다 더 많은 멤버를 가지며 이 멤버들은 서로 긴밀하게 연관되어 있을 것이다. 그런데 부모로부터 전달받은 멤버만 대입받고 이 멤버에 종속적인 다른 멤버는 바뀌지 않는다면 온전한 상태의 객체가 될 수 없다.

만약 student 클래스에 human형의 객체를 대입받는 별도의 대입 연산자가 정의되어 있고 이 함수가 human에 없는 멤버에 대해 무난한 디폴트를 위한다면 역방향의 대입이 문법적으로 가능해진다. 그러나 이런 경우는 자식과 부모의 멤버가 일치하거나 아니면 부모의 정보만으로 자식 객체를 완전히 재생성 가능한 특별한 경우이므로 일반적이라고 할 수 없다. 요약하자면 부모 객체는 자식 객체를 대입받을 수 있지만 그 반대는 안 된다.

클래스 타입의 포인터끼리도 객체간의 관계와 동일한 규칙이 그대로 적용된다. 클래스도 타입이므로 클래스형 객체를 가리킬 수 있는 포인터를 선언할 수 있다. 부모 타입의 포인터와 자식 타입의 포인터가 있을 때 이 포인터가 어떤 객체의 번지를 안전하게 대입받을 수 있는지 예제를 보자.

  • 예제 ObjectPointer

#include <iostream>

   

class human

{

protected:

char name[16];

public:

human(char *aName) { strcpy(name, aName); }

void intro() { printf("이름 : %s", name); }

void think() { printf("점심 뭐먹지?\n"); }

};

   

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("abcd..."); }

};

   

void main()

{

human h("abc");

student s("학생", 12345);

human *pH;

student *pS;

   

pH = &h;        // 가능

pS = &s;        // 가능

pH = &s;        // 가능

//pS = &h;        // 에러

   

pS = (student*)&h;

pS->intro();

}

세 번째 대입문 pH = &s의 경우 양변의 타입이 불일치하지만 컴파일해보면 문제가 없다. 부모 타입의 포인터가 자식 객체의 번지를 대입받았는데 컴파일러가 이를 허용하는 이유는 이 대입이 논리적으로 아무런 문제가 없기 때문이다. 이렇게 대입된 포인터 pH로는 human에 있는 멤버만 참조할 수 있으며 human의 모든 멤버를 student객체인 s도 가지고 있다. 그러므로 pH->think(), pH->intro()를 호출해도 이상이 없다.

그러나 그 반대는 성립하지 않는다. 모든 사람이 학생이 아니므로 학생이 할 수 있는 행동 중에 사람이 할 수 없는 행동도 있다. 그래서 학생 타입의 포인터 pS에 부모 객체 h의 번지를 대입하는 것은 허락되지 안흔ㄴ다. 물론 맞는 타입으로 캐스팅해서 강제로 대입할 수는 있지만 오작동할 위험이 높으며 그 결과는 예측할 수 없다.

포인터는 두 가지 종류의 타입을 가진다. 정적 타입(Static Type)이란 포인터가 선언될 때의 타입, 즉 포인터 자체의 타입을 의미하며 동적 타입(Dynamic Type)이란 포인터가 실행 중에 가리키고 있는 대상체의 타입, 즉 대상체의 타입을 의미한다. 대개의 경우 정적, 동적 타입이 일치하지만 위 예의 pH = &s 대입처럼 두 타입이 틀려지는 경우도 있다.

pH의 정적 타입은 human*형이지만 student형 객체의 번지를 가리키고 있으므로 동적 타입은 student*형이다.

C에서 포인터는 타입이 완전히 일치해야 대입이 허용되지만 C++에서는 상속 관계에 있는 클래스끼리 대입할 때 좌변이 더 상위의 클래스 타입이면 캐스팅을 하지 않고도 직접 대입할 수 있도록 허용한다. 이렇게 해야만 다형성을 구현할 수 있기 때문이다.

부모는 자식을 가리킬 수 있다.

나. 가상 함수의 개념

다음 예제는 가상 함수의 필요성을 설명하기 위한 잘못된 예제이다.

  • 예제 VirtFunc

#include <iostream>

   

class Base

{

public:

void outMessage() { printf("Base Class\n"); }

};

   

class Derived : public Base

{

public:

void outMessage() { printf("Derived Class\n"); }

};

   

void main()

{

Base b, *pb;

Derived d;

   

pb = &b;

pb->outMessage();

pb = &d;

pb->outMessage();

}

pb = &b; 일 때 pb->outMessage();를 호출하면 Base Class가 출력된다.

pb = &d; 일 때 pb->outMessage();를 호출해도 Base Class가 출력된다.

부모 클래스 타입의 포인터 pb가 자식 객체 d를 가리키는 것은 문법적으로 합당하다. 그런데 pb가 d를 가리키는 상황에서 멤버 함수 호출을 하면 base클래스의 함수가 호출될까??그 이유는 컴파일러가 포인터의 정적 타입을 보고 이 타입에 맞는 멤버 함수를 호출하기 때문이다. pb가 Base* 타입으로 선언되어 있으므로 Base의 멤버 함수를 호출하는 것이다.

pb가 선언된 포인터 타입(정적 타입)에 따라 멤버 함수를 선택하는 것이 아니라 pb가 가리키고 있는 객체의 타입(동적 타입)에 따라 멤버 함수가 선택되도록 의도한 것일거다.

이렇게 하고 싶다면 함수 선어문에 virtual 키워드를 붙여 이 함수를 가상 함수로 선언한다.

virtual void outMessage() { printf("Base Class\n"); }

virtual void outMessage() { printf("Derived Class\n"); }

부모의 멤버 함수가 가상 함수이면 자식의 멤버 함수도 자동으로 가상 함수가 되지만 가상함수라는 것을 분명히 하기 위해 양쪽에 모두 붙이는 것이 좋다. virtual 키워드는 클래스 선언문 내에서만 쓸 수 있으며 함수 정의부에서는 쓸 수 없다.

  • 예제 VirFunc2

#include <iostream>

   

class Base

{

public:

virtual void outMessage() { printf("Base Class\n"); }

};

   

class Derived : public Base

{

public:

virtual void outMessage() { printf("Derived Class\n"); }

};

void Message(Base *pb)

{

pb->outMessage();

}

   

void main()

{

Base b;

Derived d;

   

Message(&b);

Message(&d);

}

Message 함수는 Base*형의 포인터 pb를 받아 들여 이 포인터가 가리키는 객체의 outMessage함수를 호출한다. outMessage 함수가 가상으로 선언되어 있으므로 형식 인수 pb가 전달받는 객체의 타입에 따라 호출될 함수가 결정된다.

Message 함수의 본체 코드는 완전히 똑같은데 전달되는 객체 따라 실제 동작은 달라진다.

pb->outMessage()라는 코드가 경우에 따라 다른 동작을 할 수 있는 능력, 이것이 바로 다형성의 개념이다.

가상 함수는 포인터가 가리키는 실제 객체의 함수를 호출한다는 점이 다르다. 그래서 파생 클래스에서 재정의하는 멤버 함수 또는 앞으로라도 재정의할 가능성이 있는 멤버 함수는 가상으로 선언하는 것이 좋다. 그래야 부모 클래스의 포인터 타입으로 자식 객체의 멤버 함수를 호출해도 정확하게 호출된다.

다. 동적 결합

가상 함수는 자신을 호출하는 객체의 타입, 즉 동적 타입에 따라 실제 호출될 함수가 결정된다.

컴파일러는 함수가 어떤 주소에 있는지 알고 있으며 함수 호출문을 이 함수의 주소로 점프하는 코드로 번역할 것이다. 컴파일하는 시점(정확하게는 링크 시점)에 이미 어디로 갈 것인가가 결정되는 이런 결합 방법을 정적 결합(Static Binding)또는 이른 결합(Early Binding)이라고 한다. 결합(Binding)이란 함수 호출문에 대해 실제 호출될 함수의 번지를 결정하는 것을 말하는데 지금까지 작성하고 사용했던 일반적인 함수들은 모두 정적 결합에 의해 번역된다.

그런데 가상 함수는 포인터가 가리키는 객체의 타입에 따라 호출될 실제 함수가 달라지므로 컴파일시에 호출할 주소가 결정되는 정적 결합으로는 정확하게 호출할 수 없다. 왜냐하면 포인터가 실행 중에 어떤 타입의 객체를 가리킬지 컴파일 중에는 알 수 없기 때문이다. 대입은 실행 중에 회수가 상관없이 얼마든지 할 수 있는 연산이고 포인터는 타입만 일치하면 얼마든지 대상을 가리킬 수 있다. 컴파일러는 앞 예제의 Message 함수의 본체를 특정 번지로의 점프문으로 번역할 수 없으며 조건에 따라 호출할 함수를 결정하는 문장으로 번역해야 한다.

void Message(Base *pB)

{

if pB가 Base 객체를 가리키면 Base::OutMessage 호출

if pB가 Derived 객체를 가리키면 Derived::OutMessage 호출

}

   

실행 중에 호출할 함수를 결정하는 이런 결합 방법을 동적 결합(Dynamic Binding) 또는 늦은 결함(Late Binding)이라고 한다. pB->outMessage 호출문을 미리 고정된 번지로의 점프문으로 번역하는 것이 아니라 pB가 가리키는 객체의 타입에 따라 적절한 함수를 선택해서 점프하는 코드로 번역해야 하는 것이다. 이렇게 전달된 객체에 따라 각기 다른 동작을 할 수 있는 다형성을 구현할 수 있다.

동적 결합은 멤버 함수를 포인터(또는 레퍼런스)로 호출할 때만 동작한다. 객체로부터 함수를 호출할 때는 설사 그 함수가 가상 함수라 할지라도 컴파일 시에 호출할 함수를 정확하게 결정할 수 있다. 왜냐하면 객체는 자신이 소속된 클래스 타입일 뿐이지 다른 타입이 될 수 없기 때문이다. 포인터는 부모 타입의 포인터가 자식을 가리킬 수 있기 때문에 정확한 함수를 호출하기 위해 동적 결합을 해야 하지만 객체로 직접 호출할 때는 호출 객체의 타입을 분명히 알 수 있으므로 그럴 필요가 없다.

라. 가상 함수 테이블

정적 결합은 컴파일러 호출될 함수의 주소를 분명히 알 때 사용하는데 비해 동적 결합은 호출될 함수를 컴파일 중에 결정할 수 없을 때 사용한다. 실행 중에 호출을 결정해야 한다면 동적 결합에 의해 생성되는 코드는 객체의 타입을 판별해서 이 타입에 맞는 함수를 선택하는 동작으로 번역되어야 할 것이다.

C++ 언어는 가상 함수의 정의와 동작 방식에 대해서는 분명하게 규정하고 있지만 이 함수 호출문을 어떤 식으로 구현해야 한다고 구체적으로 명시하고 있지 않다. 그래서 동적 결합을 구현하는 방식은 컴파일러마다 다를 수 있으며 컴파일러 개발자가 C++의 요구에 맞게 작성하기만 하면 된다.

동적 결합을 구현하는 방법에는 여러 가지가 있겠지만 대부분의 컴파일러는 vtable이라는 가상 함수 목록을 작성하고 각 객체에 vtable을 가리키는 숨겨진 멤버 vptr을 추가하는 방식을 사용한다.

vtable(가상 함수 테이블)이란 가상 함수의 번지 목록을 가지는 일종의 함수 포인터 배열이다. 즉, 이 클래스에 소속된 가상 함수들이 어떤 번지에 저장되어 있는지를 표 형태로 저장해 놓는 목록이다. 컴파일러는 가상 함수를 단 한 개라도 가진 클래스에 대해 vtable을 작성하는데 이 테이블에는 클래스에 소속된 가상 함수들의 실제 번지들이 선언된 순서대로 기록되어 있다. 그리고 이 클래스 타입의 객체가 생성될 때 각 객체에 선두에 vtable의 번지인 vptr을 기록한다. vptr이 항상 객체의 선두에 오고 다음으로 이 객체의 멤버들이 순서대로 온다.

  • 예제 vtable

#include <iostream>

using namespace std;

   

class B

{

private:

int memB;

public:

B() : memB(0x11111111) {}

virtual void f1() { puts("b::f1()"); }

virtual void f2() { puts("b::f2()"); }

virtual void f3() { puts("b::f3()"); }

void f4() { puts("non virtual"); }

};

   

class D : public B

{

private:

int memD;

public:

D() : memD(0x22222222) {}

virtual void f1() { puts("d::f1()"); }

virtual void f2() { puts("d::f2()"); }

};

   

void main()

{

B *pB;

B b;

D d;

   

pB = &b;

pB->f2();

   

pB = &d;

pB->f2();

pB->f3();

}

   

컴파일러는 B 클래스를 위해 B클래스에 속한 가상 함수의 번지를 vtable로 작성한다. vtable은 가상 함수들의 포인터 배열이라고 할 수 있는 비가상 함수의 번지는 목록에서 제외된다. 그래서 vtable에 normal 함수의 번지는 없는데 이 함수는 정적으로 결합되므로 테이블에 있을 필요가 없다. B 타입의 b객체에는 자신의 멤버 변수 memB 앞에 B클래스의 vtable에 대한 포인터 vptr이 먼저 배치되고 이 포인터가 가리키는 vtable에는 자신이 호출할 수 있는 가상 함수들에 실제 번지들이 기록되어 있다.

D클래스도 가상 함수를 가지고 있으므로 컴파일러는 D에 대해서도 vtable을 작성한다. f1,f2는 D가 재정의한 함수를 가리키고 있으며 f3은 B로부터 상속받은 B::f3을 가리키고 있다.

만약 pB가 D타입의 객체인 d를 가리키고 있다면 d객체의 vptr이 가리키는 vtable에서 호출할 함수의 번지를 찾는다. 결국 어떤 타입의 객체가 전달되는가에 따라 참조하는 vtable이 바뀌고 실제 호출될 함수도 달라진다.

vtable을 사용하는 방법은 실행 중에 호출할 함수를 결정한다기보다 호출할 함수의 목록을 vtable에 미리 작성해 놓고 실해 ㅇ중에 객체의 vtable을 찾고 vtable에서 다시 호출할 함수의 번지를 찾는 방법이다. 즉, 실행 중에 호출할 함수를 신속하게 결정하기 위해 컴파일할 때 모든 예비 동작을 미리 취해 놓는다고 할 수 있다. 컴파일 속도가 좀 느려지고 실행 파일이 약간 커지겠지만 가상 함수 호출 속도는 극적으로 빨라진다.

vtable의 장점은 미래에 추가될 자식 클래스에 대해서도 아주 잘 동작한다는 점이다. D를 파생시켜 G클래스를 만들었고 G는 f1 가상 함수를 재정의한다고 했으 ㄹ때 이 클래스를 컴파일하면 G에 대한 vtable이 작성될 것이고 이 테이블에는 G::f1, D::f2, B::f3의 주소가 작성될 것이다. 그래서 B*형의 pB가 G형 객체 g를 가리키는 상황이 되더라도 pB->f1() 호출문을 새로 추가된 G의 f1을 잘 찾아간다.

클래스 타입의 포인터(p)에 의한 가상 함수 호출문의 번역 결과를 말로 표현하면 다음과 같다.

호출할 함수가 해당 클래스의 몇 번째 가상 함수인지를 먼저 조사(n)하고 포인터가 가리키는 곳의 첫 번째 숨겨진 멤버에 있는 vptr이 가리키는 곳의 vtable의 n번째 주소가 가리키는 함수를 호출하는 것이다.

p->vptr->vtable[n] 을 호출한다고 할 수 있다.

30.2 가상 함수의 활용

가. 객체의 집합 관리

동적 결합이 필요할 때 가상 함수를 사용하고 그렇지 않을 경우는 비가상 함수를 사용하면 된다.

  • 예제 GraphicObject

#include <iostream>

using namespace std;

   

class graphic

{

public :

void draw() { puts("그래픽 오브젝트"); }

};

   

class line : public graphic

{

void draw() { puts("선을 그린다."); }

};

   

class circle : public graphic

{

public:

void draw() { puts("원을 그린다."); }

};

   

class rect : public graphic

{

public:

void draw() { puts("사각형을 그린다."); }

};

   

void main()

{

graphic *ar[10] = {

new graphic(),

new rect(),

new circle(),

new rect(),

new line(),

new line,

new rect(),

new circle(),

new graphic(),

new line()

};

   

for( int i = 0; i < 10 ; i++ )

{

ar[i]->draw();

}

   

for( int i = 0 ; i < 10 ; i++ )

{

delete ar[i];

}

}

4개의 클래스가 정의되어 있는데 클래스의 계층은 다음과 같다

10번 모두 "그래픽 오브젝트" 가 출력되는데 draw()함수에 virtual 키워드를 붙여주면 다형성을 가진 클래스들이 완성된다. ar[i]->draw()를 호출하면 ar[i]가 가리키는 동적 타입에 따라 line::draw(), circle::draw(), rect::draw()가 실행된다.

만약 동적 결합을 하는 가상 함수라는 장치가 없다면 똑같은 호출로 다양한 도형을 그릴 수가 없다. 각 객체에 스스로의 타입을 판별할 수 있는 별도의 열거형 멤버를 추가하고 이 멤버로부터 타입을 판별하여 자신을 그릴 멤버 함수를 결정하는 다중 분기를 해야 할 것이다.

for( i = 0 ; i < 10 ; i++)

{

switch( ar[i].type)

{

case GR_GRAPHIC:

((graphic*)ar[i])->draw();

break;

case GR_LINE:

((line*)ar[i])->draw();

break;

case GR_CIRCLE:

((circle*)ar[i])->draw();

break;

case GR_RECT:

((rect*)ar[i])->draw();

break;

}

}

뿐만 아니라 이후 도형의 종류가 늘어나면 이 분기문의 case도 같이 늘어나야 하므로 코드의 관리도 어려워진다. 이에 비해 가상 함수는 호출 객체에 따라 선택되는 동적 결합 능력이 있으므로 ar[i]->draw() 호출만 하면 graphic 파생 클래스에 대해서는 모두 정확하게 동작할 뿐만 아니라 미래에 새로운 클래스가 추가되더라도 이 코드는 더 이상 고칠 필요가 없어진다.

처음 클래스 계층을 조직적으로 설계하고 가상 함수를 잘 작성해 놓으면 코드 관리의 유연성이 극적으로 향상된다.

구현이 조금씩 다른 객체의 집합을 관리할 때는 가상 함수를 꼭 사용해야 한다. 객체에 따라 달라지는 동작을 결정하는 작업은 개발자가 직접 할 필요가 없으며 컴파일러가 동적 결합을 위한 모든 준비를 하고 실행 중에 적합한 함수를 호출할 것이다. 가상 함수를 쓰기 위해서는 클래스 계층이 있어야 한다. 그래서 다형성의 전제 조건이 바로 상속인 것이다.

나. 멤버 함수가 호출하는 함수

  • 예제 MemCallMem

#include <iostream>

#include <windows.h>

#include <math.h>

#include <conio.h>

using namespace std;

   

void gotoxy(int x, int y);

   

class point

{

protected:

int x, y;

char ch;

public:

point(int ax, int ay, char ach) { x = ax; y = ay; ch = ach; }

virtual void show()

{

gotoxy(x,y); cout << ch ;

}

virtual void hide()

{

gotoxy(x,y); cout << ' ' ;

}

void move(int nx, int ny)

{

hide();

x = nx;

y = ny;

show();

}

};

   

class circle : public point

{

protected:

int rad;

public:

circle(int ax, int ay, char ach, int arad) : point(ax, ay, ach) { rad = arad; }

virtual 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));

cout << ch;

}

}

virtual 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));

cout << ' ' ;

}

}

};

   

void main()

{

point p(1, 1, 'p');

circle c ( 10, 10, 'c', 5);

   

p.show();

c.show();

   

getch();

p.move(40,1);

getch();

c.move(40, 10);

getch();

   

}

   

void gotoxy(int x, int y)

{

COORD Pos={x,y};

SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE),Pos);

}

circle클래스는 move를 재정의하지 않고 그대로 상속했음에도 불구하고 잘 이동한다. 도형을 움직이는 move함수의 원리는 어떤 도형에서나 원칙적으로 동일하다. 원래 자리에 그려져 있던 도형을 지우고 위치를 옮긴 후 다시 그리면 된다. 원을 이동시키는 방법도 틀리지 않기 때문에 circle클래스가 move 함수를 별도로 다시 정의할 필요가 없는 것이다.

그러나 코드가 같더라도 move함수는 완전히 같을 수는 없다. 원을 옮기는 절차와 점을 옮기는 절차가 같지만 move 함수 내부에서 호출하는 show, hide는 도형마다 달라야 한다. 그래서 move는 그대로 상속받고 이 함수 내부에서 호출하는 show, hide를 가상으로 선언하여 호출된 객체의 타입에 따라 적합한 show, hide가 호출되록하는 것이다.

멤버 함수 내에서 세부 구현을 위해 호출되어야 하는 또 다른 멤버 함수가 클래스별로 다르게 정의되어 있다면 이 함수도 가상 함수가 되어야 한다. 그래야 암시적으로 전달되는 this객체의 타입에 따라 정확한 함수가 호출된다.

다. 재정의 가능한 함수

클래스는 스스로의 상태를 저장하고 동작에 필요한 모든 것들을 가질 수 있기 때문에 재활용성이 아주 높으며 사용하기도 쉽고 안전하다. 이런 면에서 클래스라는 것은 확실히 편리하고 우수한 개발 방법임이 분명하지만 클래스를 아무리 범용적으로 작성한다 하더라도 세상의 모든 문제들에 다 적용될 수 있을 만큼 일반적일 수는 없다.

다음 예제는 상속 후 재정의하는 기본적인 방법을 보여준다.

  • 예제 Overridable

#include <iostream>

using namespace std ;

   

#define ELETYPE int

class dArray

{

protected :

ELETYPE *ar ;

unsigned size ;

unsigned num ;

unsigned growby ;

   

public :

dArray( unsigned asize = 100, unsigned agrowby = 10 ) ;

~dArray() ;

void Insert( int idx, ELETYPE value ) ;

void Delete( int idx ) ;

void Append( ELETYPE value ) ;

   

ELETYPE getAt( int idx ) { return ar[idx] ; }

unsigned getSize() { return size ; }

unsigned getNum() { return num ; }

void setAt( int idx, ELETYPE value ) { ar[idx] = value ; }

void dump( char *sMark) ;

} ;

   

class myDArray : public dArray

{

public:

myDArray(unsigned asize = 100, unsigned agrowby = 10) : dArray(asize, agrowby) {}

void dump( char *sMark);

};

   

void myDArray::dump(char *sMark)

{

printf("%16s : 개수가 %d 개수다. 나머진 몰라도돼\n", sMark, num);

}

   

void main ( void )

{

myDArray ar(10, 5);

int i;

   

for( i = 1 ; i <= 8 ; i++ ) ar.Append(i); ar.dump("8개 추가");

ar.Insert(3, 10); ar.dump("10 삽입");

}

   

dArray::dArray( unsigned asize, unsigned agrowby )

{

size = asize ;

growby = agrowby ;

num = 0 ;

ar = (ELETYPE *)malloc(size * sizeof(ELETYPE)) ;

}

   

dArray::~dArray()

{

free(ar) ;

}

   

void dArray::Insert( int idx, ELETYPE value )

{

unsigned need ;

   

need = num + 1 ;

if ( need > size )

{

size = need + growby ;

ar = (ELETYPE *)realloc(ar, size*sizeof(ELETYPE)) ;

}

memmove(ar+idx+1, ar+idx, (num-idx)*sizeof(ELETYPE)) ;

ar[idx] = value ;

num++ ;

}

   

void dArray::Delete( int idx )

{

memmove(ar+idx, ar+idx+1, (num-idx-1)*sizeof(ELETYPE)) ;

num-- ;

}

   

void dArray::Append( ELETYPE value )

{

Insert( num, value ) ;

}

   

void dArray::dump( char * sMark )

{

unsigned i ;

cout << sMark << " => 크기 = " << size << ", 개수 = " << num << " : " ;

for ( i = 0 ; i < num ; i++ )

{

cout << getAt( i ) << ' ' ;

}

cout << endl ;

}

삽입이나 삭제, 동적 메모리 관리 등의 모든 기능은 기반 클래스인 dArray의 것을 그대로 사용하고 있으므로 myDArray는 동적 배열의 모든 기능을 상속바든ㄴ다. 다만 dump 함수만 재정의하여 배열을 출력하는 방식만 다를 뿐이다. 이런 식으로 재정의가 필요한 함수는 일단 상속받은 후 원하는 대로 뜯어 고칠 수 있다.

그러나 위 예제에서는 dump 함수에 virtual키워드가 붙어있지 않기 때문에 dArry *p형으로 선언한 후 객체를 받더라도 부모의 dump만 실행 될 것이다. 이 문제를 해결하기 위헤 dump 함수가 동적 결합을 하도록 virtual 키워드를 붙여 가상함수로 선언해야 한다.

   

미래에 클래스의 재활용성을 높이려면 파생 클래스에서 재정의할 함수는 가상 함수로 선언해야 한다. 그래야 상속받은 후 이 함수를 재정의하더라도 아무런 문제가 없다. 그리고 기반 클래스로 사용되는 클래스는 자식에게 물려주고 싶은 멤버에 대해 private 보다는 protected로 선언해야 한다. 그렇지 않으면 파생 클래스가 부모의 주요 멤버를 읽지 못한다.

라. 가상 파괴자

기반 클래스의 소멸자는 반드시 가상으로 선언해야 한다.

  • 예제 VirtDestructor

#include <iostream>

   

class Base

{

private:

char *b_buf;

public:

Base() { b_buf = new char[10]; puts("base 생성"); }

~Base() { delete[] b_buf; puts("base 파괴"); }

};

   

class Derived : public Base

{

private:

int *d_buf;

public:

Derived() { d_buf = new int[32]; puts("derived 생성"); }

~Derived() { delete[] d_buf; puts("derived 파괴"); }

};

   

void main()

{

// Derived d;

Base *pB;

   

pB = new Derived;

delete pB;

}

new 연산자로 Derived의 객체를 만들고 그 포인터를 Base* 타입에 pB에 대입하면 Derived가 생성될 때 부모와 자신의 생성자가 차례대로 호출되어 두 개의 버퍼를 동적으로 할당할 것이다.

그러나 delete pB로 이 객체를 해제할 때는 부모의 파괴자만 호출되는데 왜냐하면 pB가 Base*타입이기 때문이다. 포인터의 타입에 따라 소멸자가 정적으로 결합되다 보니 실제로 파괴되는 객체는 Derived 타입이만 Derived의 소멸자를 호출하지 못하는 것이다. 이렇게 되면 부모가 할당한 char 배열은 잘 해제되지만 Derived가 할당한 int 배열은 해제되지 못하고 누수가 발생한다.

문제가 무엇인지 알았으니 해결도 간단하다.

소멸자가 동적 결합을 하도록 가상 함수를 만들어주면 된다.

마. 함수의 가상성

가상 함수는 객체 타입에 따라 정확한 버전이 호출된다는 장점이 있기는 하지만 비가상 함수에 비해 느리고 더 많은 메모리를 소모한다. 또한 런타임에 간접적으로 함술르 호출하므로 함수를 고르는 시간만큼 호출 오버헤드가 드는 셈이다.

  • 가상 함수를 만들어야 할 때 고려할 점
    • 이 클래스로부터 자식 클래스가 파생될 가능성이 조금이라도 있어야 한다. 더 이상 확장할 필요가 없을 정도로 기능이 완벽하거나 유사한 클래스를 정의할 경우가 전혀 없을 정도로 단순한 클래스라면 이 클래스의 멤버 함수들은 가상으로 선언할 필요가 없다. 가상 함수의 동적 결합 능력은 클래스 계층이 형성될 때만 의미가 있으므로 홀로 있는 클래스의 멤버 함수가 가상이어야 할 이유는 전혀 없다.
    • 파생 클래스에서 함수의 동작을 재정의할 가능성이 있어야 한다. 최상위의 부모 클래스가 기능을 정의하고 아래의 모든 파생클래스는 부모가 정의한 기능을 사용하기만 한다면 이 함수는 전체 클래스 계층에서 단 하나만 존재하므로 어떤 함수를 선택할 것인가가 문제되지 않는다.
    • 부모 클래스 타입으로부터 호출할 가능성이 있어야 한다. 가상 함수는 포인터로부터 호출될 때만 동작하므로 항상 객체로부터 호출되기만 한다면 파생 클래스에서 재정의한 함수라도 비가상일 수 있다. 그러나 포인터로 호출하지 못한다면 문법적 제약은 없으며 객체 포인터를 사용할 가능성은 항상 있으므로 이 가정은 일반적으로 위험하다고 할 수 있다.

30.3 순수 가상 함수

가. 정의

가상 함수는 재정의해도 되는 함수이지 반드시 재정의해야 하는 함수는 아니다.

이에 비해 순수 가상 함수(Pure Virtual Function)는 파생 클래스에서 반드시 재정의해야 하는 함수이다. 순수 가상 함수는 일반적으로 함수의 동작을 정의하는 본체를 가지지 않으며 이 상태에서는 호출할 수 없다. 본체가 없다는 뜻으로 함수 선언부의 끝에 =0이라는 표기를 하는데 이는 함수만 있고 코드는 비어 있다는 뜻이다.

  • 예제 PureVirt

#include <iostream>

   

class graphic

{

public:

virtual void draw()=0;

};

   

class line : public graphic

{

public:

virtual void draw() { puts("선을 그림"); }

};

   

class circle : public graphic

{

public:

virtual void draw() { puts("원을 그림"); }

};

   

class rect : public graphic

{

public:

virtual void draw() { puts("사각형 그림"); }

};

   

void main()

{

graphic *pG[3];

int i;

// grahpic g;

pG[0] = new line;

pG[1] = new circle;

pG[2] = new rect;

   

for ( i = 0 ; i < 3 ; i++ )

{

pG[i]->draw();

}

   

for( i = 0 ; i < 3 ; i++ )

{

delete pG[i];

}

}

graphic 클래스의 draw 함수가 순수 가상 함수로 선언되어 있다. 이처럼 하나 이상의 순수 가상 함수를 가지는 클래스를 추상 클래스(Abstract Class)라고 한다. 추상 클래스는 동작이 정의되지 않은 멤버 함수를 가지고 있기 때문에 이 상태로는 인스턴스를 생성할 수 없다.

추상 클래스의 반대 개념은 구체 클래스(Concrete Class)인데 위 예제의 line, circle, rect 등이 구체 클래스의 예이다.

추상 클래스는 line, circle, rect 구체 클래스의 공동의 조상이 되어 이 객체들의 집합을 관리하기 위해 필요하다.

추상 클래스의 객체를 생성할 수는 없지만 추상 클래스 타입의 포인터를 선언할 수 있다. 그래서 graphic*의 배열을 선언하면 이 배열로 graphic 파생 클래스의 객체 집합을 관리할 수 있다.

추상 클래스의 또 다른 중요한 역할을 다형적인 함수의 집합을 정의하는 것이다. 도형이 되기 위해 꼭 필요한 함수의 집합을 추상 클래스에 순수 가상 함수로 선언해 두며 이 클래스로부터 파생되는 도형 클래스는 이 가상 함수를 반드시 재정의해야 한다는 의무가 생긴다.

만약 파생클래스가 추상 클래스의 순수 가상 함수를 재정의하지 않는다면 이 클래스도 추상 클래스이므로 인스턴스를 생성할 수 없다.

이 때 추상 클래스가 정의하는 기능 목록을 인터페이스라고 한다.

순수 가상 함수는 이러한 동작이 필요하다는 것만 표현할 뿐이므로 통상 =0로 표기하며 본체를 가지지 않는다. 그러나 필요하다면 순수 가상 함수도 본체를 가질 수 있다. 후손들이 동작하는데 공통적으로 필요한 구현이 있다면 추상 클래스의 순수 가상 함수에 이 코드를 미리 작성해 넣을 수 있다.

class graphic

{

public:

virtual void draw() = 0 { clrscr(); }

};

이럴 경우 파생 클래스의 draw 함수들은 graphic::draw를 먼저 호출하여 화면을 지우는 동작을 추상 클래스의 draw함수에게 부탁할 수 있다.

class line : public graphic

{

public:

virtual void draw() { graphic::draw(); puts("선긋기"); }

};

파생된 구체 클래스들에게 어떤 공동의 동작을 물려주고 싶을 때 이런식으로 본체를 정의할 수도 있다. 순수 가상 함수의 본체는 추상 클래스 자신을 위한 것이 아니라 후손들이 공통적으로 쓸 수 있는 서브루틴을 제공하는 의미밖에 없다.

나. 추상 클래스의 예

많이 사용되는 한글과 워드 문서에 대한 분석 클래스를 작성한다면 아마도 다음과 같은 멤버 함수의 목록이 만들어 질 것이다.

두 클래스에는 중복되는 기능들이 많이 있어 상위 클래스를 정의한 후 파생 시킬 수 있다.

공통되는 기능을 상위 클래스로 정의하는 것은 아주 일반적인 상속 기법이다. Parser 클래스는 단지 문서 분석기 클래스들의 공통된 부모일 뿐 실제로 세상에 존재하는 문서를 분석하는 기능을 가지지는 못한다. 왜냐하면 문서라는 추상적인 대상을 분석하는데 필요한 기능의 목록을 정의할 뿐이므로 구체적인 구현을 가질 수 없는 것이다. 그래서 Parser의 멤버 함수들은 순수 가상 함수로 선언되어야 하며 따라서 Parser는 추상 클래스가 되는 것이다. 만약 다른 문서 분석기가 추가된다면 Parser로부터 상속받은 후 Parser가 선언한 순수 가상 함수를 반드시 재정의해야 한다. 모든 분석기들은 공통의 조상을 가지므로 Parser* 타입으로 모든 분석기의 집합을 관리할 수 있으며 Parser* 타입의 인수를 받아들인느 함수는 임의의 분석기에 대한 다형적인 동작을 처리 할 수 있다.

다. 유닛 추상 클래스

스타크래프트라는 시뮬레이션 게임을 보면 아주 많은 유닛들이 등장한다. 게임에 등장하는 유닛들을 특성별로 클래스화한다면 아마 다음과 같은 계층이 만들어 질 것이다.

이런 모든 유닛들은 공통적으로 좌표와 에너지 상태라는 속성을 가지며 이동할 수 있고(Move), 공격도 하고(Attack), 에너지가 떨어지면 죽기도(Die) 한다. 그래서 모든 유닛의 공동 조상으로 Unit 클래스를 루트로 선언했는데 이 클래스는 다음과 같은 모양을 가지고 있을 것이다. 모든 유닛의 가장 기본적인 동작을 순수 가상 함수로 포함하고 있다.

class Unit

{

protected:

int x, y;

int energy;

public:

virtual void Move(int x, int y) = 0;

virtual void Attack(int x, int y) = 0 ;

virtual void Die() = 0;

};

모든 유닛은 Unit으로부터 상속을 받아야 하며 Unit에 선언되어 있는 순수 가상 함수를 자신의 특성에 맞게 반드시 재정의해야 한다. 그래야 구체 클래스가 되어 객체를 만들 수 있다. 중간 계층인 Running, Flying 도 공통적인 특성을 표현할 뿐 구체적인 동작을 묘사할 수 없으므로 추상 클래스이다.

이런 계층 구조에서 최상위의 루트 클래스인 Unit은 실제 객체를 만들지는 못하지만 모든 유닛의 대표 타입으로 사용된다. Unit* 타입의 변수를 선언하면 이 변수로 존재하는 모든 유닛을 다 가리킬 수 있다.

12개의 유닛을 선택하고 이동 명령을 내렸다면 다음 코드로 명령을 내릴 수 있다

for( i = 0 ; i < 12 ; i++ ) pSel[i]->Move(x,y);

각각의 유닛이 목표 지점까지 이동하는 방식은 서로 다르다. 하지만 Move 함수 자체가 다형적으로 동작하기 때문에 선택된 유닛의 종류를 판단할 필요 없이 Move라는 함수만 호출하면 서택 객체의 실제 Move 함수가 호출되어 정의된 특성대로 정확하게 동작할 것이다. Attack, Die 함수도 마찬가지로 다형적으로 동작하는 가상 함수이므로 해당 유닛의 타입을 구분할 필요없이 가상 함수만 호출하면 모든 처리는 동적으로 결합되는 가상 함수가 알아서 처리한다.

반응형

'책정리 > 혼자 연구하는 C,C++ 2' 카테고리의 다른 글

32장 예외 처리  (0) 2015.03.07
31장 템플릿  (0) 2015.03.05
29장 상속  (0) 2015.02.28
28장 연산자 오버로딩  (0) 2015.02.27
27장 캡슐화  (0) 2015.02.20