일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 루아
- 비교 함수 객체
- exception
- 반복자
- Vector
- 예외
- lua
- 오블완
- effective stl
- virtual function
- implicit conversion
- 상속
- more effective c++
- 암시적 변환
- UE4
- 참조자
- Smart Pointer
- reference
- 언리얼
- Effective c++
- 영화
- 스마트 포인터
- 메타테이블
- operator new
- 게임
- 영화 리뷰
- c++
- 티스토리챌린지
- resource management class
- 다형성
Archives
- Today
- Total
스토리텔링 개발자
[More Effective C++] 31. 다중 디스패치(multiple dispatch) 본문
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, 리졸브)하는 매커니즘을 근본적으로 제어할 수 있도록 해두었다.
- 다중 메소드(multi-methods) 기능을 가진다.
- 하지만 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;
...
}
- 문제점
- lookup이 호출될 때마다 collisionMap에 멤버 함수 포인터를 넣게 된다.
- 더군다나 이 코드는 컴파일도 되지 않는다.
- 초기화는 한 번만 할 수 있도록 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);
- '충돌이 일어나기 전에 함수 포인터를 추가해야 한다'를 지키는 방법
- GameObject 파생 클래스 생성자들에 일일이
- 맵을 탐색하여 함수 포인터가 있는지 확인하고
- 없으면 생성하지 못하도록 한다.
- 다만 클래스에 대해 한 번만 해도 될 일을 모든 객체가 하기 때문에 런타임 수행 성능 저하가 발생한다.
- 타입 이름 - 충돌 함수 매핑을 담당하는 장치를 따로 만든다.
-
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 이전에 등록을 추가해주면 된다.
-
- GameObject 파생 클래스 생성자들에 일일이
728x90
'개발 > More Effective C++' 카테고리의 다른 글
[More Effective C++] 33. 추상 클래스(abstract class) (0) | 2024.10.17 |
---|---|
[More Effective C++] 32. 미래 지향적 프로그래밍 (2) | 2024.10.16 |
[More Effective C++] 30. 프록시 클래스 (0) | 2024.10.04 |
[More Effective C++] 29. 참조 카운팅 (0) | 2024.09.27 |
[More Effective C++] 28. 스마트 포인터 (0) | 2024.09.09 |
Comments