3 분 소요

객체와 포인터

가상 함수란 클래스 타입의 포인터로 멤버 함수를 호출할 때 동작하는 특별한 함수이다. 객체 지향의 중요한 특징인 다형성을 구현하는 문법적 기반이 바로 가상 함수이다.
다형성은 어려운만큼 실용적인 문법이며 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에는 없는 멤버는 대입에서 제외된다. 그러나 대입은 가능하지만 우변의 정보 중 일부가 좌변에 대입되면서 사라지는 슬라이스(Slice) 문제가 발생하는 부작용이 있다.
반대로 대입인 S = H 대입은 명백한 에러로 처리된다. 물론 이 경우도 둘사이에 공통으로 존재하는 멤버만 대입하는 방법을 쓸 수 있겟지만 이렇게 되면 S가 온전한 객체가 되지 못할 확률이 크다. 일반적으로 자식 객체는 부모보다 더 많은 멤버를 가지며 이 멤버들은 서로 긴밀하게 연관되어 있을 것이다. 그런데 부모로부터 전달받은 멤버만 대입받고 이 멤버에 종속적인 다른 멤버는 바뀌지 않는다면 온전한 상태의 객체가 될 수 없다.
클래스 타입의 포인터끼리도 객체간의 관계와 동일한 규칙이 그대로 적용된다. 클래스는 타입이므로 클래스형 객체를 가리킬 수 있는 포인터를 선언할 수 있다. 부모 타입의 포인터와 자식 타입의 포인터가 있을 때 이 포인터가 어떤 객체의 번지를 안전하게 대입받을 수 있는지 다음 예제를 보자.

class Human
{
protected:
    char Name[16];

public:
    Human(const char *aName) { strcpy(Name, aName); }
    void Intro() { printf("이름:%s", Name); }
    void Think() { puts("오늘 점심은 뭘 먹을까?"); }
};

class Student : public Human
{
private:
    int StNum;

public:
    Student(const 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();
}

pH = &S; 의 경우는 어떨까? 일단 대입 연산자 양변의 타입이 불일치해서 문제가 될 것 같지만 컴파일해 보면 아무런 문제가 없다. 부모 타입의 포인터가 자식 객체의 번지를 대입받았는데 컴파일러가 이를 허용하는 이유는 이 대입이 논리적으로 아무런 문제가 없기 때문이다. 이렇게 대입된 포인터 pH로는 Human에 있는 멤버만 참조할 수 있으며 Human의 모든 멤버를 Student객체인 S도 가지고 있다. 그러므로 pH->Think()를 호출하든 pH->Intro()를 호출하든 전혀 이상이 없는 것이다. 학생은 사람이므로(Student is a Human) 사람의 모든 속성을 가지며 사람이 할 수 있는 모든 행동을 할 수 있다.

pS = &H;

그러나 그 반대는 성립하지 않는다. 모든 사람은 학생이 아니므로 학생이 할 수 있는 행동 중에 사람이 할 수 없는 행동도 있다. 공부한다, 시험친다는 행동은 사람중에서도 학생만이 할 수 있는 행동이다. 그래서 학생 타입의 포인터 pS에 부모 객체 H의 번지를 대입하는 것은 허락되지 않는다. 물론 맞는 타입으로 캐스팅해서 강제로 대입할 수는 있지만 논리적으로 틀린 대입이기 때문에 오동작할 위험이 높으며 그 결과는 예측할 수 없다. 예제의 끝에서 &H를 Student *로 강제 캐스팅해서 억지로 pS에 대입해 보았다. 바람직한 대입이 아니지만 캐스팅을 했기 때문에 컴파일러가 별 이의를 제기하지 않는다.
pS가 Human형 객체를 가리키고 있는 상태에서 Intro 함수를 호출하면 이때 호출되는 Intro는Student::Intro가 된다. 왜냐하면 pS가 Student * 타입이기 때문이다. 호출 포인터와 함수의 쌍이 맞기는 하므로 컴파일 에러는 아니다. 또한 구조체 멤버 참조문은 멤버의 이름으로 오프셋만 취하므로 Intro에서 StNum을 읽는다 해도 문법적으로 문제가 없다. 이 함수는 이름과 학번을 출력하는데 pS가 가리키고 있는 H 객체는 이름은 가지고 있지만 학번은 가지고 있지 않으므로 엉뚱한 쓰레기값이 출력될 것이다. 이런 대입이 때로는 아주 위험한 결과를 초래할 수도 있으므로 컴파일러는 자식 포인터 타입이 상위 클래스의 객체를 가리키지 못하도록 금지하는 것이다.

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

정리하자면 포인터로 객체를 가리킬 때 부모 클래스 타입의 포인터로 후손 객체를 가리킬 수 있지만 그 반대는 성립하지 않는다. 이런 규칙은 레퍼런스에 대해서도 그대로 적용되는데 레퍼런스도 어차피 포인터이므로 결국 같은 규칙이라 할 수 있다. 정의가 좀 길어서 외우기는 어려운데 좀 간단하게 정리해 보면 “부모는 자식을 가리킬 수 있다”가 된다. 다형성과 객체 지향을 이해하는 아주 핵심적인 문구이므로 헷갈리지 않게 꼭 외워 두도록 하자. 중요한 내용이므로 한 번 더 크게 반복한다.