在 C++ 中实现 Delegate 的新方法






4.88/5 (27投票s)
解决 C++ 中某些现有 Delegate 实现的问题
目录
引言
C++ 中的委托并非一个新概念。在 Code Project 和互联网上有很多实现。在我看来,最全面的文章是 Don Clugston 的文章,其中 Don 介绍了人们在处理指向成员函数的指针时遇到的所有困难。另一方面,Sergey Ryazanov 介绍了一个非常简单的解决方案,它利用了现代 C++ 语言的“非类型模板参数”特性。对于 Boost 用户来说,Boost.Function 应该是最著名的。下面是对这些实现的比较。
Boost.Function | Sergey Ryazanov | Don Clugston | |
Characteristics | 使用动态内存存储绑定到成员函数和绑定对象的信息。 | 利用非类型模板参数。非常易于理解。 | 由于每个编译器存储指向成员函数的指针的方式不同,Don 为他的委托定义了一个统一的格式。然后,根据每个编译器,他将编译器的格式转换为自己的格式。 |
内存占用 | 由于昂贵的堆内存分配,效率不高。 | 高效 | 高效 |
符合标准 | 是 | 是 | 否。实际上,这是一种技巧。 |
可移植 | 是 | 否。并非所有 C++ 编译器都支持非类型模板参数。 | 是。但只是在他所知的范围内,并且不确定将来。 |
语法 | 简洁 | 不简洁,例如
|
简洁 |
委托可以比较吗? | 是 | 否。实际上,有一种方法可以将此功能添加到 Sergey 的实现中。 | 是 |
KISS (保持简单)? | 如果您熟悉 C++ 模板编程,答案是肯定的。否则,答案可能是否定的。 | 是。即使读者只是模板编程的初学者,也能够理解。 | 否。您需要对指针和编译器有深入的了解才能理解他的代码。Don 在他的文章中提供了一个全面的教程。 |
注意:有些人对这些列出的委托的调用速度进行了比较。但是,在我看来,10,000,000 次委托调用相差几百毫秒的差异并不十分显著。
一个理想的委托应该是符合标准的、可移植的(Boost)、内存使用高效的(Sergey & Don)、语法简洁的(Boost & Don)、可比较的(Boost & Don)并且 KISS(Sergey)的。我们能否改进其中一个使其成为理想的,或者有新的解决方案吗?是的,我将向您展示一个新的解决方案。
背景
不同种类的类
// kind_of_class.cpp
// This file is to demo about different kinds of pointer to members
class dummy_base1 { };
class dummy_base2 { };
class dummy_s : dummy_base1 { };
// Reach to here, the compiler will recognize dummy_s is a
// kind of "single inheritance".
typedef void (dummy_s::*pointer_to_dummy_s)(void);
size_t size_of_single = sizeof(pointer_to_dummy_s);
class dummy_m : dummy_base1, dummy_base2 { };
// Reach to here, the compiler will recognize dummy_m is a
// kind of "multiple inheritance".
typedef void (dummy_m::*pointer_to_dummy_m)(void);
size_t size_of_multi = sizeof(pointer_to_dummy_m);
class dummy_v : virtual dummy_base1 { };
// Reach to here, the compiler will recognize dummy_v is a
// kind of "virtual inheritance".
typedef void (dummy_v::*pointer_to_dummy_v)(void);
size_t size_of_virtual = sizeof(pointer_to_dummy_v);
class dummy_u;
// forward reference, unknown at this time
typedef void (dummy_u::*pointer_to_dummy_u)(void);
size_t size_of_unknown = sizeof(pointer_to_dummy_u);
void main()
{
printf("%d\n%d\n%d\n%d", size_of_single, size_of_multi,
size_of_virtual, size_of_unknown);
}
如果您使用 VC++ 默认项目设置编译并运行演示,输出将是
4
8
12
16
当使用其他 C++ 编译器编译时,上面示例的输出将不相同。有关更多信息,请参阅 Don 的文章 中题为“成员函数指针的实现”的部分。显然,指向“未知”类的成员函数的指针大小总是最大的,除非是编译器。从现在开始,在本文中,我们将 C++ 类区分为两类
- 已知类(单一、多个、虚函数)
- 未知类(前向声明)
同一个类的不同方法
以下是一些影响类方法的属性(修饰符)
- 调用约定:
__cdecl
,__stdcall
,__fastcall
,__thiscall
。有关更多信息,请参阅 参数传递和命名约定。据我所知,G++ 允许但会忽略方法声明中的调用约定。 - 参数的数量和类型
- 返回类型
- const 方法与非 const 方法
- 虚函数与非虚函数
幸运的是,这些属性(修饰符)都不会影响指向成员函数的指针的大小。假设 CKnownClass
是一个已知类,并且已经在别处定义;这是一个例子
// __thiscall, non-const, 1 argument
typedef void (CKnownClass::*method_type_1)(int );
// __cdecl, non-const, 2 argument, return CString
typedef CString (__cdecl CKnownClass:: method_type_2)(int, CString );
// __fastcall, const, 3 argument, return void*
typedef void* (__fastcall CKnownClass::* method_type_3)(
int, CString, void*) const;
void main()
{
printf("%d\n%d\n%d", sizeof(method_type_1),
sizeof(method_type_2),
sizeof(method_type_3));
}
不同种类的自由函数或静态方法
以下是一些影响自由函数或静态方法属性(修饰符)
- 调用约定:
__cdecl
,__stdcall
,__fastcall
- 参数的数量和类型
- 返回类型
同样,属性对指向自由函数的指针的大小没有影响。在所有 32 位平台上,大小应为 4 字节,在所有 64 位平台上应为 8 字节。
幕后
本节将逐步介绍此库的实现方式。
委托的数据结构
委托的数据结构包括以下信息
- 参数的数量和类型
- 返回类型
- 方法或自由函数
- 调用约定
- 委托指向的方法或函数的地址
注意:我们不需要存储关于“const”和“virtual”属性的信息。
为了回答第 1 点和第 2 点,这个委托将像其他实现一样,作为一个 C++ 模板来实现。例如
// Boost
typedef boost::function2<void, int, char*> BoostDelegate;
// Don Clugston
typedef fastdelegate::FastDelegate2<int, char*, void> DonDelegate;
// Sergey Ryazanov
typedef srutil::delegate2<void, int, char*> SRDelegate;
// And this implementation
typedef sophia::delegate2<void, int, char*> SophiaDelegate;
以下代码片段将回答第 3、4 和 5 个问题class delegate2 // <void (int, char*)>
{
protected:
class _never_exist_class;
typedef void (_never_exist_class::*thiscall_method)(int, char*);
typedef void (__cdecl _never_exist_class::*cdecl_method)(int, char*);
typedef void (__stdcall _never_exist_class::*stdcall_method)(int, char*);
typedef void (__fastcall _never_exist_class::*fastcall_method)(int, char*);
typedef void (__cdecl *cdecl_function)(int, char*);
typedef void (__stdcall *stdcall_function)(int, char*);
typedef void (__fastcall *fastcall_function)(int, char*);
enum delegate_type
{
thiscall_method_type,
cdecl_method_type,
stdcall_method_type,
fastcall_method_type,
cdecl_function_type,
stdcall_function_type,
fastcall_function_type,
};
class greatest_pointer_type
{
char never_use[sizeof(thiscall_method)];
};
delegate_type m_type;
_never_exist_class* m_p;
greatest_pointer_type m_fn;
public:
void operator()(int i, char* s)
{
switch(m_type)
{
case thiscall_method_type:
return (m_p->*(*(thiscall_method*)(&m_fn)))(i, s);
case cdecl_function_type:
return (*(*(cdecl_function*)(&m_fn)))(i, s);
default:
// This is just a demo, don't implement for all cases
throw;
}
}
static int compare(const delegate2& _left, const delegate2& _right)
{
// first, compare pointer
int result = memcmp(&_left.m_fn, &_right.m_fn, sizeof(_left.m_fn));
if(0 == result)
{
// second, compare object
result = ((char*)_left.m_p) - ((char*)_right.m_p);
}
return result;
}
// constructor from __cdecl function
delegate2(void (__cdecl *fn)(int, char*))
{
m_type = cdecl_function_type;
m_p = 0;
reinterpret_cast<cdecl_function_type&>(m_fn) = fn;
// fill redundant bytes by ZERO for later comparison
memset((char*)(&m_fn) + sizeof(fn), 0, sizeof(m_fn) - sizeof(fn));
}
// constructor from __thiscall method
template<class T> delegate2(T* p, void (T::*fn)(int, char*))
{
m_type = thiscall_method_type;
m_p = reinterpret_cast<_never_exist_class*>(p);
///////////////////////////////////////////////////////////
// WE WANT TO DO THE FOLLWOING ASSIGNMENT
// m_fn = fn
// BUT HOW TO DO IT IN A STANDARD COMPLIANT AND PORTABLE WAY?
// FOLLOW IS THE ANSWER
///////////////////////////////////////////////////////////
// forward reference
class _another_never_exist_class_;
typedef void (
_another_never_exist_class_::*large_pointer_to_method)(
int, char*);
COMPILE_TIME_ASSERT(sizeof(
large_pointer_to_method)==sizeof(greatest_pointer_type ));
// Now tell compiler that '_another_never_exist_class_'
// is just a 'T' class
class _another_never_exist_class_ : public T {};
reinterpret_cast<large_pointer_to_method&>(m_fn) = fn;
// Double checking to make sure the compiler doesn't change its
// mind :-)
COMPILE_TIME_ASSERT(
sizeof(large_pointer_to_method)==sizeof(greatest_pointer_type ));
}
};
正如您刚才看到的,我们强制编译器将已知类的指向成员函数的指针转换为未知类的指向成员函数的指针。换句话说,我们将指向成员函数的指针从其最小格式转换为其最大格式。这样,我们就有了所有类型的指向函数/成员函数的指针的统一格式。因此,委托实例之间的比较很容易。只需调用标准的 memcmp
C 函数即可。
使委托更快、更具扩展性
上述设计存在 2 个问题
- 首先,“switch...case”语句使此实现比其他实现运行速度稍慢。
- 其次,如果我们想为委托添加更多功能——例如支持引用计数机制,如智能指针或 COM 接口——我们需要更多的存储空间来存放这些信息。
- 使用“策略设计模式”会在调用委托时引入一点开销:用户应用程序将参数传递给委托;委托将参数传递给其策略;策略再次将参数传递给实际的方法或函数。但是,如果参数都是简单的类型,如 char、long、int、指针和引用,那么编译器将自动生成优化代码,消除这种开销。
- 谁应该持有数据:策略还是委托?数据在这里指的是指向对象的指针(
_never_exist_class* m_p
)和指向方法或函数地址的指针(greatest_pointer_type m_fn
)。如果委托持有数据,它必须将数据传递给策略。此类操作会抑制编译器优化代码。如果策略持有数据,则必须动态创建策略对象。这会涉及昂贵的内存分配(new、delete 操作)。
- 为了允许编译器优化代码,我们将数据放入策略。注意:将数据放入策略会使其看起来像桥接设计模式,但这并不重要。
- 为了避免动态内存分配,我们将整个策略对象嵌入到委托对象中,而不是像通常那样保留指向它的指针。
策略是如何实现的?
实际实现利用了模板。为了让读者更容易跟上,以下代码是作为参考编写的
class delegate_strategy // <void (int, char*)>
{
protected:
class _never_exist_class;
typedef void (_never_exist_class::*thiscall_method)(int, char*);
typedef void (__cdecl _never_exist_class::*cdecl_method)(int, char*);
typedef void (__stdcall _never_exist_class::*stdcall_method)(int, char*);
typedef void (__fastcall _never_exist_class::*fastcall_method)(int, char*);
typedef void (__cdecl *cdecl_function)(int, char*);
typedef void (__stdcall *stdcall_function)(int, char*);
typedef void (__fastcall *fastcall_function)(int, char*);
class greatest_pointer_type
{
char never_use[sizeof(thiscall_method)];
};
_never_exist_class* m_p;
greatest_pointer_type m_fn;
public:
// pure virtual function
virtual void operator()(int, char*) const
{
throw exception();
}
};
class delegate_cdecl_function_strategy : public delegate_strategy
{
// concrete strategy
virtual void operator()(int i, char* s) const
{
return (*(*(cdecl_function*)(&m_fn)))(i, s);
}
public:
// constructor
delegate_cdecl_function_strategy(void (__cdecl *fn)(int, char*))
{
m_p = 0;
reinterpret_cast<cdecl_function_type&>(m_fn) = fn;
// fill redundant bytes by ZERO for later comparison
memset((char*)(&m_fn) + sizeof(fn), 0, sizeof(m_fn) - sizeof(fn));
}
};
class delegate_thiscall_method_strategy : public delegate_strategy
{
// concrete strategy
virtual void operator()(int i, char* s) const
{
return (m_p->*(*(thiscall_method*)(&m_fn)))(i, s);
}
public:
// constructor
template<class T> delegate_thiscall_method_strategy(
T* p, void (T::*fn)(int, char*))
{
m_p = reinterpret_cast<_never_exist_class*>(p);
///////////////////////////////////////////////////////////
// WE WANT TO DO THE FOLLWOING ASSIGNMENT
// m_fn = fn
// BUT HOW TO DO IT IN A STANDARD COMPLIANT AND PORTABLE WAY?
// FOLLOW IS THE ANSWER
///////////////////////////////////////////////////////////
// forward reference
class _another_never_exist_class_;
typedef void (
_another_never_exist_class_::*large_pointer_to_method)(int, char*);
COMPILE_TIME_ASSERT(sizeof(
large_pointer_to_method)==sizeof(greatest_pointer_type ));
// Now tell compiler that '_another_never_exist_class_'
// is just a 'T' class
class _another_never_exist_class_ : public T {};
reinterpret_cast<large_pointer_to_method&>(m_fn) = fn;
// Double checking to make sure the compiler doesn't change its
// mind :-)
COMPILE_TIME_ASSERT(sizeof(
large_pointer_to_method)==sizeof(greatest_pointer_type ));
}
};
class delegate2 // <void (int, char*)>
{
protected:
char m_strategy[sizeof(delegate_strategy)];
const delegate_strategy& strategy() const
{
return *reinterpret_cast(&m_strategy);
}
public:
// constructor for __cdecl function
delegate2(void (__cdecl *fn)(int, char*))
{
new (&m_strategy) delegate_cdecl_function_strategy(fn);
}
// constructor
template<class T>
delegate2(T* p, void (T::*fn)(int, char*))
{
new (&m_strategy) delegate_thiscall_method_strategy(p, fn);
}
// Syntax 01: (*delegate)(param...)
delegate_strategy const& operator*() const throw()
{
return strategy();
}
// Syntax 02: delegate(param...)
// Note: syntax 02 might be slower than syntax 01 in some cases
void operator()(int i, char* s) const
{
return strategy()(i, s);
}
};
支持对象生命周期管理
当将对象及其方法绑定到委托实例时,委托通常只保留对象的地址和方法的地址以供以后调用。有两种可能的问题可能导致我们的应用程序在运行时崩溃
- 如果方法位于 DLL 中,但该 DLL 已在进程外卸载,会发生什么情况?我们无法处理这种情况,所以我们只是忽略这个问题。
- 如果对象因开发者的错误而被删除,会发生什么情况?简单的答案是:开发者必须自己小心,避免这种错误。然而,手动对象管理总是繁琐、容易出错,并会降低开发者的性能。所以我一直在寻找一种简单但足够好的机制来实现这个目的。以下是其中一种
Boost 引入了 Clonable & Clone Allocator 概念。虽然它不适合许多用途,但它的简单性不会使这个委托库变得复杂。因此,这个库利用了 Boost 中的概念,并公开了以下方便的 Clone Allocator 类。
- 类
view_clone_allocator
是一个什么也不做的分配器。它与 Boost 中同名的分配器相同。创建委托实例时,如果我们不指定分配器,将默认使用这个。 - 类
heap_clone_allocator
,它也与 Boost 中同名的分配器相同。它使用动态内存分配和拷贝构造函数来克隆绑定的对象。 - 类
com_autoref_clone_allocator
用于支持 COM 接口。它也应该适用于实现具有正确意义的 AddRef & Release 这两个方法的任何类对象。
- 首先,目标委托(左侧)使用其当前的
clone allocator
释放其对象。 - 源(右侧)的所有信息将被复制到目标,包括
clone allocator
。实际上,这是一个简单的按位复制。 - 目标将使用新的
clone allocator
克隆它正在持有的新对象。 - 等等,后续委托实例之间也会这样赋值。
注意:实际上,在实际实现中,我已经考虑并消除了自赋值问题,即源和目标相同。
在某些情况下,我们希望将一个已克隆的对象绑定到一个委托实例。如果是这样,我们希望委托自动释放该对象,但不再克隆它。为了实现这个目的,在将对象及其方法绑定到委托时,我们必须提供 2 个额外的信息:克隆分配器类是第一个;第二个是一个布尔值,用于告知委托是否应该克隆该对象。
delegate.bind(
&object, &TheClass::a_method,
clone_option< heap_clone_allocator >(true));
宽松委托
对于非宽松委托库,传递给委托的模板参数类型会被严格检查。例如,如果我们分配一个原型为 int (*)(long)
的函数给一个原型为 long (*)(int)
的委托,编译器会报错,说不允许赋值,因为 int
和 long
是不同的类型。实际上,这种转换是安全的,因为它满足以下三个条件
- 参数数量匹配。
- 每个匹配的参数都可以由编译器从委托的参数隐式转换为目标函数的参数。
- 返回类型可以由编译器从目标函数的返回类型隐式转换为委托的返回类型。
void
返回类型是一个特殊情况:我们可以将返回void
的委托绑定到满足上述两个条件的任何方法或函数。这与调用函数/方法但不关心其返回值时相同。
使用代码
以下代码片段演示了此库的用法
using namespace sophia;
// Base class with a virtual method
struct BaseClass
{
virtual int virtual_method(int param) const
{
printf("We are in BaseClass: (param = %d)\n", param);
return param;
}
char relaxed_method(long param)
{
printf("We are in relaxed_method: (param = %d)\n", param);
return 0;
}
};
// A virtual-inheritance class
struct DerivedClass : public virtual BaseClass {
virtual int virtual_method(int param) const
{
printf("We are in DerivedClass: (param = %d)\n", param);
return param;
}
};
void Test()
{
// Assuming we have some objects
DerivedClass object;
// Delegate declaration
typedef sophia::delegate0<DWORD> MyDelegate0;
typedef sophia::delegate1<int, int> MyDelegate1;
typedef sophia::delegate4<void, int, int, long, char> AnotherDelegateType;
// Determine size of a delegate instance
printf("sizeof(delegate) = %d\n", sizeof(AnotherDelegateType));
// Constructor
MyDelegate0 d0(&GetCurrentThreadId);
MyDelegate1 d1(&object, &DerivedClass::virtual_method);
MyDelegate1 d2; // null delegate
AnotherDelegateType dNull;
// Compare between delegates even if they are different types
assert(d2 == dNull);
// Bind to a free function or a method
d0.bind(&GetCurrentThreadId);
d0 = &GetCurrentThreadId;
d2.bind(&object, &DerivedClass::virtual_method);
// Compare again after binding
assert(d2 == d1);
// Clear a delegate
d2 = NULL; // or
d2.clear();
// Invoke with syntax 01
d1(1000);
// Invoke with syntax 02
// This syntax is faster than syntax 01
(*d1)(10000);
// RELAXED delegate
d1.bind(&object, &DerivedClass::relaxed_method);
(*d1)(10000);
// Swap between two delegates
d2.swap(d1); // now d1 == NULL
// Execute a null/empty delegate
assert(d1.empty());
try
{
d1(100);
}
catch(sophia::bad_function_call& e)
{
printf("Exception: %s\n Try again: ", e.what());
d2(0);
}
// Object life-time management
// Case 1: we want the object is cloned
d1.bind(&object, &DerivedClass::virtual_method,
clone_option<heap_clone_allocator>(true));
// Object life-time management
// Case 2: we DO NOT want the object is cloned when binding
for(int i=0; i<100; ++i)
{
DerivedClass* pObject = new DerivedClass();
d1.bind(pObject, &DerivedClass::virtual_method,
clone_option<heap_clone_allocator>(false));
d1(100);
}
}
性能比较
所有委托,包括这个,在调用实际方法或自由函数之前都会增加 2 个额外的层。
- 首先,参数被传递给委托。
- 其次,参数被传递给 Stub(Sergey)、Invoker(Boost、Don)或 Strategy(本文库)。
- 第三,参数被传递给实际的方法或函数。
- 语法 01 使用 2 个额外的层,与其他语法相同
D(param1, param2)
- 语法 02 使用解引用运算符直接调用策略对象
(*D)(param1, param2)
如果我们向上面列出的任何委托实现传递简单数据类型(char、int、pointer...),编译器将生成优化代码。因此,调用速度之间的差异并不大。但是,如果我们传递涉及构造函数和析构函数的复杂数据类型,第二种语法比其他任何语法都快。
关注点
- 本文介绍了一种将各种格式的指向成员函数的指针/自由函数转换为统一格式的方法;转换是以标准兼容的方式实现的。因此,委托实例之间的比较非常容易。
- 稍微修改后的策略设计模式使这个委托既快速又具有扩展性。
- 这是一种 KISS 方法:对 C++ 模板编程背景有限的人也可以理解源代码。
接下来呢?
多播委托
本文讨论的是 Singlecast
委托。还有另一种类型,即所谓的 Multicast
委托,它在内部只是其他委托的集合。当我们调用 Multicast
时,集合中的所有 Singlecast
委托都会逐一被调用。主要地,Multicast
用于实现 观察者设计模式。
更具可移植性
以下是已对该库进行测试的编译器列表
- VC++ 7.1
- VC++ 2005
- Mingw32-gcc-3.4.5
由于并非所有 C++ 编译器都符合 C++ 标准,如果您的编译器出现这种情况,请告知我。
历史
- 2007 年 5 月 23 日:支持宽松委托。
- 2007 年 5 月 19 日:初始版本。