C++中的前向声明和定义

在C++中,Forward declarations(前向声明)是一种声明一个标识符(通常是一个类、函数、变量等)的方法,而不提供其完整的定义。使用前向声明可以减少编译时间,因为编译器不需要查看完整的定义,只需要知道标识符的存在和类型信息就足够了。但是,需要谨慎使用前向声明,因为如果你在使用前向声明的标识符时依赖于其成员或实际定义,可能会导致编译错误。

在C++中,Forward declarations(前向声明)是一种声明一个标识符(通常是一个类、函数、变量等)的方法,而不提供其完整的定义。这允许你在使用这个标识符之前告诉编译器它的存在,而不必提供详细的信息。Forward declarations通常用于解决循环依赖或提高编译速度的问题。

看看这个示例程序:

#include <iostream>

int main()
{
    std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
    return 0;
}

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

您希望该程序产生以下结果:

The sum of 3 and 4 is: 7

但事实上,它根本无法编译!Visual Studio 产生以下编译错误:

add.cpp(5) : 错误 C3861: 'add': 找不到标识符

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

旧版本的 Visual Studio 会产生额外的错误:

add.cpp(9) : 错误 C2365: '添加'; :重新定义;以前的定义是“以前未知的标识符”

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

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

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

解决该问题的一种方法是重新排序函数定义,因此add在main之前定义:

#include <iostream>

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

int main()
{
    std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
    return 0;
}

这样,当main调用add时,编译器已经知道add是什么。因为这是一个非常简单的程序,所以这种更改相对容易完成。但是,在较大的程序中,尝试找出哪些函数调用哪些其他函数(以及以什么顺序)以便可以按顺序声明它们可能会很乏味。

此外,这种选择并不总是可行。假设我们正在编写一个具有两个函数AB的程序。如果函数A调用函数B,并且函数B调用函数A,则无法以让编译器满意的方式对函数进行排序。如果你先定义A,编译器会抱怨它不知道B是什么。如果你先定义B,编译器会抱怨它不知道A是什么。

选项 2:使用前向声明

我们还可以通过使用前向声明来解决这个问题。

前向声明允许我们在实际定义标识符之前告诉编译器标识符的存在。

对于函数来说,这允许我们在定义函数体之前告诉编译器函数的存在。这样,当编译器遇到对函数的调用时,它会理解我们正在进行函数调用,并且可以检查以确保我们正确调用该函数,即使它还不知道如何或在哪里函数已定义。

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

这是add函数的函数声明:

int add(int x, int y); // function declaration includes return type, name, parameters, and semicolon.  No function body!

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

#include <iostream>

int add(int x, int y); // forward declaration of add() (using a function declaration)

int main()
{
    std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // this works because we forward declared add() above
    return 0;
}

int add(int x, int y) // even though the body of add() isn't defined until here
{
    return x + y;
}

现在,当编译器到达 main 中对add 的调用时,它将知道add是什么样的(一​​个接受两个整数参数并返回一个整数的函数)。

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

int add(int, int); // valid function declaration

然而,我们更喜欢命名我们的参数(使用与实际函数相同的名称),因为它可以让你通过查看声明来理解函数参数是什么。否则,您必须找到函数定义。

为什么要前向声明?

您可能想知道如果我们可以重新排序函数以使我们的程序正常工作,为什么我们要使用前向声明。

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

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

不太常见的是,有时我们有两个相互调用的函数。在这种情况下也不可能进行重新排序,因为无法对函数进行重新排序,使每个函数都位于另一个函数之前。前向声明为我们提供了一种解决这种循环依赖的方法。

忘记函数体

新程序员经常想知道如果他们转发声明一个函数但不定义它会发生什么。

答案是:视情况而定。如果进行了前向声明,但从未调用该函数,则程序将编译并正常运行。但是,如果进行了前向声明并调用了函数,但程序从未定义该函数,则程序可以正常编译,但链接器无法解析函数调用。

例如:

#include <iostream>

int add(int x, int y); // forward declaration of add()

int main()
{
    std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
    return 0;
}

// note: No definition for function add

在这个程序中,我们前向声明了add,并调用add,但我们从未在任何地方定义add。当我们尝试编译该程序时,Visual Studio 会生成以下消息:

正在编译... 
add.cpp
链接... 
add.obj : 错误 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 x;                 // 告诉编译器有一个整数变量名为 x

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

以下是一些定义示例:

int add(int x, int y) // 实现函数 add()
{
    int z{ x + y };   // 实例化变量 z

    return z;
}

int x;                // 实例化变量 x

在 C++ 中,所有定义都是声明。因此int x;它既是定义也是声明。

相反,并非所有声明都是定义。不是定义的声明称为纯声明。纯声明的类型包括函数、变量和类型的前向声明。

当编译器遇到标识符时,它将检查以确保该标识符的使用有效(例如,标识符在范围内,以语法上有效的方式使用,等等)。

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

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

这是一个汇总表:

名词定义例子
定义实现一个函数或实例化一个变量。
定义也是声明。
void foo() { } // 函数定义
int x; // 变量定义
声明告诉编译器有关标识符的信息。
不创建对象或函数。
无效 foo(); // 函数声明
int x; // 变量声明
纯粹声明不是定义的声明。无效 foo();
初始化器为已定义的对象提供初始值。int x { 2 }; // 2 是初始化器

单一定义规则 (ODR)

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

  1. 在给定文件中,函数、变量、类型或模板只能有一个定义。
  2. 在给定的程序中,变量或普通函数只能有一个定义。做出这种区别是因为程序可以有多个文件(我们将在下一课中介绍这一点)。
  3. 类型、模板、内联函数和内联变量允许在不同文件中具有相同的定义。我们还没有介绍其中大部分内容,所以现在不用担心——我们会在相关的时候重新介绍它。

违反 ODR 第 1 部分将导致编译器发出重定义错误。违反 ODR 第 2 部分将导致链接器发出重新定义错误或导致未定义的行为。违反 ODR 第 3 部分将导致未定义的行为。

以下是违反第 1 部分的示例:

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

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

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

由于上述程序违反了 ODR 第 1 部分,因此这会导致 Visual Studio 编译器发出以下编译错误:

project3.cpp(9): 错误 C2084: 函数“int add(int,int)”已经有一个主体
project3.cpp(3): 注意:请参阅“add”的先前定义
project3.cpp(16): 错误C2086: 'int x':重新定义
project3.cpp(15):注意:请参阅'x'的声明

ODR 不适用于纯声明(它是一个定义规则,而不是一个声明规则),因此您可以根据需要为标识符拥有任意多个纯声明(尽管拥有多个纯声明是多余的)。

原创文章,作者:jkhxw,如若转载,请注明出处:https://www.jkhxw.com/cpp-forward-declarations/

(0)
上一篇 2023年10月7日
下一篇 2023年10月8日

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注