2 函数和文件
2.1 函数
更新一下定义:函数是一串用于完成特定任务的可重用的语句序列,您自己编写的函数称为用户定义函数。
returnType functionName() // This is the function header (tells the compiler about the existence of the function)
{
// This is the function body (tells the compiler what the function does)
}
第一行称为函数头(非正式),它告诉编译器函数的存在、函数的名称和一些其他信息。
花括号和其间的语句被称为函数体,放置决定函数功能的代码。
函数必须先定义才能被调用。
#include <iostream> // for std::cout
// Definition of user-defined function doPrint()
// doPrint() is the called function in this example
void doPrint()
{
std::cout << "In doPrint()\n";
}
// Definition of user-defined function main()
int main()
{
std::cout << "Starting main()\n";
doPrint(); // Interrupt main() by making a function call to doPrint(). main() is the caller.
std::cout << "Ending main()\n"; // This statement is executed after doPrint() ends
return 0;
}
c++不支持嵌套函数,函数内不能再定义其他函数,以下是错误示例:
#include <iostream>
int main()
{
void foo() // Illegal: this function is nested inside function main()
{
std::cout << "foo!\n";
}
foo(); // function call to foo()
return 0;
}
2.2 函数的返回值
当你编写用户定义函数时,你需要决定函数是否返回一个值给调用者。要返回一个值给调用者,需要满足两个条件。
首先,你的函数必须指明返回值的类型。这可以通过设置函数的返回类型来实现,返回类型是在函数名称前定义的类型。
其次,在返回值的函数内部,我们使用return 语句来指示返回给调用者的具体值。return 语句由return
关键字 ,后跟一个表达式(有时称为返回表达式)组成,并以分号结尾。
当执行return语句时:
- 对返回表达式进行求值以产生一个值。
- 返回表达式产生的值会被复制回给调用者。这个副本被称为函数的返回值。
- 函数退出,控制权返回给调用者。
将复制的值返回给调用者的过程称为按值返回。
当被调用函数返回一个值时,调用者可以决定在表达式或语句中使用该值(例如,用它来初始化变量,或将其传递给std::cout
),或者忽略它(不执行任何其他操作)。如果调用者忽略了返回值,则该值将被丢弃(不执行任何操作)。
现在你已经可以理解main()
函数的实际工作原理。程序执行时,操作系统会调用main()
。然后,执行会跳转到 的顶部main()
。 中的语句main()
会按顺序执行。最后,main()
返回一个整数值(通常为0
),程序终止。
在 C++ 中,main()
有两个特殊要求:
main()
需要返回一个int
。main()
不允许显式调用函数。
void foo()
{
main(); // Compile error: main not allowed to be called explicitly
}
void main() // Compile error: main not allowed to have non-int return type
{
foo();
}
一个常见的误解是main
始终执行第一个函数。
全局变量在main
执行之前初始化。如果此类变量的初始化器调用了一个函数,那么该函数将在执行之前执行main
。
main()
返回值有时被称为状态码(或者不太常见地被称为退出码,或者很少被称为返回码)。状态码用于指示程序是否成功。
非零状态代码通常用于指示某种故障(虽然这在大多数操作系统上运行良好,但严格来说,它不能保证可移植)。
C++标准只定义了3个状态码的含义:0
、EXIT_SUCCESS
、EXIT_FAILURE
。0
和EXIT_SUCCESS
都表示程序执行成功。EXIT_FAILURE
表示程序执行失败。
状态码会被传回操作系统。操作系统通常会将状态码提供给启动了返回该状态码的程序的程序。这为任何启动其他程序的程序提供了一种粗略的机制,可以判断被启动的程序是否成功运行。
返回值的函数称为值返回函数。如果返回类型不是void
,则该函数为值返回函数。
不返回值的值返回函数将产生未定义的行为。在大多数情况下,编译器会检测你是否忘记返回值。然而,在某些复杂的情况下,编译器可能无法在所有情况下都正确判断你的函数是否返回值,所以你不应该依赖编译器。
值返回函数必须通过 return 语句返回值这一规则的唯一例外是main()
函数。如果未提供return语句,该函数main()
将隐式返回该值0
。也就是说,最佳做法是显式地从main
中返回一个值,这既可以表明你的意图,也可以与其他函数保持一致(如果未指定返回值,其他函数将表现出未定义的行为)。
值返回函数每次调用时只能向调用者返回一个值。
请注意,return 语句中提供的值不必是字面量——它可以是任何有效表达式的结果,包括变量,甚至是对另一个返回值的函数的调用。
2.3 Void函数
有些函数无需向调用者返回值。为了告知编译器函数不返回值,可以使用void作为返回类型。例如:
#include <iostream>
// void means the function does not return a value to the caller
void printHi()
{
std::cout << "Hi" << '\n';
// This function does not return a value so no return statement is needed
}
int main()
{
printHi(); // okay: function printHi() is called, no value is returned
return 0;
}
在上面的例子中,printHi
函数有一个有用的行为(它打印“Hi”),但它不需要向调用者返回任何内容。因此,printHi
它被赋予了一个void
返回类型。
不返回值的函数称为无值返回函数(或void 函数)。void 函数将在函数结束时自动返回给调用者。无需 return 语句。
尝试从非值返回函数返回值将导致编译错误:
void printHi() // This function is non-value returning
{
std::cout << "In printHi()" << '\n';
return 5; // compile error: we're trying to return a value
}
2.4 函数形参和参数
如果我们想编写一个函数来将两个数字相加,我们需要某种方式在调用函数时告诉它要将哪两个数字相加。否则,函数怎么知道要加什么呢?我们通过函数形参和实参来实现这一点。
函数参数是函数头中使用的变量。函数参数的工作方式与函数内部定义的变量几乎相同,但有一个区别:它们由函数调用者提供的值初始化。
函数参数在函数头中定义,放在函数名后的括号内,多个参数之间用逗号分隔。
以下是具有不同数量参数的函数的一些示例:
// This function takes no parameters
// It does not rely on the caller for anything
void doPrint()
{
std::cout << "In doPrint()\n";
}
// This function takes one integer parameter named x
// The caller will supply the value of x
void printValue(int x)
{
std::cout << x << '\n';
}
// This function has two integer parameters, one named x, and one named y
// The caller will supply the value of both x and y
int add(int x, int y)
{
return x + y;
}
当调用一个函数时,该函数的所有形参都会被创建为变量,并且每个实参的值都会被复制到对应的形参中(使用复制初始化)。这个过程称为值传递。使用值传递的函数形参称为值形参。例如:
#include <iostream>
// This function has two integer parameters, one named x, and one named y
// The values of x and y are passed in by the caller
void printValues(int x, int y)
{
std::cout << x << '\n';
std::cout << y << '\n';
}
int main()
{
printValues(6, 7); // This function call has two arguments, 6 and 7
return 0;
}
当使用参数6
和7
调用函数printValues
时,将创建printValues
的参数x
并将其初始化为值6
,同时将创建printValues
的参数y
并将其初始化为值7
。
输出结果如下:
6
7
请注意,实参的数量通常必须与函数形参的数量匹配,否则编译器会抛出错误。传递给函数的实参可以是任何有效的表达式(因为实参本质上只是形参的初始化器,而初始化器可以是任何有效的表达式)。
2.5 局部作用域
2.5.1 局部变量
在函数体内定义的变量称为局部变量:
int add(int x, int y)
{
int z{ x + y }; // z is a local variable
return z;
}
函数参数通常也被认为是局部变量,我们将其作为以下变量:
int add(int x, int y) // function parameters x and y are local variables
{
int z{ x + y };
return z;
}
2.5.2 局部变量的生命周期
函数参数在进入函数时创建并初始化,函数体内的变量在定义时创建并初始化。例如:
int add(int x, int y) // x and y created and initialized here
{
int z{ x + y }; // z created and initialized here
return z;
}
那么,实例化的变量何时会被销毁?局部变量的销毁顺序与创建顺序相反,是在定义它的花括号的末尾(或者,对于函数参数来说,是在函数的末尾)。
int add(int x, int y)
{
int z{ x + y };
return z;
} // z, y, and x destroyed here
对象的生命周期被定义为从创建到销毁的时间。需要注意的是,变量的创建和销毁发生在程序运行时(称为运行时),而不是编译时。因此,生命周期是一个运行时属性。
上述关于创建、初始化和销毁的规则是保证。也就是说,对象的创建和初始化必须不晚于其定义点,并且销毁必须不早于其定义处的花括号集合的末尾(或者,对于函数参数,则在函数末尾)。
实际上,C++ 规范赋予编译器很大的灵活性来决定局部变量的创建和销毁时间。对象可以更早创建,也可以为了优化而稍后销毁。通常,局部变量在函数进入时创建,并在函数退出时以与创建相反的顺序销毁。在销毁后的某个时刻,对象使用的内存将被释放(释放以供重用)。
当一个物体被毁坏时会发生什么?大多数情况下,什么也不会发生。被销毁的对象只是变得无效而已。如果对象是类类型对象,则在销毁之前,会调用一个称为析构函数的特殊函数。很多情况下,析构函数不执行任何操作,因此不会产生任何开销。
2.5.3 局部作用域(块作用域)
标识符的作用域决定了它在源代码中可以被查看和使用的位置。当一个标识符可以被查看和使用时,我们称它处于作用域内 (in scope)。当一个标识符无法被查看和使用时,我们就无法使用它,我们称它处于作用域外 (out of scope)。作用域是一个编译时属性,尝试使用不在作用域内的标识符将导致编译错误。
局部变量的标识符具有局部作用域。具有局部作用域(技术上称为块作用域)的标识符的有效范围从其定义点到包含该标识符的最内层一对花括号的末尾(对于函数参数,则为函数末尾)。这确保了局部变量在定义点之前(即使编译器选择在此之前创建它们)或销毁之后都无法使用。在一个函数中定义的局部变量在被调用的其他函数中也不在作用域内。
以下程序演示了名为x
的变量的作用域:
#include <iostream>
// x is not in scope anywhere in this function
void doSomething()
{
std::cout << "Hello!\n";
}
int main()
{
// x can not be used here because it's not in scope yet
int x{ 0 }; // x enters scope here and can now be used within this function
doSomething();
return 0;
} // x goes out of scope here and can no longer be used
2.5.4 “Out of scope” vs “going out of scope”
标识符在代码中任何无法访问的地方都超出了作用域(Out of scope)。
“going out of scope”这个术语通常适用于对象,而不是标识符。一个对象在其实例化的作用域的末尾(函数末尾的花括号)超出作用域(going out of scope)。
局部变量的生存期在其超出范围时(goes out of scope)结束,因此局部变量此时会被销毁。请注意,并非所有类型的变量在超出作用域时都会被销毁。
#include <iostream>
int add(int x, int y) // x and y are created and enter scope here
{
// x and y are usable only within add()
return x + y;
} // y and x go out of scope and are destroyed here
int main()
{
int a{ 5 }; // a is created, initialized, and enters scope here
int b{ 6 }; // b is created, initialized, and enters scope here
// a and b are usable only within main()
std::cout << add(a, b) << '\n'; // calls add(5, 6), where x=5 and y=6
return 0;
} // b and a go out of scope and are destroyed here
请注意,如果函数add
被调用两次,参数x
和变量y
也会被创建和销毁两次——每次调用一次。在一个包含大量函数和函数调用的程序中,变量的创建和销毁是频繁的。
函数参数或函数体中声明的变量的名称仅在声明它们的函数内部可见。这意味着函数内的局部变量的命名无需考虑其他函数中变量的名称。这有助于保持函数的独立性。
在现代 C++ 中,最佳实践是函数体内的局部变量应尽可能合理地定义在靠近第一次使用的位置:
#include <iostream>
int main()
{
std::cout << "Enter an integer: ";
int x{}; // x defined here
std::cin >> x; // and used here
std::cout << "Enter another integer: ";
int y{}; // y defined here
std::cin >> y; // and used here
int sum{ x + y }; // sum can be initialized with intended value
std::cout << "The sum is: " << sum << '\n';
return 0;
}
2.5.5 临时对象
临时对象(有时也称为匿名对象)是一个未命名的对象,用于保存仅在短时间内需要的值。临时对象由编译器在需要时生成。
创建临时值的方法有很多种,但以下是一种常见的方法:
#include <iostream>
int getValueFromUser()
{
std::cout << "Enter an integer: ";
int input{};
std::cin >> input;
return input; // return the value of input back to the caller
}
int main()
{
std::cout << getValueFromUser() << '\n'; // where does the returned value get stored?
return 0;
}
在上面的程序中,函数getValueFromUser()
将存储在局部变量中的值input
返回给调用者。由于input
局部变量会在函数结束时被销毁,因此调用者会收到该值的副本,这样即使在被销毁之后,调用者仍然可以使用这个值input
。
但是复制回调用者的值存储在哪里呢?我们没有在main()
中定义任何变量。答案是返回值存储在一个临时对象中。这个临时对象随后被传递给std::cout
进行打印。
按值返回会向调用者返回一个临时对象(其中包含返回值的副本)。
临时对象根本没有范围(这是有道理的,因为范围是标识符的属性,而临时对象没有标识符)。临时对象在创建它们的完整表达式结束时被销毁。这意味着临时对象总是在下一个语句执行之前被销毁。
在现代 C++ 中(尤其是 C++ 17 之后),编译器使用了许多技巧来避免在以前需要的地方生成临时变量。例如,当我们使用返回值初始化变量时,通常会导致创建一个临时变量来保存返回值,然后使用该临时变量来初始化变量。然而,在现代 C++ 中,编译器通常会跳过创建临时变量的步骤,直接用返回值初始化变量。
2.6 使用函数
我们不能把所有代码都放在main
函数里吗?对于简单的程序来说,完全可以。然而,函数有很多优点,使得它们在长度或复杂度都相当高的程序中非常有用:
- 组织——随着程序复杂度的增加,将所有代码都放在 main() 函数中变得越来越复杂。函数就像一个微型程序,我们可以将其与主程序分开编写,而无需在编写时考虑程序的其余部分。这使我们能够将复杂的程序分解成更小、更易于管理的块,从而降低程序的整体复杂性。
- 可重用性——函数编写完成后,可以在程序内部多次调用。这避免了代码重复(“不要重复”),并最大限度地降低了复制/粘贴错误的可能性。函数还可以与其他程序共享,从而减少每次从头编写(并重新测试)的代码量。
- 测试——由于函数减少了代码冗余,因此首先需要测试的代码就更少了。此外,由于函数是独立的,一旦我们测试过某个函数并确保其正常工作,除非对其进行了修改,否则无需再次测试。这减少了我们一次需要测试的代码量,从而更容易发现错误(或从一开始就避免错误)。
- 可扩展性——当我们需要扩展程序来处理以前没有处理过的情况时,函数允许我们在一个地方进行更改,并使该更改在每次调用该函数时生效。
- 抽象——要使用一个函数,你只需要知道它的名称、输入、输出以及它的位置。你不需要知道它是如何工作的,也不需要知道它依赖于哪些其他代码来使用它。这降低了使用其他人的代码(包括标准库中的所有内容)所需的知识量。
何时以及如何有效地使用函数:
- 程序中多次出现的语句组通常应该写成函数。例如,如果我们以相同的方式多次读取用户的输入,那么就很适合用函数来实现。如果我们在多个地方以相同的方式输出某些内容,那么也非常适合用函数来实现。
- 具有明确定义的输入和输出的代码非常适合用作函数(尤其是在代码比较复杂的情况下)。例如,如果我们有一个需要排序的项目列表,那么用于执行排序的代码即使只执行一次,也能成为一个很棒的函数。输入是未排序的列表,输出是已排序的列表。另一个适合用作函数的代码是模拟六面骰子掷法的代码。您当前的程序可能只会在一个地方用到它,但如果您将其转换为函数,那么在以后扩展程序或将来的程序中,它就可以被重复使用。
- 一个函数通常应该执行一项(且仅一项)任务。新程序员经常将计算值和打印计算值合并到一个函数中。然而,这违反了函数“单一任务”的经验法则。计算值的函数应该将值返回给调用者,并让调用者决定如何处理计算值。
- 当一个函数变得太长、太复杂或难以理解时,可以将其拆分成多个子函数。这称为重构。
2.7 前向声明和定义
2.7.1 前向声明
看一下这个看似无辜的示例程序:
#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;
}
实际上,它根本编译不出来,Visual Studio 会产生以下编译错误:
add.cpp(5) : error C3861: 'add': identifier not found
这个程序编译不通过的原因是编译器是按顺序编译代码文件内容的。当编译器运行到main
函数第 5 行的add()
函数调用时,它不知道add
是什么,因为直到第 9 行我们才定义add
。这会导致错误:identifier not found
。
而为了解决该问题,除了将函数前置,还可以通过前向声明来解决问题。前向声明通常用于告知编译器某个函数已在其他代码文件中定义。在这种情况下,无法进行重新排序,因为调用者和被调用者位于完全不同的文件中。偶尔,我们也会遇到两个函数互相调用的情况。在这种情况下,重新排序也是不可能的,因为没有办法重新排序函数,使它们一个在另一个之前。前向声明为我们提供了一种解决这种循环依赖的方法。
前向声明允许我们在实际定义标识符之前告知编译器该标识符的存在。这样,当编译器遇到对该函数的调用时,它就能理解我们正在进行函数调用,并可以检查以确保我们正确地调用了该函数,即使它还不知道该函数是如何定义的以及在哪里定义的。
函数声明包括返回类型,名称,参数和分号。 没有函数主体!
现在,这是使用函数声明作为函数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;
}
值得注意的是,函数声明不需要指定参数的名称(因为它们不被视为函数声明的一部分)。在上面的代码中,你也可以像这样预先声明你的函数:
int add(int, int); // valid function declaration
但是,我们更喜欢为参数命名(使用与实际函数相同的名称)。这样,您只需查看声明即可了解函数参数的含义。例如,如果您看到声明void doSomething(int, int, int)
,您可能认为自己记得每个参数代表什么,但也可能记错。
此外,许多自动文档生成工具还会根据头文件的内容生成文档,而声明通常位于头文件的位置。
新程序员经常想知道如果他们预先声明一个函数但没有定义它会发生什么。
答案是:视情况而定。如果进行了前向声明,但函数从未被调用,程序将能够编译并正常运行。但是,如果进行了前向声明并调用了函数,但程序从未定义过该函数,程序虽然可以编译,但链接器会报错,无法解析函数调用。
前向声明最常用于函数。然而,前向声明也可以用于 C++ 中的其他标识符,例如变量和类型。变量和类型的前向声明语法有所不同,
2.7.2 声明与定义
声明会告知编译器标识符的存在及其关联的类型信息。以下是一些声明的示例:
int add(int x, int y); // tells the compiler about a function named "add" that takes two int parameters and returns an int. No body!
int x; // tells the compiler about an integer variable named x
定义是实际上实现(对于函数和类型)或实例化(对于变量)标识符的声明:
// because this function has a body, it is an implementation of function add()
int add(int x, int y)
{
int z{ x + y }; // instantiates variable z
return z;
}
int x; // instantiates variable x
在 C++ 中,所有定义都是声明。因此int x;
既是定义,也是声明。
当编译器遇到标识符时,它将检查确保该标识符的使用是有效的(例如,标识符在范围内,以语法有效的方式使用等…)。大多数情况下,声明足以让编译器确保标识符被正确使用。但是,在某些情况下,编译器必须能够看到完整的定义才能使用标识符(例如模板定义和类型定义),以下是一个汇总表:
Term | Technical Meaning | Examples |
---|---|---|
声明 | 告诉编译器有关标识符及其相关类型信息。 | void foo(); // 函数前向声明(无函数体)void goo() {}; // 函数定义(有函数体)int x; // 变量定义 |
定义 | 实现一个函数或实例化一个变量。 定义也是一种声明。 | void foo() { } // 函数定义(有函数体)int x; // 变量定义 |
纯声明 | 声明不是定义。 | void foo(); // 函数前向声明(无函数体) |
初始化 | 为定义的对象提供初始值。 | int x { 2 }; // x 初始化为值 2 |
术语“声明”通常用于表示“纯声明”,而术语“定义”则用于表示既是定义又是声明的事物。
2.7.3 单一定义规则(ODR)
ODR 包含三个部分:
- 在一个文件中,给定作用域内的每个函数、变量、类型或模板只能有一个定义。出现在不同作用域中的定义(例如,在不同函数内定义的局部变量,或在不同命名空间内定义的函数)不违反此规则。
- 在一个程序中,给定作用域内的每个函数或变量只能有一个定义。此规则的存在是因为程序可以包含多个文件。链接器不可见的函数和变量不在此规则范围内。
- 类型、模板、内联函数和内联变量可以在不同的文件中有重复的定义,只要每个定义相同即可。
违反 ODR 的第 1 部分将导致编译器发出重定义错误。违反 ODR 的第 2 部分将导致链接器发出重定义错误。违反 ODR 的第 3 部分将导致未定义的行为。
共享标识符但具有不同参数集的函数也被视为不同的函数,因此此类定义不违反 ODR。
2.8 具有多个代码的程序
将文件添加到项目
随着程序越来越大,为了便于组织或复用,通常会将其拆分成多个文件。使用 IDE 的一个优点是,它能更轻松地处理多个文件。
在2.7中,我们研究了一个无法编译的单文件程序:
#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;
}
当编译器运行到main
函数第 5 行的add
函数调用时,它并不知道add
是什么,因为直到第9行才定义add
。我们的解决方案是重新排序函数(将add
放在最前面),或者对add
使用前向声明。
现在我们了解了多文件程序,可以在项目中添加一个.cpp
源文件:
int add(int x, int y)
{
return x + y;
}
而主程序:
#include <iostream>
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // compile error
return 0;
}
现在编译器可能会先编译add.cpp或main.cpp。无论哪种情况,main.cpp都会编译失败,并出现与上例相同的编译器错误:
main.cpp(5) : error C3861: 'add': identifier not found
原因也完全相同:当编译器到达main.cpp的第5行时,它不知道标识符add
是什么。
这是因为编译器会单独编译每个文件。它不知道其他代码文件的内容,也不会记住之前编译过的代码文件中的内容。因此,即使编译器之前可能见过函数add
的定义(如果它先编译了add.cpp
),它也不会记住。
这种有限的可见性和短暂的记忆是故意的,原因如下:
- 它允许以任何顺序编译项目的源文件。
- 当我们更改源文件时,只需要重新编译该源文件。
- 它减少了不同文件中的标识符之间命名冲突的可能性
此处的解决方案选项与之前相同:将函数add
的定义放在main
函数之前,或者使用前向声明来满足编译器的要求。在本例中,由于函数add
位于另一个文件中,因此无法使用重新排序选项。
这里的解决方案是在主源码中使用前向声明,而add.cpp保持不变:
#include <iostream>
int add(int x, int y); // needed so main.cpp knows that add() is a function defined elsewhere
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
现在,当编译器编译main.cpp时,它就能知道标识符add
是什么,链接器会将main.cpp中对add
的函数调用与add.cpp中对add
函数的定义连接起来。使用此方法,我们可以让文件访问另一个文件中的函数。
因为编译器单独编译每个代码文件(然后忘记它所看到的内容),所以每个使用std::cout
或std::cin
的代码文件都需要#include<iostream>
。
当在表达式中使用标识符时,该标识符必须与其定义相连:
- 如果编译器在正在编译的文件中既没有看到该标识符的前向声明也没有看到该标识符的定义,那么它将在使用该标识符的地方出错。
- 否则,如果同一文件中存在定义,则编译器将把标识符的使用与其定义联系起来。
- 否则,如果定义存在于不同的文件中(并且对链接器可见),则链接器将把标识符的使用连接到其定义。
- 否则,链接器将发出错误,表明无法找到该标识符的定义。
2.9 命名冲突和命名空间
2.9.1 命名冲突
C++ 要求所有标识符都无歧义。如果在同一个程序中引入两个相同的标识符,而编译器或链接器无法区分它们,编译器或链接器就会报错。这种错误通常被称为命名冲突。如果冲突的标识符被引入到同一个文件中,将会导致编译器错误。如果冲突的标识符被引入到属于同一个程序的不同文件中,将会导致链接器错误。下面是一个错误示例:
a.cpp:
#include <iostream>
void myFcn(int x)
{
std::cout << x;
}
main.cpp:
#include <iostream>
void myFcn(int x)
{
std::cout << 2 * x;
}
int main()
{
return 0;
}
当编译器编译该程序时,它将独立编译a.cpp和main.cpp,并且每个文件都可以顺利编译。
然而,当链接器执行时,它会将a.cpp和main.cpp中的所有定义链接在一起,并发现函数myFcn()
的定义存在冲突。链接器随后会中止并报错。请注意,即使myFcn()
从未被调用,也会发生此错误!
大多数命名冲突发生在两种情况下:
- 两个(或多个)同名函数(或全局变量)被引入到属于同一程序的不同文件中。这将导致链接器错误。
- 在同一个文件中引入两个(或多个)同名函数(或全局变量)。这将导致编译器错误。
2.9.2 命名空间
作用域是源代码中的一个区域,其中所有声明的标识符都被视为与其他作用域中声明的名称不同。两个同名的标识符可以在不同的作用域中声明,而不会引起命名冲突。但是,在给定的作用域内,所有标识符必须是唯一的,否则会导致命名冲突。函数体就是作用域的一个例子。两个同名的标识符可以在不同的函数中定义,而不会出现问题——因为每个函数都提供了单独的作用域,所以不会发生冲突。但是,如果你尝试在同一个函数中定义两个同名的标识符,则会导致命名冲突,编译器会报错。
命名空间提供了另一种作用域(称为命名空间作用域),允许在其中声明或定义名称以消除歧义。在命名空间中声明的名称与其他作用域中声明的名称相互隔离,从而使这些名称可以共存而不会发生冲突。
命名空间通常用于在大型项目中对相关标识符进行分组,以确保它们不会无意中与其他标识符冲突。例如,如果您将所有数学函数放在名为math
的命名空间中,那么这些数学函数就不会与命名空间math
外部同名的函数发生冲突。
在 C++ 中,任何未在类、函数或命名空间内定义的名称都被视为隐式定义的命名空间的一部分,该命名空间称为全局命名空间(有时也称为全局范围)。
在上面的示例中,函数main()
和myFcn()
的两个版本都在全局命名空间内定义。示例中遇到的命名冲突是因为 的两个版本myFcn()
最终都位于全局命名空间内,这违反了作用域内所有名称必须唯一的规则。
注意:
- 在全局范围内声明的标识符的范围是从声明点到文件末尾。
- 尽管可以在全局命名空间中定义变量,但通常应该避免这样做。
例如:
#include <iostream> // imports the declaration of std::cout into the global scope
// All of the following statements are part of the global namespace
void foo(); // okay: function forward declaration
int x; // compiles but strongly discouraged: non-const global variable definition (without initializer)
int y { 5 }; // compiles but strongly discouraged: non-const global variable definition (with initializer)
x = 5; // compile error: executable statements are not allowed in namespaces
int main() // okay: function definition
{
return 0;
}
void goo(); // okay: A function forward declaration
2.9.3 std命名空间
最初设计 C++ 时,C++ 标准库中的所有标识符(包括 std::cin 和 std::cout)都可以不带std::
前缀使用(它们是全局命名空间的一部分)。然而,这意味着标准库中的任何标识符都可能与您为自己的标识符(也在全局命名空间中定义)选择的任何名称发生冲突。当您的代码中包含标准库的不同部分时,曾经可以运行的代码可能会突然发生命名冲突。更糟糕的是,在某个版本的 C++ 下编译的代码可能无法在下一个版本的 C++ 下编译,因为引入到标准库中的新标识符可能与已经编写的代码发生命名冲突。因此,C++ 将标准库中的所有功能都移到了名为std
(“standard”的缩写)的命名空间中。
所以std::cout
的名称并非真正的std::cout
。它实际上只是cout
,并且是该标识符所属std
命名空间的名称。
当您使用在非全局命名空间(例如std
命名空间)内定义的标识符时,您需要告诉编译器该标识符位于命名空间内。有几种不同的方法可以做到这一点:
显式命名空间限定符 std::
::
符号是一个称为作用域解析运算符的运算符。符号::
左侧的标识符标识了符号::
右侧名称所在的命名空间。如果符号::
左侧未提供标识符,则假定为全局命名空间。
当标识符包含命名空间前缀时,该标识符称为限定名称。
使用命名空间 std(以及为什么要避免使用它)
访问命名空间内的标识符的另一种方法是使用 using 指令语句。以下是我们原始的带有 using 指令的“Hello world”程序:
#include <iostream>
using namespace std; // this is a using-directive that allows us to access names in the std namespace with no namespace prefix
int main()
{
cout << "Hello world!";
return 0;
}
using 指令允许我们无需使用命名空间前缀即可访问命名空间中的名称。因此,在上面的例子中,当编译器判断标识符cout
是什么时,它会匹配std::cout
,而由于 using 指令的作用,它可以直接通过cout
来访问。
以这种方式使用 using 指令时,我们定义的任何标识符都可能与命名空间中任何同名的标识符冲突。
花括号和缩进代码
在 C++ 中,花括号通常用于划分嵌套在某个作用域内的作用域(花括号也用于一些与作用域无关的用途,例如列表初始化)。例如,在全局作用域内定义的函数使用花括号将该函数的作用域与全局作用域分隔开来。
在某些情况下,花括号外定义的标识符可能仍然是花括号定义的范围的一部分,而不是周围范围的一部分——函数参数就是一个很好的例子。
例如:
#include <iostream> // imports the declaration of std::cout into the global scope
void foo(int x) // foo is defined in the global scope, x is defined within scope of foo()
{ // braces used to delineate nested scope region for function foo()
std::cout << x << '\n';
} // x goes out of scope here
int main()
{ // braces used to delineate nested scope region for function main()
foo(5);
int x { 6 }; // x is defined within the scope of main()
std::cout << x << '\n';
return 0;
} // x goes out of scope here
// foo and main (and std::cout) go out of scope here (the end of the file)
嵌套范围区域内的代码通常缩进一级,这既是为了提高可读性,也是为了帮助表明它存在于单独的范围区域内。
2.10 预处理器
2.10.1 预处理器
在编译之前,每个代码 (.cpp) 文件都会经历一个预处理阶段。在这个阶段,一个称为预处理器的程序会对代码文件的文本进行各种更改。预处理器实际上不会以任何方式修改原始代码文件——相反,它所做的所有更改要么暂时在内存中进行,要么使用临时文件进行。
从历史上看,预处理器是一个独立于编译器的程序,但在现代编译器中,预处理器可以直接内置于编译器本身中。
预处理器的大部分功能都比较平淡。例如,它会删除注释,并确保每个代码文件都以换行符结尾。然而,预处理器有一个非常重要的作用:它负责处理#include
指令。
当预处理器完成代码文件处理后,其结果被称为翻译单元(预处理、编译和链接的整个过程称为翻译)。该翻译单元随后由编译器进行编译。
预处理器运行时,它会从上到下扫描代码文件,查找预处理器指令。预处理器指令(通常简称为*“directives” *)是以#
符号开头、以换行符(而非分号)结尾的指令。这些指令指示预处理器执行某些文本操作任务。请注意,预处理器不理解 C++ 语法——相反,这些指令有自己的语法(在某些情况下类似于 C++ 语法,而在其他情况下则不太相似)。预处理器的最终输出不包含指令——只有指令的输出被传递给编译器。
当*#include*一个文件时,预处理器会用被包含文件的内容替换 #include 指令。然后,被包含的内容会被预处理(这可能会导致其他 #include 指令被递归预处理),最后,文件的其余部分也会被预处理。
考虑以下程序:
#include <iostream>
int main()
{
std::cout << "Hello, world!\n";
return 0;
}
当预处理器运行该程序时,预处理器将#include <iostream>
用名为“iostream”的文件的内容替换,然后预处理包含的内容和文件的其余部分。
2.10.2 宏定义
#define
指令可用于创建宏。在 C++ 中,宏是定义如何将输入文本转换为替换输出文本的规则。
宏有两种基本类型:类对象宏和类函数宏。
类函数宏的作用类似于函数,并且具有类似的用途。它们的使用通常被认为是不安全的,并且它们能做的几乎所有事情都可以由普通函数完成。
类似对象的宏可以通过以下两种方式之一定义:
#define IDENTIFIER
#define IDENTIFIER substitution_text
上面的定义没有替换文本,而下面的定义有。由于它们是预处理器指令(而非语句),请注意,这两种形式都没有以分号结尾。
宏的标识符使用与普通标识符相同的命名规则:它们可以使用字母、数字和下划线,不能以数字开头,也不能以下划线开头。按照惯例,宏名通常全部大写,并用下划线分隔。
带有替换文本的类对象宏
当预处理器遇到此指令时,宏标识符和替换文本之间会建立关联。所有后续出现的宏标识符(除了在其他预处理器命令中使用的)都会被替换为替换文本。
考虑以下程序:
#include <iostream>
#define MY_NAME "Alex"
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
预处理器将上述内容转换为以下内容:
// The contents of iostream are inserted here
int main()
{
std::cout << "My name is: " << "Alex" << '\n';
return 0;
}
带有替换文本的类对象宏(在 C 语言中)曾被用来为字面量赋值。由于 C++ 中提供了更好的方法,现在不再需要这样做了。带有替换文本的类对象宏现在主要出现在旧版代码中,我们建议尽可能避免使用它们。
没有替换文本的类对象宏
类对象宏也可以在没有替换文本的情况下定义。例如:
#define USE_YEN
这种形式的宏的工作方式与您预期的一样:大多数后续出现的标识符都会被删除,并且被替换为空!
这看起来似乎没什么用,而且对于文本替换来说也没什么用。然而,这种形式的指令通常不用于文本替换。我们稍后会讨论这种形式的用途。与带有替换文本的对象宏不同,这种形式的宏通常被认为是可以使用的。
条件编译
条件编译预处理器指令允许您指定在什么条件下某些代码可以编译或不可以编译。条件编译指令有很多种,但我们只介绍最常用的几种:#ifdef
、#ifndef
和#endif
。
#ifdef
预处理器指令允许预处理器检查某个标识符是否已通过 #define 定义。如果已定义,则编译#ifdef
和对应的#endif
之间的代码。如果没有定义,则忽略该代码。
考虑以下程序:
#include <iostream>
#define PRINT_JOE
int main()
{
#ifdef PRINT_JOE
std::cout << "Joe\n"; // will be compiled since PRINT_JOE is defined
#endif
#ifdef PRINT_BOB
std::cout << "Bob\n"; // will be excluded since PRINT_BOB is not defined
#endif
return 0;
}
由于PRINT_JOE
已被 #define
,因此std::cout << "Joe\n"
被编译。由于PRINT_BOB
尚未被#define
,因此std::cout << "Bob\n"
将被忽略。
#ifndef
与#ifdef
相反,它允许您检查标识符是否尚未被#define
。
#if 0
条件编译的另一个常见用法是使用#if 0
排除一段代码的编译(就像它在注释块中一样):
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 0 // Don't compile anything starting here
std::cout << "Bob\n";
std::cout << "Steve\n";
#endif // until this point
return 0;
}
上述代码仅打印“Joe”,因为#if 0
预处理器指令将“Bob”和“Steve”排除在编译之外。
这提供了一种方便的方法来“注释掉”包含多行注释的代码(由于多行注释不可嵌套,因此不能使用另一个多行注释来注释掉):
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 0 // Don't compile anything starting here
std::cout << "Bob\n";
/* Some
* multi-line
* comment here
*/
std::cout << "Steve\n";
#endif // until this point
return 0;
}
要临时重新启用已包装在 中的代码#if 0
,您可以将#if 0
更改为#if 1
:
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 1 // always true, so the following code will be compiled
std::cout << "Bob\n";
/* Some
* multi-line
* comment here
*/
std::cout << "Steve\n";
#endif
return 0;
}
其他预处理命令中不进行宏替换
#define PRINT_JOE
int main()
{
#ifdef PRINT_JOE
std::cout << "Joe\n"; // will be compiled since PRINT_JOE is defined
#endif
return 0;
}
考虑上面一段代码,既然我们在第一行将PRINT_JOE
定义为空,那么预处理器为什么不是将第5行的#ifdef PRINT_JOE
中的PRINT_JOE
替换成无,并从编译中排除输出语句?
该规则至少有一个例外:大多数形式的#if
和#elif
在预处理器命令中执行宏替换。
再举一个例子:
#define FOO 9 // Here's a macro substitution
#ifdef FOO // This FOO does not get replaced with 9 because it’s part of another preprocessor directive
std::cout << FOO << '\n'; // This FOO gets replaced with 9 because it's part of the normal code
#endif
#define 的范围
考虑以下程序:
#include <iostream>
void foo()
{
#define MY_NAME "Alex"
}
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
尽管看起来像是在函数foo内部定义了*#define MY_NAME “Alex”,但预处理器并不理解函数之类的 C++ 概念。因此,该程序的行为与在函数foo之前或之后立即定义的#define MY_NAME “Alex”*完全相同。为了避免混淆,通常应该在函数外部使用 #define 标识符。
由于#include指令会用被包含文件的内容替换#include指令,因此#include可以将被包含文件中的指令复制到当前文件中。然后,这些指令将按顺序进行处理。
例如,以下内容的行为也与前面的示例相同:
Alex.h:
#define MY_NAME "Alex"
main.cpp:
#include "Alex.h" // copies #define MY_NAME from Alex.h here
#include <iostream>
int main()
{
std::cout << "My name is: " << MY_NAME << '\n'; // preprocessor replaces MY_NAME with "Alex"
return 0;
}
预处理器完成后,该文件中所有已定义的标识符都将被丢弃。这意味着指令仅在定义点到定义文件的末尾有效。在一个文件中定义的指令不会对其他文件产生任何影响(除非它们被 #include 到另一个文件中)。例如:
fnc.cpp:
#include <iostream>
void doSomething()
{
#ifdef PRINT
std::cout << "Printing!\n";
#endif
#ifndef PRINT
std::cout << "Not printing!\n";
#endif
}
main.cpp:
void doSomething(); // forward declaration for function doSomething()
#define PRINT
int main()
{
doSomething();
return 0;
}
上面的程序将输出:
Not printing!
尽管 PRINT 在main.cpp中定义,但这对function.cpp中的任何代码都没有任何影响(PRINT 仅在定义处到 main.cpp 结尾处被 #defined 定义)。这在头文件保护时会很重要。
2.11 头文件
当程序只包含几个小文件时,手动在每个文件顶部添加几个前向声明还不算太麻烦。然而,随着程序规模越来越大(并使用更多文件和函数),手动在每个文件顶部添加大量(可能不同的)前向声明会变得极其繁琐。例如,如果你有一个包含 5 个文件的程序,每个文件都需要 10 个前向声明,那么你将不得不复制/粘贴 50 个前向声明。现在考虑一下你有 100 个文件,每个文件都需要 100 个前向声明的情况。这根本无法扩展!
为了解决这个问题,C++ 程序通常采用不同的方法。
2.11.1 头文件
C++ 代码文件(扩展名为 .cpp)并非 C++ 程序中唯一常见的文件。另一种文件类型称为头文件。头文件通常以 .h 为扩展名,但偶尔也会看到以 .hpp 为扩展名或根本没有扩展名的文件。
通常,头文件用于将一堆相关的前向声明传播到代码文件中。
头文件允许我们将声明放在一个地方,然后在需要的地方导入它们。这可以在多文件程序中节省大量的输入工作。
2.11.2 标准库头文件
考虑以下程序:
#include <iostream>
int main()
{
std::cout << "Hello, world!";
return 0;
}
这段程序使用std::cout在控制台打印了“Hello, world!” 。然而,这段程序并没有提供std::cout的定义或声明,那么编译器如何知道std::cout是什么呢?
答案是std::cout已在“iostream”头文件中进行了前向声明。当我们 时#include <iostream>
,我们请求预处理器将所有内容(包括 std::cout 的前向声明)从名为“iostream”的文件复制到执行 #include 的文件中。
2.11.3 使用头文件进行前向声明
有两个文件,add.cpp和main.cpp,如下所示:
add.cpp:
int add(int x, int y)
{
return x + y;
}
main.cpp:
#include <iostream>
int add(int x, int y); // forward declaration using function prototype
int main()
{
std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
return 0;
}
在此示例中,我们使用了前向声明,以便编译器在编译main.cpp时知道标识符**add是什么。如前所述,为每个要使用的、位于其他文件中的函数手动添加前向声明很快就会变得繁琐乏味。
让我们编写一个头文件来减轻这个负担。编写头文件出奇的简单,因为头文件只包含两部分:
- 标题保护器。
- 头文件的实际内容,应该是我们希望其他文件能够看到的所有标识符的前向声明。
向项目中添加头文件与添加源文件类似。
头文件通常与代码文件配对,头文件为相应的代码文件提供前向声明。由于我们的头文件将包含add.cpp中定义的函数的前向声明,因此我们将新的头文件命名为add.h。
这是我们完成的头文件:
add.h:
// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)
// This is the content of the .h file, which is where the declarations go
int add(int x, int y); // function prototype for add.h -- don't forget the semicolon!
为了在 main.cpp 中使用这个头文件,我们必须#include 它(使用引号,而不是尖括号)。
main.cpp:
#include "add.h" // Insert contents of add.h at this point. Note use of double quotes here.
#include <iostream>
int main()
{
std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
return 0;
}
add.cpp:
#include "add.h" // Insert contents of add.h at this point. Note use of double quotes here.
int add(int x, int y)
{
return x + y;
}
当预处理器处理该#include "add.h"
行时,它会将 add.h 的内容复制到当前文件中的该位置。由于我们的add.h包含函数add()的前向声明,因此该前向声明将被复制到main.cpp中。最终生成的程序在功能上与我们在main.cpp顶部手动添加前向声明的程序相同。
2.11.4 在头文件中包含定义如何导致违反单一定义规则
目前,应避免将函数或变量定义放在头文件中。如果头文件被包含到多个源文件中,这样做通常会导致违反单一定义规则 (ODR)。
让我们来说明一下这是如何发生的:
add.h:
// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)
// definition for add() in header file -- don't do this!
int add(int x, int y)
{
return x + y;
}
main.cpp:
#include "add.h" // Contents of add.h copied here
#include <iostream>
int main()
{
std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
return 0;
}
add.cpp:
#include "add.h" // Contents of add.h copied here
编译过程正常完成,但最后链接器将看到函数现在有两个定义add()
:一个在 main.cpp 中,另一个在 add.cpp 中。这违反了 ODR 第 2 部分的规定:“在给定程序中,一个变量或普通函数只能有一个定义。”
2.11.5 源文件应该包含其配对的头文件
在 C++ 中,代码文件最好使用 #include 包含其对应的头文件(如果存在)。这样可以让编译器在编译时而不是链接时捕获某些类型的错误。例如:
add.h:
// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)
int add(int x, int y);
main.cpp:
#include "add.h"
#include <iostream>
int main()
{
std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
return 0;
}
add.cpp:
#include "add.h" // copies forward declaration from add.h here
double add(int x, int y) // oops, return type is double instead of int
{
return x + y;
}
编译add.cpp时,前向声明int add(int x, int y)
会被复制到add.cpp的 #include 位置。当编译器运行到double add(int x, int y)
时,它会注意到前向声明和定义的返回类型不匹配。由于函数的返回类型不同而有所差异,编译器会报错并立即终止编译。在较大的项目中,这可以节省大量时间并帮助查明问题所在。
如果返回类型不同的同时参数类型也不同,则此方法无效。这是因为 C++ 支持重载函数(函数名相同但参数类型不同),因此编译器会将参数类型不匹配的函数视为不同的重载。
如果#include "add.h"
不存在,编译器将无法发现问题,因为它无法识别不匹配的情况。我们必须等到链接时才能发现问题。
尖括号和引号
为什么我们使用尖括号表示iostream
,而使用双引号表示add.h
?因为同名的头文件可能存在于多个目录中。我们使用尖括号而不是双引号,是为了给预处理器提供线索,让它知道应该在哪里查找头文件。
当我们使用尖括号时,我们是在告诉预处理器这是一个我们自己没有编写的头文件。预处理器只会在指定的目录include directories
中搜索头文件。预处理器不会在项目的源代码目录中搜索头文件。
当我们使用双引号时,我们告诉预处理器这是我们编写的头文件。预处理器将首先在当前目录中搜索头文件。如果在那里找不到匹配的头文件,它将搜索include directories
。
使用双引号包含您编写的或预期在当前目录中找到的头文件。使用尖括号包含编译器、操作系统或系统上其他位置安装的第三方库自带的头文件。
iostream和iostream.h
另一个常见问题是“为什么 iostream(或任何其他标准库头文件)没有 .h 扩展名?”。答案是iostream.h和iostream是不同的头文件。
C++ 最初创建时,标准库中的所有头文件都以*.h后缀结尾。cout和cin的原始版本在全局命名空间的iostream.h中声明。当 ANSI 委员会对该语言进行标准化时,他们决定将标准库中使用的所有名称移至std命名空间,以避免与用户声明的标识符发生命名冲突。然而,这带来了一个问题:如果他们将所有名称都移至std*命名空间,那么所有旧程序(包括 iostream.h)都将无法运行!
为了解决这个问题,C++ 引入了新的头文件,这些文件没有*.h扩展名。这些新的头文件声明了**std命名空间内的所有名称。这样,包含旧代码的程序#include <iostream.h>
无需重写,而新程序则可以#include <iostream>
。
包含来自其他目录的头文件
如何包含来自其他目录的头文件?
一种(不好的)做法是,在 #include 语句中包含要包含的头文件的相对路径。例如:
#include "headers/myHeader.h"
#include "../moreHeaders/myOtherHeader.h"
虽然这种方法可以编译(假设文件存在于这些相对目录中),但它的缺点是它要求你在代码中反映目录结构。如果你更新了目录结构,你的代码将不再有效。
更好的方法是告诉你的编译器或 IDE,你在其他位置有一些头文件,这样当它在当前目录中找不到它们时,就会去那里查找。这通常可以通过在 IDE 项目设置中设置包含路径或搜索目录来实现。
传递包含
当你的源文件 #include 一个头文件时,你还会获得被该头文件 #include 的任何其他头文件(以及它包含的任何头文件,等等)。这些额外的头文件有时被称为传递包含 (transitive includes ) ,因为它们是隐式包含的,而不是显式包含的。
每个文件都应该显式地 #include 编译所需的所有头文件。不要依赖从其他头文件间接引入的头文件。
头文件的包含顺序
如果您的头文件编写正确并且#include 了它们所需的一切,那么包含的顺序就不重要了。
现在考虑以下场景:假设头文件 A 需要头文件 B 中的声明,但忘记包含它。在我们的代码文件中,如果我们在头文件 A 之前包含头文件 B,我们的代码仍然会编译通过!这是因为编译器会先编译 B 中的所有声明,然后再编译依赖于这些声明的 A 代码。
但是,如果我们首先包含头文件 A,那么编译器就会报错,因为在编译器看到来自 B 的声明之前,来自 A 的代码就会被编译。这实际上是更好的选择,因为错误已经浮出水面,然后我们就可以修复它。
为了最大限度地提高编译器标记缺失包含项的机会,请按如下顺序排列 #includes(跳过任何不相关的包含项):
- 此代码文件的配对头文件(例如
add.cpp
should#include "add.h"
) - 来自同一项目的其他头文件(例如
#include "mymath.h"
) - 第三方库头文件(例如
#include <boost/tuple/tuple.hpp>
) - 标准库头文件(例如
#include <iostream>
)
以下是有关创建和使用头文件的一些建议:
- 始终包含标题保护。
- 不要在头文件中定义变量和函数(目前)。
- 为头文件赋予与其关联的源文件相同的名称(例如
grades.h
与 配对grades.cpp
)。 - 每个头文件都应该有特定的用途,并且尽可能独立。例如,你可以把所有与功能 A 相关的声明放在 A.h 中,把所有与功能 B 相关的声明放在 B.h 中。这样,如果你以后只需要功能 A,就可以直接包含 A.h,而不用获取任何与功能 B 相关的内容。
- 请注意您需要明确包含哪些头文件以实现您在代码文件中使用的功能,以避免无意的传递包含。
- 头文件应该包含其所需功能的任何其他头文件。当将此类头文件单独 #include 到 .cpp 文件中时,应该能够成功编译。
- 仅#include 您需要的内容(不要仅仅因为可以就包含所有内容)。
- 不要#include .cpp 文件。
- 最好将关于某个函数功能或使用方法的文档放在头文件中。这样更容易被看到。描述某个函数工作原理的文档应该保留在源文件中。
2.12 头文件保护
2.12.1 重定义问题
如果程序多次定义同一个变量标识符,将导致编译错误:
int main()
{
int x; // this is a definition for variable x
int x; // compile error: duplicate definition
return 0;
}
类似地,多次定义一个函数的程序也会导致编译错误:
#include <iostream>
int foo() // this is a definition for function foo
{
return 5;
}
int foo() // compile error: duplicate definition
{
return 5;
}
int main()
{
std::cout << foo();
return 0;
}
虽然这些程序很容易修复(删除重复的定义),但使用头文件时,很容易出现头文件中的定义被多次引用的情况。当一个头文件 #include 另一个头文件时(传递包含),就会发生这种情况。
头文件保护是一种条件编译指令,其形式如下:
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
// your declarations (and certain types of definitions) here
#endif
当此头文件被 #include 时,预处理器会检查SOME_UNIQUE_NAME_HERE是否已在此翻译单元中定义。如果这是第一次包含此头文件,则SOME_UNIQUE_NAME_HERE尚未定义。因此,它会 #define SOME_UNIQUE_NAME_HERE并包含文件内容。如果此头文件再次被包含到同一个文件中,则SOME_UNIQUE_NAME_HERE在第一次包含头文件内容时就已经被定义,因此头文件内容将被忽略(这要归功于 #ifndef)。
所有头文件都应该有头保护。SOME_UNIQUE_NAME_HERE可以是任何你想要的名字,但按照惯例*,*它应该设置为头文件的完整文件名,全部大写,并使用下划线代替空格或标点符号。
在大型程序中,可能会有两个独立的头文件(从不同的目录包含)最终具有相同的文件名(例如,directoryA\config.h 和 directoryB\config.h)。如果仅使用文件名作为包含保护(例如,CONFIG_H),则这两个文件最终可能会使用相同的保护名称。如果发生这种情况,任何包含(直接或间接)这两个 config.h 文件的文件都将无法接收第二个要包含的包含文件的内容。这可能会导致编译错误。
由于存在保护器名称冲突的可能性,许多开发者建议在头文件保护器中使用更复杂/独特的名称。一些不错的建议是采用 PROJECT_PATH_FILE_H、FILE_LARGE-RANDOM-NUMBER_H 或 FILE_CREATION-DATE_H 这样的命名约定。
2.12.2 #pragma once
现代编译器使用预处理器指令支持更简单的替代形式的头保护#pragma
:
#pragma once
// your code here
#pragma once
其目的与头文件保护相同:避免头文件被多次包含。使用传统的头文件保护时,开发人员负责保护头文件(通过使用预处理器指令#ifndef
、#define
和#endif
)。使用#pragma once
,我们请求编译器保护头文件。具体如何实现这一点,则是一个特定于实现的细节。
有一种已知的情况#pragma once
通常会失败。如果一个头文件被复制,并存在于文件系统的多个位置,如果以某种方式包含该头文件的两个副本,头文件保护程序将成功删除相同的头文件,但实际上#pragma once
不会执行(因为编译器无法识别它们实际上是相同的内容)。
对于大多数项目来说,#pragma once
它运行良好,现在许多开发人员更喜欢它,因为它更简单,错误更少。许多 IDE 还会#pragma once
在通过 IDE 生成的新头文件的顶部自动包含它。
由于#pragma once
C++ 标准未定义,某些编译器可能无法实现它。因此,一些开发公司(例如 Google)建议使用传统的头文件保护机制。在本系列教程中,我们将优先使用头文件保护机制,因为它是保护头文件最常用的方式。然而,目前对#pragma once
的支持已经相当普遍,如果你想使用#pragma once
,现代 C++ 也普遍接受 。