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으로부터 상속받고 가상 함수만 재정의하면 된다. 관리 코드를 완벽하게 작성해 놓고 클래스만 늘려가면 대규모의 프로그램을 쉽게 만들 수 있다.

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