函数声明与实现分离

问题引入:函数调用顺序

让我们来看一个看似无害的示例程序:

#include <iostream>

int main()
{
    std::cout << "3 和 4 的和是:" << add(3, 4) << '\n';
    return 0;
}

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

你可能会期望这个程序产生以下结果: 3 和 4 的和是:7

但实际上,它根本无法编译!Visual Studio 产生了以下编译错误: add.cpp(5) : error C3861: 'add': 找不到标识符

这个程序无法编译的原因是编译器会按顺序编译代码文件的内容。当编译器到达 main 函数的第5行的 add() 函数调用时,它不知道 add 是什么,因为我们直到第9行才定义 add!这导致了错误,找不到标识符。

编译错误的特点

旧版本的 Visual Studio 会产生一个额外的错误: add.cpp(9) : error C2365: 'add'; : 重新定义;之前的的定义是 '以前未知的标识符'

这有点误导,因为 add 根本就没有定义过。尽管如此,通常需要注意的是,一个错误产生许多冗余或相关错误或警告是相当常见的。有时很难判断任何一个错误或警告是否是第一个问题的后果,或者它是否是一个需要单独解决的独立问题。

处理编译错误的最佳实践

最佳实践 在处理程序中的编译错误或警告时,先解决第一个列出的问题,然后再次编译。

解决方案:两种常见方法

要解决这个问题,我们需要解决编译器不知道 add 是什么的事实。有两种常见的解决方法。

选项1:重新排序函数定义

解决这个问题的一种方法是重新排序函数定义,以便在 main 之前定义 add:

#include <iostream>

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

int main()
{
    std::cout << "3 和 4 的和是:" << add(3, 4) << '\n';
    return 0;
}

这样,当 main 调用 add 时,编译器已经知道 add 是什么了。因为这个程序非常简单,所以这个改变相对容易做到。然而,在更大的程序中,试图弄清楚哪些函数调用哪些其他函数(以及它们的顺序)以便它们可以顺序声明可能会很繁琐。

重新排序的局限性

此外,这个选项并不总是可能的。假设我们正在编写一个有两个函数 A 和 B 的程序。如果函数 A 调用函数 B,而函数 B 调用函数 A,那么就没有办法按顺序排列函数以使编译器满意。如果你先定义 A,编译器会抱怨它不知道 B 是什么。如果你先定义 B,编译器会抱怨它不知道 A 是什么。

选项2:使用向前声明

我们也可以通过对函数使用向前声明来解决这个问题。 向前声明允许我们在实际定义标识符之前告诉编译器标识符的存在。 在函数的情况下,这允许我们在定义函数体之前告诉编译器函数的存在。这样,当编译器遇到对函数的调用时,它会理解我们正在进行函数调用,并可以检查确保我们正确地调用了函数,即使它还不知道函数是如何或在哪里定义的。

函数声明语法

要编写函数的向前声明,我们使用函数声明语句(也称为函数原型)。函数声明由函数的返回类型、名称和参数类型组成,以分号结束。参数的名称可以可选地包括在内。声明中不包括函数体。 以下是 add 函数的函数声明:

int add(int x, int y); // 函数声明包括返回类型、名称、参数和分号。没有函数体!

使用向前声明的示例

现在,这里是我们原始的无法编译的程序,使用函数声明作为函数 add 的向前声明:

#include <iostream>

int add(int x, int y); // 对 add() 的向前声明(使用函数声明)

int main()
{
    std::cout << "3 和 4 的和是:" << add(3, 4) << '\n'; // 这有效,因为我们在上面向前声明了 add()
    return 0;
}

int add(int x, int y) // 即使 add() 的体在这里才定义
{
    return x + y;
}

现在当编译器到达 main 中对 add 的调用时,它会知道 add 的样子(一个接受两个整数参数并返回整数的函数),它不会抱怨。

函数声明的参数命名

值得注意的是,函数声明不需要指定参数的名称(因为它们不被认为是函数声明的一部分)。在上面的代码中,你也可以像这样向前声明你的函数:

int add(int, int); // 有效的函数声明

然而,我们更喜欢命名我们的参数(使用与实际函数相同的名称)。这允许你仅通过查看声明就能理解函数参数是什么。例如,如果你看到声明 void doSomething(int, int, int),你可能认为你记得每个参数代表什么,但你也可能记错。

参数命名的最佳实践

最佳实践 在函数声明中保留参数名称。

提示 你可以通过复制/粘贴函数的头部并添加分号来轻松创建函数声明。

为什么使用向前声明?

你可能想知道,如果我们可以通过重新排序函数来使程序工作,为什么要使用向前声明。

跨文件函数调用

大多数情况下,向前声明用于告诉编译器某个函数的存在,该函数在不同的代码文件中定义。在这种情况下,重新排序是不可能的,因为调用者和被调用者位于完全不同的文件中!

提高代码组织性

向前声明也可以用于以无序的方式定义我们的函数。这允许我们以最大化组织(例如,通过将相关函数聚集在一起)或读者理解的顺序定义函数。

解决循环依赖

较少的情况下,我们有两个相互调用的函数。在这种情况下,重新排序也是不可能的,因为没有一种方法可以重新排序函数,使得每个函数都在另一个函数之前。向前声明为我们提供了一种解决这种循环依赖的方法。

忘记函数体的后果

新程序员经常想知道,如果他们向前声明了一个函数但没有定义它会发生什么。

答案是:这取决于。如果进行了向前声明,但函数从未被调用,程序将编译和运行得很好。然而,如果进行了向前声明并且函数被调用,但程序从未定义该函数,程序将编译得很好,但链接器会抱怨它无法解析函数调用。

考虑以下程序:

#include <iostream>

int add(int x, int y); // 对 add() 的向前声明

int main()
{
    std::cout << "3 和 4 的和是:" << add(3, 4) << '\n';
    return 0;
}

// 注意:没有函数 add 的定义

在这个程序中,我们向前声明了 add,我们调用了 add,但我们从未在任何地方定义 add。当我们尝试编译这个程序时,Visual Studio 产生了以下消息: 编译… add.cpp 链接… add.obj : error LNK2001: 无法解析的外部符号 “int __cdecl add(int,int)” (?add@@YAHHH@Z) add.exe : 致命错误 LNK1120: 1 个无法解析的外部符号

如你所见,程序编译得很好,但在链接阶段失败了,因为 int add(int, int) 从未被定义。

其他类型的向前声明

向前声明最常用于函数。然而,向前声明也可以用于 C++ 中的其他标识符,如变量和类型。变量和类型的向前声明有不同的语法,因此我们将在以后的课程中介绍这些。

声明与定义的区别

在 C++ 中,你经常会听到"声明"和"定义"这两个词被使用,并且经常互换使用。它们是什么意思?你现在已经有了足够的基础知识来理解这两者之间的区别。

声明的概念

声明告诉编译器关于标识符及其关联的类型信息的存在。以下是一些声明的例子:

int add(int x, int y); // 告诉编译器一个名为 "add" 的函数,它接受两个 int 参数并返回一个 int。没有体!
int x;                 // 告诉编译器一个名为 x 的整数变量

定义的概念

定义是一个实际实现(对于函数和类型)或实例化(对于变量)标识符的声明。

以下是一些定义的例子:

// 因为这个函数有体,它是函数 add() 的实现
int add(int x, int y)
{
    int z{ x + y };   // 实例化变量 z

    return z;
}

int x;                // 实例化变量 x

在 C++ 中,所有定义都是声明。因此 int x; 既是定义也是声明。 相反,并非所有声明都是定义。不是定义的声明称为纯声明。纯声明的类型包括函数、变量和类型的向前声明。

术语使用说明

在通用语言中,术语"声明"通常用来表示"纯声明",而"定义"用来表示既是定义又是声明的东西。因此,我们通常会称 int x; 为定义,即使它既是定义又是声明。

编译器与标识符的关系

当编译器遇到一个标识符时,它会检查以确保该标识符的使用是有效的(例如,标识符是否在作用域内,它是否以语法上有效的方式使用等…)。

在大多数情况下,声明足以允许编译器确保标识符被正确使用。例如,当编译器遇到函数调用 add(5, 6) 时,如果它已经看到了 add(int, int) 的声明,那么它可以验证 add 实际上是一个接受两个 int 参数的函数。它不需要实际看到函数 add 的定义(可能存在于其他文件中)。

然而,在一些情况下,编译器必须能够看到完整的定义才能使用一个标识符(例如模板定义和类型定义,我们将在以后的课程中讨论)。

声明与定义总结表

术语技术含义示例
声明告诉编译器关于一个标识符及其关联的类型信息。void foo(); // 函数向前声明(没有体)void goo() {}; // 函数定义(有体)int x; // 变量定义
定义实现一个函数或实例化一个变量。定义也是声明。void foo() { } // 函数定义(有体)int x; // 变量定义
纯声明不是定义的声明。void foo(); // 函数向前声明(没有体)
初始化为定义的对象提供初始值。int x { 2 }; // x 初始化为值 2

“声明"这个术语通常用来表示"纯声明”,而"定义"这个术语用于表示既是定义又是声明的东西。我们在示例列注释中使用这种通用术语。

单一定义规则(ODR)

单一定义规则(或简称 ODR)是 C++ 中一个众所周知的规则。ODR 有三个部分:

  1. 在一个文件中,给定作用域中的每个函数、变量、类型或模板只能有一个定义。在不同作用域中出现的定义(例如,在不同函数中定义的局部变量,或在不同命名空间中定义的函数)不违反此规则。

  2. 在一个程序中,给定作用域中的每个函数或变量只能有一个定义。这条规则存在是因为程序可以有多个文件(我们将在下一课中介绍)。对链接器不可见的函数和变量不包括在此规则中

  3. 类型、模板、内联函数和内联变量允许在不同文件中有重复的定义,只要每个定义都是相同的。我们还没有涵盖这些事物中的大多数,所以现在不用担心这个 – 我们会在相关时再提。

ODR违反的后果

违反 ODR 第一部分会导致编译器发出重新定义错误。违反 ODR 第二部分会导致链接器发出重新定义错误。违反 ODR 第三部分会导致未定义行为。

以下是一个违反第一部分的例子:

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

int add(int x, int y) // 违反 ODR,我们已经定义了函数 add(int, int)
{
     return x + y;
}

int main()
{
    int x{};
    int x{ 5 }; // 违反 ODR,我们已经定义了 x
    return 0;
}

在这个例子中,函数 add(int, int) 在全局作用域中定义了两次,局部变量 int x 在 main() 的作用域中定义了两次。因此,Visual Studio 编译器发出了以下编译错误:

project3.cpp(9): error C2084: function 'int add(int,int)' already has a body
project3.cpp(3): note: see previous definition of 'add'
project3.cpp(16): error C2086: 'int x': redefinition
project3.cpp(15): note: see declaration of 'x'

然而,main() 有一个局部变量定义为 int x,add() 也有一个函数参数定义为 int x,这不是违反 ODR 第一部分的行为。这些定义发生在不同的作用域中(在各自函数的作用域中),因此它们被认为是两个不同对象的独立定义,而不是同一个对象的定义和重新定义。

共享一个标识符但具有不同参数集的函数也被认为是不同的函数,所以这样的定义不违反 ODR。

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

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

公众号二维码

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