
Paul DiLascia (영문)
지난 달에 저는, 순수한 C++를 이용해서 이벤트를 구현하는 방법(C++ At Work: Event Programming)에 대해서 대답해 드렸습니다. 저는 일반적인 이벤트의 의미와 클라이언트가 이벤트를 다루기 위해서 반드시 필요한 이벤트를 핸들러를 인터페이스를 사용하여 어떻게 정의해야 하는 지에 대해서 보여드렸습니다. 저의 구현은 약간의 단점이 있고, 다음 호 에서 이 단점을 개선 하겠다고 말씀 드렸었습니다.

그림 1 소수 계산하기
먼저, 지난 호의 내용을 간략하게 다시 살펴 보겠습니다. 저는 CPrimeCalculator라는 소수를 계산하는 클래스를 이용하여, PrimeCalc라는 프로그램을 작성했었습니다. CPrimeCalculator는 두 개의 이벤트를 발생시킵니다. 진행과 완료 이벤트가 그것이죠. 소수를 계산 하면서, CPrimeCalculator 클래스는 지금까지 몇 개의 소수가 발견되었는지를 알려주는 진행 이벤트를 발생시킵니다. 소수를 다 찾은 뒤에는 완료 이벤트를 발생시킵니다. 이러한 이벤트 들은 IPrimeEvents 라는 인터페이스에 의해 정의되어 있습니다:
class IPrimeEvents {
public:
virtual void OnProgress(UINT nPrimes) = 0;
virtual void OnDone() = 0;
};
이러한 이벤트를 핸들링 하기를 원하는 클라이언트들은 반드시 IPrimeEvents로 부터 상속을 받아서, 핸들러 함수를 구현해 주어야 하고, CPrimeCalculator의 Register 함수를 이용하여, 인터페이스를 등록해 주어야 합니다. CPrimeCalculator::Register는 클라이언트 객체와 인터페이스를 내부의 리스트에 추가합니다. 그리고, 이벤트를 발생시킬 때가 왔을 때, CPrimeCalculator는 NotifyProgress라는 헬퍼 함수를 호출합니다. 아래와 같이 말입니다:
void CPrimeCalculator::NotifyProgress(UINT nFound)
{
list<IPrimeEvents*>::iterator it;
for (it=m_clients.begin(); it!=m_clients.end(); it++) {
(*it)->OnProgress(nFound);
}
}
NotifyProgress는 클라이언트 리스트를 순환 탐색하여, 각각의 클라이언트들의 OnProgress 핸들러를 호출합니다. CPrimeCalculator를 사용하고 있는 프로그래머의 입장에서는, 이벤트를 핸들링하기 위한 코드를 작성하는 것은 이해하기 쉽고, 간단합니다. IPrimeEvents를 상속 받아 이벤트 핸들러를 구현해 주면 됩니다. 이에 반하여, 이벤트를 발생시키는 CPrimeCalculator를 구현하는 프로그래머 입장에서는, 이 일은 야간 지루합니다. 프로그래머가 NotifyFoo라는 함수를 모든 Foo 이벤트에 대해서 작성해 주어야 하기 때문입니다. 비록 모든 이벤트가 똑같은 패턴을 가지고 있다고 해도 말입니다. 그리고, 이벤트를 발생시키는 코드는 이벤트 인터페이스(IPrimeEvents)와 이벤트 소스 클래스(CPrimeCalculator)로 나뉘어져 있습니다. 만약 여러분이 다른 이벤트 소스에 대해서 똑같은 이벤트 인터페이스를 사용하기를 원한다면 어떻게 하시겠습니까? 여기서의 IPrimeEvents 인터페이스는 상당히 일반적인(generic) 클래스입니다. 저는 이 인터페이스를 IProgressEvents로 명명하고, 정수 형식으로 진행 사항을 보고하고, 끝났을 때, 완료 메세지를 보고하는 다른 클래스에서도 쓰도록 하고 싶습니다. 그렇게 하려면, 각각의 클래스가 진행 이벤트를 발생시키는 함수를 다시 구현 해줘야 합니다. 그렇기 때문에, 이상적으로는 모든 이벤트 코드가 한 클래스에 존재해야 합니다.
이러한 이벤트를 발생시키는 함수가 예측할 수 있는 패턴을 보이고 있기 때문에, "이러한 함수들을 일반적으로 구현할 수 있는 다른 방법이 없나요?"라고 물어보는 것은 너무나 당연합니다. 이 이벤트 메카니즘를 하나의 클래스 혹은 템플릿 혹은 매크로등을 이용해서 처리할 수 있지 않을까요? 예, 그렇습니다. 제가 여러분께 매크로와 템플릿을 이용해서, 코딩을 가장 최소화 하면서 이벤트 시스템을 생성할 수 있는 방법을 보여 드리겠습니다. 이 방법은 C++의 중첩 템플릿(nested template)와 functor 클래스가 필요합니다.
저는 이 글에서 몇 단계를 거쳐서, 이러한 이벤트 시스템을 구현하겠습니다. 최종 목적은 NotifyProgress와 NotifyDone 통지함수를 작성해 주는 템플릿을 구현하는 것입니다. 이 두 개의 함수는 유사하지만, 동일하지 않는 패턴을 가지고 있습니다.:
// NotifyFoo — raise Foo event
list<IPrimeEvents*>::iterator it;
for (it=m_clients.begin(); it!=m_clients.end(); it++) {
(*it)->OnFoo(/*args*/);
}
잠시 Foo 이벤트를 발생시키는 NotifyFoo함수의 일반적인 형태를 살펴 보자면, 위의 코드와 같습니다. 즉, 클라이언트 리스트를 순환해서 각각의 클라이언트들의 OnFoo함수를 호출하고, 이벤트의 인자를 넘겨주는 것입니다. 이것을 어떻게 템플릿으로 변형할 수 있을까요? 인터페이스 IPrimeEvents에 대해서는 타입 T로 인자화(parameterize) 할 수 있습니다. 그러나, 이름이 정해져 있지 않고, 프로그래머가 선택할 수 있는 함수 시그니쳐(signature)가 있는 이벤트 함수인 OnFoo를 어떻게 인자화할 수 있을 까요?
여러분이 함수를 인자화 할 필요가 있을 때 마다, functor 로 알려진 함수 클래스를 생각하셔야 합니다. 함수 클래스는 함수를 클래스로 변형하는 C++에서의 특별한 기교입니다. 여러분이 과거에 했던 것처럼 콜백 함수에 포인터를 넘겨주는 대신, 함수 클래스의 객체를 넘겨주는 것이죠. 이러한 functor 들을 이용하여 여러 개의 알고리즘을 구현하는 STL에서 많이 찾아볼 수 있습니다. 특히 for_each 알고리즘은 알아두시면 아주 유용하게 사용하실 수 있습니다:
/ for_each(m_clients.begin(), m_clients.end(),
NotifyProgress(nFound));
for_each 알고리즘은 컨테이너의 시작부터 끝까지 순환하여, 각각의 요소에 대해서 NotifyProgress 함수를 호출합니다. 그러면, 이 함수 객체란 정확히 무엇일까요? 정확히 말하자면 함수가 아니라 객체입니다. 이 클래스는 아래와 같습니다:
class NotifyProgress {
protected:
UINT m_nFound;
public:
NotifyProgress(UINT n) : nFound(n) { }
void operator()(IPrimeEvents* obj)
{
obj->OnProgress(nFound);
}
};
NotifyProgress는 for_each 알고리듬이 필요한 함수 operator()(IPrimeEvents*) 에 대해서 구현하고 있습니다. 일반적으로, 여러분이 타입 T에 대한 콜랙션(collection) 객체를 가지고 있을 때, for_each 알고리즘은 operator()(T)에 대한 구현이 있을 것을 기대하고, 콜렉션 안에서 각각의 T 객체에 대한 여러분의 operator()를 호출합니다. 그래서 이 경우, 함수 오퍼레이터는 IPrimeEvents에 대한 포인터를 취하여, void를 리턴해 줍니다? 왜냐하면, 클라이언트 리스트들은 IPrimeEvents들의 포인터 리스트들이기 때문입니다. 추가의 인자를 넘겨주기 위해서는, 생성자가 이 인자들을 각각의 멤버 변수들로 저장합니다. 그래서 NotifyProgress(nFound)를 호출하는 것은, m_nFound=nFound로 초기화된 인스턴스를 스택에 생성하는 생성자를 호출하는 것과 동일합니다. 그래서, 어떤 Foo 이벤트를 발생시키는 Foo functor의 일반적인 패턴은 아래와 같습니다:
class NotifyFoo {
protected:
ARG1 m_arg1; // 필요한 만큼 인자를 멤버변수로 선언해 주시면 됩니다
public:
NotifyProgress(ARG1 a1, ...) : m_arg1(a1) { }
void operator()(IMyEvents* obj)
{
obj->OnFoo(m_arg1, ...);
}
};
생성자가 이벤트의 인자들을 멤버 변수로 저장하고, 함수 오퍼레이터는 객체의 이벤트 핸들러 함수에 이 값들을 전달해 줍니다. 이 모든 것의 장점은-일반적인 모든 functors의 장점은-OnFoo함수가 클래스 NotifyFoo로 변환했다는 것입니다. 이것이 유용한 이유는, 제가 템플릿을 쓸 수 있기 때문입니다. 이렇게 하기 이전에, 말씀 드려야 할 것이 있습니다. 좀 더 멋지게 이 일을 하기 위해서, 여러분이 여러분의 functor를 STL안의 unary_function로 부터 상속을 받아야 한다는 것 입니다:
class NotifyProgress :
public unary_function<IPrimeEvents*, void>
{
.
. // 이전과 동일하게 작성
.
};
위의 NotifyProgress는 함수 오퍼레이터가 IPrimeEvents 포인터를 하나의 인자만 받는 단항 함수(unary function) 입니다. 이 단항 함수는 여러분의 functor 클래스를 융통성 있게 만들어 주어, STL의 not1, bind2nd 같은 어댑터(adapters)들과 결합할 수 있게 해줍니다. 여러분이 이러한 어댑터들을 여기서의 이벤트 핸들러에 쓸 계획이 없다고 하더라도, 이 단항 함수는 이것이 여러분의 functor이라고 알려줄 수 있기 때문에, 여전히 좋은 아이디어입니다. 어댑터에 대한 좀 더 자세한 설명은, 스캇 마이어스가 집필한 Effective STL: 50 Specific Ways to Improve Your Use of the Standard Template Library (Addison-Wesley, 2001) 책을 참조해 보시기 바랍니다.
STL 구루들은 왜 제가 mem_fun 어댑터를 이용하여 IPrimeEvents::OnProgress 이벤트를 함수 객체로 바꾸지 않는지에 대해서 의아하게 생각하실 것입니다. 그 이유는 바로, OnProgress가 가상 함수이고, 여러분은 가상 함수를 mem_fun 어댑터에 사용하실 수 없습니다. 만약, 여러분이 그렇게 한다면, 상속을 받은 OnProgress 함수가 아닌, 베이스 클래스의 함수를 호출하기 때문입니다. 여러분이 Boost 라이브러리를 이용한다면, 여러분은 Boost의 bind 어댑터를 이용하여, 직접 OnFoo같은 가상 이벤트 핸들러를 functor를 구현하지 않고, functor로 변환할 수 있습니다. 위의 내용이 무슨 말인지 파악하지 못하는 초보자 분들은 일단은 신경 쓰지 마시고 계속 읽어 주시기 바랍니다.
물론, 완료 이벤트에 대한 NotifyDone 함수 역시 필요합니다. 완료 이벤트는 인자가 없기 때문에, 생성자 역시 인자가 없습니다:
class NotifyDone :
public unary_function<IPrimeEvents*, void>
{
public:
NotifyDone() { }
void operator()(IPrimeEvents* obj)
{
obj->OnDone();
}
};
이렇게 해서, 저의 functor 클래스는 수동으로 클라이언트 리스트를 순환하는 대신 for_each를 쓸 수 있게 되었습니다. 이 코드들을 어디에 놓아야 할까요? functors는 이벤트의 설계(specification)에 속해 있습니다. 그래서, 저는 이 것들을 IPrimeEvents 안에 중첩된 클래스로 놓겠습니다. 그림 2는 이 코드를 보여줍니다.
똑똑한 독자 분들이라면, 제가 몇 개의 이 코드에 작은 수정을 했다는 것을 아실 겁니다. functor NotifyProgress라고 부르는 대신, 저는 이것을 Progress 라고 명명하였습니다. 여러분은 향후에, 이 조금한 변화가 코드를 얼마나 가독성 있게 만드는지 보실 수 있습니다. 그리고, 순수 함수로 이벤트 핸들러를 선언하는 대신, 코드 구현 부분에 아무런 코딩도 추가하지 않은 채 정의했습니다. IPrimeEvents는 두 개의 이벤트가 있습니다만, 일반적인 이벤트 메카니즘에서는, 프로그래머가 선언된 이벤트 중 몇 개의 이벤트만 필요함에도 불구하고, 모든 이벤트를 핸들링하는 방법은 조금은 불편해 보입니다. 이런 경우, 순수 가상 함수를 이용하지 않고, 구현부에 아무런 코딩도 하지 않는 채로 클래스를 만드는 방법은 일반적인 C++ 트릭중의 하나입니다. 오직 하나의 단점은 여러분은 dtor(소멸자)를 선언해야 합니다. 소멸자가 아닌 이상 순수 가상 함수는 정의가 없습니다. 그리고 각각의 상속받은 클래스가 dtor이 베이스 클래스의 dtor을 호출하기 때문에, 순수 가상 함수라고 해도, 베이스 클래스가 구현 부분이 필요하게 됩니다.:
inline IPrimeEvents::~IPrimeEvents() { }
이렇게 해서 functor가 선언되었으면, CPrimeCalculator는 Progress이벤트를 아래와 같이 일으킬 수 있습니다:
// CPrimeCalculator 클래스 안에서:
void NotifyProgress(UINT nFound)
{
for_each(m_clients.begin(), m_clients.end(),
IPrimeEvents::Progress(nFound));
}
지금까지, 저는 functor 클래스들인 Progress 와 Done을 소개시켜 드렸고, 이제 NotifyProgress와 NotifyDone 함수는 STL의 for_each 알고리즘을 쓸 수 있게 되었습니다. 그 다음은 무엇을 해야할까요? 우리의 목표는 NotifyFoo 함수를 완전히 제거해서, NotifyFoo함수를 템플릿을 만들어서, 프로그래머가 자신이 정의하는 모든 이벤트에 대해서, 헬퍼 함수들을 작성할 필요가 없게 하는 것입니다. for 루프를 for_each 알고리즘으로 바꾸는 것은 우리의 첫 번째 단계였습니다.
OnFoo함수를 Foo functor 타입으로 변환함으로써, 위의 functor를 템플릿화 할 수 있습니다. (Functor는 어떤 면에서는 .NET의 대리자(delegate) 와 동일합니다.) 그리고, 통지 함수들이 함수 이름이 대신 타입에 따라 다양하기 때문에, 저는 이것들을 인자화할 수 있습니다. 제가 인자화 할 때, 저는 전부의 구현을 원래의 소스 파일인 CPrimeCalculator이 아닌, 새로운 일반적인(Generic) 템플릿 클래스 CEventMgr에서 할 수 있습니다. 그림 3 은 이러한 결과를 보여 줍니다.
CEventMgr<I>는 I* 포인터들의 리스트를 가지고 있습니다. 이 클래스는 클라이언트들을 리스트에 등록하고 제거하기 위해서 Register 와 Unregister 메써드를 사용하고, 이벤트를 일으키는 템플릿 멤버 함수도 가지고 있습니다:
template <typename I>
class CEventMgr
{
...
template <typename F>
void Raise(F fn)
{
for_each(m_clients.begin(), m_clients.end(), fn);
}
};
이런, 굉장하죠! 여러분은 이렇게 템플릿 안에 템플릿을 쓰는 경험을 해 보신 적이 있으신가요? 너무 어려워 하시지 말기 바랍니다, 여기서 단지 이런 방식이 필요했을 뿐 입니다.
이벤트를 발생시키기 위해서는 아래와 같은 방식을 이용해야 합니다:
void NotifyProgress(UINT nFound)
{
m_eventmgr.Raise(IPrimeEvents::Progress(nFound));
}
이거 정말 좋지 않습니까? 루프도 없습니다. 그리고, 더 이상 for_each문도 필요 없습니다. 모든 자세한 사항은 CEventMgr 안에 감쳐져 있고, 이벤트를 발생 시키는 것은 안의 내부구조를 몰라도, 단 한 줄의 코드이면 됩니다. 원하기만 하면, NotifyProgress 함수 전체를 없애 버리고, 제가 원하기만 하면, 단순히 CEventMgr::Raise 이벤트를 호출해도 됩니다.-그러나, 제가 혹시 CEventMgr를 바꾸거나, 이벤트를 노출하는 함수를 클라이언트에게 노출할 수 있도록, 그렇게 하지는 않겠습니다. 실제로 NotifyProgress 함수가 인라인 함수이기 때문에, 효율 문제도 없습니다.
템플릿이 여러분의 두뇌를 아프게 한다면, 제가 무슨 일이 방금 일어났는지 설명해 드리겠습니다. CEventMgr 는 이벤트 인터페이스가 인자화된 템플릿 클래스입니다. 그래서, CEventMgr<IPrimeEvents>는 IPrimeEvents를 기반으로 한 이벤트 메니져의 인스턴스를 생성합니다. 이 인스턴스는 m_clients라는 IPrimeEvent의 포인터를 가지고 있는 리스트를 멤버 변수로 가지고 있습니다. CEventMgr 안에, functor 인자 F를 for_each에게 전달해 줘서, F 이벤트를 발생시키는 템플릿 멤버 변수가 있습니다. 그래서, 여러분이 이 함수를 쓰실 때 아래와 같이 작성하면,
m_eventmgr.Raise(IPrimeEvents::Progress(nFound));
컴파일러의 눈에는, 여러분이 IPrimeEvents::Progress 형식의 인자를 가지고 CEventMgr::Raise함수를 호출하려고 애쓰는 것이 보일 것 입니다. 그래서, 컴파일러는 CEventMgr::Raise(IPrimeEvents::Progress) 멤버 함수를 생성하기 위해서 템플릿을 사용할 것 입니다. 이 구현은 for_each 함수에 functor 인스턴스를 넘겨 주어, 클라이언트 리스트안의 각각의 I* 객체에 대해서, functor의 함수 오퍼레이터() 호출합니다. functor는 각 객체의 OnProgress 핸들러를 호출합니다.-바로 제가 원하던 것이죠! 템플릿, 정말 멋지지 않습니까?
이제 우리가 처음에 원하던 곳까지 거의 다 왔습니다. functors는 이벤트 메써드와 for_each의 사용을 인자화 하게 해주었습니다, 그러나 여전히, 그 코딩은 몇 줄의 길이를 가졌고, 저는 타이핑을 무지 무지 싫어합니다. 그래서, 가장 마지막 단계는 매크로를 도입해서, 저의 손목의 노동을 좀 줄여 주는 것입니다. 아래에서 IPrimeEvents에 대한 최종적으로 응축된 정의가 있습니다:
class IPrimeEvents {
DECLARE_EVENTS(IPrimeEvents);
public:
DEFINE_EVENT1(IPrimeEvents, Progress, UINT /*nFound*/);
DEFINE_EVENT0(IPrimeEvents, Done);
};
IMPLEMENT_EVENTS(IPrimeEvents);
그림 4는 전체의 소스 코드를 보여 드릴 것입니다.
여러분은 제가 충분히 압축해서, 더 이상 줄여지지 않을만큼 코드를 줄였다는 사실을 보실 수 있을 것 입니다. 오직 중요한 정보만이 나타나 있습니다: 이름들과 각각의 이벤트 핸들러에 대한 함수 시그니쳐. 이 매크로는 Foo이벤트 핸들러의 이름을 OnFoo라고 가정합니다. 어떤 프로그래밍 순수주의자 분들은 매크로를 싫어하시지만, 저는 아닙니다. 여러 분 손에 있는 도구를 왜 안 쓰시겠다는 건가요? DECLARE_EVENTS 는 생성자와 소멸자를 선언합니다; IMPLEMENT_EVENTS 는 inline dtor를 구현합니다. 매크로 DEFINE_EVENT0, DEFINE_EVENT1, 과 DEFINE_EVENT2 는 OnFoo이벤트 핸들러들과 인자가 하나도 없는, 하나만 있는, 혹은 두개 있는 Foo functors에 대한 선언과 정의를 합니다. 여러분이 두 개 이상의 인자가 필요하시다면, 여러분이 구조체를 정의하시고, 이 구조체의 포인터를 하나의 이벤트 파라미터로 넘겨 주시면 됩니다:
MumbleArgs args; args.a = 1; args.b = 2; // etc. m_eventMgr.Raise(IMyEvents::Mumble( args));
이것을 대체해서, 여러분이 DEFINE_EVENT3 이벤트를 구현하실 수도 있습니다. 그러나, 꼭 기억하세요: functor 객체들은 값에 의해서 전달됩니다, 그래서 반드시 사이즈가 작아야 합니다. 왜 수 많은 인자들을 포인터로 넘겨주기만 하면 되는데, 그렇게 하지 않고, 힙과 스택을 오가며 복사해 줘야 하나요? 여러분의 이벤트 핸들러가 무언가를 반환할 필요가 있을 경우에도, 여러분은 구조체를 사용하실 수 있습니다. 그러나, 저는 저의 인생을 단순하게 하기 위해서, 반환값이 필요 없는 void 형으로 이벤트 핸들러를 만들었습니다.
프로그래머들은, 때때로 functors들이 많은 오버헤드가 필요한지 물어 봅니다. 실제로는 functors 객체는 함수보다 더 효율적입니다. 그 이유는 인라이닝(inlining) 때문입니다. 여러분이 객체 인스턴스를 템플릿 함수에 전달하면, 컴파일러는 여러분이 여러분의 함수를 인라인으로 선언했으면, 모든 함수를 인라인으로 만들어 줄 수 있습니다.
그러면 행복한 프로그래밍 하시기 바랍니다.
질문이나 코멘트는 cppqa@microsoft.com로 보내주시기 바랍니다.