2016년 3월 19일 토요일

[c++기초] 6. 다형성

30-1.가상 함수

30-1-가.객체와 포인터

가상 함수란 클래스 타입의 포인터로 멤버 함수를 호출할 때 동작하는 특별한 함수이다. 객체 지향의 중요한 특징인 다형성을 구현하는 문법적 기반이 바로 가상 함수인데 나름대로 난이도가 있어서 무척 어렵다. 특히 C++을 처음 공부하는 사람에게 있어서는 C의 포인터만큼이나 어려운 고비로 여겨진다. 한마디로 간결하게 정의하기에는 부피가 너무 큰 개념이므로 이 장에서는 가상 함수와 다형성을 아주 점진적인 방법으로 천천히 연구해 보기로 한다.
다소 복잡하고 컴파일러의 내부 동작까지 이해해야 하므로 솔직히 혼자 공부하기는 어려운 주제이다. 처음 읽을 때는 개념 파악에 치중하고 예제를 주의깊게 관찰해 보도록 하자. 다형성은 어려운만큼 실용적인 문법이며 MFC 프레임워크의 토대가 되고 상속에 단순한 재활용 이상의 의미를 부여하는 수단이다. 한마디로 OOP의 꽃이라고 할 수 있을 정도로 중요한 기능이다.
본격적으로 가상 함수를 논하기 전에 상대적으로 쉬운 클래스 타입의 포인터와 객체와의 관계에 대해 먼저 연구해 보도록 하자. 이 연구를 위해 앞장에서 만들었던 InheritStudent 예제의 Human, Student 클래스를 사용하도록 하자. Human형의 객체 H가 있고 Student 형의 객체 S가 있을 때 다음 두 대입문을 보자.

Human H("이놈");
Student S("저놈",9900990);

H=S;        // 가능
S=H;        // 에러

부모 클래스의 객체인 H가 자식 클래스의 객체인 S를 대입받는 것은 논리적으로 가능하다. 왜냐하면 H가 대입받을 모든 멤버가 S에도 있기 때문이다. 좀 유식하게 표현하면 학생은 일종의 사람이며 IS A관계가 성립하므로 학생이 사람이 될 수 있다. S와 H에 동시에 존재하는 모든 멤버가 H로 대입되며 S에는 있지만 H에는 없는 멤버는 대입에서 제외된다.
S 객체의 이름 정보인 Name은 H 객체에 그대로 대입되지만 StNum은 대입할 수 없는데 왜냐하면 H 객체에는 이 값에 대응되는 멤버가 없기 때문이다. H객체에게 StNum은 필수 정보가 아니며 학번이 없어도 얼마든지 사람이 될 수 있다. H=S 대입에 의해 H는 S가 가지고 있는 이름 정보를 가지게 될 것이다. 그러나 대입은 가능하지만 우변의 정보 중 일부가 좌변에 대입되면서 사라지는 슬라이스(Slice) 문제가 발생하는 부작용이 있다.
반대로의 대입인 S=H 대입은 명백한 에러로 처리된다. 물론 이 경우도 둘 사이에 공통으로 존재하는 멤버만 대입하는 방법을 쓸 수 있겠지만 이렇게 되면 S가 온전한 객체가 되지 못할 확률이 크다. 일반적으로 자식 객체는 부모보다 더 많은 멤버를 가지며 이 멤버들은 서로 긴밀하게 연관되어 있을 것이다. 그런데 부모로부터 전달받은 멤버만 대입받고 이 멤버에 종속적인 다른 멤버는 바뀌지 않는다면 온전한 상태의 객체가 될 수 없다.
예를 들어 어떤 학생의 정보를 표현하고 있는 객체에 이름만 변경하면 이 학생의 이름과 학번은 불일치의 상태가 될 것이며 논리적으로 의미없는 불완전한 객체가 되어 버린다. Human과 Student는 워낙 간단한 클래스라 이 정도 문제밖에 없지만 좀 더 복잡한 클래스 계층에서는 이런 문제가 치명적인 에러의 원인이 될 수도 있다. 가령 부모가 정의하는 버퍼의 한 지점을 가리키는 포인터 멤버 변수를 자식이 추가로 정의할 때 이 포인터가 엉뚱한 곳을 가리킨다면 어떻게 되겠는가? 이렇게 위험하기 때문에 컴파일러는 이런 대입을 허용하지 않는 것이다.
만약 Student 클래스에 Human형의 객체를 대입받는 별도의 대입 연산자가 정의되어 있고 이 함수가 Human에 없는 멤버에 대해 무난한 디폴트를 취한다면 역방향의 대입이 문법적으로 가능해진다. 그러나 이런 경우는 자식과 부모의 멤버가 일치하거나 아니면 부모의 정보만으로 자식 객체를 완전히 재생성 가능한 특별한 경우이므로 일반적이라고 할 수 없다. 요약하자면 부모 객체는 자식 객체를 대입받을 수 있지만 그 반대는 안된다.
클래스 타입의 포인터끼리도 객체간의 관계와 동일한 규칙이 그대로 적용된다. 클래스는 타입이므로 클래스형 객체를 가리킬 수 있는 포인터를 선언할 수 있다. 부모 타입의 포인터와 자식 타입의 포인터가 있을 때 이 포인터가 어떤 객체의 번지를 안전하게 대입받을 수 있는지 다음 예제를 보자.

  : ObjectPointer
#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()
{
     Human H("김사람");
     Student S("이학생",1234567);
     Human *pH;
     Student *pS;

     pH=&H;         // 당연히 가능
     pS=&S;         // 당연히 가능
     pH=&S;         // 가능
//  pS=&H;         // 에러

     pS=(Student *)&H;
     pS->Intro();
}

앞의 두 대입문은 좌우변이 완전히 같은 타입이므로 지극히 당연한 대입문이다. Human을 가리키는 포인터 pH가 Human형 객체 H의 번지를 대입받는 것은 하나도 이상할 것이 없다. 이렇게 대입된 pH로부터 pH->Intro(), pH->Think() 함수를 호출할 수 있다. 물론 pH를 통한 참조는 클래스 외부에서 이루어지므로 public 멤버에 대해서만 참조 가능하다. 마찬가지로 Student 타입의 포인터 pS가 S의 번지를 가질 수 있는 것도 지극히 자연스럽다.
그렇다면 세 번째 대입문 pH=&S의 경우는 어떨까? 일단 대입 연산자 양변의 타입이 불일치해서 문제가 될 것 같지만 컴파일해 보면 아무런 문제가 없다. 부모 타입의 포인터가 자식 객체의 번지를 대입받았는데 컴파일러가 이를 허용하는 이유는 이 대입이 논리적으로 아무런 문제가 없기 때문이다. 이렇게 대입된 포인터 pH로는 Human에 있는 멤버만 참조할 수 있으며 Human의 모든 멤버를 Student객체인 S도 가지고 있다. 그러므로 pH->Think()를 호출하든 pH->Intro()를 호출하든 전혀 이상이 없는 것이다. 학생은 사람이므로(Student is a Human) 사람의 모든 속성을 가지며 사람이 할 수 있는 모든 행동을 할 수 있다.
그러나 그 반대는 성립하지 않는다. 모든 사람은 학생이 아니므로 학생이 할 수 있는 행동 중에 사람이 할 수 없는 행동도 있다. 공부한다, 시험친다는 행동은 사람중에서도 학생만이 할 수 있는 행동이다. 그래서 학생 타입의 포인터 pS에 부모 객체 H의 번지를 대입하는 것은 허락되지 않는다. 물론 맞는 타입으로 캐스팅해서 강제로 대입할 수는 있지만 논리적으로 틀린 대입이기 때문에 오동작할 위험이 높으며 그 결과는 예측할 수 없다. 예제의 끝에서 &H를 Student *로 강제 캐스팅해서 억지로 pS에 대입해 보았다. 바람직한 대입이 아니지만 캐스팅을 했기 때문에 컴파일러가 별 이의를 제기하지 않는다.
pS가 Human형 객체를 가리키고 있는 상태에서 Intro 함수를 호출하면 이때 호출되는 Intro는 Student::Intro가 된다. 왜냐하면 pS가 Student * 타입이기 때문이다. 호출 포인터와 함수의 쌍이 맞기는 하므로 컴파일 에러는 아니다. 또한 구조체 멤버 참조문은 멤버의 이름으로 오프셋만 취하므로 Intro에서 StNum을 읽는다 해도 문법적으로 문제가 없다. 이 함수는 이름과 학번을 출력하는데 pS가 가리키고 있는 H 객체는 이름은 가지고 있지만 학번은 가지고 있지 않으므로 엉뚱한 쓰레기값이 출력될 것이다. 이런 대입이 때로는 아주 위험한 결과를 초래할 수도 있으므로 컴파일러는 자식 포인터 타입이 상위 클래스의 객체를 가리키지 못하도록 금지하는 것이다.
포인터는 두 가지 종류의 타입을 가진다. 정적 타입(Static Type)이란 포인터가 선언될 때의 타입, 즉 포인터 자체의 타입을 의미하며 동적 타입(Dynamic Type)이란 포인터가 실행중에 가리키고 있는 대상체의 타입, 즉 대상체의 타입을 의미한다. 대개의 경우 정적, 동적 타입이 일치하지만 위 예의 pH=&S 대입처럼 두 타입이 틀려지는 경우도 있다. pH의 정적 타입은 Human *형이지만 Student형 객체의 번지를 가리키고 있으므로 동적 타입은 Student *형이다.
C에서 포인터끼리는 타입이 완전히 일치할 때만 대입이 허용된다. 그러나 C++에서는 상속 관계에 있는 클래스끼리 대입할 때 좌변이 더 상위의 클래스 타입이면 캐스팅을 하지 않고도 직접 대입할 수 있도록 허용한다. 이렇게 해야만 다형성을 구현할 수 있기 때문이다. 단, 가상 기반 클래스가 아닌 부모로부터 다중 상속된 관계라면 간접적인 중복 상속에 의해 애매함이 발생할 소지가 있으므로 이런 대입이 허용되지 않는다. 다중 상속은 이래 저래 복잡하다.
정리하자면 포인터로 객체를 가리킬 때 부모 클래스 타입의 포인터로 후손 객체를 가리킬 수 있지만 그 반대는 성립하지 않는다. 이런 규칙은 레퍼런스에 대해서도 그대로 적용되는데 레퍼런스도 어차피 포인터이므로 결국 같은 규칙이라 할 수 있다. 정의가 좀 길어서 외우기는 어려운데 좀 간단하게 정리해 보면 "부모는 자식을 가리킬 수 있다"가 된다. 다형성과 객체 지향을 이해하는 아주 핵심적인 문구이므로 헷갈리지 않게 꼭 외워 두도록 하자. 중요한 내용이므로 한 번 더 크게 반복한다.

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

"클래스는 타입이다"라는 정의와 함께 OOP를 이해하는 가장 핵심적인 문구이므로 반드시 기억하자. 저 간단한 문장을 왜 저렇게 엽기적으로 크게 외쳐 대는지 잘 이해가 안가겠지만 이 간단한 문장이 나중에 공부하다 보면 또 그렇게 헷갈릴 수가 없다. 포인터와 객체와의 관계를 머리속에 잘 정리해 놓고 가상 함수에 대한 개념을 공부해 보자.

30-1-나.가상 함수의 개념

다음 예제는 가상 함수의 필요성을 설명하기 위한 잘못된 예제이다. 이 예제가 어떤 문제점을 가지고 있는지 분석해 보고 해결 방법을 생각해 보자. 설명을 위한 예제이므로 실용성은 전혀 없다.

  : VirtFunc
#include <Turboc.h>

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

Base 클래스에 OutMessage라는 멤버 함수가 작성되어 있으며 이 함수는 자신의 소속을 화면으로 출력하기만 한다. Base로부터 파생된 Derived는 OutMessage 멤버 함수를 재정의하여 원래의 함수와 다른 문자열을 출력하도록 했다. main에서는 Base형의 B와 Derived형의 D를 선언하고 Base형의 포인터 pB로 이 두 객체의 번지를 차례대로 대입받은 후 포인터로 OutMessage를 호출했다.
pB가 B를 가리키는 상황에서 pB->OutMessage는 Base의 OutMessage를 호출할 것이다. 그렇다면 pB가 D를 가리킬 때는 Derived의 OutMessage를 호출할 것처럼 보인다. 이 예제를 만든 사람의 의도는 바로 이런 동작이었다. 그러나 실행해 보면 예상과는 다른 결과가 나온다.

Base Class
Base Class

앞 항에서 알아 봤다시피 부모 클래스 타입의 포인터 pB가 자식 객체 D를 가리키는 것은 문법적으로 합당하다. 그런데 pB가 D를 가리키는 상황에서 멤버 함수 호출은 왜 Base의 것이 호출되는가? 그 이유는 컴파일러가 포인터의 정적 타입을 보고 이 타입에 맞는 멤버 함수를 호출하기 때문이다. pB가 Base * 타입으로 선언되어 있이므로 Base의 멤버 함수를 호출하는 것이다.
이것은 원하는 결과가 아니다. 이 예제가 의도하는 바는 pB가 선언된 포인터 타입(정적 타입)에 따라 멤버 함수를 선택하는 것이 아니라 pB가 가리키고 있는 객체의 타입(동적 타입)에 따라 멤버 함수가 선택되도록 하는 것이다. pB가 Base *로 선언되었지만 Derived의 객체를 가리키고 있을 때는 Derived의 멤버 함수가 호출되도록 하고 싶다. 이렇게 하고 싶다면 원하는 함수의 선언문에 virtual 키워드를 붙여 이 함수를 가상 함수로 선언한다. 예제를 다음과 같이 수정해 보자.

class Base
{
public:
     virtual void OutMessage() { printf("Base Class\n"); }
};

class Derived : public Base
{
public:
     virtual void OutMessage() { printf("Derived Class\n"); }
};

부모의 멤버 함수가 가상 함수이면 자식의 멤버 함수도 자동으로 가상 함수가 되므로 Derived의 OutMessage에는 굳이 virtual 키워드를 쓰지 않아도 되지만 이 함수가 가상 함수라는 것을 분명히 표시하기 위해 양쪽에 모두 붙이는 것이 더 좋다. virtual 키워드는 클래스 선언문 내에서만 쓸 수 있으며 함수 정의부에서는 쓸 수 없다. 정의부에 virtual을 쓰면 에러 처리되므로 함수를 외부 정의할 때는 virtual 키워드없이 함수의 본체만 기술해야 한다.
이렇게 가상 함수로 선언하면 포인터의 타입이 아닌 포인터가 가리키는 객체의 타입에 따라 멤버 함수를 선택하므로 원하는 결과가 나온다. 즉, 가상 함수란 포인터의 정적 타입이 아닌 동적 타입을 따르는 함수이다. 수정 후의 출력 결과는 다음과 같다.

Base Class
Derived Class

OutMessage 함수가 가상으로 선언되었으므로 pB가 가리키는 객체의 타입에 따라 누구의 멤버 함수를 호출할 것인가가 결정된다. 이 예제의 경우 객체를 가지고 있는 상황에서 포인터로 간접 호출할 필요는 사실 없다. D.OutMessage()라고 호출하면 되는 것이다. 그러나 객체를 함수의 인수로 전달하거나 객체의 배열을 작성할 때는 사정이 다르다. 객체는 덩치가 커서 통상 포인터로 전달하므로 객체 포인터로 멤버 함수를 호출하는 경우가 오히려 더 흔하다고 할 수 있다. 다음은 예제의 모양을 조금 변경하여 클래스 타입의 포인터를 받아들이는 함수를 작성해 보자.

  : VirtFunc2
#include <Turboc.h>

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 함수를 호출한다. main에서 &B에 대해 그리고 &D에 대해 Message 함수를 두 번 호출했는데 전달되는 객체 타입에 따라 실제 호출될 OutMessage 함수가 달라진다. 만약 OutMessage가 가상 함수가 아니라면 Message 함수는 Base의 멤버 함수만 호출하므로 결과는 항상 "Base Class"가 될 것이다.
OutMessage 함수가 가상으로 선언되어 있으므로 형식 인수 pB가 전달받는 객체의 타입에 따라 호출될 함수가 결정된다. Message 함수의 본체 코드는 완전히 똑같은데 전달되는 객체에 따라 실제 동작은 달라진다. pB->OutMessage() 라는 코드가 경우에 따라 다른 동작을 할 수 있는 능력, 이것이 바로 다형성의 개념이다.
부모 클래스형의 포인터로부터 멤버 함수를 호출할 때 비가상 함수는 포인터가 어떤 객체를 가리키는가에 상관없이 항상 포인터 타입 클래스의 멤버 함수를 호출한다. 반면 가상 함수는 포인터가 가리키는 실제 객체의 함수를 호출한다는 점이 다르다. 그래서 파생클래스에서 재정의하는 멤버 함수, 또는 앞으로라도 재정의할 가능성이 있는 멤버 함수는 가상으로 선언하는 것이 좋다. 그래야 부모 클래스의 포인터 타입으로 자식 객체의 멤버 함수를 호출해도 정확하게 호출된다.


30-1-다.동적 결합

가상 함수는 자신을 호출하는 객체의 타입, 즉 동적 타입에 따라 실제 호출될 함수가 결정된다. 이해하기 어려운 동작은 아니지만 컴파일러가 가상 함수 호출문을 어떻게 번역하는가를 생각해 보면 다소 이상한 점을 발견할 수 있다. 다음의 일반적인 함수 호출문을 보자.

gotoxy(...);
printf(...);

컴파일러는 gotoxy 함수가 어떤 주소에 있는지 알고 있으며 그래서 gotoxy 호출문을 이 함수의 주소로 점프하는 코드로 번역할 것이다. 컴파일하는 시점(정확하게는 링크 시점)에 이미 어디로 갈 것인가가 결정되는 이런 결합 방법을 정적 결합(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가 가리키는 객체의 타입에 따라 적절한 함수를 선택해서 점프하는 코드로 번역해야 하는 것이다. 이렇게 해야 전달된 객체에 따라 각기 다른 동작을 할 수 있는 다형성을 구현할 수 있다.
동적 결합은 멤버 함수를 포인터(또는 레퍼런스)로 호출할 때만 동작한다. 객체로부터 함수를 호출할 때는 설사 그 함수가 가상 함수라 할 지라도 컴파일 시에 호출할 함수를 정확하게 결정할 수 있다. 왜냐하면 객체는 자신이 소속된 클래스 타입일 뿐이지 다른 타입이 될 수 없기 때문이다. 포인터는 부모 타입의 포인터가 자식을 가리킬 수 있기 때문에 정확한 함수를 호출하기 위해 동적 결합을 해야 하지만 객체로 직접 호출할 때는 호출 객체의 타입을 분명히 알 수 있으므로 그럴 필요가 없다. 다음 코드는 어떤 함수를 호출할지 분명히 알 수 있다.

B.OutMessage();
D.OutMessage();

만약 누군가가 여러분에게 가상 함수란 무엇인가라는 질문을 한다면 어떻게 대답할 수 있겠는가? 이 질문에 대한 가장 짧고도 정확한 대답은 "동적 결합을 하는 함수"일 것이다. 사실 가상 함수를 칭하는 vitrual이라는 키워드는 상당히 잘못 선택되었으며 이 말을 한국어로 번역한 결과인 "가상"이라는 말도 마찬가지이다. 가상이라는 말은 "~이 아니다"라는 뜻이므로 이 말을 처음 들었을 때 누구나 함수가 아닌 것처럼 오해할 소지가 있으며 독자의 직관력을 전혀 쓸모없게 만들어 버린다. virtual이라는 용어에 뜻이 분명히 표현되지 않으므로써 안그래도 어려운 개념을 더 어렵게 만든다. "가상"이라는 단어에서 의미를 찾으려고 하면 헷갈리기만 할 뿐이다.
차라리 애초부터 "동적 결합 함수"라고 칭하고 runbinding이나 dynamic 또는 overridable같은 키워드를 사용했더라면 훨씬 이해하기 쉬웠을 것이다. 이 함수에 virtual이라는 용어를 쓴 이유는 전통적인 함수처럼 정적 결합을 하지 않으며 파생 클래스가 재정의해도 안전하다는 뜻이다. virtual이라는 용어의 또 다른 잘못된 사용예는 가상 기반 클래스를 지정할 때 사용되는 virtual 키워드이다. 이때 사용된 virtual과 가상 함수로 지정할 때 사용되는 virtual은 아무런 연관이 없고 비슷도 아니하다. 그래서 더 혼란스럽다. 이 문제는 여러 번 지적되었지만 표준 위원회는 새로운 키워드 도입에 대해 사용자의 명칭 선택권을 제한할 수도 있다는 이유로 부정적인 입장이다. C언어에도 이런 예가 있는데 정적변수와 외부 정적변수를 선언할 때 사용하는 키워드 static도 사실은 키워드를 중복해서 사용하는 것이다.



30-2.가상 함수의 활용

30-2-가.객체의 집합 관리

가상 함수의 정의와 동작 그리고 내부적인 구현 방법까지 알아봤는데 이런 가상 함수를 어떻게 잘 활용할 수 있을지 연구해 보자. 가상 함수를 꼭 사용해야 하는 경우와 그렇지 않은 경우가 있는데 결론만 얘기하자면 동적 결합이 필요할 때 가상 함수를 사용하고 그렇지 않을 경우는 비가상 함수를 사용하면 된다. 그러나 이런 지침만으로 가상 함수를 사용해야 할 시점을 정확하게 선정하기란 쉽지 않으므로 가상 함수를 제대로 활용하는 몇 가지 예들을 구경해 보도록 하자.
예를 구경해 보면 가상 함수의 정의와 필요성, 그리고 장점에 대해 확실하게 느낄 수 있다. 다음 예제는 여러 가지 도형을 그리고 관리하는 그래픽 편집 프로그램의 구현 예이다. 각각의 그래픽 객체들은 Graphic 클래스로부터 파생되는 클래스의 객체로 표현하며 모두 Draw라는 멤버 함수를 가지고 있어 스스로 자신을 그릴 수 있다. 물론 콘솔 환경에서 진짜 그래픽을 그릴 수는 없으므로 문자열을 출력하는 것으로 그래픽 출력 흉내만 낸다.

  : GraphicObject
#include <Turboc.h>

class Graphic
{
public:
     void Draw() { puts("그래픽 오브젝트입니다."); }
};

class Line : public Graphic
{
public:
     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 Line(),new Graphic(),new Circle() };
     int i;

     for (i=0;i<10;i++) {
          ar[i]->Draw();
     }
     for (i=0;i<10;i++) {
          delete ar[i];
     }
}

4개의 클래스가 정의되어 있는데 그래픽 클래스의 계층은 다음과 같다.
사용자는 마우스를 사용하여 그래픽 객체들을 그리고 이동시키고 편집할 것이며 프로그램은 사용자에 의해 생성되는 그래픽 객체의 집합을 관리하기 위해 동적 배열이나 연결 리스트를 사용해야 한다. 이 예제는 객체의 집합을 관리하기 위해 크기 10의 Graphic *형 배열을 선언하고 이 배열에 Graphic 파생 클래스의 객체 포인터를 저장했다. 부모형의 포인터가 자식 객체를 가리킬 수 있으므로 최상위 클래스인 Graphic의 포인터 배열을 선언하면 모든 그래픽 객체의 집합을 관리할 수 있다.
이때 Graphic *는 모든 자식 클래스를 대표하는 대표 타입이며 이 타입의 배열은 모든 자식 객체들의 번지를 저장할 수 있다. 실제 프로그램이라면 이 배열의 크기는 동적으로 관리될 것이고 배열내의 객체들을 편집하는 기능을 제공해야 할 것이다. 예제의 ar 배열 초기식은 사용자가 이런 객체들을 만들어 놓은 상황을 가정하기 위한 것이다.
이렇게 만들어진 객체의 집합을 화면으로 출력하고자 한다면 루프를 돌며 배열에 저장된 객체의 포인터를 꺼내 각 객체의 Draw 멤버 함수를 호출하면 된다. 모든 객체들은 스스로를 그릴 수 있는 Draw 멤버 함수를 가지고 있다. 그러나 실행해 보면 원하는 결과는 나오지 않을 것이며 "그래픽 오브젝트입니다"만 10번 출력된다.
왜 이렇게 출력되는가 하면 ar 배열이 Graphic * 타입을 요소로 가지므로 ar[i]에 의해 호출되는 Draw는 항상 Graphic::Draw로 정적 결합되기 때문이다. 이 문제를 해결하려면 앞에서 배운대로 Draw 멤버 함수를 가상 함수로 선언하면 된다. Graphic::Draw앞에만 virtual을 붙이면 파생 클래스도 자동으로 가상이 된다. 물론 원칙대로 하자면 모든 파생 클래스의 Draw에도 virtual을 붙이는 것이 좋다.

class Graphic
{
public:
    virtual void Draw() { puts("그래픽 오브젝트입니다."); }
};

이렇게 하면 컴파일러가 각 클래스의 Draw 함수 번지를 vtable에 작성하고 생성되는 모든 객체에 vptr을 붙여 동적 결합을 위한 준비를 한다. Draw 함수는 자신을 호출하는 객체의 타입에 맞는 버전으로 선택(동적 결합)될 것이고 배열에 저장된 객체들이 제대로 그려진다.

그래픽 오브젝트입니다.
요건 사각형입니다.
동그라미 그렸다 치고.
요건 사각형입니다.
선을 긋습니다.
선을 긋습니다.
요건 사각형입니다.
선을 긋습니다.
그래픽 오브젝트입니다.
동그라미 그렸다 치고.

똑같은 ar[i]->Draw() 호출임에도 ar[i]가 가리키는 동적 타입에 따라 실제로 그려지는 모양은 달라지는데 그래서 가상 함수의 동작이 다형적이라고 하는 것이다.
만약 동적 결합을 하는 가상 함수라는 장치가 없다면 똑같은 호출로 다양한 도형을 그릴 수가 없다. 각 객체에 스스로의 타입을 판별할 수 있는 별도의 열거형 멤버를 추가하고 이 멤버로부터 타입을 판별하여 자신을 그릴 멤버 함수를 결정하는 다중 분기를 해야 할 것이다.

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 파생 클래스에 대해서는 모두 정확하게 동작할 뿐만 아니라 미래에 새로운 클래스가 추가되더라도 이 코드는 더 이상 고칠 필요가 없어진다. 과연 그런지 삼각형 도형을 추가해 보자.

class Triangle : public Graphic
{
public:
     void Draw() { puts("나는 새로 추가된 삼각형이다."); }
};

이 클래스를 추가하고 main의 ar 배열에 삼각형 도형 생성문을 하나 작성한 후 실행해 보면 삼각형 도형도 잘 그려짐을 확인할 수 있다. 실제 도형을 그리는 코드인 ar[i]->Draw()는 그대로 사용할 수 있으며 전혀 편집할 필요가 없다. 심지어 이 코드가 이미 컴파일되어 있어도 확장성에는 아무 문제가 없다. vptr로부터 vtable을 찾고 vtable에서 호출할 함수를 찾는 논리는 동일하므로 참조하는 vtable만 새로 추가된 도형의 것으로 바뀌면 된다. 프로그램을 확장하려면 클래스는 계속 늘려야겠지만 객체들을 관리하는 코드는 더 이상 수정하지 않아도 되는 것이다.
처음부터 클래스 계층을 조직적으로 설계하고 가상 함수를 잘 작성해 놓으면 코드 관리의 유연성이 극적으로 향상된다. 실제로 이런 그래픽을 그리고 관리하는 대표적인 프로그램인 파워포인터의 경우를 보자. 이 프로그램은 다양한 각약 각색의 도형들을 그리고 관리할 수 있다.
이 프로그램의 내부에는 모든 도형들을 대표할 수 있는 클래스 타입(예를 들면 Graphic이나 Shape 등)이 선언되어 있을 것이고 각 도형들은 이 클래스의 파생 클래스로 표현될 것이다. 파생 클래스들은 도형 관리에 필요한 모든 멤버 함수를 도형에 맞는 가상 함수로 정의하고 있다. 그래서 똑같은 방법으로 가상 함수만 호출하면 모든 도형을 일관된 방법으로 관리할 수 있는 것이다. 그리기 뿐만 아니라 도형을 편집하는 코드들도 모두 마찬가지이다.

마우스 드래그 시 : Move 가상 함수 호출
트래커 드래그 시 : Resize 가상 함수 호출
더블클릭시 : SetProperty 가상 함수 호출

만약 이런 식으로 가상 함수를 사용하지 않는다면 수많은 도형에 대해 또한 각 동작에 대해 if else if나 switch case로 관리해야 하는데 이는 너무 너무 비효율적이고 복잡하다. 클래스 계층을 잘 만들어 놓고 파생 클래스가 적절히 가상 함수를 재정의하면 도형의 종류에 상관없이 필요한 가상 함수만 호출하여 도형에 따라 다형적으로 동작할 수 있다. 가상 함수를 만들어 놓으면 이후 추가되는 도형도 Graphic으로부터 상속받고 가상 함수만 재정의하면 된다. 관리 코드를 완벽하게 작성해 놓고 클래스만 늘려가면 대규모의 프로그램을 쉽게 만들 수 있다.
구현이 조금씩 다른 객체의 집합을 관리할 때는 가상 함수를 꼭 사용해야 한다. 객체에 따라 달라지는 동작을 결정하는 작업은 개발자가 직접 할 필요가 없으며 컴파일러가 동적 결합을 위한 모든 준비를 하고 실행중에 적합한 함수를 호출할 것이다. 가상 함수를 쓰기 위해서는 클래스 계층이 있어야 한다. 그래서 다형성의 전제 조건이 바로 상속인 것이다.



30-2-나.멤버 함수가 호출하는 함수

다음 예제는 앞 장에서 구경해 본 적이 있는 이차 상속 예제를 약간 변형한 것인데 가상 함수의 또 다른 활용예를 보여 준다.

  : MemCallMem
#include <Turboc.h>
#include <math.h>

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);putch(ch);
     }
     virtual void Hide() {
          gotoxy(x,y);putch(' ');
     }
     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));
              putch(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));
              putch(' ');
          }
     }
};

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

점을 표현하는 Point 클래스로부터 Circle 클래스를 파생시켰으며 Point 클래스에 점을 이동시키는 Move 함수가 정의되어 있다. 예제를 실행하면 (1,1)에 점이 찍히고 (10,10)에 반지름 5의 원이 그려지며 키보드를 누르면 점과 원이 각각 오른쪽으로 이동할 것이다. Circle 클래스는 Move를 재정의하지 않고 그대로 상속했음에도 불구하고 잘 이동된다.
도형을 움직이는 Move 함수의 원리는 어떤 도형에서나 원칙적으로 동일하다. 원래 자리에 그려져 있던 도형을 지우고 위치를 옮긴 후 다시 그리면 되는데 Point::Move는 이 원칙대로 멤버 함수를 호출하고 있다. Hide를 호출하여 점을 숨기고 인수로 전달된 nx, ny로 좌표를 옮긴 후 Show 함수를 호출하여 새로 이동한 좌표에 점을 다시 그린다. 원을 이동시키는 방법도 이와 전혀 틀리지 않기 때문에 Circle 클래스가 Move 함수를 별도로 다시 정의할 필요가 없는 것이다.
그러나 아무리 코드가 같더라도 Move 함수가 완전히 같을 수는 없다. 원을 옮기는 절차와 점을 옮기는 절차는 같지만 Move 함수 내부에서 호출하는 Show, Hide는 도형마다 달라야 한다. 즉, 이 두 함수가 객체의 타입에 따라 동적 결합을 하지 않으면 이 예제는 제대로 동작하지 않는다. 과연 그런지 Show, Hide의 virtual 선언을 삭제한 후 테스트해 보자. 점은 제대로 이동하지만 원은 이동하지 않을 것이다. 왜냐하면 Circle이 상속받은 Move에서 호출하는 Show, Hide가 Point의 것이기 때문이다. 그래서 원을 다시 그릴 때 중심점만 그려진다. 이 상태에서 문제를 해결하려면 Circle 클래스에도 Point와 똑같은 코드를 가지는 Move 함수를 작성해야 한다.

class Circle : public Point
{
     ....
     void Move(int nx,int ny) {
          Hide();
          x=nx;
          y=ny;
          Show();
     }
};

이렇게 하면 Circle::Move에서 호출하는 Hide, Show는 Circle의 멤버 함수가 되므로 원도 제대로 이동할 것이다. 그러나 보다시피 단 한 글자도 틀리지 않는 코드를 상속받지 않고 재정의한다는 것은 분명히 낭비이며 똑같은 코드가 두 군데 있다는 것은 어느 모로 보나 좋지 않다. 그래서 Move는 그대로 상속받고 이 함수 내부에서 호출하는 Show, Hide를 가상으로 선언하여 호출된 객체의 타입에 따라 적합한 Show, Hide가 호출되도록 하는 것이다. 이렇게 하면 Show, Hide가 호출된 객체의 타입에 따라 다형적으로 동작한다.
앞에서 동적 결합은 클래스 타입의 포인터로부터 호출할 때만 동작한다고 했었다. 이 경우 Move에서 호출하는 Show, Hide는 포인터와 상관없이 그냥 단순히 멤버 함수를 호출하는 것처럼 보인다. 이런 의심이 가는 사람은 잠시 깜박한 것이 있는데 모든 멤버 함수들에게 숨겨진 this가 전달되고 멤버 함수 내에서 멤버의 참조문앞에는 암시적으로 this->가 숨겨져 있다는 것을 상기해 보자. this는 분명히 호출 객체의 포인터이므로 이 포인터로부터 호출되는 Show, Hide 가상 함수는 동적 결합되어야 마땅하다.
그렇다면 Move 함수를 가상으로 선언하는 것은 어떤 효과가 있을까? 직접 테스트해 보면 알겠지만 Move가 가상인 것은 문제 해결에 아무런 도움이 되지 않는다. Move의 코드는 어차피 같으므로 어떤 클래스의 멤버 함수가 결합되나 전혀 다르지 않다. 중요한 것은 객체별로 달라야 하는 동작인 Show, Hide가 동적 결합을 하느냐 아니냐이다.
멤버 함수내에서 세부 구현을 위해 호출되어야 하는 또 다른 멤버 함수가 클래스별로 다르게 정의되어 있다면 이 함수도 가상 함수가 되어야 한다. 그래야 암시적으로 전달되는 this객체의 타입에 따라 정확한 함수가 호출된다.


30-2-다.재정의 가능한 함수

클래스는 스스로의 상태를 저장하고 동작에 필요한 모든 것들을 다 가질 수 있기 때문에 재활용성이 아주 높으며 사용하기도 쉽고 안전하다. 이런 클래스들을 자신이 일일이 만들지 않더라도 실력있는 개발자가 미리 완성해 놓은 클래스를 구할 수만 있다면 자신의 프로젝트에 조립해 넣을 수도 있다. 철수가 만들었건 영희가 만들었건 바다 건너 마이클이나 캐빈이 만들었건 그런건 몰라도 인터페이스대로 사용하기만 하면 필요한 기능을 공짜로 쓸 수 있다.
이런 면에서 볼 때 클래스라는 것은 확실히 편리하고 우수한 개발 방법임이 분명하다. 그러나 클래스를 아무리 범용적으로 작성한다 하더라도 세상의 모든 문제들에 다 적용될 수 있을만큼 일반적일 수는 없다. 개발 중에 실제로 만나는 문제는 너무 너무 특수해서 남이 만든 클래스를 약간씩은 수정해 가며 써야 할 경우가 많다. 이런 경우 클래스 개발자는 수정이 예상되는 기능에 대해서는 가상 함수로 선언해 둔다. 사용자는 수정이 필요없을 때는 이 클래스를 그냥 쓰고 수정할 필요가 있으면 상속받은 후 가상 함수를 재정의하면 된다.
앞 장에서 동적 배열을 관리하는 DArray라는 클래스를 만들어 본 바 있다. 이 클래스에는 동적 배열을 관리하는 모든 속성과 동작들이 정의되어 있어 DArray 객체를 선언하고 Insert, Delete 따위의 멤버 함수만 호출하면 신축성있는 배열을 쉽게 활용할 수 있다. 그런데 이 클래스의 Dump 기능이 다소 마음에 안든다거나 자신의 목적에 맞지 않다면 DArray를 상속받은 후 Dump 함수를 재정의할 수 있다. 다음 예제는 상속 후 재정의하는 기본적인 방법을 보여준다.

  : Overridable
=========== DArray 클래스 정의는 생략 ===========

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()
{
     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 예제와 동일하므로 소스 리스트를 생략했다. DArray 클래스로부터 새로운 클래스 MyDArray를 파생시킨 후 Dump 멤버 함수만 재정의했다. 생성자는 원래 상속되지 않으므로 파생 클래스마다 따로 만들어야 한다. 실행 결과는 다음과 같다.

8개 추가 : 개수가 8개다. 나머진 몰라도 돼!
10 삽입 : 개수가 9개다. 나머진 몰라도 돼!

삽입이나 삭제, 동적 메모리 관리 등의 모든 기능은 기반 클래스인 DArray의 것을 그대로 사용하므로 MyDArray는 동적 배열의 모든 기능을 상속받는다. 다만 Dump 함수만 재정의하여 배열을 출력하는 방식만 다를 뿐이다. 이런 식으로 재정의가 필요한 함수는 일단 상속 받은 후 원하는대로 뜯어 고칠 수 있다.
그러나 이 예제는 아직 문제가 있다. MyDArray 객체를 생성한 후 이 객체로부터 Dump를 호출할 때는 재정의된 Dump 함수가 호출되지만 DArray 타입의 포인터로부터 호출할 때는 비록 호출한 객체는 MyDArray 타입의 객체이더라도 DArray의 Dump 함수가 호출된다. main 함수의 테스트 코드를 다음과 같이 수정해 보자.

void main()
{
     MyDArray ar(10,5);
     int i;

     DArray *p=&ar;
     for (i=1;i<=8;i++) p->Append(i);p->Dump("8개 추가");
     p->Insert(3,10);p->Dump("10 삽입");
}

DArray * 타입의 p를 선언한 후 이 포인터 변수에 ar 객체의 번지를 대입했다. 이 상태에서 p로부터 Dump를 호출하면 포인터의 타입에 따라 DArray::Dump가 호출된다. 왜냐하면 Dump 함수가 정적 결합을 하기 때문이다. p를 MyDArray *타입으로 바꾸면 일단은 잘 실행되나 이렇게 선언된 p는 MyDArray객체만 가리킬 수 있어 정확한 해결책이 아니다. 포인터는 이것 저것 바꿔 가며 가리키는 것이 본래의 기능이므로 가급적 많은 객체를 가리키도록 상위의 타입을 선택하는 것이 좋다. 이 문제를 해결하려면 Dump 함수가 동적 결합을 하도록 가상 함수로 선언해야 한다. DArray 클래스 선언문의 Dump 함수를 virtual로 선언하면 문제가 해결된다.

class DArray
{
     ....
     virtual void Dump(char *sMark);
};

꼭 필요한 것은 아니지만 MyDArray의 Dump 함수 앞에도 virtual 키워드를 붙이는 것이 코드를 읽는 사람을 위해 좋다. 이제 MyDArray는 Dump 함수만 제외하고 DArray의 모든 기능을 완전히 상속받았으며 객체로부터 호출하나 포인터로부터 호출하나 항상 정확한 함수가 선택된다. Dump 함수뿐만 아니라 Insert, Delete, Append등도 모두 재정의될 가능성이 있으므로 가상 함수여야 한다. 이런 예는 앞의 예제에서도 찾을 수 있는데 InheritStudet 예제에 다음 테스트 코드를 작성해 보자.

void main()
{
     Student K("김상형",9506299);
     Human H("김기문");

     K.Intro();puts("");
     H.Intro();puts("");
}

이 예제는 Human으로부터 Student를 파생시키고 Intro 함수를 재정의하는 기법을 설명하는데 이렇게 객체로 호출할 때는 재정의된 함수가 정확하게 호출된다.

이름:김상형,학번:9506299
이름:김기문

Student 객체에 대해서는 이름과 학번이 출력되고 Human 객체에 대해서는 이름만 출력된다. 그러나 다음과 같이 포인터로 호출하면 그렇지 않다는 것을 확인할 수 있다.

void main()
{
     Student K("김상형",9506299);
     Human H("김기문");
     Human *p;

     p=&K;p->Intro();puts("");
     p=&H;p->Intro();puts("");
}

둘 다 학번이 출력되지 않고 이름만 출력될 것이다. Human 타입의 포인터 p는 자식 객체인 K를 가리킬 수 있다. 그러나 p로부터 호출되는 함수는 p의 정적 타입을 따라 가므로 p->Intro()는 항상 Human::Intro()이다. Student가 Intro와 Think를 안전하게 재정의하려면 Human이 이 두 함수를 virtual로 선언해야 한다. 그래야 포인터로부터 이 함수들을 호출하더라도 항상 정확하게 동작할 것이다. 수정한 후 테스트해 보면 p가 Student형 객체를 가리킬 때는 학번도 같이 출력된다.
남을 위해서 또는 미래의 나를 위해서 재활용성이 높은 클래스를 만들 일은 많다. 이때 재활용성을 더욱 높이려면 파생 클래스에서 재정의할 함수는 가상 함수로 선언해야 한다. 그래야 상속받은 후 이 함수를 재정의하더라도 아무런 문제가 없다. 이렇게 되면 이 클래스를 사용하는 사람은 대부분의 기능을 공짜로 쓸 수 있어서 좋고 수정이 꼭 필요한 부분은 상속받은 후 재정의할 수 있어서 더 좋다. 또한 기반 클래스로 사용되는 클래스는 자식에게 물려 주고 싶은 멤버에 대해 private보다는 protected로 선언해야 한다. 그렇지 않으면 파생 클래스가 부모의 주요 멤버를 읽지 못한다.
가상 함수의 이런 장점을 적극적으로 활용한 라이브러리가 바로 MFC이다. MFC에는 윈도우 프로그래밍에 필요한 대부분의 코드들이 클래스로 작성되어 있으며 위저드라는 툴을 사용하면 사용자가 요구하는 조건에 맞는 클래스를 조립하여 완성된 프로그램이 만들어진다. 사용자는 위저드가 만들어 준 대부분의 코드를 그대로 사용함으로써 복잡한 프로그램을 쉽게 만들 수 있으며 변경이 필요한 부분은 가상 함수를 재정의함으로써 원하는대로 작성할 수 있다.
MFC 라이브러리는 초보 개발자들도 조립식으로 쉽게 프로그램을 작성할 수 있도록 마이크로소프트의 노련한 프로그래머들이 모든 세부 코드를 미리 작성해 놓은 것이다. 그래서 이 라이브러리의 클래스를 쓰기만 하면 원하는 기능들을 쉽게 추가할 수 있다. 단, 프로그램별로 고유한 동작이 필요한 부분은 가상 함수로 선언하여 적절한 가상 함수를 찾아 재정의할 수 있도록 해 놓았다. 예를 들어 그리기 코드는 OnDraw 가상 함수에 작성하며 문서의 내용을 비우는 코드는 DeleteContents 가상 함수에 작성하면 된다. 프레임워크의 기본 동작에 변화를 주고 싶은 부분에 대해 가상 함수를 재정의하고 여기에 원하는 코드를 작성하는 식이다.
그래서 MFC를 사용한 개발 방법은 생산성이 대단히 높다. 복잡해 보이는 프로그램도 몇 번의 클릭과 가상 함수 재정의만으로 쉽게 만들 수 있다. 단, 이 라이브러리를 제대로 사용하기 위해서는 코드를 어떻게 작성하는가 뿐만 아니라 클래스의 계층 각각이 어떤 역할을 하며 추가하고 싶은 기능을 위해 어떤 함수를 재정의해야 하는가를 잘 알아야 한다. 작성하는 코드의 내용보다도 최적의 위치를 선정하는 능력이 필요한데 그러기 위해서는 클래스 계층과 프레임워크의 구조를 파악하고 있어야 한다.


30-2-라.가상 파괴자

기반 클래스의 파괴자는 반드시 가상으로 선언해야 한다. 왜 파괴자는 가상 함수여야 하는지 아주 간단한 예제를 만들어 보고 파괴자가 가상이 아닐 때 어떤 문제가 발생하는지 보자.

  : VirtDestructor
#include <Turboc.h>

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;
}

Base의 생성자는 크기 10의 문자형 배열을 동적으로 할당하며 파괴자에서 이 배열을 해제한다. Base로부터 상속받은 Derived는 생성자에서 크기 32의 정수형 배열을 할당하며 마찬가지로 파괴자에서 해제한다. 각 클래스가 동적 할당을 하고 있지만 파괴자에서 할당된 배열을 제대로 해제하고 있으므로 메모리 누수는 없을 것 같다.
과연 그런지 main에 Derived D; 선언문만 남겨 놓고 실행해 보자. 각 클래스의 생성자와 파괴자는 자신이 호출되었음을 알리기 위해 문자열을 출력한다. D 객체가 생성될 때는 부모의 생성자가 먼저 호출되고 자신의 생성자가 실행되며 파괴될 때는 반대 순서로 파괴자가 호출된다. 실행 결과는 다음과 같다.

Base 생성
Derived 생성
Derived 파괴
Base 파괴

생성자와 파괴자의 호출이 아주 정상적이다. D 객체가 완전히 생성되었을 때 이 객체는 크기 10의 char 배열과 크기 32의 int 배열을 소유할 것이며 이 배열들은 생성된 역순으로 파괴자에서 차례대로 해제된다. 그러나 여기에 포인터가 개입되면 문제가 달라진다. new 연산자로 Derived의 객체를 만들고 그 포인터를 Base * 타입의 pB에 대입하면 Derived가 생성될 때 부모와 자신의 생성자가 차례대로 호출되어 두 개의 버퍼를 동적으로 할당할 것이다.
그러나 delete pB로 이 객체를 해제할 때는 부모의 파괴자만 호출되는데 왜냐하면 pB가 Base * 타입이기 때문이다. 포인터의 타입에 따라 파괴자가 정적으로 결합되다 보니 실제로 파괴되는 객체는 Derived 타입이지만 Derived의 파괴자가 호출되지 못하는 것이다. 이렇게 되면 부모가 할당한 char 배열은 잘 해제되지만 Derived가 할당한 int 배열은 해제되지 못하는 메모리 누수가 발생한다. 실행해 보면 Derived의 파괴자가 호출되지 않는다는 것을 확인할 수 있다.

Base 생성
Derived 생성
Base 파괴

이렇게 되면 Derived가 할당한 32바이트는 회수 불가능하다. 메모리만 조금 잃어버린다면야 심각한 정도는 아니지만 하드웨어 환경 변경, 네트워크 연결, 스레드 생성 등의 더 중요한 일을 했다면 치명적인 결과를 초래할 수도 있다. 문제가 무엇인지를 알았으므로 해결하는 것은 아주 간단하다. 파괴자가 동적 결합을 하도록 가상 함수로 만들면 된다. Base에만 virtual을 붙여도 되지만 가급적이면 둘 다 붙이는 것이 좋다.

virtual ~Base() { delete [] B_buf;puts("Base 파괴"); }
....
virtual ~Derived() { delete [] D_buf;puts("Derived 파괴"); }

이런 이유로 기반 클래스로 사용될 가능성이 있는 클래스의 파괴자는 항상 가상 함수로 선언하는 것이 좋다. 물론 파괴자에서 특별한 정리 작업을 할 필요가 없다거나 파생되지 않는 클래스라면 굳이 느린 가상 파괴자를 쓰지 않아도 상관없다. 하지만 당장은 필요치 않다 하더라도 클래스란 언제든지 확장될 수 있고 누가 언제 이 클래스로부터 상속받을지 알 수 없기 때문에 가급적이면 파괴자는 가상으로 선언해 두는 것이 안전하다. 그래야 포인터로부터 삭제해도 깨끗하게 삭제되며 언제든지 클래스를 안심하고 확장할 수 있다.
앞 장에서 만들었던 Person 클래스도 동적 할당을 사용하므로 이 클래스의 파괴자도 당연히 가상이어야 하며 상속을 고려하여 private는 protected로 바꾸는 것이 좋다. 당장은 Person이 홀로 쓰이더라도 언제 이 클래스로부터 Boy, Girl 따위의 클래스가 파생될지 알 수 없기 때문이다. 파괴자에 비해 생성자는 가상이 아니어도 상관없으며 가상으로 선언할 수도 없다. 왜냐하면 객체를 생성할 때는 객체의 타입을 분명히 명시하므로 어떤 생성자가 호출될지 정확하게 결정할 수 있기 때문이다. Person3 예제의 파괴자를 가상으로 선언하도록 하자.

virtual ~Person() {
     delete [] Name;
}

그리고 파생 클래스에서 Name, Age를 읽을 수 있도록 protected로 액세스 지정을 변경한다. 이제 드디어 Person 클래스가 완벽해졌다. 생성자, 파괴자에서 동적 버퍼를 잘 관리하고 있으며 복사 생성, 대입 연산에 대해서도 안전하게 사본을 작성하며 상속 관계에서 사용될 수도 있다. Person 클래스를 제대로 만들기 위해 굉장히 긴 실습 과정을 거쳤는데 이 클래스 확장 과정은 C++ 필수 문법을 모두 포함하므로 한 번쯤 총 정리를 해 두는 것이 좋다.


30-3.순수 가상 함수

30-3-가.정의

가상 함수는 파생 클래스가 안전하게 재정의할 수 있는 함수이다. 만약 상속 관계가 아니라면 가상 함수를 선언할 필요가 없으므로 가상 함수는 상속 계층내에서만 의미가 있으며 파생 클래스에게 재정의 기회를 주기 위해 존재하는 것이라고 할 수 있다. 그러나 가상 함수를 반드시 재정의해야만 하는 것은 아니다. 기반 클래스의 동작을 그대로 쓰고 싶으면 단순히 상속만 받고 변경할 필요가 있을 때만 재정의하면 된다. 기반 클래스가 가상 함수를 만드는 이유는 혹시라도 재정의하고 포인터로 호출할 때를 대비한 것이다. 가상 함수는 재정의해도 되는 함수이지 반드시 재정의해야 하는 함수는 아니다.
이에 비해 순수 가상 함수(Pure Virtual Function)는 파생 클래스에서 반드시 재정의해야 하는 함수이다. 순수 가상 함수는 일반적으로 함수의 동작을 정의하는 본체를 가지지 않으며 따라서 이 상태에서는 호출할 수 없다. 본체가 없다는 뜻으로 함수 선언부의 끝에 =0이라는 표기를 하는데 이는 함수만 있고 코드는 비어 있다는 뜻이다. 다음 예제를 통해 순수 가상 함수의 정의를 연구해 보자.

  : PureVirt
#include <Turboc.h>

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;

//  Graphic 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)라고 한다. 추상 클래스는 동작이 정의되지 않은 멤버 함수를 가지고 있기 때문에 이 상태로는 인스턴스를 생성할 수 없다. 위 예제에서 Graphic G 선언문의 주석을 풀어 보면 순수 가상 함수가 있어 인스턴스를 만들 수 없다는 에러가 발생할 것이다.
추상 클래스의 반대 개념은 구체 클래스(Concrete Class)인데 위 예제의 Line, Circle, Rect 등이 구체 클래스의 예이며 지금까지 작성했던 일반적인 클래스들은 모두 구체 클래스이다. 이런 클래스들은 현실 세계의 선, 원, 사각형이라는 구체적인 대상을 표현하고 있으며 대상 표현을 위한 모든 속성과 동작을 포함하고 있다. 예제에서는 편의상 문자열 출력으로 그리기를 대신하는 사기 행각을 벌이고 있지만 제대로 만든다면 이 클래스들에 시작점, 끝점, 반지름, 선굵기 따위의 정보들이 포함될 것이다.
그러나 추상 클래스인 Graphic은 도형이라는 너무 일반적인 대상을 표현하기 때문에 그리기와 관련된 어떠한 정보도 가질 수 없으며 따라서 동작도 정의할 수 없다. 그러다 보니 이런 타입의 객체를 생성하는 것은 아무 의미가 없는 것이다. 그렇다면 객체를 만들지도 못하는 추상 클래스는 도대체 왜 정의하는 것일까? 추상 클래스는 Line, Circle, Rect 구체 클래스의 공동의 조상이 되어 이 객체들의 집합을 관리하기 위해 필요하다.
추상 클래스의 객체를 생성할 수는 없지만 추상 클래스 타입의 포인터를 선언할 수는 있다. 그래서 Graphic *의 배열을 선언하면 이 배열로 Graphic 파생 클래스의 객체 집합을 관리할 수 있다. 만약 Graphic이라는 추상 클래스가 없다면 Line, Circle, Rect라는 도형의 집합을 어떻게 관리할 수 있을 것인가 생각해 보라. Line을 파생해서 Circle을 만들거나 Line으로부터 Rect를 정의할 수는 없는데 왜냐하면 이 도형들은 IS A 관계가 성립하지 않기 때문이다. 이들은 형제 관계라고는 할지언정 부모 자식 관계가 될 수는 없다. 그래서 비록 객체를 생성할 수 없는 추상 클래스라도 상징적인 공동의 조상이 필요한 것이다.
추상 클래스의 또 다른 중요한 역할은 다형적인 함수의 집합을 정의하는 것이다. 예를 들어 도형이라 한다면 당연히 자신을 그릴 수 있는 기능(Draw)이 필요하고 이동(Move)할 수 있어야 하며 크기를 변경(Resize)할 수도 있을 것이다. 도형이 되기 위해 꼭 필요한 함수의 집합을 추상 클래스에 순수 가상 함수로 선언해 두면 이 클래스로부터 파생되는 도형 클래스는 이 가상 함수를 반드시 재정의해야 한다는 의무가 생긴다. 물론 그 외에 더 필요한 멤버들을 추가할 수 있음은 물론이다.
만약 파생 클래스가 추상 클래스의 순수 가상 함수를 재정의하지 않는다면 이 클래스도 여전히 추상 클래스이므로 인스턴스를 생성할 수 없다. 즉 Draw, Resize, Move 중 하나라도 할 수 없다면 이는 실세계에 존재하는 도형이 아닌 것이다. 추상 클래스는 도형이 되기 위해 필요한 기능의 목록과 원형만 정의하고 실제 구현은 파생 클래스가 재정의해야 한다. 이때 추상 클래스가 정의하는 기능 목록을 인터페이스라고 한다. 위 예제의 Graphic 추상 클래스는 개발자에게 "도형이 되기 위해서는 적어도 이 정도의 기능은 꼭 필요하다"라는 것을 강제하고 있는 것이다.
순수 가상 함수는 이러 이러한 동작이 필요하다는 것만 표현할 뿐이므로 통상 =0 로 표기하고 구체적인 동작을 기술하는 본체를 가지지 않는다. 그러나 원한다면 그리고 필요하다면 순수 가상 함수도 본체를 가질 수는 있다. 후손들이 동작하는데 공통적으로 필요한 구현이 있다면 추상 클래스의 순수 가상 함수에 이 코드를 미리 작성해 넣을 수 있다. 예를 들어 도형을 그리기 전에 화면을 먼저 지우는 준비 동작이 필요하다면 파생 클래스의 Draw가 일일이 이 작업을 하지 않도록 추상 클래스의 순수 가상 함수가 이 코드를 구현한다.

class Graphic
{
public:
     virtual void Draw()=0 { clrscr(); }
};

함수 선언문에 =0 가 있으면서도 본체가 정의되어 있다. 이럴 경우 파생 클래스의 Draw 함수들은 Graphic::Draw를 먼저 호출하여 화면을 지우는 동작을 추상 클래스의 Draw 함수에게 부탁할 수 있다. 그리고 깨끗해진 화면에서 자기가 하고 싶은 일을 하는 것이다.

class Line : public Graphic
{
public:
     virtual void Draw() { Graphic::Draw();puts("선을 긋습니다."); }
};

Graphic::Draw 함수가 화면을 지우는 준비 동작을 대신 하므로 이 함수를 먼저 호출한 후 선을 그으면 깨끗한 화면에 선이 출력될 것이다. 물론 이 예제의 경우 Graphic::Draw()보다 clrscr() 호출이 더 짧아 굳이 기반 클래스를 호출할 필요없이 파생 클래스가 직접 clrscr()을 호출하는 것이 더 편리할 것이다. 그러나 이 예의 clrscr()은 그리기에 필요한 준비 동작에 대한 비유일 뿐이며 얼마든지 복잡하고 길어질 수 있다. 그리기 준비 과정이 이보다 훨씬 더 복잡하다면 Line, Circle, Rect의 Draw들 각자가 매번 이 작업을 하는 것보다 상위 클래스인 Draw에서 딱 한 번만 하고 파생 클래스는 이 코드들을 호출하는 것이 더 효율적이고 반복을 최소화한다는 기본적인 원칙에도 부합된다.
순수 가상 함수가 본체를 가지더라도 =0로 선언되어 있기 때문에 Graphic 클래스는 여전히 추상 클래스이며 Graphic 타입의 인스턴스를 생성할 수는 없다. 화면을 지우는 것은 실제로 도형을 그리는 것이 아니라 단순히 도형을 그리기 위한 준비 동작일 뿐이므로 화면만 지워서는 제대로 된 도형이라 할 수 없기 때문이다. 순수 가상 함수가 본체를 가지는 경우는 일반적이지 않지만 파생된 구체 클래스들에게 어떤 공동의 동작을 물려 주고 싶을 때 이런 식으로 본체를 정의할 수도 있다. 순수 가상 함수의 본체는 추상 클래스 자신을 위한 것이 아니라 후손들이 공통적으로 쓸 수 있는 서브루틴을 제공하는 의미밖에 없다.

30-3-나.추상 클래스의 예

추상 클래스를 사용하는 실제 프로젝트의 예를 들어 보자. 워드 프로세서의 문서를 분석하는 기능을 캡슐화하여 클래스로 만들고자 한다. 이 클래스는 문서를 순서대로 읽으면서 문서에 속한 문단, 도표, 그림 등등의 요소를 추출해 내며 이렇게 분석한 결과는 출력, 인쇄, 다른 문서 형식으로의 변환, 검색 등에 사용될 것이다. 많이 사용되는 아래한글과 워드 문서에 대한 분석 클래스를 작성한다면 아마도 다음과 같은 멤버 함수의 목록이 만들어질 것이다.
문서라는 복잡한 대상을 분석하기 위해서는 메모리도 필요할 것이고 때로는 외부 라이브러리(XML 파서 등)의 도움이 필요하기도 하므로 분석 준비 과정이 필요하며 준비를 했으면 해제하는 과정도 당연히 필요하다. 그래서 Prepare, CleanUp 따위의 멤버 함수가 선언되어 있다. 또한 어떤 워드 프로세서 문서든지 문단으로 구성되어 있고 문단안에 도표와 그림이 있는 기본적인 구조는 동일하므로 ReadPara, ReadTable, ReadPicture 등의 함수들도 필요하다.
이 함수들의 내부 구현은 분석 대상 문서별로 상당히 다르겠지만 함수의 원형은 동일하다. 이 외에 문서 타입별로 고유한 데이터도 있을 수 있으므로 이런 데이터를 읽는 멤버 함수들도 필요할 것이다. 예를 들어 아래한글은 글맵시라는 문자 장식이 있고 워드에는 하이퍼 링크라는 것이 있다. 보다시피 두 클래스에는 중복되는 기능들이 아주 많이 있어 상위 클래스를 정의한 후 파생시킬 수 있다. Parser라는 이름으로 일반적인 분석기 클래스를 정의한다면 아마 다음과 같은 상속 계층이 만들어질 것이다.
공통되는 기능을 상위 클래스로 정의하는 것은 아주 일반적인 상속 기법이다. 자, 그럼 이때 만들어진 Parser 클래스는 과연 어떤 문서를 분석하는 클래스라고 할 수 있겠는가? 이 클래스는 단지 문서 분석기 클래스들의 공통된 부모일 뿐 실제로 세상에 존재하는 문서를 분석하는 기능을 가지지는 못한다. 왜냐하면 "문서"라는 추상적인 대상을 분석하는데 필요한 기능의 목록을 정의할 뿐이므로 구체적인 구현을 가질 수 없는 것이다.
ParseHwp나 ParseDoc 클래스는 구체적인 문서에 대한 분석 동작을 하지만 Parser는 기능이 너무 일반적이어서 이런 동작을 정의할 수 없다. 그래서 Parser의 멤버 함수들은 순수 가상 함수로 선언되어야 하며 따라서 Parser는 추상 클래스가 되는 것이다. 이 클래스의 역할은 문서 분석기가 가져야 할 필수 인터페이스의 목록을 정의한다. 만약 이후 훈민정음이나 HTML 문서에 대한 분석기를 추가해야 한다면 Parser로부터 상속받은 후 Parser가 선언한 순수 가상 함수를 반드시 재정의해야 한다.
Parser는 인터페이스 목록만 정의하고 파생 클래스는 상속 받은 가상 함수가 요구하는 구체적인 동작을 재정의할 의무를 가진다. 그래야 최소한의 요구 사항을 만족하는 문서 분석기가 될 수 있다. 이렇게 되면 Parser로부터 파생된 클래스를 사용하는 방법에 일관성이 생겨 어떤 종류의 문서 분석기든지 획일된 방법으로 사용할 수 있게 된다. 추상 클래스가 정의하는 인터페이스에 의해 복잡한 클래스 계층에 어떤 질서가 부여되는 것이다. 모든 분석기들은 공통의 조상을 가지므로 Parser * 타입으로 모든 분석기의 집합을 관리할 수 있으며 Parser * 타입의 인수를 받아들이는 함수는 임의의 분석기에 대한 다형적인 동작을 처리할 수 있다.
시간이 지난 후 이 프로젝트를 다시 분석할 때는 추상 클래스의 순수 가상 함수 목록만 봐도 프로젝트의 전체 구조를 한눈에 파악할 수 있다. 후임자에게 프로젝트를 인수하거나 팀작업을 할 때도 추상 클래스 자체가 워낙 설명적이어서 별다른 해설이 필요치 않다. 물론 그렇게 되려면 후임자나 팀원이 C++에 대한 기본 개념이 확립된 사람이어야 한다.
아주 다음에 배우게 되겠지만 COM의 인터페이스는 모든 멤버 함수들이 순수 가상 함수인 완전 추상 클래스로 정의되어 있다. COM은 재사용 가능한 컴포넌트를 정의하고 컴포넌트끼리 통신할 수 있는 방법이며 ActiveX, DirectX 등 최신 기술의 기반 문법이다.


30-3-다.유닛 추상 클래스

스타크래프트(StarCraft)라는 시뮬레이션 게임을 보면 아주 많은 유닛들이 등장한다. 갑자기 특정 게임 얘기를 꺼내 이 게임을 모르는 사람들은 당황스러울지도 모르겠지만 믿을만한 통계에 의하면 이 책을 읽는 사람의 96%는 스타크래프트를 해 본 적이 있고 나머지 3.8%는 해 보지는 않아도 게임을 알고는 있다고 하니 예를 들어도 무난할 것 같다. 게임에 등장하는 유닛들을 특성별로 클래스화한다면 아마 다음과 같은 계층이 만들어질 것이다. 실제로는 더 많은 중간 계층이 존재하겠지만 간단하게 개념적인 계층을 만들어 보자.
모든 유닛들은 특징별로 뛰어 다니는 것들, 날아다니는 것들, 땅속으로 숨을 수 있는 것들로 일차 분류할 수 있다. Running 클래스에 속한 마린과 탱크는 땅 위를 아장 아장 걸어다니고 날 수 없다는 공통점을 가지고 있으면서 또한 스팀팩, 시지 모드같은 고유의 동작을 가지기도 한다. 뮤탈, 레이스, 캐리어 등은 날아다닌다는 면에서는 공통적이므로 Flying으로부터 상속을 받으며 마찬가지로 클로킹, 쓰리쿠션, 인터셉터 발사 등등 각각의 특성들을 추가로 가질 것이다.
이런 모든 유닛들은 공통적으로 좌표와 에너지 상태라는 속성을 가지며 이동할 수 있고(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은 게임 유닛이 되기 위한 최소한의 요구 조건만을 명시하는 추상 클래스이다. 여기에 어떤 식으로 이동하고 어떤 식으로 공격하는지 좀 더 구체적인 특성이 정의되어야 게임에 등장하는 실제 유닛이 될 수 있다. 모든 유닛은 Unit으로부터 상속을 받아야 하며 Unit에 선언되어 있는 순수 가상 함수를 자신의 특성에 맞게 반드시 재정의해야 한다. 그래야 구체 클래스가 되어 객체를 만들 수 있다.
Unit 클래스와 실제 유닛들의 중간 계층인 Running, Flying 등은 뛰어다니고 날아다니는 유닛의 공통적인 특성을 표현할 뿐 실제로 구체적인 동작을 묘사할 수는 없으므로 역시 추상 클래스이다. 이 클래스들은 또 나름대로의 순수 가상 함수들을 선언하고 있을 것이다. 예를 들어 Flying은 이동시 목적지까지 바로 날아갈 수 있지만 Running은 장애물을 피해 최단 거리를 찾아 이동해야 하므로 FindShortestPath 따위의 동작을 필요로 한다.
이런 계층 구조에서 최상위의 루트 클래스인 Unit은 실제 객체를 만들지는 못하지만 모든 유닛의 대표 타입으로 사용된다. Unit * 타입의 변수를 선언하면 이 변수로 존재하는 모든 유닛을 다 가리킬 수 있다. 스타크래프트는 한 번에 12개의 유닛을 선택하여 동시에 명령을 내릴 수 있는데 이때 선택된 유닛들의 목록은 Unit *pSel[12] 배열로 간단하게 기억할 수 있다. 최상위 루트 클래스를 가리키는 포인터 타입이면 못 가리킬 유닛이 없지 않은가? 12개가 선택된 상태에서 사용자가 이동 명령을 내렸다면 이때 다음 코드로 선택된 모든 유닛에게 명령을 내릴 수 있다.

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

각각의 유닛이 목표 지점까지 이동하는 방식은 서로 다르다. 질럿은 뒤뚱 뒤뚱 걸어갈 것이고 뮤탈은 가로 질러 날아가고 캐리어는 아주 여유 부리면서 천천히 기는 듯 날아간다. 하지만 Move 함수 자체가 다형적으로 동작하기 때문에 선택된 유닛의 종류를 판단할 필요없이 Move라는 함수만 호출하면 선택 객체의 실제 Move 함수가 호출되어 정의된 특성대로 정확하게 동작할 것이다. 공격이나 사망 처리도 마찬가지로 다형적으로 동작하는 가상 함수이므로 해당 유닛의 타입을 일일이 구분할 필요없이 가상 함수만 호출하면 모든 처리는 동적으로 결합되는 가상 함수가 알아서 처리한다. 핵폭탄이 터졌을 때의 처리는 다음 루프면 된다.

for (pUnit=첫 유닛 ~ 생성된 모든 유닛까지) {
     if (pUnit->x, y 좌표가 핵폭탄 범위 안이면) {
          pUnit->Die();
     }
}

생성되어 있는 모든 유닛을 순회하면서 좌표를 점검하여 핵폭탄 사정 거리안일때 Die만 호출하면 다들 각자의 방법으로 알아서 사망하시므로 더 이상 신경쓸 게 없다. 게다가 스타크래프트가 확장되어 새로운 유닛이 추가되었다 하더라도 Unit 추상 클래스의 기본 요건을 반드시 만족해야 하므로 게임 운영 코드는 크게 수정할 필요없이 그대로 적용되는 이점도 있다.
실제로 스타크래프트라는 게임이 C++로 만들어졌는지, 다형성을 사용하는지는 확인해 본 바 없다. 그러나 분명한 것은 C++로 다형적인 코드를 작성하면 이런 게임을 쉽게 만들고 관리할 수 있다는 것이다. 그래픽 환경이라면 그럴싸한 예제를 한 번 만들어 볼 수도 있겠지만 콘솔 환경에서 Z, M, C 따위의 문자로 유닛을 표현하는데는 한계가 있어 지금은 하지 않기로 한다. 다음에 여러분들이 그래픽 환경을 배우게 되면 미니 스타크래프트를 한 번 만들어 보기 바란다.

여기까지 C++의 가장 기본적인 문법들에 대해 모두 연구해 보았다. 이 시점에서 25장에 있는 OOP의 특징들인 캡슐화, 추상화, 정보 은폐, 상속, 다형성의 개념들을 다시 한 번 읽어 보자. 처음에는 무슨 말인지 도통 이해가 가지 않았겠지만 이제 어렴풋이나마 이해가 될 것이다. 공부해 봐서 알겠지만 짧은 정의로 간단하게 설명하기는 참 어려운 개념들이다.
아직도 이해가 잘 가지 않는다면 예제를 통해 좀 더 연구해 보고 그럭 저럭 이해가 된다면 C++을 잘 모르는 친구를 앞에 앉혀 놓고 설명을 해보자. 그 친구가 알아 듣든 고개만 갸웃거리고 있든 객체 지향이란 바로 이런 것이야 라는 설명을 자신있게 할 수 있다면 현재 단계에서는 충분하다. 객체 지향 프로그래밍이 확실이 몸에 베려면 역시 좀 더 많은 실습이 필요하다.





댓글 없음:

댓글 쓰기

[Effective C++] 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 인라인 함수의 아이디어는  함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것  남발했다가는 코드의 크기가 커질 게 뻔하다. 인라인 함수로 부풀려진 ...