35.가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
순수 가상 함수가 아닌 가상 함수로 선언되어 있다는 것은 기본 구현이 제공된다는 사실을 알 수 있다(34장 참고).
가상 함수는 반드시 private 멤버로 둬야 한다고 주장하는 가상 함수 은폐론이 있다고 한다.
난 이해가 안간다. 가상 함수라면 파생 클래스에서 오버라이드 해서 사용할 수 있지만, 기본 클래스의 구현된 가상 함수를 쓸 경우엔 파생 클래스의 객체에서는 사용하지 못하니 무조건 오버라이드 하란 소린데?
가상 함수 은폐론자들이 제안하는 설계방법이 있다.
비가상 인터페이스(non-virtual interface : NVI) 관용구를 통한 템플릿 메서드 패턴(Template Method)
템플릿 메서드 패턴은 C++에서 사용하는 템플릿과는 무관한 이름이니 참고하자.
가상 함수를 public 멤버 함수로 두되 비가상 함수로 선언하고, 내부적으로 실제 동작을 맡는 private 가상 함수를 호출하는 식으로 구현하는 방법이다.
원본
class Character
{
public:
virtual int HealthValue() const
{
//구현
}
};
NVI 관용구를 통한 템플릿 메서드 패턴
class Character
{
public:
int HealthValue() const
{
//사전 동작
int retVal = HealthValueHelper();
//사후 동작
return retVal;
}
private:
virtual int HealthValueHelper() const
{
//구현
}
};
public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 NVI 관용구 방법이다.
NVI 관용구의 장점은 사전 동작 과 사후 동작이 가능하다.
함수 포인터로 구현한 전략 패턴
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 healthValue() const
{
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};
처음 보자마자 이해가 안되서 정리한다.
typedef int (*HealthCalcFunc)(const GameCharacter&);는 int형으로 정의했다.
즉, healthValue 함수의 반환 값이 int 자료형인데 healthFunc(*this)는 int형이란 소리다.
GameCharacter 클래스를 생성할 때 HealthFunc 라는 함수포인터가 매개변수인데, 기본값으로 defaultHealthCalc함수로 넣었다.
멤버 변수로 HealthcalcFunc를 두어 생성 시 함수 포인터가 healthFunc로 초기화된다.
클래스 계통에 가상 함수를 심는 방법과 비교하면 꽤 많은 융통성을 갖고 있다.
같은 타입으로부터 만들어진 객체들이 각각 생성시 필요한 함수포인터에 다른 함수로 초기화 할 수 있다.
class GameCharacter { ... };
class EvilBadGuy : public GameCharacter
{
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{}
};
int loseHealthQuickly(const GameCharacter& gc); //다른 동작 원리로 구현된 함수
int loseHealthSlowly(const GameCharacter& gc); //다른 동작 원리로 구현된 함수
int main()
{
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg1(loseHealthSlowly);
system("Pause");
return 0;
}
이 코드에서는 객체마다 다른 체력 계산 함수로 초기화가 가능하다는 것을 보여준다.
특정 객체에 대한 함수를 바꿀 수 있다.
예를들어 GameCharacter 클래스에서 setHealthCalculator라는 멤버 함수를 제공하고 있다면 이를 통해 현재 쓰이는 계산 함수의 교체가 가능해진다.
class GameCharacter
{
public:
...
void SetHealthCalc(HealthCalcFunc hcf)
{
healthFunc = hcf;
}
...
};
int main()
{
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg1(loseHealthSlowly);
ebg1.SetHealthCalc(loseHealthQuickly); //함수 포인터 변경
system("Pause");
return 0;
}
함수포인터로 구현한 전략 패턴 방법은 파생 클래스에 사용한 함수 포인터가 더 이상 기본 클래스의 멤버 함수가 아니라는 점이다. 즉, defaultHealthCalc 함수는 EvilBadGuy 객체의 public 멤버가 아닌 부분을 건들 수 없다.
무슨 말인지 느낌은 오지만 정확하게 이해는 안된다.
tr1::funciton으로 구현한 전략 패턴
템플릿과 암시적 인터페이스(41장 참고)를 알고 있다면 함수 포인터 기반의 방법은 답답할 수 있다.
위 코드에서 함수 포인터로 사용한 GameCharacter 기본 클래스의 healthFunc를 대신 tr1::function 타입의 객체로 사용할 수 있다. tr1::fucntion 계열의 객체는 함수호출성 개체(callable entity)를 가질 수 있다(54장 참고).
tr1::fucntion은 어떤 함수가 가진 시그니처와 호환되는 시그니처를 갖는 함수 호출성 개체의 표현을 가능하게 해 주는 템플릿이다.
#include <functional> //tr1::function이 들어있는 헤더
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
typedef tr1::function<int(const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};
HealthFunc는 tr1::function 템플릿을 인스턴스화한 것에 대한 typedef 타입이다. 즉, 일반화된 함수 포인터 타입처럼 동작한다는 뜻이다.
typedef tr1::function<int(const GameCharacter&)> HealthCalcFunc;
이 시그니처를 풀어보면 const GameCharacter에 대한 참조자를 받아 int로 반환하는 함수를 HealthCalcFunc라고 부를게 라는 뜻이다. HealthCalcFunc로 만들어진 객체는 앞으로 대상 시그니처와 호환되는 함수 호출성 개체를 갖을 수 있다.
쉽게 풀어보면 const GameCharacter&이거나 const GameCharacter으로 암시적 변환이 가능한 타입은 함수호출성 개체의 매개변수 타입으로 사용이 가능하며, 반환 타입도 int로 변환될 수 있다.
함수 포인터를 사용한 방법과 비교하면 차이점을 못느낄 수 있지만, 일반화된 함수 포인터를 물 수 있다는 차이가 있다.
#include <functional>
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter { ... };
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 { ... };
int main()
{
EvilBadGuy ebg1(calcHealth); //반환형이 short인 일반 함수
EvilBadGuy ebg2(HealthCalculator()); //반환형이 int인 구조체
GameLevel currentLevel;
EyeCandyCharacter ecc1(tr1::bind(&GameLevel::health, currentLevel, placeholders::_1)); //bind 함수
GameCharacter gc([](const GameCharacter&) //람다 함수
{
return 100;
}); //아직 람다는 공부가 필요하지만 파생 클래스는 람다 불가능함
//기본 클래스만 가능한 이유는 아마도 tr1::function이 기본 클래스에 있어서 인거 같은데 아직 모름
system("Pause");
return 0;
}
위 코드처럼 방대한 자유도를 갖을 수 있다.
tr1::bind 함수는 ecc1객체에 대해 GameLevel::health이 쓰일떄 GameLevel의 객체인 currentLevel이 사용되도록 묶어준 용도이다. placeholder::_1은 ecc1에 대해 currentLevel과 묶인 GameLevel::health 함수를 호출할 떄 넘기는 첫 번째 자리의 매개변수를 뜻한다.
고전적인 전략 패턴
함수를 나타내는 클래스 계통을 따로 만들고, 실제 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.
이 그림의 의미는 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::function과 호환되는 시그니처를 가진 함수 호출성 개체를 사용하도록 만든다.
전략 패턴의 한 형태
한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체
tr1::funcition이나 함수 포인터로 사용했던 함수를 클래스로 대체한다.
전략 패턴의 전통적인 구현 형태
이것만은 잊지 말자
가상 함수 대신에 쓸법한 다른 방법으로 NVI 관용구 및 전략 패턴을 쓸 수 있다. NVI 관용구는 템플릿 메서드 패턴의 한 예이다.
객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
tr1::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원한다.