问题引入:函数调用顺序
让我们来看一个看似无害的示例程序:
#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 有三个部分:
在一个文件中,给定作用域中的每个函数、变量、类型或模板只能有一个定义。在不同作用域中出现的定义(例如,在不同函数中定义的局部变量,或在不同命名空间中定义的函数)不违反此规则。
在一个程序中,给定作用域中的每个函数或变量只能有一个定义。这条规则存在是因为程序可以有多个文件(我们将在下一课中介绍)。对链接器不可见的函数和变量不包括在此规则中
类型、模板、内联函数和内联变量允许在不同文件中有重复的定义,只要每个定义都是相同的。我们还没有涵盖这些事物中的大多数,所以现在不用担心这个 – 我们会在相关时再提。
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。