- 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴이 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예이다.
- 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
- tr1::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수 호출성 개체를 지원한다.
가상 함수 대체 방법
가상 함수 대신 사용할 방법들이 몇 가지 있다.
게임 캐릭터 클래스를 설계하는 상황을 가정해 보자.
class GameCharacter {
public:
virtual int healthValue() const;
...
};
healthValue가 순수 가상 함수로 선언되지 않을 것을 보아, 체력을 계산하는 기본 알고리즘이 제공된다는 사실을 알 수 있다. 또한, 가상 함수이니 파생 클래스에서 재정의가 가능할 것이다.
이러한 방법 말고 다른 방법을 이용하여 구현할 수 있다.
비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴
외부에서 함부로 재정의하는 것을 막기 위해서 가상 함수는 private 멤버로 두어야 한다.
그렇다면 healthValue의 인터페이스는 public으로 실제 구현은 private으로 처리하면 된다.
class GameCharacter {
public:
int healthValue() const
{
... // 사전 동작
int retVal = doHealthValue();
... // 사후 동작
}
private:
virtual int doHealthValue() const
{
...
}
};
사용자는 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 된다.
이러한 기법을 비가상 함수 인터페이스(Non-Virtual Interface: NVI)라고 한다.
NVI는 템플릿 메서드 패턴을 C++로 구현한 것이다. 이때, 비가상 함수를 가상함수의 랩퍼라고 한다.
NVI의 이점은 사전 동작과 사후 동작을 할 수 있다는 것이다. 실제 동작을 수행하는 가상 함수를 호출하기 전과 호출된 후에 코드를 작성할 수 있다.
예를 들면 mutex 잠금, 로깅, 오류 검증 등의 작업을 실행할 수 있다.
private 가상 함수를 파생 클래스에서 재정의 할 수 있다.
지금 경우에서는 private 가상 함수(doHealthValue)는 어떤한 동작을 하는지를 구현하는 함수이다.
가상 함수를 호출하는 일, 즉 동작이 수행될 시점을 지정하는 일은 public에 있는 비가상 함수(HealthValue)에서 지정하고 있다.
파생 클래스에서는 동작에 대한 구현을 결정하는 권한이 있는 것이고 기본 클래스는 시점을 결정하는 권한이 있는 것이다.
NVI에서 가상 함수는 엄격하게 private 멤버일 필요는 없다.
어떤 클래스 계통의 경우엔, 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우에 적법한 함수 호출이 되려면 가상 함수가 private 멤버가 아니라 protected 멤버이어야 한다.
함수 포인터로 구현한 전략 패턴
NVI는 public 가상 함수를 대신할 수 있는 좋은 방법이지만 눈속임과 다름없다.
실제 체력을 계산하는 데 가상 함수를 사용한다는 점은 여전하기 때문이다.
캐릭터의 체력을 계산하는 작업은 캐릭터의 타입과 분리하는 편이 좋을 것이다.
즉, 체력 계산이 굳이 캐릭터의 일부일 필요가 없게 만들 수 있다는 뜻이다.
예를 들어, 각 캐릭터 생성자에 체력 계산용 함수의 포인터를 넘기게 만들고, 이 함수를 호출하여 실제 계산을 수행하도록 할 수 있다.
class GameCharacter; // 전방 선언
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc) (const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) {}
int healthValue() const { return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
이러한 방법을 전략 패턴(Strategy)이라고 한다.
GameCharacter 클래스 계통에 가상 함수를 사용하는 방법과 비교하면 융통성을 갖고 있다.
같은 캐릭터 타입으로 만들어진 객체라 해도 체력 계산 함수를 다르게 적용할 수 있다.
class EvilBadGuy: public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc) : GameCharacter(hcf) { ... }
...
};
int loseHealthQuickly(const GameCharacter&); // 체력 계산 함수
int loseHealthSlowly(const GameCharacter&); // 체력 계산 함수
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly);
또한, 실행되는 도중에 특정 캐릭터에 대한 체력 계산 함수를 바꿀 수 있다.
예를 들어, GameCharacter 클래스에서 setHealthCalculator라는 멤버 함수를 제공한다면 이를 통해 계산하는 함수를 교체할 수 있다.
하지만, 체력 계산 함수가 이제 GameCharacter 클래스 계통의 멤버 함수가 아니기 때문에 체력 계산이 필요한 대상 객체의 비공개 데이터는 접근이 불가능하다.
예를 들어 defaultHealthCalc 함수는 EvilBadGuy 객체의 public 멤버만 접근할 수 있다.
이러한 문제는 클래스 외부에 동등한 기능을 구현하면 나타나는 문제이다.
클래스 외부에서 private 영역의 데이터에 대한 접근이 필요하다면 그 클래스의 캡슐화를 약화시키는 방법밖에 없다.
함수 포인터를 사용하여 얻는 융통성과 캡슐화의 중요도를 판단하여 설계해야 한다.
tr1::function으로 구현한 전략 패턴
체력을 계산하는데 꼭 함수가 필요하지는 않다.
tr1::function 타입의 객체를 사용하여 기존의 함수 포인터를 대신하게 만들 수 있다.
tr1::function 계열의 객체는 함수호출성 개체를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그니처와 호환된다.
(함수호출성 개체: 함수 포인터, 함수 객체, 멤버 함수 포인터)
class GameCharacter; // 전방 선언
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
// HealthCalcFunc는 함수호출성 개체, GameCharacter와 호환되는 어떤 것이든 넘겨받아 호출 가능, int와 호환되는 어떤 것이든 반환
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) {}
int healthValv() const { return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
HealthCalcFunc는 tr1::function 템플릿을 인스턴스화한 것에 대한 typedef 타입이다.
즉, 이 타입은 일반화된 함수 포인터 타입처럼 동작한다는 뜻이다.
tr1::function을 인스턴스화하기 위해 매개변수로 쓰인 시그니처를 보면 const GameCharacter에 참조자를 받고 int를 반환하는 함수이다.
이렇게 정의된 tr1::function타입으로 만들어진 객체는 호환되는 시그니처를 가진 모든 함수호출성 개체를 가질 수 있다.
즉, 매개변수 타입이 const GameCharacter&으로 변환이 가능한 타입이며, 반환 타입도 암시적으로 int로 변환될 수 있는 모든 것을 가질 수 있다.
함수 포인터를 사용했을 때랑 다른 점은 없지만 일반화된 함수 포인터를 가질 수 있게 된것이다.
short calcHealth(const GameCharacter&); // 체력 계산 함수
struct HealthCalculator { // 체력 계산 함수 객체
int operator() (const GameCharacter&) const { ... }
};
class GameLevel {
public:
float health(const GameCharacter&) const; // 클래스 멤버 함수
...
};
class EveilBadGuy: public GameCharacter {
...
};
class EyeCandyCharacter: public GameCharacter {
...
};
EvilBadGuy ebg1(calcHelth); // 체력 계산 함수 사용
EyeCandyCharacter eccl(HealthCalculator()); // 체력 계산 함수 객체 사용
GameLevel currentLevel;
...
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::health, currentLevel, _1)); // 체력 계산 멤버 함수 사용
ebg2의 체력을 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 사용해야 한다.
GameLevel::health 함수는 매개변수 하나를 받는 것으로 선언되어 있지만, 실제로는 두 개를 받는다.
GameLevel 객체 하나를 암시적으로 받아들이기 때문이다. 이 객체는 this 포인터가 가리키는 것이다.
하지만, GameCharacter 객체에 쓰는 체력 계산 함수가 받는 매개변수는 GameCharacter 객체 하나뿐이다.
그러니 ebg2의 생성자에 매개변수를 넘기기 위해서는 매개변수 두 개를 받는 함수를 매개변수 한 개만 받는 함수로 바꿔야 한다.
(GameCharacter, GameLevel -> GameCharacter)
지금 같은 경우에는 GameLevel::health 함수가 호출될 때마다 currentLevel이 사용되도록 묶어 준다.
즉, ebg2의 체력 계산 함수는 항상 currentLevel만을 GameLevel 객체로 쓴다고 지정한 것이다.
_1은 ebg2에 대해 currentLevel과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수를 뜻한다.
고전적인 전략 패턴
위의 방법 말고 고전적인 전략 패턴을 사용하여 구현할 수 있다.
체력 계산 함수를 나타내는 클래스 계통을 따로 만들고, 실제 체력 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.
class GameCharacter;
class HealthCaclFunc {
public:
...
virtual int calc(const GameCharacter& gc) const { ... }
...
};
HealthClacFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}
int healthValue() const { return pHealthCalc->calc(*this); }
...
private:
HealthCalcFunc *pHealthCalc;
};
이 방법은 HealthClacFunc 클래스 계통에 파생 클래스를 추가함으로써 기존 체력 계산 알고리즘을 조정, 개조할 수 있는 가능성이 열려있다는 장점이 있다.