728x90
반응형

순수 가상 함수가 아닌 가상 함수로 선언되어 있다는 것은 기본 구현이 제공된다는 사실을 34장을 통해 알 수 있었다.

하지만, 정작 다른 작동을 해야 할 때 까먹고 재정의를 하지 않으면 디버깅이 아주 힘들어질 것이다.

이번 장은 이러한 문제를 해결하기 위해 가상 함수를 대체할 무언가가 필요하다는 내용을 담고 있다.

 

비가상 인터페이스(Non-Virtual Interface: NVI) 관용구를 통한 템플릿 메서드 패턴

가상 함수는 반드시 private 멤버로 두어야 한다고 주장하는 사람들이 제안하는 설계이다.(가상 함수 은폐론)

이 이론에 따르면, healthValue는 public 비가상 함수로 선언하고 내부적으로 실제 동작을 맡은 private 가상 함수를 호출하는 식으로 구현해야 한다.

아래의 코드를 봐보자.

class GameCharacter
{
public:
  int healthValue() const
  {
    ... // 사전 동작
    int retval = doHealthValue(); // 실제 동작
    ... // 사후 동작
    return retVal;
  }
private:
  virtual int doHealthValue() const // 파생 클래스는 이 함수를 재정의 할 수 있다.
  {
    // 캐릭터 체력 계산 로직
  }
}

지금 멤버함수의 본문이 클래스의 정의 안에 들어가 있으므로 암시적으로 인라인 함수로 선언되지만, 인라인 함수를 말하고자 하는 게 아니기 때문에 이건 상관없다.

이러한 방법은 공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 간접적으로 호출하는 템플릿 메서드 패턴의 한 형태라고 볼 수 있다.

여기서 비가상 함수를 가상 함수의 래퍼라고 부른다.

이 관용구의 이점은 코드에 주석으로 써둔 사전 동작과 사후 동작에 들어있다. 가상 함수가 호출되기 전에 어떤 상태를 구성하고, 가상 함수가 호출된 후에 그 상태를 없애는 작업이 래퍼를 통해 가능해진다는 것이다.

 

함수 포인터로 구현한 전략 패턴

NVI관용구는 public 가상 함수를 대신할 수 있는 괜찮은 방법일 수 있지만, 클래스 설계 관점에서 보면 눈속임이다.

디자인 패턴인 전략(Strategy) 패턴의 단순한 응용 버전으로 함수의 포인터를 넘겨 이 함수를 호출해서 구현할 수 있다.

class GameCharacter; // 전방 선언

int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter
{
public:
  typedef int (*HealthCalcFunc) (const GameCharacter&);

  explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) { }

  int health Value() const
  {
    return healthFunc(*this);
  }
private:
  HealthCalcFunc healthFunc;

즉, 같은 캐릭터 타입으로 만들어진 객체(인스턴스)들도 체력치 계산 함수를 각각 다르게 가질 수 있고, 런타임 중 변경이 가능하다.

class EvilBadGuy : public GameCharacter
{
public:
  explicit GameCharacter(HealthCalcFunc hcf = defaultHeathCalc) : healthFunc(hcf) {...}
  ...
};

// 다른 동작 원리로 구현된 체력치 계산 함수들
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

// 같은 타입인테도 체력치 변화가 다르게 나오는 캐릭터들
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly);

하지만 이 패턴에도 단점이 존재한다. 체력치 계산 함수가 이제 GameCharacter 클래스 계통의 멤버 함수가 아니라는 점은, 체력치가 계산되는 객체의 private 데이터는 이 함수로 접근할 수가 없다는 뜻도 된다.

예를 들어 defaultHealthCalc()는 EvilBadGuy의 public멤버가 아닌 부분을 건들 수 없다. 만약 그 캐릭터의 public 인터페이스만 가지고 체력을 계산할 수 있다면 상관없지만, 아니라면 문제가 발생한다.

사실 이러한 문제는 클래스 내부 기능을 클래스 외부 기능으로 대체하려고 하면 항상 생기는 문제로, 이를 해결하는 유일한 방법은 그 클래스의 캡슐화를 약화시키는 방법밖에 없다.

 

결과적으로 함수 포인터로 얻는 이점(객체별로 체력치 계산 함수를 둘 수 있다는 점, 이런 함수들을 런타임 도중 바꿀 수 있다는 점)과 단점 GameCharacter 클래스의 캡슐화를 떨어트려서 얻는 불이익 이 둘을 적당히 계산해서 설계에 맞게 판단하는 것이 좋다.

 

tr1::function으로 구현한 전략 패턴

템플릿과 암시적 인터페이스(41장 참고)를 알고 있다면 함수 포인터 기반의 방법은 답답할 수 있다.

tr1::function 계열의 객체는 함수 호출성 개체를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그니처와 호환되는 시그니처를 갖고 있다.(항목 54)

tr1::function은 어떤 함수가 가진 시그니처와 호환되는 시그니처를 갖는 함수 호출성 개체의 표현을 가능하게 해주는 템플릿이다.

class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter
{
public:
    // HealthCalcFunc는 함수 호출성 개체로서, GameCharacter와 호환되는 어떤 것이든 넘겨받아서 호출 될 수 있으며 int와 호환되는 모든 타입의 객체를 반환한다.
	typedef tr1::function<int(const GameCharacter&)> HealthCalcFunc;
	explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) {}

	int healthValue() const
	{
		return healthFunc(*this);
	}

private:
	HealthCalcFunc healthFunc;
};

Health는 tr1:function 템플릿을 인스턴스화 한 것에 대한 typedef 타입이다. 즉, 일반화된 함수 포인터 타입처럼 동작한다는 뜻이다.

이 시그니처를 풀어보면 const GameCharacter에 대한 참조자를 받아 int로 반환하는 함수를 HealthCalcFunc라고 부른다는 뜻이다. HealthCalcFunc로 만들어진 객체는 앞으로 대상 시그니처와 호환되는 함수 호출성 개체를 가질 수 있다.

쉽게 풀어보면 const GameCharacter&이거나 const GameCharacter으로 암시적 변환이 가능한 타입은 함수호출성 개체의 매개변수 타입으로 사용이 가능하며, 반환 타입도 int로 변환될 수 있다.

함수 포인터를 사용한 방법과 비교하면 차이점을 못 느낄 수 있지만, 일반화된 함수 포인터를 물 수 있다는 차이가 있다.

그렇게 되면 자유도가 증가하는데, 아래의 샘플 코드를 봐보자.

short calcHealth(const GameCharacter& gc); //반환형이 short인 일반 함수

struct HealthCalculator //구조체
{
	int operator()(const GameCharacter& gc) const;
};

class GameLevel //GameCharacter를 상속하지 않는 클래스
{
public:
	float health(const GameCharacter& gc) const;
};

class EvilBadGuy : public GameCharacter { ... };
class EyeCandyCharacter : public GameCharacter { ... };

EvilBadGuy ebg1(calcHealth); //반환형이 short인 일반 함수
EvilBadGuy ebg2(HealthCalculator()); //반환형이 int인 구조체

GameLevel currentLevel;
...
EyeCandyCharacter ecc1(tr1::bind(&GameLevel::health, currentLevel, _1)); //bind 함수

tr1::bind 함수는 ecc1객체에 대해 GameLevel::health이 쓰일 때 GameLevel의 객체인 currentLevel이 사용되도록 묶어준 용도이다. _1은 ecc1에 대해 currentLevel과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수를 뜻한다.

 

고전적인 전략 패턴

함수를 나타내는 클래스 계통을 따로 만들고, 실제 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.

전략 패턴 UML

이 그림의 의미는 GameCharacter가 상속 계통의 최상위 클래스고 EvilBadGuy 및 EyeCandyCharacter는 파생 클래스이며, HealthCalcFunc는 SlowHealthLoser, FastHealthLoser 등을 파생 클래스로 거느린 최상위 클래스라는 의미이다. GameCharacter 타입을 따르는 모든 객체는 HealthCalcFunc 타입의 객체에 대한 포인터를 포함하고 있다.

class GameCharacter; // 전방 선언

class HealthCalcFunc
{
public:
	virtual int calc(const GameCharacter& gc) const
	{
		...
	}
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter
{
public:
	explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}
	int healthValue() const { return pHealthCalc->calc(*this); }

private:
	HealthCalcFunc* pHealthCalc;
};

HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 확장성을 가질 수 있다.

 

지금까지 공부한 것들

비가상 인터페이스 관용구(NVI 관용구)

공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태

 

가상 함수를 함수 포인터 멤버로 대체

전력 패턴의 핵심만을 보여주는 형태

 

가상 함수를 tr1::function 데이터 멤버로 대체

전략 패턴의 한 형태로 호환되는 시그니처를 가진 함수 호출성 개체를 사용하도록 만든다.

 

한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체

전략 패턴의 전통적인 구현 형태로 tr1::funcition이나 함수 포인터로 사용했던 함수를 클래스로 대체한다.

 

요약

  • 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예이다.
  • 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
  • tr1::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수 호출성 개체를 지원한다.
728x90
반응형