Impossible fast C++ 委托,已修复





5.00/5 (38投票s)
基于 Sergey Ryazanov 的文章“Impossible fast C++ 委托”的衍生作品:此优秀解决方案已修复并使用 C++11 进行了进一步开发。
这是基于 Sergey Ryazanov 在其 2005 年 7 月 18 日的文章“Impossible fast C++ 委托”中发表的思路的衍生作品。
我发现这个想法很有趣,尤其是在阅读了讨论区的批评之后。尽管代码和某些方法存在一些问题,但让委托变得非常快速的想法似乎很有价值,主要的技巧也是如此。我希望我已将其重塑成真正可用的东西。
添加了什么?
代码是从零开始编写的,完全基于原文章中清晰表达思想的文本:使用函数存根并通过工厂方法的模板参数填充存根指针。显而易见,调用参数和返回值的类型应该进一步被抽象为模板参数。自然,这需要任意数量的参数。
因此,作为第一步,我通过 C++11 的可变参数模板和部分模板特化,增加了对类模板的泛化,以创建 Sergey 所称的“首选语法”<RET(PARAMS…)>
,完全消除了所有预处理器代码。
值得注意的是,在一般情况下,模板和模板实例化有两个级别:首先,委托配置文件通过指定返回类型和参数类型来实例化。在此基础上,通过指定类类型及其静态或实例函数来实例化所需的工厂函数。这样,委托配置文件和调用目标的实例化被分开,以接近最优的方式进行。
至于工厂函数,首先要做的是注意到它们可以被赋予相同的名称(所谓的“重载”),我选择了“create
”。
我还添加了对lambda 表达式的实际重要支持,其风格类似于 std::function
。委托可以同时被赋值给 lambda 表达式的实例并在稍后调用。重要的是,lambda 表达式执行的闭包捕获会在委托实例中保留。
以上所有内容都包含在一个单独的 delegate
模板类中。我还添加了一个模板,multicast delegate
,采用 .NET 风格。
我还添加了两种类型委托实例之间的语义比较,以及与空指针的比较,赋值运算符使编译器能够通过类型推断隐式实例化相应的成员函数模板,以及类似的附加功能。
值得注意的是,两种委托类型的实例都可以创建为“空”。这反映了委托使用的主要范式,即委托实例由某个类托管;使用该类的代码在其使用过程中设置或添加自己的处理程序。
性能比较是使用 std::function
完成的。在所有时间测量可以被认为是相对有效的情况下(非常粗略地说,从一百个委托实例每个调用一百次开始),delegate
类型相对于 std::function
表现出更优越的性能。粗略地说,根据许多因素,delegate/std::function
创建时间上的增益为 10 到 60 倍,调用操作上的性能增益为 1.1 到 3 倍。我在 Windows 上使用 Clang、GCC 和Microsoft 编译器在两个平台(x86 (IA-32) 和 x86-64)上进行了测量 — 请参阅兼容性和构建。
通过示例使用
class Sample {
public:
double InstanceFunction(int, char, const char*) { return 0.1; }
double ConstInstanceFunction(int, char, const char*) const { return 0.2; }
static double StaticFunction(int, char, const char*) { return 0.3; }
}; //class Sample
//...
Sample sample;
delegate<double(int, char, const char*)> d;
auto dInstance = decltype(d)::create<Sample, &Sample::InstanceFunction>(&sample);
auto dConst = decltype(d)::create<Sample, &Sample::ConstInstanceFunction>(&sample);
auto dFunc = decltype(d)::create<&Sample::StaticFunction>();
// same thing with non-class functions
dInstance(0, 'A', "Instance method call");
dConst(1, 'B', "Constant instance method call");
dFunc(2, 'C', "Static function call");
int touchPoint = 1;
auto lambda = [&touchPoint](int i, char c, const char* msg) -> double {
std::cout << msg << std::endl;
// touch point is captured by ref, can change:
return (++touchPoint + (int)c) * 0.1 - i;
}; //lambda
decltype(d) dLambda = lambda; // lambda to delegate
// or:
//decltype(d) dLambda(lambda);
if (d == nullptr) // true
d(1, '1', "lambda call"); //won't
d = dLambda; // delegate to delegate
if (d == dLambda) // true, and also d != nullptr
d(1, '1', "lambda call"); //will be called
顺便说一句,与使用 std::function
的情况相比,请比较类/结构实例函数的情况。
//...
Sample sample;
using namespace std::placeholders;
std::function<double(double(int, char, const char*))> f
= std::bind(&Sample::InstanceFunction, &sample, _1);
令人困惑的部分是使用 std::placeholders
和 _1
(用于表达传递给实例函数调用的第一个隐式参数的实例指针的概念),这似乎并不明显。
多播委托用法
multicast_delegate<double(int, char, const char*)> md;
multicast_delegate<double(int, char, const char*)> mdSecond;
if (md == nullptr) // true
md(5, '6', "zero calls"); //won't
// add some of the delegate instances:
md += mdSecond; // nothing happens to md
md += d; // invocation list: size=1
md += dLambda; // invocation list: size=2
if (md == dLambda) //false
std::cout << "md == dLambda" << std::endl;
if (dLambda == md) //false
std::cout << "dLambda == md" << std::endl;
if (md == mdSecond) //false
std::cout << "md == mdSecond" << std::endl;
//adding lambda directly:
md += lambda; // invocation list: size=3
md(7, '8', "call them all");
上述多播委托使用示例丢弃了每个操作返回的对象。上面显示的所有代码示例都可以与 void
返回类型一起工作。如何处理返回的对象?它们可以以某种方式累积;一些值可以被丢弃,等等。那么,通用的解决方案是什么?可以通过提供一个在每个返回时调用的处理程序来实现。
带返回对象处理程序的多播调用
除了 operator()
,还可以使用一个单独的调用模板 operator()
和一个附加参数 — 一个返回对象的处理程序 — 来执行调用。
double total = 0;
md(9, 'a', "handling the return values:",
[&total](size_t index, double* ret) -> void {
std::cout << "\t"
<< "index: "
<< index
<< "; returned: " << *ret
<< std::endl;
total += *ret;
});
请注意,这里的手柄是 delegate
模板类的一个实例,该实例是从 lambda 表达式创建的。还有一种基于 std::function
的处理程序形式。有趣的是,不需要进行函数模板实例化,因为处理程序的类型可以由编译器从上下文中推断出来。
此代码示例演示了用于在 total
中累积返回值的闭包捕获的效果。当然,可以使用任何其他逻辑;另请参阅“DelegateDemo.h”。
为什么它很快?
基本思想在 S. Ryazanov 的文章中进行了说明。
该机制之所以快速,是因为大部分工作被委托给产品生命周期的编译时阶段。
委托调用 operator ()
只有一级间接寻址,即调用三个存根函数之一(我增加了一个来支持lambda)。原始方法的地址不存储在委托类实例中;相反,它在编译时通过模板实例化生成。这样,创建基于不同函数的委托实例的每个代码片段都会实例化一个单独的存根版本。在每个存根中,要调用的函数的地址作为立即常量呈现给编译器。在运行时只传递一个指针:指向用作“this
”调用参数的对象(对于静态函数,带有传递 nullptr
的开销),后来我开始将其重用为指向lambda 表达式实例的指针,以支持 lambda。
这里重要的性能因素是,不存在任何运行时检查,可以用来区分所有四种情况:类的实例函数、类的常量实例函数、静态函数和 lambda 表达式。根据我简略且不太准确的研究,这种检查几乎会使与调用机制开销相关的执行时间加倍。
可变参数模板和模板参数解析
这是用于为模板参数提供结构化类函数语法的技术。
template <typename T> class delegate;
template<typename RET, typename ...PARAMS>
class delegate<RET(PARAMS...)> final : private delegate_base<RET(PARAMS...)> {
//...
};
特化 delegate<RET(PARAMS…)>
创建了方便的模板实例化配置文件,类似于模板 std::function
。
delegate<double(int, const string*)> del;
delegate<void(double&)> byRefVoidDel;
// ...and the like
Lambda 表达式
Lambda 背后的思想是重用委托实例数据中的“this
”指针。在其他情况下,此指针用于保存类实例的实例指针,该类实例的方法用于委托中,以将其作为方法的第一个(通常是隐式的)参数传递。
为了执行 lambda 表达式调用,我添加了另一个存根函数 lambda_stub
(参见“Delegate.h”)。
template <typename LAMBDA>
static RET lambda_stub(void* this_ptr, PARAMS... arg) {
LAMBDA* p = static_cast<LAMBDA*>(this_ptr);
return (p->operator())(arg...);
}
这种解决方案的原因与我上面已经解释过的有关 — 检查不同情况的开销太高。
在将 lambda 实例传递给委托实例时,最重要的问题是支持主要的 lambda,即闭包捕获。值得注意的是,当 lambda 实例按值复制时(如果捕获集不为空,复制会在调用时抛出异常),此功能会丢失。对我来说,这种 lambda 设计值得商榷,但这是标准行为。通过指针传递实例(由调用者创建)可行,但这可能不是一个可行的设计,因为需要处理空指针。因此,唯一合理的解决方案是通过常量引用传递 lambda 实例,并在工厂函数内部创建指针。
工厂函数的实现方式如下。
template <typename LAMBDA>
static delegate create(const LAMBDA & instance) {
return delegate((void*)(&instance), lambda_stub<LAMBDA>);
}
这是为该函数实例化模板的正式方法。
auto d = delegate<double(int, char, const char*)>
::create<decltype(lambda)>(lambda);
(参见上面委托 d
和 lambda
的声明。)
但是,没有必要这样做,因为 lambda 类型将由编译器从委托类型中推断出来。因此,如果从上述代码片段中省略 <decltype(lambda)>
,它将起作用。此外,可以通过为 delegate
类定义的赋值运算符来实现。
delegate<double(int, char, const char*)> dl = lambda;
这是可能的,因为有模板复制构造函数和模板赋值运算符。再次,不需要模板实例化(且构造函数不可能),因为模板参数是从委托和 lambda 类型推断出来的。
多播委托
首先,由于多播委托以调用列表的形式表示一组处理程序,并且使用了堆(这是唯一使用堆的代码),列表操作支持和列表迭代的内在性能成本要高得多,完全掩盖了列表项调用的精细性能细节。尽管如此,调用列表项持有与 delegate
中相同的 this/stub
指针对,而不是指向 delegate
实例的指针。
multicast_delegate
实例在创建时具有一个空的调用列表,然后可以使用来自其他 multicast_delegate
、delegate
实例以及匹配配置文件的 lambda 表达式的“+=
”运算符集来填充该列表。当使用现有的列表项时,它们会被克隆。
委托可以比较吗?
是的,它们是,尽管 Sergey 的文章中有所说明。“比较”在此情况下并非完全准确的术语;所有讨论实际上都围绕着相等性或同一性关系。只要“比较”是指这个意思,委托就是“可比较的” — 请参阅“==
”和“!=
”运算符集。几个运算符代表了所有相等性检查的情况:delegate
和 multicast_delegate
的每个类都可以等于或不等于其自身类型或另一种类型的实例,此外,每个类型的实例可以等于或不等于 nullptr
。考虑到交换性,这给出了 12 种情况。对于这种关系的所有目的而言,这是一组完全有效的运算符。
Sergey 认为“委托不包含指向方法的指针”。但为什么呢?它实际上确实包含这样的指针,只是该指针在运行时不会传递给委托实例。相反,所有模板实例化都以立即常量的形式生成所有方法的地址,因此对这些指针的比较正确地完成,但间接地,通过比较不同的存根。如果两个存根指针不同,则始终意味着底层函数指针不同,反之亦然。
由同一函数或同一类和同一“this
”指针创建的两个委托实例比较为相同。由某个 lambda 表达式实例创建的委托实例也是如此。如果出于某种原因,有人设法在不同的编译单元中独立编译某些源代码片段并成功链接这些单元,则会产生不同的类和函数指针,而不是相同的。同样,同一个堆栈帧中的两个单独的 lambda 表达式,即使代码相同,仍然会产生两种不同的类型,这很容易检查;这样做是有原因的。在这两种情况下,从这些不完全相同的对象实例化的委托实例根本必须比较为不相同。
毕竟,“==
”和“!=
”运算符集定义了委托实例集上的关系,该关系与等价关系的定义相匹配:它是自反的、对称的(可交换的)和传递的 — 所有这些都很重要。
兼容性和构建
所有 delegate/multicast_delegate
解决方案都包含在三个文件中。
- “DelegateBase.h”,
- “Delegate.h”,
- “MultiCastDelegate.h”;
它们按给定顺序相互包含,并可以添加到任何项目中。
编译器应支持 C++11 或更高版本。对于 GCC,这应该设置为 -std=c++11
或 -std=c++14
等选项。
演示和基准测试项目提供两种形式:1)使用 Microsoft C++ 编译器和Clang 的 Visual Studio 2015 解决方案和项目 — 请参阅“CppDelegates.sln”;2)使用GCC 的 Code::Blocks 项目 — “CPPDelegates.cbp”。对于所有其他选项,可以通过将代码目录“CppDelegates”中的所有“*.h”和“*.cpp”文件添加进来来组装一个项目或 make 文件。
我已在 Visual Studio 2015、Clang 4.0.0、GCC 5.1.0 上测试了代码。
C++ 选项包括“禁用语言扩展”(Microsoft 和 Clang 为 /Za
),这似乎对 Microsoft 至关重要。但是,使用此选项时,一个奇怪的 Microsoft 问题是无法编译文件末尾的“//
”注释;该问题可以通过例如在文件末尾添加空行来解决;我在每个文件末尾设置了一个“Microsoft guard”,形式为“/* … */
”。