两个绑定机制的故事: 一些 C++ 的爱






4.75/5 (4投票s)
利用 C++ 的强大功能,优化你的代码调用方式。
引言
C++ 一如既往地提供了多种实现同一功能的方法,每种方法都有不同的影响和对生成代码的冲击。
在这里,我们将探讨几种让你的代码“绑定”到函数上的不同方式。
在这种情况下,我说的“绑定”是什么意思?我的意思是让编译器解析一个函数并调用它。
我从未见过有人讨论过这个话题,但我认为应该有人来写。
理解这个项目
典型的 C++ 编译器是一个非常强大的工具。它可以对你的代码进行精细分析,直到生成的汇编代码看起来与原始源代码完全不同,以便对其进行优化。
拥有这种能力,也意味着需要有责任去理解——至少是大概地理解——你编写的代码如何影响最终的输出。
一方面,C++ 通过指针或类的 `vtable`(本质上是一个指向你的 `virtual` 函数的函数指针列表)来调用你的函数。另一方面,函数体被移除并内联到你的代码中——根本不被调用(函数体的代码被复制到调用者例程中)。
如果函数足够小,C++ 会尝试将其内联——特别是当方法体最终生成的代码比调用函数所需的代码更小时。
当你的编译器可以直接访问函数并在编译时解析它时——可能(但并非必须)会消除函数指针——我称之为源代码级绑定。
C++——除了最新的 C++ 编译器在最新标准下某些特殊情况——无法内联 `vtable` 调用或任何对函数指针的调用。你必须承担创建堆栈帧和调用函数的额外开销。
这就是运行时绑定。当我提到运行时绑定时,我指的是通过某种形式的函数指针进行绑定。
编写这堆乱七八糟的代码
运行时绑定
考虑以下代码:
typedef class runtime_interface {
public:
virtual int add(int lhs,int rhs)=0;
virtual int subtract(int lhs,int rhs)=0;
virtual int multiply(int lhs,int rhs)=0;
virtual int divide(int lhs,int rhs)=0;
} runtime_interface_t;
typedef class runtime_binding final :
public runtime_interface_t {
public:
virtual int add(int lhs,int rhs) override {
return lhs+rhs;
}
virtual int subtract(int lhs,int rhs) override {
return lhs-rhs;
}
virtual int multiply(int lhs,int rhs) override {
return lhs*rhs;
}
virtual int divide(int lhs,int rhs) override {
return lhs/rhs;
}
} runtime_binding_t;
在这里,我们创建了一个 `runtime_interface_t` 的纯虚类,它作为我们的多态运行时接口。这基本上构建了我们的最终 vtable——它也是一系列函数指针——或者在这种情况下,是函数指针的槽位,因为我们还没有填充它们(纯虚)。
`runtime_binding_t` 实现这个接口,基本上用指向我们函数实现的函数指针填充 `vtable`。
当我们通过 `runtime_interface_t` 调用时,我们强制编译器生成代码在运行时绑定到实现它的实例。它会创建一个堆栈帧*,然后调用函数。
* 假设一个典型的调用约定,其中调用者创建堆栈帧。
然后,我们可以创建一个使用它的函数,如下所示:
void runtime_bind(runtime_interface_t& obj) {
printf("2 + 2 = %d\r\n",obj.add(2,2));
printf("5 - 2 = %d\r\n",obj.subtract(5,2));
printf("2 * 3 = %d\r\n",obj.multiply(2,3));
printf("4 / 2 = %d\r\n",obj.divide(4,2));
}
请注意,我在这里使用的是 `printf` 而不是 `cout`。`iostream` 构造会向生成的汇编代码中添加很多我们不需要的杂乱内容,会分散注意力。
更重要的是,我正在调用 `runtime_interface_t` vtable 中的每个函数。
让我们看看生成的汇编代码。我使用的是 godbolt.org,设置为 GCC x64 并开启优化(-O)。在许多情况下,GCC 生成的代码比 MSVC 更易于理解,在这里也是如此。
.LC0:
.string "2 + 2 = %d\r\n"
.LC1:
.string "5 - 2 = %d\r\n"
.LC2:
.string "2 * 3 = %d\r\n"
.LC3:
.string "4 / 2 = %d\r\n"
runtime_bind(runtime_interface&):
push rbx
mov rbx, rdi
mov rax, QWORD PTR [rdi]
mov edx, 2
mov esi, 2
call [QWORD PTR [rax]]
mov esi, eax
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov rax, QWORD PTR [rbx]
mov edx, 2
mov esi, 5
mov rdi, rbx
call [QWORD PTR [rax+8]]
mov esi, eax
mov edi, OFFSET FLAT:.LC1
mov eax, 0
call printf
mov rax, QWORD PTR [rbx]
mov edx, 3
mov esi, 2
mov rdi, rbx
call [QWORD PTR [rax+16]]
mov esi, eax
mov edi, OFFSET FLAT:.LC2
mov eax, 0
call printf
mov rax, QWORD PTR [rbx]
mov edx, 2
mov esi, 4
mov rdi, rbx
call [QWORD PTR [rax+24]]
mov esi, eax
mov edi, OFFSET FLAT:.LC3
mov eax, 0
call printf
pop rbx
ret
这相当直接。每隔几行,它就会将参数加载到寄存器中,调用相应的 `vtable` 函数,然后调用 `printf()`。
源代码绑定
源代码绑定为编译器提供了更多关于如何实现你的函数的选择,但多态性通常不包括在内(除非 C++ 可以在没有 `vtable` 的情况下优化函数重载)。因此,你不能进行源代码级别的运行时绑定——这甚至毫无意义。如果你正在考虑从 DLL 等运行时库导出函数,那么源代码级别绑定就不用想了。如果你在考虑暴露 COM 对象之类的东西,同样,也别想源代码级别绑定。
然而,当你能够使用它时,编译器就可以自由地重新排列你的代码以优化调用,在某些情况下,甚至可以完全消除它!
为了实现一种伪多态的源代码级别绑定,你可以使用 `template`。你将要传递的对象类型作为模板参数,这样编译器就拥有了在编译时而不是运行时解析你函数所需的一切。
首先,让我们退一步考虑以下情况:
typedef class source_only_binding final {
public:
int add(int lhs,int rhs) {
return lhs+rhs;
}
int subtract(int lhs,int rhs) {
return lhs-rhs;
}
int multiply(int lhs,int rhs) {
return lhs*rhs;
}
int divide(int lhs,int rhs) {
return lhs/rhs;
}
} source_only_binding_t;
这与 `runtime_binding_t` 非常相似,只是它不实现接口。它本身并没有太大的说明性。当我们在调用它时,奇迹就发生了。
template<typename T>
void source_bind(T& obj) {
printf("2 + 2 = %d\r\n",obj.add(2,2));
printf("5 - 2 = %d\r\n",obj.subtract(5,2));
printf("2 * 3 = %d\r\n",obj.multiply(2,3));
printf("4 / 2 = %d\r\n",obj.divide(4,2));
}
你可以看到这与我们的 `runtime_bind()` 非常相似,只是它是一个模板函数,并将目标对象的类型作为模板参数。
你的调用方式与 `runtime_bind()` 相同,传递 `source_only_binding_t` 的一个实例。编译器会自动为你填充模板参数。
source_only_binding_t src;
source_bind(src);
现在让我们检查汇编输出。查看输出,并没有 `source_bind()` 函数。它已经被内联到 `main()` 中了。
main:
sub rsp, 8
mov edi, OFFSET FLAT:.LC4
call puts
mov esi, 4
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov esi, 3
mov edi, OFFSET FLAT:.LC1
mov eax, 0
call printf
mov esi, 6
mov edi, OFFSET FLAT:.LC2
mov eax, 0
call printf
mov esi, 2
mov edi, OFFSET FLAT:.LC3
mov eax, 0
call printf
mov eax, 0
add rsp, 8
ret
你可能需要仔细看看,因为编译器已经完全改变了东西。除了调用 `printf()` 之外,没有函数调用。函数不仅全部被内联了,而且参数和调用也完全折叠成了简单的字面值。例如,编译器不是 `add(2,2)`,而是直接在输出中放入 4!它已经知道了答案,这得益于源代码级绑定的魔力。当然,这是为了说明目的而采取的极端情况,但有了更多的编译时信息,你的编译器就能做很多魔法来让你的代码更紧凑——有时甚至非常紧凑。
为什么不能兼得?
将 `runtime_binding_t` 的实例传递给 `source_bind()` 绝对没有任何问题。这是因为在源代码绑定中,唯一重要的是函数名称,而不是类的类型。事实上,这样做将使你拥有两全其美,允许源代码级别或运行时绑定。
历史
- 2023 年 10 月 3 日:初始版本