早期绑定与晚期绑定

ss在本课以及下一课中,我们将更深入地探讨虚函数的实现方式。虽然这些信息对于有效使用虚函数并非绝对必要,但它们非常有趣。不过,你也可以将这两部分内容视为选读。

当 C++ 程序运行时,它会按顺序执行,从 main() 函数的顶部开始。当遇到函数调用时,执行点会跳转到被调用函数的起始位置。CPU 是如何知道要这样做的呢?

当程序被编译时,编译器会将 C++ 程序中的每条语句转换为一条或多条机器语言指令。每条机器语言指令都被赋予一个唯一的顺序地址。函数也不例外——当遇到函数时,它会被转换为机器语言并赋予下一个可用地址。因此,每个函数最终都有一个唯一的地址。

绑定与调度

我们的程序包含许多名称(标识符、关键字等)。每个名称都有一组相关的属性:例如,如果名称代表一个变量,那么该变量有类型、值、内存地址等属性。

例如,当我们声明 int x 时,我们是在告诉编译器将名称 x 与类型 int 关联起来。稍后,如果我们写 x = 5,编译器可以利用这种关联来进行类型检查,以确保赋值操作是有效的。

在一般编程中,绑定 是将名称与这些属性关联的过程。函数绑定(或方法绑定)是确定函数调用与函数定义之间关联的过程。实际调用已绑定函数的过程称为 调度

在 C++ 中,绑定 一词的使用较为随意(调度通常被视为绑定的一部分)。我们将在下面探讨 C++ 中这些术语的用法。

术语

绑定 是一个多义词。在其他上下文中,绑定可能指:

  • 将引用绑定到对象上
  • std::bind
  • 语言绑定

早期绑定

编译器遇到的大多数函数调用都是直接函数调用。直接函数调用是指直接调用函数的语句。例如:

#include <iostream>

struct Foo
{
    void printValue(int value)
    {
        std::cout << value;
    }
};

void printValue(int value)
{
    std::cout << value;
}

int main()
{
    printValue(5);   // 直接调用非成员函数 printValue(int)
    Foo f{};
    f.printValue(5); // 直接调用成员函数 Foo::printValue(int)
    return 0;
}

在 C++ 中,当直接调用非成员函数或非虚成员函数时,编译器可以在编译时确定应该将哪个函数定义与调用匹配。这有时被称为 早期绑定(或静态绑定),因为它可以在编译时完成。然后,编译器(或链接器)可以生成机器语言指令,告诉 CPU 直接跳转到函数的地址。

对于高级读者

如果我们查看 printValue(5) 调用生成的汇编代码(使用 clang x86-64),我们会看到类似以下内容:

        mov     edi, 5           ; 将参数 5 复制到 edi 寄存器,为函数调用做准备
        call    printValue(int)  ; 直接调用 printValue(int)

可以清楚地看到,这是一个直接调用 printValue(int) 的函数调用。

重载函数和函数模板的调用也可以在编译时解析:

#include <iostream>

template <typename T>
void printValue(T value)
{
    std::cout << value << '\n';
}

void printValue(double value)
{
    std::cout << value << '\n';
}

void printValue(int value)
{
    std::cout << value << '\n';
}

int main()
{
    printValue(5);   // 直接调用非成员函数 printValue(int)
    printValue<>(5); // 直接调用函数模板 printValue<int>(int)

    return 0;
}

让我们来看一个使用早期绑定的简单计算器程序:

#include <iostream>

int add(int x, int y)
{
    return x + y;
}

int subtract(int x, int y)
{
    return x - y;
}

int multiply(int x, int y)
{
    return x * y;
}

int main()
{
    int x{};
    std::cout << "Enter a number: ";
    std::cin >> x;

    int y{};
    std::cout << "Enter another number: ";
    std::cin >> y;

    int op{};
    std::cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
    std::cin >> op;

    int result {};
    switch (op)
    {
        // 使用早期绑定直接调用目标函数
        case 0: result = add(x, y); break;
        case 1: result = subtract(x, y); break;
        case 2: result = multiply(x, y); break;
        default:
            std::cout << "Invalid operator\n";
            return 1;
    }

    std::cout << "The answer is: " << result << '\n';

    return 0;
}

由于 add()subtract()multiply() 都是直接调用非成员函数,编译器将在编译时将这些函数调用与各自的函数定义匹配。

请注意,由于 switch 语句的存在,实际调用哪个函数是在运行时确定的。然而,这是一个执行路径问题,而不是绑定问题。

晚期绑定

在某些情况下,函数调用直到运行时才能解析。在 C++ 中,这有时被称为 晚期绑定(或在虚函数解析的情况下,称为动态调度)。

作者注

在一般编程术语中,“晚期绑定” 通常意味着仅凭静态类型信息无法确定被调用的函数,而必须使用动态类型信息来解析。

在 C++ 中,该术语的使用较为宽松,指的是任何函数调用,其实际被调用的函数在函数调用实际发生时,编译器或链接器都无法知晓。

在 C++ 中,实现晚期绑定的一种方法是使用函数指针。简单回顾一下函数指针,函数指针是一种指向函数而不是变量的指针类型。可以通过在指针上使用函数调用运算符 () 来调用函数指针所指向的函数。

例如,以下代码通过函数指针调用 printValue() 函数:

#include <iostream>

void printValue(int value)
{
    std::cout << value << '\n';
}

int main()
{
    auto fcn { printValue }; // 创建一个函数指针并使其指向函数 printValue
    fcn(5);                  // 通过函数指针间接调用 printValue

    return 0;
}

通过函数指针调用函数也被称为 间接函数调用。在实际调用 fcn(5) 时,编译器在编译时无法知晓正在调用哪个函数。相反,在运行时,会通过函数指针所持有的地址进行间接函数调用。

对于高级读者

如果我们查看 fcn(5) 调用生成的汇编代码(使用 clang x86-64),我们会看到类似以下内容:

        lea     rax, [rip + printValue(int)] ; 确定 printValue 的地址并放入 rax 寄存器
        mov     qword ptr [rbp - 8], rax     ; 将 rax 寄存器中的值移动到与变量 fcn 相关联的内存中

        mov     edi, 5                       ; 将参数 5 复制到 edi 寄存器,为函数调用做准备
        call    qword ptr [rbp - 8]          ; 调用变量 fcn 所持有的地址处的函数

可以清楚地看到,这是一个通过其地址间接调用 printValue(int) 的函数调用。

以下计算器程序在功能上与上面的计算器示例完全相同,只是它使用了函数指针而不是直接函数调用:

#include <iostream>

int add(int x, int y)
{
    return x + y;
}

int subtract(int x, int y)
{
    return x - y;
}

int multiply(int x, int y)
{
    return x * y;
}

int main()
{
    int x{};
    std::cout << "Enter a number: ";
    std::cin >> x;

    int y{};
    std::cout << "Enter another number: ";
    std::cin >> y;

    int op{};
    std::cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
    std::cin >> op;

    using FcnPtr = int (*)(int, int); // 为丑陋的函数指针类型创建别名
    FcnPtr fcn { nullptr }; // 创建一个函数指针对象,初始值为 nullptr

    // 将 fcn 设置为指向用户选择的函数
    switch (op)
    {
        case 0: fcn = add; break;
        case 1: fcn = subtract; break;
        case 2: fcn = multiply; break;
        default:
            std::cout << "Invalid operator\n";
            return 1;
    }

    // 通过指针调用 fcn 所指向的函数,并将 x 和 y 作为参数
    std::cout << "The answer is: " << fcn(x, y) << '\n';

    return 0;
}

在这个例子中,我们没有直接调用 add()subtract()multiply() 函数,而是将 fcn 设置为指向我们想要调用的函数。然后通过指针调用函数。

编译器无法使用早期绑定来解析函数调用 fcn(x, y),因为它无法在编译时知晓 fcn 将指向哪个函数!

由于涉及额外的间接层,晚期绑定的效率略低于早期绑定。在早期绑定中,CPU 可以直接跳转到函数的地址。而在晚期绑定中,程序需要读取指针中持有的地址,然后跳转到该地址。这多了一个步骤,使得速度略慢。然而,晚期绑定的优点在于它比早期绑定更灵活,因为关于调用哪个函数的决策可以推迟到运行时再做。

在课程“虚函数与多态”中,我们将探讨如何使用晚期绑定来实现虚函数。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"