스토리텔링 개발자

[More Effective C++] 31. 다중 디스패치(multiple dispatch) 본문

개발/More Effective C++

[More Effective C++] 31. 다중 디스패치(multiple dispatch)

김디트 2024. 10. 7. 11:45
728x90

항목 31. 함수를 두 개 이상의 객체(타입)에 대해 가상 함수처럼 동작하도록 만들기

 

 

 

우주선, 정거장, 소행성 의 충돌 구현
class GameObject { ... };

// 아래의 각 타입은 충돌 타입에 따라 충돌 시 다른 동작을 한다.
class SpaceShip : public GameObject { ... };
class SpaceStation : public GameObject { ... };
class Asteroid : public GameObject { ... };

void checkForCollision(GameObject& object1, GameObject& object2)
{
    if(theyJustCollided(object1, object2))
    {
        // 아래 함수의 동작이 문제이다.
        // object1, object2의 타입에 따라 다른 구현이 필요하다.
        processCollision(object1, object2);
    }
    else
    {
        ...
    }
}
  • 만약 object1의 동적 타입으로만 충돌처리가 돌아간다면..
    • processCollision을 GameObject의 가상 함수로 선언하고
    • object1.processCollision(object2)를 호출하면 모든 문제가 해결될 것이다.
  • 하지만, 각 충돌처리를 위해선 object1과 object2의 동적 타입이 모두 필요하다.
  • 결국 오직 하나의 객체에 대해 동작하는 가상함수로는 충분하지 않다는 것이 문제..
    • 두 개 이상의 객체 타입에 대해 가상 함수처럼 동작하는 함수가 필요하다.

 

 

다중 디스패치(multiple dispatch)
  • 두 개 이상의 객체 타입에 대해 가상 함수처럼 동작하는 함수
  • C++의 일반적인 가상 함수 호출은 메시지 디스패치(message dispatch)라고 한다.
  • C++은 해당 기능을 지원하지 않으므로 가상 함수 구현부를 직접 구현해야 한다.(항목 24 참조)

 

 

 

쉬운 해결법. C++을 버리고 다른 프로그래밍 언어를 사용한다.
  • CLOS(Common Lisp Object System)
    • 다중 메소드(multi-methods) 기능을 가진다.
      • 매개변수가 몇 개이든 상관 없이 그 매개변수의 동적 타입에 다라 가상 함수처럼 동작하는 함수.
    • 더군다나 오버로딩된 다중 메소드들의 호출을 구분(resolve, 리졸브)하는 매커니즘을 근본적으로 제어할 수 있도록 해두었다.
  • 하지만 C++로 해결을 해야겠지? C++ 기술에 대한 글이니까..

 

 

해결법 1. 가상 함수와 RTTI를 사용하여 구현하기
  • 가장 흔하면서 지저분한 방법이다.
class GameObject
{
public:
    virtual void collide(GameObject& otherObject) = 0;
    ...
};

class SpaceShip : public GameObject
{
public:
    virtual void collide(GameObject& otherObject);
    ...
} // 이하 다른 자식 클래스도 동일하게 구현한다.

// 충돌 객체의 타입이 정의되어 있지 않을 때 발생시킬 예외
class CollisionWithUnknownObject
{
public:
    CollisionWithUnknownObject(GameObject& whatWeHit);
    ...
};

void SpaceShip::collide(GameObject& otherObject)
{
    const type_info& objectType = typeid(otherObject);
    
    // RTTI로 if-else 문을 줄줄이 구현하기
    if(objectType == typeid(SpaceShip))
    {
        SpaceShip& ss = static_cast<SpaceShip&>(otherObject);
        
        우주선 - 우주선 충돌 처리;
    }
    else if(objectType == typeid(SpaceStation))
    {
        SpaceStation& ss = static_cast<SpaceStation&>(otherObject);
        
        우주선 - 우주정거정 충돌 처리;
    }
    else if(objectType == typeid(Asteroid))
    {
        Asteroid& a = static_cast<Asteroid&>(otherObject);
        
        우주선 - 소행성 충돌 처리;
    }
    else
    {
        throw CollisionWithUnknownObject(otherObject);
    }
}
  • 충돌한 두 객체 타입 중 하나는 가상 함수로 처리하고, 나머지 하나만 if-else로 처리하는 방법이다.
  • 하지만 이는 캡슐화를 포기하는 방법이다.
    • collide 멤버 함수는 자신의 형제 클래스를 모두 알고 있어야 한다.
    • 클래스 구조가 바뀔 때마다 유지 보수가 필요한 코드라는 의미이다.

 

 

해결법 2. 가상 함수만 사용하여 구현하기

 

  • RTTI가 위험하다면, 그걸 제거해보자.
class SpaceShip;
class SpaceStation;
class Asteroid;
class GameObject
{
public:
    virtual void collide(GameObject& otherObject) = 0;
    virtual void collide(SpaceShip& otherObject) = 0;
    virtual void collide(SpaceStation& otherObject) = 0;
    virtual void collide(Asteroid& otherObject) = 0;
    ...
};

class SpaceShip : public GameObject
{
public:
    virtual void collide(GameObject& otherObject);
    virtual void collide(SpaceShip& otherObject);
    virtual void collide(SpaceStation& otherObject);
    virtual void collide(Asteroid& otherObject);
    ...
};

void SpaceShip::collide(GameObject& otehrObject)
{
    // 매개변수만 반전된 재귀호출처럼 보이지만, 그렇지 않다.
    // *this의 타입이 SpaceShip으로 결정된 상태이므로
    // void Collide(SpaceShip& otherObject); 가 호출된다.
    otherObject.collide(*this);
}

 void SpaceShip::collide(SpaceShip& otherObject)
 {
     우주선 - 우주선 충돌 처리;
 }
 void SpaceShip::collide(SpaceStation& otherObject)
 {
     우주선 - 우주 정거장 충돌 처리;
 }
 void SpaceShip::collide(Asteroid& otherObject)
 {
     우주선 - 소행성 충돌 처리;
 }
  • 첫 번째 방법보다는 깔끔하지만, 역시 문제점이 있다.
    • 모든 클래스가 다른 형제 클래스를 모두 알고 있어야 한다는 전제 조건이 따라붙는다는 점이다.
    • 역시나 클래스 상속 구조에 변경이 있다면 유지보수가 필요하다.
  • 유지보수를 할 수 없는 상황이 될 수도 있지 않겠는가?
    • 라이브러리로 배포를 했다면, 해당 클래스는 사용자 측에서 도저히 구현을 수정할 수 없다.
    • 수정할 수 있다 치더라도, 다시 컴파일하는 데는 어마어마한 비용이 든다.

 

 

 

해결법 3. 가상 함수 테이블을 유사구현(emulation)하기
  • 가상 함수를 사용할 때는
    • vtbl을 사용하기 때문에 if-then-else 연산이 필요없고
    • 가상 함수가 호출된 위치에 똑같은 기능의 코드를 만들어낼 수 있다.
    • 즉, 첫 번째 예제의 RTTI를 제거할 수 있다.
  • 가상 함수 호출 과정
    • vtbl 내의 인덱스를 결정하고
    • 해당 인덱스에 위치한 함수 포인터로 가상 함수 호출
  • 이 정도의 기능이라면 직접 구현도 가능할 것이다.
class GameObject
{
public:
    virtual void collide(GameObject& otehrObject) = 0;
    ...
};

class SpaceShip : public GameObject
{
public:
    virtual void collide(GameObject& otherObject);
    
    // 충돌 처리 함수
    virtual void hitSpaceShip(SpaceShip& otehrObject);
    virtual void hitSpaceStation(SpaceStation& otherObject);
    virtual void hitAsteroid(Asteroid& otehrObject);
    ...
    
private:
    // 함수 포인터
    typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
    // 클래스 이름 - 함수 포인터 맵
    typedef map<string, HitFunctionPtr> HitMap;
    
    // 호출할 함수 포인터를 결정하는 함수
    static HitFunctionPtr lookup(const GameObject& whatWeHit);
    ...
};

SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit)
{
    // 함수 내에서만 사용하며, 생성/초기화/소멸을 적절히 통제하기 위해 static으로 선언
    static HitMap collisionMap;
    
    // RTTI로 가져온 클래스 이름으로 맵 탐색
    HitMap::iterator mapEntry = collisionMap.find(typeid(whatWeHit).name());
    
    if(mapEntry == collisionMap.end())
        return nullptr; // 요소가 없으면 널 리턴
    
    return (*mapEntry).second;
}

// 충돌 처리 함수
void SpaceShip::hitSpaceShip(SpaceShip& otehrObject)
{
    우주선 - 우주선 충돌 처리;
} 
void SpaceShip::hitSpaceStation(SpaceStation& otherObject)
{
    우주선 - 정거장 충돌 처리;
}
void SpaceShip::hitAsteroid(Asteroid* otehrObject)
{
    우주선 - 소행성 충돌 처리;
}
void SpaceShip::collide(GameObject* otehrObject)
{
    HitFunctionPtr hfp = lookup(otherObject); // 호출할 함수를 찾는다.
    if(hfp)
        (this->*hfp)(otherObject); // 찾은 함수를 호출한다.
    else
        throw CollisionWithUnknownObject(otherObject);
}
  • 이제 collisionMap의 초기화를 생각해야 한다.
  • 간단하게는 아래처럼 생각할 수도 있겠지만 잘못된 방법이다.
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit)
{
    static HitMap collisionMap;
    
    collisionMap["SpaceShip"] = &hitSpaceShip;
    collisionMap["SpaceStation"] = &hitSpaceStation;
    collisionMap["Asteroid"] = &hitAsteroid;
    ...
}
  • 문제점
    1. lookup이 호출될 때마다 collisionMap에 멤버 함수 포인터를 넣게 된다.
    2. 더군다나 이 코드는 컴파일도 되지 않는다.
  • 초기화는 한 번만 할 수 있도록 static 함수를 하나 마련하자.
class SpaceShip : public GameObject
{
private:
    static HitMap initializeCollisionMap();
    ...
};

SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit)
{
    // 허나, 함수 호출을 통해 나온 HitMap이 할당과 함께 복사된다는 점이 걸린다.
    static HitMap collisionMap = initializeCollisionMap();
    ...
}
  • static 맵을 스마트 포인터로 바꾼다면 만사 해결된다.
class SpaceShip : public GameObject
{
private:
    static HitMap* initializeCollisionMap();
    ...
};

SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit)
{
    static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
    ...
};

SpaceShip::HitMap* SpaceShip::initializeCollisionMap()
{
    HitMap* phm = new HitMap;
    
    // 컴파일 에러!
    // 함수 포인터는 GameObject&를 매개변수로 받게 되어 있지만..
    // 각각의 함수들은 매개변수로 받는 형식이 자식 클래스 타입이다.
    (*phm)["SpaceShip"] = &hitSpaceShip;
    (*phm)["SpaceStation"] = &hitSpaceStation;
    (*phm)["Asteroid"] = &hitAsteroid;
    
    return phm;
}
  • reinterpret_cast를 사용해서 억지로 할당할 수는 있을 것이다.
  • 하지만 아래와 같은 문제점이 있다.
    • 타입이 명백히 다른데 같은 것으로 캐스팅하는 것은 거짓말이다.
    • 이렇게 하면 GameObject의 파생 클래스들이
      • 다중 상속이거나
      • 가상 기본 클래스를 가지고 있기라도 하면
      • 함수 호출 시 잘못된 코드를 제공한다.
  • 예를 들면 아래와 같은 메모리 구조의 클래스라면

  • D 객체에 들어 있는 네 개의 클래스 부분이 각각 모두 서로 다른 주소를 가지고 있다.
  • 그러므로 reinterpret_cast를 통해 속인 함수 포인터의 매개변수로는 잘못된 주소인, GameObject 주소부를 넘기게 된다.
    • 어떤 버그가 발생할지 예측할 수 없다..
  • 결국 각 hit 함수의 매개변수를 GameObject로 바꾸는 것이 최선의 방법이다.
    • 사실, 그렇기 때문에 collide 함수를 오버로딩 하지 않고 각 함수를 따로 만든 것이다.
class GameObject
{
public:
    virtual void collide(GameObject& otherObject) = 0;
    ...
};

class SpaceShip : public GameObject
{
public:
    virtual void collide(GameObject& otherObject);
    
    // 함수 포인터 형식과 맞춰 매개변수를 GameObject로 변경
    virtual void hitSpaceShip(GameObject& spaceShip);
    virtual void hitSpaceStation(GameObject& spaceStation);
    virtual void hitAsteroid(GameObject& asteroid);
    ...
};

SpaceShip::HitMap* SpaceShip::initializeCollisionMap()
{
    HitMap* phm = new HitMap;
    
    (*phm)["SpaceShip"] = &hitSpaceShip;
    (*phm)["SpaceStation"] = &hitSpaceStation;
    (*phm)["Asteroid"] = &hitAsteroid;
    
    return phm;
}

// dynamic_cast를 사용하여 로직을 처리한다.
void SpaceShip::hitSpaceShip(GameObject& spaceShip)
{
    SpaceShip& otehrShip = dynamic_cast<SpaceShip&>(spaceShip);
    우주선 - 우주선 충돌 처리;
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation)
{
    SpaceStation& station = dynamic_cast<SpaceStation&>(spaceStation);
    우주선 - 우주정거장 충돌 처리;
}
void SpaceShip::hitAsteroid(GameObject& asteroid)
{
    Asteroid& theAsteroid = dynamic_cast<Asteroid&>(asteroid);
    우주선 - 소행성 충돌 처리;
}

 

 

 

충돌처리 함수를 클래스 멤버로 넣지 않고 구현하는 이중 디스패치
  • 함수 포인터가 '멤버 함수에 대한 포인터'라는 점이 껄끄럽다.
    • GameObject의 새로운 파생 클래스가 게임에 추가되기라도 하면 보완해줘야 하기 때문이다.
    • 즉, 재컴파일이 필요한 여지가 있다.
  • 비멤버 함수의 포인터로 변경하면 되지 않을까?
#include "SpaceShip.h"
#include "SpaceStation.h"
#include "Asteroid.h"

// 이름 없는 네임스페이스(unnamed namespace)
// 현재의 컴파일 단위에서만 의미를 가지고, 외부에서는 볼 수 없다.
namespace
{
    void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);
    void shipStation(GameObject& spaceShip, GameObject& spaceStation);
    void asteroidStation(GameObject& asteroid, GameObject& spaceStation);
    ...
    // 매개변수가 반전되어 들어왔을 경우에 대한 대비
    void asteroidShip(GameObject& asteroid, GameObject& spaceShip)
    {
        shipAsteroid(spaceShip, asteroid);
    }
    void stationShip(GameObject& spaceStation, GameObject& spaceShip)
    {
        shipStation(spaceShip, spaceStation);
    }
    void stationAsteroid(GameObject& spaceStation, GameObject& asteroid)
    {
        asteroidStation(asteroid, spaceStation);
    }
    ...
    
    // 비멤버 함수 포인터 정의
    typedef void (*HitFunctionPtr)(GameObject& GameObject&);
    
    // 두 개의 타입으로 비멤버 함수 포인터를 가져오는 Map
    typedef map< pair<string, string>, HitFunctionPtr > HitMap;
    
    // 맵의 key를 생성하는 함수
    pair<string, string> makeStringPair(const char* s1, const char* s2)
    {
        return pair<string, string>(s1, s2);
    }
    
    // collisionMap 초기화 함수
    HitMap* initializeCollisionMap()
    {
        HitMap* phm = new HitMap();
        (*phm)[makeStringPair("SpaceShip", "Asteroid")] = &shipAsteroid;
        (*phm)[makeStringPair("SpaceShip", "SpaceStation")] = &shipStation;
        ...
        return phm;
    }
    
    // 함수 포인터를 가져오는 함수
    HitFunctionPtr lookup(const string& class1, const string& class2)
    {
        static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
        
        HitMap::iterator mapEntry = collisionMap->find(make_pair(class1, class2));
        if(mapEntry == collisionMap->end())
            return 0;
        
        return (*mapEntry).second;
    }
}

void processCollision(GameObject& object1, GameObject& object2)
{
    HitFunctionPtr phf = lookup(typeid(object1).name(), typeid(object2).name());
    if(phf)
        phf(object1, object2);
    else
        throw UnknownCollision(object1, object2); // 예외 시 두 개의 매개변수를 받는다.
}

 

 

 

클래스 상속이 유사 가상 함수 테이블에 끼치는 영향
  • 클래스 상속 구조가 더 깊어진다면?

우주선 클래스가 확장된다면?

  • shipAsteroid 함수가 호출되길 기대하지만...
    • typeid()로 가져오는 이름이 SpaceShip이 아니기 때문에 함수 포인터를 찾아오지 못한다.
  • 결국 일일이 유지 보수를 하고 재컴파일 하는 수밖에 없다..

 

 

 

유사 가상 함수 테이블의 초기화 ver 2
  • 지금까지의 맵은 런타임에 충돌처리 함수를 추가하는 등의 동적인 기능이 빠져 있다.
  • 그런 기능을 지원하기 위해서 map 클래스를 만들어보자.
class CollisionMap
{
public:
    typedef void (*HitFunctionPtr) (GameObject&, GameObject&);
    
    void addEntry(const string& type1,
                  const string& type2,
                  HitFunctionPtr collisionFunction,
                  bool symmetric = true);
    
    void removeEntry(const string& type1, const string& type2);
    
    HitFunctionPtr lookup(const string& type1, const string& type2);
    
    // 이 함수는 유일한 단 하나의 맵에 대한 참조자를 반환한다.(항목 26 참조)
    static CollisionMap& theCollisionMap();
    
private:
    // 여러 개의 맵을 생성할 수 없도록 private 처리
    CollisionMap();
    CollisionMap(const CollisionMap&);
};

// 다음처럼 동적으로 함수 포인터를 추가할 수 있게 된다.
// 주의점은, '충돌이 일어나기 전에 함수 포인터를 추가해야 한다'는 룰을 지켜야 한다는 점.
void shipAsteroid(GameObject& spaceShip), GameObject& asteroid);
CollisionMap::theCollisionMap().addEntry("SpaceShip", "Asteroid", &shipAsteroid);
  • '충돌이 일어나기 전에 함수 포인터를 추가해야 한다'를 지키는 방법
    1. GameObject 파생 클래스 생성자들에 일일이
      • 맵을 탐색하여 함수 포인터가 있는지 확인하고
      • 없으면 생성하지 못하도록 한다.
      • 다만 클래스에 대해 한 번만 해도 될 일을 모든 객체가 하기 때문에 런타임 수행 성능 저하가 발생한다.
    2. 타입 이름 - 충돌 함수 매핑을 담당하는 장치를 따로 만든다.
      • class RegisterCollisionFunction
        {
        public:
            RegisterCollisionFunction(const string& type1,
                                      const string& type2,
                                      CollisionMap::HitFunctionptr collisionFunction,
                                      bool symmetric = true)
            {
                CollisionMap::theCollisionMap().addEntry(type1, type2, collisionFunction, symmetric);
            }
        };
        
        // 다음처럼 등록하여 사용한다.
        RegisterCollisionFunction cf1("SpaceShip", "Asteroid", &shipStation);
        ...
        int main(int argc, char* argv[])
        {
            ...
        }
      • 타입이 추가되면 main 이전에 등록을 추가해주면 된다.
728x90
Comments