65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (4投票s)

2023 年 10 月 3 日

MIT

5分钟阅读

viewsIcon

6439

downloadIcon

62

利用 C++ 的强大功能,优化你的代码调用方式。

binding

引言

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 日:初始版本
© . All rights reserved.