35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
게임 캐릭터를 설계하는 상황이다. 캐릭터의 체력이 얼마나 남았는지 나타내는 정수 값을 반환하는 함수를 설계한다.
class GameCharacter {
public:
// 캐릭터의 체력을 반환
// 파생 클래스는 이 함수를 재정의할 수 있다.
virtual int healthValue() const;
};
healthValue가 순수 가상 함수가 아닌 점을 보아, 기본 알고리즘이 제공된다는 것을 알 수 있다. 너무나 당연한 설계이지만 이것 말고 적당한 다른 방법은 어떤게 있을까?
비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴
가상 함수는 반드시 private 멤버로 두어야 한다는 생각에서 시작되었다. healthValue를 public 멤버 함수로 그대로 두되 비가상 함수로 선언하고, 내부적으로는 실제 동작을 맡은 private 가상 함수를 호출하는 식으로 만드는 방법이다.
class GameCharacter {
public:
int healthValue() const
{
// 사전 동작을 수행
doHealthValue(); // 실제 동작을 수행
// 사후 동작을 수행
}
private:
// 파생 클래스에서는 이 함수를 재정의할 수 있다.
virtual int doHealthValue() const
{
// 캐릭터의 체력 계산을 위한 기본 알고리즘 구현
}
};
사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 방법으로, 비가상 함수 인터페이스(non-virtual interface: NVI) 관용구라고 많이 알려져 있다. 이런 방법의 이점은 가상 함수가 호출되기 전에 어떤 상태를 구성하고 가상 함수가 호출된 후에 그 상태를 없애는 작업이 비가상 멤버 함수를 통해 공간적으로 보장된다는 것이다. 사전 동작과 사후 동작이 보장된다. 만약에 사용자 쪽에서 가상 함수를 직접 호출하도록 놔두었다면, 지금처럼 사전 동작/사후 동작을 끼워 넣을 좋은 방법이 없었을 것이다.
NVI 관용구에서는 파생 클래스의 가상 함수 재정의를 허용하기 때문에, 어떤 기능을 어떻게 구현할지를 조정하는 권한은 파생 클래스가 갖게 되지만, 함수를 언제 호출할지를 결정하는 것은 기본 클래스만의 고유 권한이다.
NVI 관용구에서 가상 함수는 엄격하게 private 멤버일 필요는 없다. protected, public 이여도 괜찮지만, public 까지 오면 NVI 관용구를 적용하는 의미가 없다.
함수 포인터로 구현한 전략 패턴
NVI 관용구는 가상 함수를 사용하는 것은 여전하여 클래스 설계의 관점에서는 눈속임이나 다름없다. 조금 더 극적인 설계로 가자면, 캐릭터의 체력치를 계산하는 작업은 캐릭터의 타입과 별개로 놓는 편이 맞을 것이다. 각 캐릭터의 생성자에 체력치 계산용 함수의 포인터를 넘기게 만들고, 이 함수를 호출해서 실제 계산을 수행하도록 하면 되지 않을까?
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) 패턴의 단순한 응용 예이다. NVI와 비교하면 융통성이 더 좋다.
- 같은 캐릭터 타입으로부터 만들어진 객체(인스턴스)들도 체력치 계산 함수를 각각 다르게 가질 수 있다. 즉, 이런게 가능하다.
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);
- 게임이 실행되는 도중에 특정 캐릭터에 대한 체력치 계산 함수를 바꿀 수 있다. 예를 들어 setHealthCalculator라는 멤버 함수를 제공하여 계산 함수의 교체가 가능해지는 것이다.
하지만 체력 계산 함수가 이제 GameCharacter 클래스 계통의 멤버 함수가 아니므로, 비공개 데이터는 이 함수로 접근할 수 없다. 접근할 수 있게 하려면 캡슐화를 약화시키는 방법밖에 없다.
tr1::function으로 구현한 전력 패턴
함수 포인터 기반의 방법은 뭔가 꽉 막혀 보일 수 있다. "체력치 계산을 왜 꼭 함수가 해야 해? 그냥 함수처럼 동작하는 다른 놈(즉, 함수 객체)을 쓰면 안되나?"라고 반박하고 싶다. 혹여 반드시 함수여야 한다면, 어째서 멤버 함수는 안 되느냐는 의문도 나온다. 반환 값도 int로 바꿀 수 있는 임의의 타입이면 충분한데, 왜 꼭 int가 아니면 안되는 걸까?
이럴 때, tr1::function 타입의 객체를 써서 기존의 함수 포인터를 대신하게 만드는 순간 이 모든 것이 시원하게 사라진다. tr1::function 계열의 객체는 함수호출성 개체(callable entity)(풀어서 말하면 함수 포인터, 함수 객체 혹은 멤버 함수 포인터)를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그너처와 호환되는 시그너처를 갖고 있다.
class GameCharacter; // 전방 선언
// 체력 계산에 대한 기본 알고리즘을 구현한 함수
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
// 함수호출성 개체로서, GameCharacter와 호환되는 어떤 것이든
// 넘겨받아서 호출될 수 있으며 int와 호환되는 모든 타입의 객체를 반환한다.
typedef std::tr1::function<int(const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};
HealthCalcFunc는 tr1::function 템플릿을 인스턴스화한 것에 대한 typedef 타입이다. 다시 말해 이 타입은 일반화된 함수 포인터 타입처럼 동작한다는 뜻이다. 대상 시그너처를 그대로 읽으면 "const GameCharacter에 대한 참조자를 받고 int를 반환하는 함수"이다. 앞으로 대상 시그너처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있다.
앞의 함수 포인터와 비교하면 이제는 좀더 일반화된 함수 포인터를 물게 된다는 것이다. 융통성을 만끽할 수 있게 되었다.
short calcHealth(const GameCharacter&);
struct HealthCalculator {
int operator()(const GameCharacter&) const {}
};
class GameLevel {
public:
float health(const GameCharacter&) const;
};
class EvilBadGuy : public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{}
};
class EvilCandyCharacter : public GameCharacter {
explicit EvilCandyCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{}
};
EvilBadGuy ebg1(calcHealth);
EvilCandyCharacter ecc1(HealthCalculator());
GameLevel currentLevel;
EvilBadGuy ebg2(
std::tr1::bind(&GameLevel::health, currentLevel, _1)
)
위의 tr1::bind가 의미하는 바는, ebg2의 체력치를 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 써야 한다는 뜻이다. GameLevel:health 함수가 호출될 때마다 currentLevel이 사용되도록 묶어 준 것이다. 다시 말해, ebg2의 체력치 계산 함수는 항상 currentLevel 만을 GameLevel 객체로 쓴다고 지정한 것이다.
함수 포인터 대신에 tr1::function을 사용함으로써, 사용자가 게임 캐릭터의 체력치를 계산할 때, 시그너처가 호환되는 함수호출성 개체는 어떤 것도 원하는 대로 구사할 수 있도록 융통성을 열어 줬다는 것이다.
"고전적인 " 전략 패턴
체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력치 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.
class GameCharacter; // 전방 선언
class HealthCalcFunc {
public:
virtual int calc(const GameCharacter& gc) const
{}
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc* phcf = defaultHealthCalc)
: phealthFunc(hcf)
{}
int healthValue() const
{
return phealthFunc->calc(*this);
}
private:
HealthCalcFunc* phealthFunc;
};
이 방법은 "표준적인" 전략 패턴 구현 방법에 친숙한 경우에 빨리 이해할 수 있다는 점에서 매력적이다. 게다가 HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 기존의 체력치 계산 알고리즘을 조정/개조할 수 있는 가능성을 열어 놓았다는 점도 플러스이다.
지금까지 공부한 것들에 대한 요약
핵심은 '어떤 문제를 해결하기 위한 설계를 찾을 때 가상 함수를 대신하는 방법들도 고려해 보자'라는 것이다.
- 비가상 인터페이스 관용구(NVI 관용구)를 사용한다.
- 공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태
- 가상 함수를 함수 포인터 데이터 멤버로 대체한다.
- 군더더기 없이 전략 패턴의 핵심만을 보여주는 형태
- 가상 함수를 tr1::function 데이터 멤버로 대체하여, 호환되는 시그너처를 가진 함수호출성 개체를 사용할 수 있도록 만든다.
- 역시 전략 패턴의 한 형태
- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다.
- 전략 패턴의 전통적인 구현 형태
이것만은 잊지 말자!
- 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예이다.
- 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
- tr1::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그너처와 호환되는 모든 함수호출성 개체를 지원한다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 37: 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자 (0) | 2023.11.05 |
---|---|
[Effective C++] 36: 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물! (0) | 2023.11.05 |
[Effective C++] 34: 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 (0) | 2023.11.02 |
[Effective C++] 33: 상속된 이름을 숨기는 일은 피하자 (0) | 2023.11.02 |
[Effective C++] 32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 (1) | 2023.10.31 |