예전에 devpia에서 본 글인데 작성자는 누군지 모르겠다..
c++ callback & delegate
delegate 또는 closure 등등으로 불려지는 개념은 이미 여러 다른 객체 지향 프로그래밍(OOP)에서는 상당히 오래전 부터 존재해 왔지만 C++에서는 비교적 최근에서야 비슷한 개념을 구현하여 사용하고자 하는 시도가 빈번해지고 있는것 같습니다. 물론 delegate 가 수행하는 모든 기능은 C++ 기존의 다른 프로그래밍 기법들을 통해서 유사하게 구현할 수 있습니다. 하지만 delegate를 사용하면서 얻어질 수 있는 이점들은 최근에 자주 불거지고 있는 바람직한 프로그래밍을 위한 지침들과 부합하는 점들이 많기에 알아두면 도움이 될 것이라 생각합니다. OOP에서 프로그래밍의 흐름은 객체와 이 객체의 상태를 변경시키는 멤버 함수들의 호출을 통해서 이루어진다고 해도 과언이 아닐 것입니다. 따라서 멤버 함수를 저장할 수 있는 진정한 의미에서의 C++ 콜백이라고 할 수 있는 delegate의 특징과 기존에 존재하던 다른 콜백 방법들과의 차이점에 대해서 살펴봅니다.
delegate의 특징
객체 지향적인 콜백 (Orient Object Callback) - C++ 프로그램에서는 대부분의 기능은 클래스에 속한 멤버 함수를 통해서 이루어집니다. 불행하게도 멤버 함수는 일반적인 함수 포인터를 통해서 접근 또는 호출하는 것이 불가능합니다. 멤버 함수는 대상 객체에 대해서 수행이되기 때문에 이때 호출이 되어지는 대상 객체(callee)의 정보를 포함하고 있어야 합니다. delegate는 멤버 함수 바인딩을 통하여 이러한 대상 객체(callee)의 바인딩을 수행 할 수 있게 해줍니다. 따라서 delegate를 이용한 콜백은 기존의 일반 함수 포인터를 이용한 콜백에 비해서 더욱 C++에 부합하는 즉, 보다 객체 지형적인 콜백 메카니즘을 제공해 줍니다.
타입 안정성 (Type Safe) - C++의 기본적인 특징 중 하나가 컴파일 시에 강력한 타입 체크를 통해서 보다 견고한 프로그램을 작성할 수 있도록 해준다는 점입니다. delegate 역시 이러b한 C++의 장점 그대로 강력한 타입 체크를 지원해 줍니다. 호환이 되지 않는 멤버 함수나 대상 객체를 바인딩 하는 경우 컴파일 시 적절한 경고/오류 메세지를 발생시켜 줍니다.
낮은 결합도 (Non-Coupling) - 최근에 많은 객체 지향 디자인 패턴에 관한 문헌들과 보다 나은 코딩을 위한 지침서 들에서 쉽게 볼 수 있는 내용 중의 하나는 개체간의 결함도를 낮추는 것을 강조한다는 사실입니다. 다중 상속을 회피하고 컴퍼지션(composition)을 이용하는 것이 차후에 코드 관리 용이성이나 확장성등에 있어서 우위에 있다는 내용을 쉽게 접할 수 있습니다. delegate는 추상 베이스 클래스(인터페이스)를 사용한 기법에 비해서 더욱 낮은 개체간의 결합도를 가지게 됩니다.
기존 코드의 수정 불 필요 (Non-Type-Intrusive) - 기존의 콜백 메카니즘들은 대부분 호출을 하는 객체 또는 호출이 되어지는 객체가 콜백의 메카니즘에 부합되도록 수정되어지는 과정을 필요합니다. 일반적으로 이러한 코드 수정은 호출하는 클래스 (Caller) 또는 호출 되어지는 클래스 (Callee)가 특정 베이스 클래스를 상속받도록 수정 되어지는 과정을 의미합니다. delegate를 사용하는 경우 기존 코드의 수정이 불필요합니다.
범용적인 콜백 (Generic Callback) - 콜백으로 주어지는 멤버 함수는 보통 같은 함수 시그니쳐(리턴 타입, 입력 인자 갯수, 각각의 입력 인자의 타입)를 가지게 되지만 멤버 함수가 속하는 클래스의 형이 서로 다르다는 차이점을 가지게 됩니다. 범용적인 콜백이란 이러한 멤버 함수가 속하는 클래스의 타입이 다르다는 사실에 무관하게 단지 주어지는 함수의 시그니쳐에 의해서만 호환성이 결정될 수 있다는 것을 의미합니다. 즉 콜백으로 주어지는 멤버 함수가 어떤 클래스에 속하는지에 관계없이 멤버 함수의 함수 시그이쳐만 호환된다면 이러한 delegate들은 값에 의한 (by-value) 연산이 가능합니다. 이러한 범용성 때문에 delegate는 다른 범용적인 STL 컨테이너 또는 알고리즘에서 사용하는 것이 가능합니다.
C++ 콜백(Callback) 기법 들
1) C 스타일 콜백
class CDPlayer
{
public:
void play();
void stop();
static void play_static(void * obj) { reinterpret_cast<CDPlayer *>(obj)->play(); }
static void stop_static(void * obj) { reinterpret_cast<CDPlayer *>(obj)->stop(); }
};
class Button
{
public:
typedef void (*CallBack)(void *);
Button(Callback callback) : callback_(callback)
{
}
void click(void * obj)
{
(*callback_)(obj);
}
private:
Callback callback_;
};
void main()
{
CDPlayer cd;
Button play_button(&CDPlayer::play_static);
Button stop_button(&CDPlayer::stop_static);
play_button.click(&cd);
stop_button.click(&cd);
}
위의 예제에서 보여준 C 스타일 콜백은 우리가 흔히 자주 사용할 수 밖에 없는 방법입니다. 이는 Window API들이 C를 기반으로 작성되었기에 Window API의 콜백들이 모두 C 스타일 콜백을 요구한다는 사실 때문입니다. C++의 관점에서 보면 위의 접근 방법은 객체 지향적이지도 못하며 타입 안정하지도 못합니다. CDPlayer의 static 멤버 함수에서 reinterpret_cast<>를 사용하여 주어지는 대상 객체(callee)의 형변환이 이루어지기 때문에 obj 가 CDPlayer형 객체가 아닌 임의의 전혀 관계가 없는 객체의 포인터를 입력으로 준다 하여도 컴파일러는 문제를 발견할 수가 없습니다. 행여 이러한 문제를 해결하고자 Button의 생성자와 click() 멤버 함수가 void 포인터가 아닌 CDPlayer를 가리키는 포인터를 받도록 수정한다면 Button 클래스는 범용성을 잃게 되고 단지 CDPlayer를 위한 Button 클래스가 되어 버립니다. 차라리 이러한 클래스는 CDPlayerButton 클래스라고 불리우는게 옳으며 만약 DVDPlayer를 위한 Button 클래스가 필요하다면 DVDPlayer를 가리키는 포인터를 생성자, 멤버 함수의 입력으로 받는 DVDPlayerButton 이라는 새로운 클래스를 작성해야 합니다.
2) 공통 베이스 클래스 (Rooted Base Class)
// common root class
class RootObject { };
class CDPlayer : public RootObject
{
public:
void play();
void stop();
};
class Button
{
public:
typedef void (RootObject::*Callback)();
Button(Callback callback, RootObject * obj)
: callback_(callback), obj_(obj)
{
}
void click()
{
(obj_->*callback_)();
}
private:
Callback callback_;
RootObject * obj_;
};
void main()
{
CDPlayer cd;
Button cd_play_button(&CDPlayer::play, &cd);
Button cd_stop_button(&CDPlayer::stop, &cd);
cd_play_button.click(); // invoke cd.play();
cd_stop_button.click(); // invoke cd.stop();
}
콜백이 호출되어지는 대상 클래스는 반드시 공통 베이스 클래스로 부터 직적/간접적으로 상속받아야합니다. 즉 소스 코드의 수정이 필요합니다. 가장 큰 문제는 실전 프로그램에서는 대상 클래스(CDPlayer)가 공통 베이스 클래스(RootObject)를 직적/간접적으로 여러번 상속받게 되는 경우가 빈번히 발생할 수 있다는 사실입니다. 이런 경우 다이아몬드 형태의 상속 관계가 이루어 질 수 있고 가상 상속을 사용하지 않는한 클래스 상속관계가 모호하다는 컴파일 에러가 발생하게 됩니다. 가상 상속을 사용하여 해결한다고 할지라도 개체 간의 결합도가 상당히 높게 증가하기 때문에 최근에 권장되고 있는 설계 관점에서 매우 바람직하지 못한 상황에 봉착하게 됩니다. 아주 간단한 콜백 관계의 경우에만 제한적으로 사용할 수 있는 방법입니다.
2) 추상 베이스 클래스(ABC: Abstract Base Class) 또는 인터페이스(ABI: Abstract Base Interface)
class IButton
{
public:
virtual void click() = 0;
};
class CDPlayer
{
public:
void play();
void stop();
};
template<class T>
class Button : public IButton
{
public:
typedef void (T::*Callback)();
Button(Callback callback, T * obj) : callback_(callback), obj_(obj)
{
}
void click()
{
(obj_->*callback_)();
}
private:
Callback callback_;
T * obj_;
};
void main()
{
CDPlayer cd;
Button<CDPlayer> cd_play_button(&CDPlayer::play, &cd);
Button<CDPlayer> cd_stop_button(&CDPlayer::stop, &cd);
cd_play_button.click(); // invoke cd.play();
cd_stop_button.click(); // invoke cd.stop();
}
추상 베이스 클래스(ABC)와 인터페이스(ABI)는 의미상 약간 다른 부류이긴 하지만 콜백 관점에서 볼때는 거의 유사하다고 할 수 있습니다. 따라서 여기서는 두 가지를 동등하다고 가정하겠습니다.
위의 예제에서 Button<CDPlayer> 은 'CDPlayer'라는 호출이 되어지는 대상 객체를 알고 있어야 한다는 것을 명백하게 (템플릿 파라미터로 주어짐) 보여줍니다. 따라서 이는 함수를 호출하는 클래스(Button<CDPlayer>)가 함수흘 호출 받는 클래스(CDPlayer)에 높은 결합도를 갖는 것을 의미합니다.
또한 Button<CDPlayer> 클래스는 추상 베이스 클래스 IButton 을 반드시 상속 받아야 합니다. 즉 기존의 코드가 특정한 클래스를 상속받도록 수정되어야 한다는 것을 의미합니다.
이 경우 입력으로 주어지는 템플릿 파라미터가 다른 경우, 즉 Button<X> 와 Button<Y>는 전혀 다른 객체로 간주되기 때문에 서로 값을 비교하거나 대입하는 것이 불가능합니다. 즉 값에 의한 (by-value) 연산이 불가능하다는 것을 의미하며 이것은 Button<X> 개체를 범용적인 방법으로는 접근이 불가능 하다는 것을 의미하며 이는 Button<T>를 STL 컨테이너에 직접 대입하여 사용하는 것이 불가능하게 만듭니다. 굳이 Button<X>의 리스트를 사용하고자 하는 경우라면 서로 다른 타입의 객체를 저장할 수 있도록 특별히 설계된 커스텀 리스트를 제작해야합니다.
따라서 play_button을 클릭하였을 때 단순히 CDPlayer 만이 아닌 DVDPlayer, TV 등등 서로 다른 객체를 동시에 play 하고자 하는 경우(multicast)에 ABC 또는 ABI를 이용한 위의 방법은 구현하기가 매우 어려워진다는 문제점을 내포하게 됩니다.
마지막으로 ABC 또는 ABI는 가상 함수의 오버헤드를 가집니다. 인터페이스가 한 두 개 정의되어 있고 각각의 인터페이스에 가상 멤버 함수 역시 한 두개만 정의되어 있는 경우라면 문제가 되지 않겠지만 매우 복잡한 여러개의 인터페이스가 주어지고 각각의 인터페이스 또한 많은 수의 멤버함수를 정의하고 있는 경우라면 가상 함수에 의한 오버헤드가 프로그램 성능에 영향을 줄 수 도 있습니다.
3) 콜리 믹스인 클래스 (Callee Mix-In)
class Button
{
public:
class Notifiable
{
public:
virtual void notify() = 0;
};
Button(Notifiable * callee) : callee_(callee)
{
}
void click()
{
callee_->notify();
}
private:
Notifiable * callee_;
};
class CDPlayer
{
public:
void play();
void stop();
};
class MyCDPlayer : public CDPlayer, public Button::Notifiable
{
public:
void notify()
{
play();
}
};
void main()
{
MyCDPlayer cd;
Button cd_play_button(&cd);
button.click(); // invoke cd.play();
}
위의 방법은 타입 안정하며 Button 과 CDPlayer의 결합도가 낮지만 (Button의 객체를 정의할 때 CDPlayer의 타입을 템플릿 인자로 제공할 필요가 없습니다.) 실전에서 실질적으로 사용하는데는 많은 문제를 가지고 있습니다.
첫 번째 문제는 콜백을 호출하는 Button의 결합도는 낮췄지만 콜백이 호출되는 MyCDPlayer의 겹할도가 다중 상속으로 인해서 높아졌습니다. 만약 복잡한 관계의 여러개의 콜백을 사용하고자 할 경우 MyCDPlayer은 더욱 복잡한 다중 상속 관계를 가지게 되고 결합도 역시 매우 높아지게 됩니다.
두 번째 문제는 만약 CDPlayer 클래스가 소스 레벨에서 접근이 불가능하다면 CDPlayer로 부터 상속하는 것이 불가능해지기 때문에 이 방법은 전혀 사용할 수 없게 됩니다. 믹스인 클래스 접근 방법의 가장 큰 문제점이라고 할 수 있습니다.
마지막으로 위의 예제에서 이미 드러났듯이 Button이 cd.play()를 호출하는 것은 가능하지만 cd.stop()을 호출하는 것은 불가능합니다. 즉 한가지 notify 만을 호출 하는 것이 가능합니다. cd.stop()을 호출하고자 하는 경우 Button2 와 같이 또 다른 별개의 클래스를 작성해야 한다는 문제점을 가지게 됩니다.
4) delegate
class CDPlayer
{
public:
void play();
void stop();
};
class Button
{
public:
typedef delegate0<void> Callback;
Button(Callback callback) : callback_(callback)
{
}
void click()
{
callback_();
}
private:
CallBack callback_;
};
void main()
{
CDPlayer cd;
Button cd_play_button(make_delegate(&CDPlayer::play, &cd));
Button cd_stop_button(make_delegate(&CDPlayer::stop, &cd));
cd_play_button.click(); // invoke cd.play();
cd_stop_button.click(); // invoke cd.stop();
}
delegate를 사용한 콜백은 앞서 언급되어진 기존의 C++ 콜백들의 단점들이 모두 해결된 좀더 진화된 C++ 콜백 메카니즘이라고 할 수 있습니다. 복잡한 콜백 관계인 경우에도 쉽게 콜백의 추가/수정이 용이하며 범용적인 특성으로 인해 기존의 STL 컨테이너나 알고리즘을 이용하여 복잡한 콜백 관계라도 쉽게 구현할 수 있습니다. 콜백을 호출하는 클래스(Caller, Button)의 입장에서 콜백이 호출되는 클래스(Callee, CDPlayer)에 대한 어떠한 타입 정보를 필요로 하지 않으며 이러한 클래스들이 특별한 상속관계를 가지도록 요구하지도 않기 때문에 개체의 결합도 역시 매우 낮습니다.
delegate 이전에 가장 빈번히 사용되어졌던 그리고 현재도 가장 많이 사용되어지고 있는 콜백 메카니즘은 추상 베이스 클래스(인터페이스)를 이용한 방법입니다. 기본적으로 delegate로 할 수 있는 콜백은 추상 베이스 클래스(인터페이스) 방법으로 똑같이 할 수 있습니다만 위에서 이미 설명 되었듯이 추상 베이스 클래스를 사용하는 방법의 경우 개체의 결함도가 증가하며 이러한 콜백 메카니즘이 제대로 동작할 수 있도록 지원하는 부수적인 코딩량 역시 증가하게 됩니다.
다음은 제가 어떤 사이트에서 본 delegate의 단점이라고 기술된 내용입니다.
class Department
{
public:
typedef delegate0<void> Fireable;
Department(Fireable fireable) : fireable_(fireable)
{
}
void FireEmployee()
{
fireable_();
}
private:
Fireable fireable_;
};
class Military
{
public:
typedef delegate0<void> Fireable;
Military(Fireable fireable) : fireable_(fireable)
{
}
void FireMissle()
{
fireable_();
}
private:
Fireable fireable_;
};
class Missle
{
public:
void fire() { cout << "missle fired" << endl; }
};
class Employee
{
public:
void fire() { cout << "employee fired" << endl; }
};
void main()
{
Employee e1;
Missle e2;
// Department department(make_delegate(&e1.fire, &e1));
Department department(make_delegate(&e2.fire, &e2));
department.FireEmployee();
}
- Output -
missle fired
위의 코드는 main()에서 실수로 e1 대신에 e2를 Department에 입력으로 주었다고 가정했을 때 컴파일러가 에러(?)를 발견하지 못합니다. 따라서 실재로는 부서에서 고용인을 해고하려고 하였는데 결과는 미사일이 발사됩니다. 이 예제를 제시한 사이트에서는 이러한 delegate의 특징을 타입 안정하지 못하다고 해석하였습니다. 또한 이 사이트에서는 다음과 같은 방법을 인터페이스를 사용한 방법이라고 설명하면서 delegate 방법보다 나은 대안으로써 제시하였습니다.
class Department
{
public:
class IFireable
{
public:
void fire() = 0;
};
void FireEmployee(IFireable * fireable)
{
fireable->fire();
}
};
class Military : public IFireable
{
public:
class IFireable
{
public:
void fire() = 0;
};
void FireMissle(IFireable * fireable)
{
fireable->fire();
}
};
class Missile : public Military::IFireable
{
public:
void Fire() { cout << "missle fired" << endl;
};
class Employee : public Department::IFireable
{
public:
void Fire() { cout << "employee fired" << endl;
};
void main()
{
Employee e1;
Missile e2;
Department department;
// department.fire(e1);
department.fire(e2); // compile error!
}
일단 위의 방법은 콜리 믹스인 클래스로 분류된 방법과 동일하다고 볼 수 있습니다. 따라서 위에서 이미 설명되어진 단점들을 그대로 내포하고 있습니다. 제 개인적인 견해로는 delegate의 위와 같은 특징은 오히려 개체의 결합도를 낮추는 바람직한 효과라고 생각되어지는데 오히려 이 특징을 타입 안정하지 못하다고 해석할 수도 있다는 것을 알았습니다. 실재로 위의 사이트는 C# delegate FAQ 사이트로써 C++ delegate 경우에는 위와 같은 실수를 할 가능성이 훨씬 적습니다.
main() 함수에서 다음의 코드는 (C#의 원래 코드와 유사하게 일부러 조작한 코드입니다.)
// Department department(make_delegate(&e1.fire, &e1));
Department department(make_delegate(&e2.fire, &e2));
실재로 C++ 에서는 위의 코드 보다는 오히려 다음과 같은 코드를 사용하기 때문입니다.
// Department department(make_delegate(&Employee::fire, &e1));
Department department(make_delegate(&Missle::fire, &e2));
이러한 상황에서 컴파일러가 에러를 발생시켜주지는 않지만 비교적 명백하게 로직 에러가 발생한다는 사실을 인지할 수 있습니다.
마지막으로 delegate 가 진정한 C++ 콜백이라는 문구를 보시고서 delegate를 사용하고자 하였다가 좌절하시는 경우는 대부분 C 스타일 콜백만을 받는 Window API 에 delegate를 사용하고자 했는데 이것이 불가능하기 때문이라고 알고 있습니다. C 스타일 콜백을 받도록 설계되어진 Window API에 delegate 를 입력으로 주는 것은 기본적으로 가능하지 않습니다. 그러나 스마트 포인터 기법에서 응용되어지는 프락시 임시 객체를 이용한 기법을 응용하면서 TLS를 이용하여 제한적으로 delegate를 C 스타일 콜백으로 전달해주는 방법을 구상하고 있는 중입니다.
class delegate_adapter1
{
public:
typedef void (stdcall__ * Callback)();
template<class T>
class ThreadLocalStorageManager
{
public:
ThreadLocalStorageManager() : dwTLS_(::TlsAlloc())
{
}
~ThreadLocalStorageManager()
{
::TlsFree(dwTLS_);
}
T * get_tls_obj()
{
return static_cast<T *>(::TlsGetValue(dwTLS_));
}
BOOL set_tls_obj(T * obj)
{
return ::TlsSetValue(dwTLS_, obj);
}
private:
DWORD dwTLS_;
};
// singleton
static ThreadLocalStorageManager<delegate_adapter1> tls_man_;
class proxy
{
public:
proxy(delegate_adapter1 * dga)
{
tls_man_.set_tls_obj(dga_);
}
~proxy()
{
tls_man_.set_tls_obj(0);
}
operator Callback ()
{
return &static_callback;
}
};
friend class proxy;
explicit delegate_adapter1(delegate1<void> const & dg) : dg_(dg)
{
}
proxy get_c_style_callback()
{
return proxy(this);
}
static void stdcall_ static_callback();
{
tls_man_.get_obj()->dg_();
}
private:
delegate1<void> dg_;
};
ThreadLocalStorageManager<delegate_adapter1> delegate_adapter1::tls_man_;
typedef void (stdcall__ * Win32Callback)();
void SomeWin32API(Win32Callback callback);
class CDPlayer
{
public:
void play();
void stop();
};
void main()
{
CDPlayer cd;
delegate1<void> dg_play(&CDPlayer::play, &cd);
delegate1<void> dg_stop(&CDPlayer::stop, &cd);
SomeWin32API(delegate_adapter1(dg_play).get_c_style_callback());
SomeWin32API(delegate_adapter1(dg_stop).get_c_style_callback());
}
TLS(Thread Local Storage)를 사용하기 때문에 멀티쓰레드 환경에서도 동기화 문제를 따로 고려해줄 필요가 없지만 비동기호출을 하는 콜백의 경우에는 사용할 수 없습니다. proxy 임시 변수는 get_c_style_callback()이 호출되는 라인에서만 유효하기 때문입니다. 하지만 대부분의 일반적인 콜백의 경우에서는 별도의 코드 수정없이 delegate 를 쉽게(그리고 자동으로) C 스타일 콜백으로 변경할 수 있습니다. 아직 완전하게 구현되지 않은 상태지만 위와 같은 형태의 어뎁터 클래스를 포함하고 몇 가지 기능이 수정/추가된 fast delegate의 다음 버전을 준비하고 있는 중입니다.
:)
'Coding Note' 카테고리의 다른 글
[MFC] MDI tab에 다른 기능의 자식 추가 (0) | 2012.07.15 |
---|---|
[C++] 다차원 배열 파라미터 (0) | 2012.06.12 |
[Data Structure] Red Black Tree (0) | 2012.05.27 |
[Lua] table (0) | 2012.03.19 |
[Lua] 함수 (0) | 2012.03.19 |