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