在写C++工程的时候,习惯性的是将头文件与其实现分开编写的。

今天被问到了这个问题:

“在写C++代码的时候,将函数实现直接写在头文件里,会怎么样,有什么不好的地方?为什么要将函数的实现单独写在源文件”

我的第一反应想到的,我实际开发时就这样做的,为啥这样做呢?编译比较快?编译比较省事?……

但似乎没有一个透彻的理解,需要在这里重新梳理一下。

从代码编译的角度来重新理解(CMake进行编译系统管理的时候,也有一套比较好的实践方式

C++编译角度的理解

在C++编程中,编译过程通常包括预处理、编译、汇编和链接这几个阶段。每个阶段都在为最终生成可执行文件做准备,而在这些过程中,头文件和源文件的角色和影响各不相同。

1. 预处理阶段

在这一阶段,预处理器处理源代码中的预处理指令,如#include#define、条件编译指令#ifdef#ifndef等。特别是#include指令,它会将头文件的内容直接包含到源文件中。如果没有适当的头文件保护,如#ifndef#define#endif头文件可能被重复包含,导致预处理后的源文件中出现重复的内容。

2. 编译阶段

在这一阶段,编译器将预处理后的源代码(现在已经包含了头文件中的内容,就是已经展开了所有的#include等)转换成汇编代码。如果实现代码(函数定义等)包含在头文件中,且头文件被多个源文件包含,那么就会导致:

  • 代码膨胀且编译时间增加:每个源文件(.cpp文件)在编译时都会包含它使用的所有头文件(.h文件),导致生成的目标文件体积增大。如果头文件中包含了大量的函数实现,每次这些头文件被包含时,这些代码都需要被重新编译。因此,如果多个源文件包括同一个包含大量实现的头文件,这些源文件每次编译时都会重新编译头文件中的实现代码,从而显著增加整体编译时间。并且,每次头文件被修改,所有包含该头文件的源文件都必须重新编译,哪怕是一个小改动,也会导致大量的重复编译工作。

  • 多重定义错误(在链接阶段发生):如果头文件中包含函数或变量的实现(而不是仅仅声明),并且这个头文件被多个源文件包括,那么每个源文件都会尝试定义这些函数或变量。在后续链接这些源文件生成可执行文件或库文件时,链接器会发现同一个函数或变量被定义了多次,这会导致链接错误,具体表现为“multiple definition”错误。

3. 汇编阶段

编译器生成的汇编代码被转换成机器可执行的代码,形成目标文件。这一阶段主要是编译器的工作,不涉及头文件和源文件的分离问题。

4. 链接阶段

链接器将所有的目标文件以及所需的库文件链接成一个单独的可执行文件或库文件。如果多个目标文件包含同一个全局符号(函数或变量)的定义,链接器会检测到多重定义错误。这通常是因为头文件中包含了不应该存在的定义,并被多个源文件包括所导致。可以使用inline关键字或将模板定义放在头文件中可以避免多重定义问题,因为内联函数和模板被设计为可以在多个文件中安全地重复定义。

最佳实践

  • 函数声明与实现分离:在头文件中只声明函数和类,而在源文件中提供具体的实现。这是最基本的规则,有助于减少编译时间、避免多重定义问题,并提高代码的可读性和维护性。
  • 使用inline关键字:对于需要在头文件中定义的小函数,可以使用inline关键字。inline函数通知编译器每次调用函数时尝试将函数体插入到每个调用点,以减少函数调用的开销。不过,滥用inline也可能导致代码膨胀
  • 模板实现:由于模板的特殊性,模板定义通常包括在头文件中。但应当小心控制模板实现的复杂度,避免导致所有上述问题。

补充

在 C++ 中,使用模板和 inline 函数可以提高代码的可重用性和效率。

以下是简单的举例说明:

模板

模板是 C++ 中实现泛型编程的工具,允许编写与数据类型无关的代码。可以创建一个泛型函数或类来处理不同类型的数据,比如用于交换两个变量的值:

template<typename T>
void swap(T& a, T& b) {
    T temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 10, y = 20;
    swap(x, y);  // 使用 int 类型
    std::cout << "x: " << x << " y: " << y << std::endl;

    double m = 1.1, n = 2.2;
    swap(m, n);  // 使用 double 类型
    std::cout << "m: " << m << " n: " << n << std::endl;

    return 0;
}

在这个示例中,swap 函数是一个模板,可以接受任何类型的参数。可以使用用 int 类型的变量调用它,也可以使用用 double 类型的变量调用它,展示了模板的灵活性和通用性。

2. inline 函数

inline 函数是一种在编译时将函数体插入到每个调用点的函数,用以减少函数调用的开销。inline 指示编译器尽可能地将函数的定义插入到每个调用该函数的地方。比如用于计算两数之和的例子:

inline int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(5, 3);  // 直接在这里插入 add 函数的体
    std::cout << "Result: " << result << std::endl;

    return 0;
}

在这个示例中,add 函数被定义为 inline。这意味着在编译时,add(5, 3) 可能会被替换为 5 + 3,从而避免了函数调用的开销。这种方式特别适合于小而频繁调用的函数。

注意事项

  • 模板 通常定义在头文件中,因为它们需要在编译时对每个用到模板的类型进行实例化。
  • inline 函数 也应该在头文件中定义,以确保编译器在每个调用点都能看到 inline 函数的定义,这是使函数内联化的先决条件。

通过使用模板和 inline 函数,可以编写出更高效、更灵活的 C++ 程序。不过也需要注意:滥用 inline 可能会导致程序的最终二进制体积增大,而滥用模板可能会增加编译时间。