Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

로또

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

책/Effective C++

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

아롱로또 2023. 12. 4. 17:01

다루는 내용

비가상 인터페이스 관용구(Non-Virtual Interface: NVI)를 통한 템플릿 메서드 패턴, 함수 포인터로 구현한 전략 패턴, std::function으로 구현한 전략 패턴, 고전적인 전략 패턴들을 이용해 가상 함수를 대신할 수 있는 방법에 대해 알아보자.

 

가상 함수를 이용한 설계

캐릭터가 다치거나 체력이 깎이는 경우가 많은 게임에 대해 HealthValue 라는 이름의 멤버 함수를 제공하여 현재 캐릭터의 체력이 얼마나 남았는지를 나타내는 정수 값을 return한다.

체력을 어떻게 계산하는지는 캐릭터마다 다르므로 해당 함수를 가상 함수로 선언하고, 체력을 계산하는 기본적인 알고리즘을 제공하기 위해 순수 가상 함수여서는 안된다.

class GameCharacter{
public:
	virtual int HealthValue() const; // 캐릭터의 현재 체력 반환, 파생 클래스에서 재정의 가능
	...
}

 

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

가상 함수는 반드시 private 멤버로 두어야 한다.

HealthValue()를 public 멤버 함수로 그대로 두되, 비가상 함수로 선언하고 내부적으로는 실제 동작을 맡은 private 가상 함수를 호출하는 식으로 만들자는 것이다. 실제 동작을 맡은 함수의 이름은 DoHealthValue 정도로 하자.

더보기

private 가상 함수가 가능한 지에 대해 의문을 갖는 사람을 위해 다음 코드를 준비했다.

#include <iostream>
using namespace std;

class Base{
public:
	void Func(){
		cout << "Base::Func() Start" << endl;
		Something();
		cout << "Base::Func() End" << endl;
	}
private:
	virtual void Something(){
		cout << "Base::Something()" << endl;
	}
};

class Derived: public Base{
private:
	virtual void Something(){
		cout << "Derived::Something()" << endl;
	}
};

int main(){
	Derived d;
	d.Func();
	return 0;
}
실행 결과
class GameCharacter{
public:
	int HealthValue() const // 비가상 함수로 변경
	{
		// 사전 동작
		int ret_val = DoHealthValue(); // 실제 동작
		// 사후 동작
		return ret_val;
	}
	...
private:
	virtual int DoHealthValue() const; // 파생 클래스에서 재정의 가능한 함수
	{
		// 캐릭터의 체력치 계산을 위한 기본 알고리즘 구현
	}
};

위 코드가 기본적인 설계이다. 사용자로 하여금 public 비가상 멤버 함수를 통해 private 멤버 함수를 간접적으로 호출하게 만드는 방법으로, 비가상 함수 인터페이스(Non-Virtual Interface: NVI) 관용구라고 널리 알려져 있다. 이 관용구는 템플릿 메서드(Template method)라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것으로, 해당 관용구에 사용되는 비가상 함수를 가상 함수의 랩퍼(Wrapper)라 부른다.

 

NVI 관용구의 이점은 주석으로 나타낸 "사전 동작"과 "사후 동작"에 존재한다. 가상 함수가 호출되기 전에 어떤 상태를 구성하고, 호출된 후에 해당 상태를 없애는 작업이 Wrapper를 통해 공간적으로 보장될 수 있다. 예를 들어 Mutex, 로그생성, 검증 등이 존재한다.

 

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

앞서 다룬 NVI 관용구는 public 가상 함수를 대신할 수 있는 방법이지만 게임 캐릭터의 체력을 계산하는데 가상 함수를 사용하는 것은 여전하므로 클래스 설계의 관점에서는 눈속임과 다름 없다.

이번에는 체력 계산 함수를 캐릭터 클래스의 내부가 아닌 외부에 두고, 캐릭터의 생성자에 체력 계산 함수 포인터를 전달하는 방식으로 바꾸어보자.

class GameCharacter; // 전방 선언
int DefaultHealthCalc(const GameCharacter& gc); // 체력 계산용 기본 함수

class GameCharacter{
public:
	typedef int (*HealthCalcFunc) (const GameCharacter& gc);
	explicit GameCharacter(HealthCalcFunc hcf = DefaultHealthCalc): HealthFunc(hcf)
	{
		// 생성자
	}
	int HealthValue() const
	{
		return HealthFunc(*this); // 함수 포인터를 통한 함수 호출
	}
private:
	HealthCalcFunc HealthFunc; // 체력 계산 함수 포인터
};

이러한 방법은 가상 함수를 사용하는 방법과 비교하였을 때, 다음과 같은 장점을 가진다.

 

1. 같은 캐릭터 타입으로부터 만들어진 객체들도 체력 계산 함수를 다르게 가질 수 있다.

int LoseHealthQuickly(const GameCharacter& gc); // 다른 동작 원리로 구현된 체력 계산 함수들
int LoseHealthSlowly(const GameCharacter& gc);
EvilBadGuy ebg1(LoseHealthQuickly); // 동일한 타입의 캐릭터, 다른 체력 계산 함수 적용
EvilBadGuy ebg2(LoseHealthSlowly);

2. 게임이 실행되는 도중에 특정 캐릭터에 대한 체력 계산 함수를 변경할 수 있다.

int LoseHealthQuickly(const GameCharacter& gc); // 다른 동작 원리로 구현된 체력 계산 함수들
int LoseHealthSlowly(const GameCharacter& gc);
...
EvilBadGuy ebg(LoseHealthQuickly);
...
ebg.SetHealthCalcFunc(LoseHealthSlowly); // 체력 계산 함수 변경

 

물론 장점만 존재하지는 않는다. 체력 계산 함수들은 더 이상 캐릭터의 멤버 함수가 아니므로 public 영역을 제외한 다른 영역에는 체력 계산 함수에서 접근이 불가능하다. 이를 위해 비멤버 함수를 프렌드 함수로 선언하거나 세부 구현 사항에 대한 접근 함수를 public 멤버로 제공할 수도 있을 것이다. 함수 포인터를 통해 얻는 이점이 클래스의 캡슐화 정도를 떨어뜨리면서 발생하는 불이익보다 클 지는 우리의 설계에 달렸다.

 

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

함수 포인터를 이용해 체력 계산을 하지 않고, 함수처럼 동작하는 함수 객체를 사용할 수도 있을 것이다.

#include <functional>

using namespace std;

class GameCharacter; // 전방 선언
int DefaultHealthCalc(const GameCharacter& gc); // 체력 계산용 기본 함수

class GameCharacter{
public:
	typedef function<int(const GameCharacter&)> HealthCalcFunc; // 함수호출성 객체
	explicit GameCharacter(HealthCalcFunc hcf = DefaultHealthCalc): HealthFunc(hcf)
	{
		// 생성자
	}
	int HealthValue() const
	{
		return HealthFunc(*this); // 함수 포인터를 통한 함수 호출
	}
private:
	HealthCalcFunc HealthFunc; // 체력 계산 함수 포인터
};

함수 포인터를 사용할 때와 달라진 점을 찾아보자.

std::function은 함수 객체, 함수 포인터, 멤버 함수 포인터와 같은 Callable Entity(함수호출성 개체)을 저장할 수 있다.

typedef function<int(const GameCharacter&)> HealthCalcFunc

위 코드에 의해 HealthCalcFunc는 int를 return하고 const GameCharacter의 참조자를 parameter로 전달받는 함수처럼 동작하게 된다. 이렇게 정의된 HealthCalcFunc로 만들어진 객체는 대상 시그니처(int (const GameCharacter&) )와 호환 가능한 함수호출성 개체를 가질 수 있다. 호환 가능하다는 의미는 매개변수 타입이 const GameCharacter& 이거나 해당 타입으로 암시적 변환이 가능한 타입을 의미하며, return 타입도 암시적으로 int로 변환될 수 있다는 의미이다.

 

std::function을 사용하면 함수 포인터와 크게 다를 바 없지만, 보다 일반화된 함수 포인터를 사용할 수 있게 되며 다음과 같은 코드 구현도 가능하다.

class GameCharacter;
short CalcHealth(const GameCharacter&); // short를 반환하는 체력 계산 함수

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

class GameLevel{
public:
	float Health(const GameCharacter&) const; // float를 반환하는 체력 계산 멤버 함수
	...
};

class EvilBadGuy: public GameCharacter{...}; // 다른 캐릭터 타입
class EyeCandyCharacter: public GameCharacter{...}; // 다른 캐릭터 타입

...

EvilBadGuy ebg1(CalcHealth); // 체력 계산 함수 포인터 사용
EyeCandyCharacter ecc1(HealthCalculator()); // 체력 계산 함수 객체 사용
GameLevel current_level;

// 체력 계산 멤버 함수를 사용하는 캐릭터
EvilBadGuy ebg2(std::bind(&GameLevel::Health, current_level, std::placeholders::_1));

마지막 줄을 보자.

std::bind(&GameLevel::Health, current_level, std::placeholders::_1)

ebg2의 체력 계산 함수에 GameLevel의 멤버 함수인 Health()를 적용하는 코드이다.

GameLevel::Health()는 const GameCharacter&를 매개 변수로 받지만, 멤버 함수이므로 GameLevel 객체를 암시적으로 받아들인다. 해당 객체는 this 포인터가 가리키는 것이다. 즉, 실제로 GameLevel::Health()를 외부에서 호출하기 위해 두 개의 매개 변수(GameLevel, const GameCharacter&)를 필요로 하는데, 호출에 필요한 매개 변수를 하나로 바꾸는데 bind 함수를 사용한 것이다. 위 코드에 의해 ebg2에서 체력 계산 함수를 호출하면 current_level 객체가 사용되도록 만든다. 

 

결론적으로, 함수 포인터 대신 std::function을 사용함으로써 시그니처가 호환된다면 그 어떤 함수호출성 개체도 호출할 수 있도록 만들 수 있다.

 

고전적인 전략 패턴

전통적인 방법으로 구현한 전략 패턴이다.

체력 계산 함수를 나타내는 클래스를 별도로 만들고, 실제 체력 계산 함수는 해당 클래스 계통의 가상 멤버 함수로 만든다.

고전적인 전략 패턴 UML

class GameCharacter;

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

HealthCalcFunc DefaultHealthCalc;

class GameCharacter{
public:
	explicit GameCharacter(HealthCalcFunc* phcf = &DefaultHealthCalc)
	: p_health_calc(phcf)
	{}
	int HealthValue() const
	{ return p_health_calc->calc(*this); }
private:
	HealthCalcFunc* p_health_calc;
};

이러한 방법은 표준적인 전략 패턴 구현 방법에 익숙하다면 빠르게 이해할 수 있다는 점에서 매력적이다. 또한 HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 기존 체력 계산 함수를 조정, 개조할 수 있는 가능성도 열어놓았다.

 

이것만은 잊지 말자!

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

참고 자료

https://modoocode.com/254

 

씹어먹는 C ++ - <14. 함수를 객체로! (C++ std::function, std::mem_fn, std::bind)>

모두의 코드 씹어먹는 C ++ - <14. 함수를 객체로! (C++ std::function, std::mem_fn, std::bind)> 작성일 : 2019-02-24 이 글은 75772 번 읽혔습니다. 이번 강좌에서는Callable 의 정의std::function std::mem_fnstd::bind에 대해

modoocode.com