KoreanFoodie's Study

Effective C++ | 항목 35 : 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자 본문

Tutorials/C++ : Advanced

Effective C++ | 항목 35 : 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

GoldGiver 2022. 10. 25. 16:28

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 35 : 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

핵심 :

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

 

체력을 계산하는 함수를 다음과 같이 만든다고 해 보자.

class GameCharacter
{
public:
    // 파생 클래스는 이제 이 함수를 재정의할 수 없다
	int healthValue() const
	{
		// 사전 동작 수행
		int retVal = doHealthValue();
		// 사후 동작 수행
		return retVal;
	}
private:
    // 파생 클래스는 이 함수를 재정의할 수 있다
	virtual int doHealthValue() const
	{
		// 캐릭터 체력치 계산을 위한 기본 알고리즘 구현
	}
};

위와 같은 방식을 비가상 함수 인터페이스(non-virtual interface: NVI) 관용구라고 한다. 사실 이 관용구는 템플릿 메서드(Template Method) 라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것이다.

 

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;
};

이렇게 만들면, 같은 캐릭터 타입의 객체라도 다른 체력치 계산 함수를 가질 수 있다. 다음처럼 말이다.

class EvilBadGuy: public GameCharacter 
{
public:
	explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
	: GameCharacter(hcf)
	{}
};

int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

EvilBadGuy ebg1(loseHealthSlowly);
EvilBadGuy ebg2(loseHealthQuickly);

물론 이와 같은 방식은, 체력치 계산을 위해 public 함수보다 더 보호 수준이 높은 함수를 활용할 수 없다는 단점을 가지고 있다.

 

세 번째로, std::function 으로 구현한 전략 패턴을 살펴보자.

// typedef int (*HealthCalcFunc)(const GameCharacter&);
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;

GameCharacter 의 함수 포인터 정의에서 위의 한 줄만 바꿔주면 된다! 위 함수 객체는 'const GameCharacter 에 대한 참조자를 받고 int 를 반환하는 함수' 가 된다. 이 객체는 앞으로 대상 시그니처와 '호환되는' 함수 호출성 개체를 가질 수 있으며, 이는 매개변수 타입이 'const GameCharacter&' 이거나 암시적으로 'const GameCharacter&' 로 변환이 가능한 타입이어도 된다는 뜻이다. 반환 타입도 암시적으로 int 로 변환되면 사용할 수 있다. 예시를 보자.

// 반환 타입이 short 인 함수도 사용할 수 있다.
short calcHealth(const GameCharacter&);

// 체력치 계산용 함수 객체를 만들기 위한 클래스
struct HealthCalculator
{
	int operator()(const GameCharacter&) const {}
};

class GameLevel
{
public:
	// 체력치 계산에 쓰일 멤버 함수
	// 반환 타입이 int 가 아닌 float 이다
	float health(const GameCharacter&) const;
};

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

EvilBadGuy ebg1(calcHealth);
EyeCandyCharacter ecc1(HealthCalculator());

GameLevel currentLevel;
EvilBadGuy ebg2(
	std::bind(
		&GameLevel::health,
		currentLevel,
		_1)
);

bind 문에 대해 설명을 덧붙이자면, ebg2 의 체력치를 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 써야 하는데, 실제로는 GameLevel 객체 하나를 암시적으로 받으므로 매개 변수를 두 개 받는다. 이 객체는 this 포인터가 가리키는 것인데, 이 매개변수 두개(GameCharacter 및 GameLevel) 를 받는 함수를 매개변수 한 개(GameCharacter)만 받는 함수로 바꾸기 위해, GameLevel::health 함수가 호출될 때마다 currentLevel 이 사용되도록 묶어 주고 있는 것이다. "_1" 은 "ebg2 에 대해 currentLevel 과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수" 를 뜻한다!

 

마지막으로, "고전적인" 전략 패턴에 대해 알아보자. 체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만드는 방식이다.

// 전방 선언
class GameCharacter;

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

class GameCharacter
{
public:

	explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
	: pHealthFunc(phcf)
	{}

	int healthValue() const
	{
		return pHealthFunc->calc(*this);
	}

private:
	HealthCalcFunc *pHealthFunc;
};

 

어쨌든, 가상 함수를 대신하는 설계 4가지를 잘 고려해 보자!

  1. 비가상 인터페이스 관용구(NVI 관용구)
  2. 가상 함수를 함수 포인터 데이터 멤버로 대체
  3. 가상 함수를 std::function 데이터 멤버로 대체
  4. 전통적인 전략 패턴 사용(다른 쪽 계통에 속해 있는 가상함수로 대체)

 

Comments