[Effective C++] 35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
순수 가상 함수가 아닌 가상 함수로 선언되어 있다는 것은 기본 구현이 제공된다는 사실을 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 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수를 뜻한다.
고전적인 전략 패턴
함수를 나타내는 클래스 계통을 따로 만들고, 실제 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것이다.
이 그림의 의미는 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 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수 호출성 개체를 지원한다.
'Books > Effective C++' 카테고리의 다른 글
[Effective C++] 37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자 (0) | 2022.07.02 |
---|---|
[Effective C++] 36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물! (0) | 2022.07.02 |
[Effecitve C++] 34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 (0) | 2022.06.25 |
[Effective C++] 33. 상속된 이름을 숨기는 일은 피하자 (0) | 2022.06.25 |
[Effective C++] 32. public 상속 모형은 반드시 "is-a(..는..의 일종이다)"를 따르도록 만들자 (0) | 2022.06.25 |
댓글
이 글 공유하기
다른 글
-
[Effective C++] 37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자
[Effective C++] 37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자
2022.07.02 -
[Effective C++] 36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
[Effective C++] 36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
2022.07.02 -
[Effecitve C++] 34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
[Effecitve C++] 34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
2022.06.25 -
[Effective C++] 33. 상속된 이름을 숨기는 일은 피하자
[Effective C++] 33. 상속된 이름을 숨기는 일은 피하자
2022.06.25