A-4 C++ 常见问题解答
有些问题总是被反复提及。本常见问题解答将尝试解答其中最常见的问题。
问题 1:为什么不应该使用“using namespace std”?
语句 using namespace std; 是一个 using 指令using directive。using 指令允许在该指令的作用域内访问指定命名空间中的所有标识符。
你可能见过类似这样的代码:
#include <iostream>
using namespace std;
int main()
{
cout << "Hello world!";
return 0;
}
这使我们能够使用 std 命名空间中的名称,而无需反复显式地输入 std::。在上面的程序中,我们只需输入 cout 即可,而不必输入 std::cout。听起来很棒,对吧?
然而,当编译器遇到 using namespace std 时,它会将 std 命名空间中的每一个标识符都置于全局作用域中(因为 using 指令被放置在那里)。这带来了 3 个关键挑战:
- 您所选名称与 std 命名空间中已存在名称发生冲突的概率将大幅增加。
- 标准库的新版本可能会导致您当前运行的程序无法正常工作。这些未来版本可能会引入引发新命名冲突的名称,或者在最坏的情况下,您的程序行为可能会悄无声息地发生意想不到的变化!
- 由于缺少 std:: 前缀,读者将更难分辨哪些是标准库名称,哪些是用户自定义名称。
鉴于上述原因,我们建议完全避免使用 using namespace std(或任何其他 using 指令)。虽然这样能节省一点输入时间,但并不值得为此带来额外的麻烦和未来可能的风险。
相关内容
请参阅第 7.13 节——使用 using 声明和 using 指令,以获取更多详细信息和示例。
问题 2:为什么我可以在不包含声明该函数或类型的头文件的情况下使用某些函数或类型?
例如,许多读者询问,为什么他们的程序在使用 std::string_view 时需要 #include <string_view>,尽管不包含该头文件似乎也能正常运行。
头文件可以包含其他头文件。当你包含一个头文件时,你也会获得它所包含的所有其他头文件(以及这些头文件所包含的所有头文件)。这些你未显式包含却随之而来的额外头文件被称为“传递性包含”。
在你的 main.cpp 文件中,你很可能包含了
尽管这在你的编译器上可能能编译通过,但你不应该依赖这种机制。现在能编译通过的代码,在其他编译器上可能无法编译,甚至在你的编译器的未来版本中也可能无法编译。
当这种情况发生时,既无法发出警告,也无法加以预防。您能做的最好办法是务必为所使用的所有内容显式包含相应的头文件。在多种不同的编译器上编译您的程序,有助于识别那些在其他编译器上被传递包含的头文件。
相关内容
详见第 2.11 节——头文件。
问题 3:我的代码虽然会产生未定义行为,但看起来运行正常。这样可以吗?
又名:“我做了你告诉我不该做的那件事,结果居然成功了。那问题出在哪里?”
当执行某项操作时,如果该操作的行为未被 C++ 语言所定义,就会发生未定义行为。实现未定义行为的代码可能会表现出以下任一症状:
- 你的程序每次运行时都会产生不同的结果。
- 你的程序行为不一致(有时能产生预期结果,有时则不能)。
- 你的程序总是产生相同的错误结果。
- 你的程序起初看似正常运行,但在程序后半段会产生错误结果。
- 你的程序会崩溃,可能是立即崩溃,也可能是稍后崩溃。
- 你的程序在某些编译器或平台上能正常运行,但在其他编译器或平台上却无法运行。
- 你的程序原本能正常运行,直到你修改了某些看似无关的代码。
- 你的程序似乎无论如何都能产生预期结果。
未定义行为的最大问题之一在于,程序的行为可能会在任何时候、出于任何原因发生变化。因此,尽管此类代码目前看似正常运行,但无法保证日后再次运行时仍能正常工作。
相关内容
未定义行为在第 1.6 课——未初始化变量与未定义行为中进行了讲解。
问题 4:为什么我的代码明明会产生未定义行为,却得出了特定结果?
读者经常询问,究竟是什么原因导致在他们的系统上产生了特定结果。在大多数情况下,很难给出确切答案,因为生成的结果可能取决于当前的程序状态、编译器设置、编译器对特定功能的实现方式、计算机架构以及/或操作系统。例如,如果你输出一个未初始化变量的值,可能会得到垃圾数据,也可能总是得到某个特定值。这取决于变量的类型、编译器在内存中如何布局该变量,以及该内存区域之前存储的内容(这可能受操作系统或程序此前状态的影响)。
虽然从技术层面看,这样的回答或许很有趣,但总体而言很少有实际用处(而且一旦其他因素发生变化,这个答案很可能也会随之改变)。这就像问:“当我把安全带穿过方向盘并连接到油门踏板上时,为什么在下雨天转头时车子会向左偏?”最好的答案并不是对发生现象的物理解释,而是“别这么做” 。
问题 5:为什么我尝试编译一个看起来应该能运行的示例时,却出现了编译错误?
最常见的原因是您的项目使用了错误的语言标准进行编译。
C++ 随着每一版新语言标准的发布都会引入许多新特性。如果我们的示例中使用了 C++17 引入的某个特性,而您的程序却使用 C++14 语言标准进行编译,那么程序很可能无法编译,因为我们使用的特性不被所选的语言标准所支持。
请尝试将语言标准设置为编译器支持的最新版本,并查看是否能解决问题。您还可以通过运行此处的程序来检查编译器是否已正确配置为使用您期望的语言标准:0.13 -- 我的编译器正在使用哪种语言标准?
相关内容
详见第 0.12 课——配置编译器:选择语言标准。
也有可能是您的编译器尚未支持某项特定功能,或者存在导致某些情况下无法使用的错误。在这种情况下,请尝试将编译器更新至最新版本。
CPPReference 网站会跟踪各语言标准中各项功能的编译器支持情况。您可以在该网站首页右上角的“编译器支持”(按语言标准分类)下找到相关支持表格的链接。例如,您可以在此处查看哪些 C++23 功能得到了支持。
问题 6:为什么要在 foo.cpp 中 #include “foo.h”?
源文件(例如 foo.cpp)包含其对应的头文件(例如 foo.h)是一种最佳实践。在许多情况下,foo.h 包含 foo.cpp 正确编译所需的定义。
然而,即使 foo.cpp 在不包含 foo.h 的情况下也能编译通过,包含对应的头文件仍能让编译器发现两个文件之间某些类型的不一致(例如,当函数的返回类型与其前向声明的返回类型不匹配时)。如果不包含头文件,这可能会导致未定义行为。
#include 的开销微乎其微,因此包含头文件几乎没有弊端。
相关内容
详见第 2.11 节——头文件。
问题 7:为什么只有在“main.cpp”中包含“#include “foo.cpp””时,我的项目才能编译成功?
这几乎总是因为忘记将 foo.cpp 添加到项目和/或编译命令行中。请更新项目和/或命令行,确保包含每个源文件(.cpp)。当您编译项目时,应该能看到每个源文件都被编译。
通常在包含多个文件的项目中,编译器会分别编译每个源文件(.cpp)。在所有源文件编译完成后,链接器会将它们链接在一起,并生成最终的输出文件(例如可执行文件)。但是,如果你将代码拆分为两个或多个文件(例如 main.cpp 和 foo.cpp),然后只编译 main.cpp,你很可能会遇到编译错误或链接器错误,因为项目所需的部分代码并未被编译。
新手程序员有时会发现,只需在 main.cpp 中添加 #include “foo.cpp”,而不必将 foo.cpp 添加到项目或编译命令行中,就能让程序运行。这样做后,当 main.cpp 被编译时,预处理器会生成一个由 foo.cpp 和 main.cpp 代码组成的翻译单元,随后该单元将被编译并链接。在较小的项目中,这种做法或许可行。那么,为什么不这样做呢?
原因有以下几点:
- 这可能会导致文件命名冲突。
- 很难避免 ODR 违规。
- 对任何 .cpp 文件进行的任何修改都会导致整个项目重新编译。这可能需要很长时间。
相关内容
详见第 2.11 节——头文件。
问题 8:为什么我在 main 函数末尾需要返回 0?
其实不需要。main() 函数的特殊之处在于,如果你没有提供 return 语句,它会隐式返回 0。
但是,任何其他返回值的函数,如果在未遇到 return 语句的情况下到达函数体末尾,都会产生未定义行为。
为了保持一致性,我们建议在 main() 中显式返回 0。但如果你为了简洁而希望省略 main() 中的 return 语句,也可以这样做。只是不要误以为其他返回值的函数也会以同样的方式工作。
相关内容
已在第 2.2 节中讲解——函数返回值(返回值的函数)。
Q9:当我编译本网站上的示例时,会出现类似“类模板 XXX 的参数列表缺失”的错误。为什么?
最可能的原因是该示例使用了名为“类模板参数推导”(CTAD)的功能,这是 C++17 中的特性。许多编译器默认使用 C++14,而该版本不支持此功能。
如果以下程序无法编译,原因就在于此:
#include <utility> // for std::pair
int main()
{
std::pair p2{ 1, 2 }; // CTAD used to deduce std::pair<int, int> from the initializers (C++17)
return 0;
}
相关内容
您可以通过第 0.13 课中的程序——我的编译器使用的是哪种语言标准?——来查看您的编译器配置的语言标准。
我们在第 13.14 课——类模板参数推导 (CTAD) 与推导指南中介绍了 CTAD。
问题10:为什么在按值传递或返回时,不将函数形参或返回类型声明为 const?
我们通常不会将按值传递的函数形参声明为 const,原因如下:
- 将此类形参声明为 const 对函数调用者没有任何实质意义,却会使函数接口变得冗余。
- 我们通常并不关心函数是否修改了这些形参,因为它们只是副本,无论如何都会在函数结束时被销毁。
我们不将按值返回的返回值设为 const,因为:
- 如果返回类型是非类类型(例如基本类型),const 无论如何都会被忽略。
- 如果返回类型是类类型,const 可能会阻碍某些类型的优化(例如移动语义)。
注意:const 在按地址或按引用传递/返回时才相关。
相关内容
详见第 5.1 节——常量变量(命名常量)
问题11:为什么应该使用 constexpr?
constexpr 及其他编译时编程技术提供了诸多优势,包括:
- 代码更精简、运行更高效。
- 编译器能够检测某些类型的错误,并在发生时终止编译。
- 编译时不允许出现未定义行为。
- 能够在需要常量表达式的地方使用变量和函数。
最后一点或许是最不可避免的,因为某些 C++ 特性需要编译时已知的值。
相关内容
详见第 5.5 节——常量表达式
问题 12:既然我知道某个符合条件的函数在我的程序中只会在运行时被求值,为什么还要将其声明为 constexpr?
这样做可能有以下几个原因:
- 使用 constexpr 几乎没有弊端,即使在无法在编译时求值的上下文中,它也有助于编译器进行优化。
- 仅仅因为你当前没有在可编译时求值的上下文中调用该函数,并不意味着在修改或扩展程序时,你不会在这样的上下文中调用它。而且,如果你尚未为该函数添加 constexpr 声明,那么当你开始在该上下文中调用它时,可能不会想到要添加,从而错失性能优势。或者,当你需要在某个要求常量表达式的上下文中使用返回值时,可能会被迫在后期为其添加 constexpr 声明。
- 重复有助于将最佳实践内化。
在处理非简单的项目时,最好以“这些函数将来可能会被重用(或扩展)”的心态来实现它们。每次修改现有函数时,都存在导致其失效的风险,这意味着需要重新测试,而这会消耗时间和精力。通常,多花一两分钟“第一次就做对”,以避免日后不得不重做(并重新测试),是值得的。
相关内容
在第 F.1 节中介绍——constexpr 函数
问题13:为什么不应在表达式中多次调用同一个输入函数?
在大多数情况下,C++ 标准并未规定操作数(包括函数参数)的求值顺序。运算符的优先级和结合性仅用于确定操作数如何与运算符分组,以及值的计算顺序。
例如,对于语句 std::cout << subtract(getUserInput(), getUserInput()),函数调用 subtract() 中的左侧参数或右侧参数都可能被优先求值。假设用户输入的值为 5 和 2。如果左侧参数被优先求值,则左侧参数的计算结果为 5,右侧参数的计算结果为 2。5 - 2 等于 3。如果右侧参数先被求值,则右侧参数的计算结果为 5,左侧参数的计算结果为 2。2 - 5 等于 -3。因此,该语句可能输出 3 或 -3。
可以通过将每次对 getUserInput() 的调用拆分为独立的语句(从而使求值顺序确定),并将返回值存储在变量中直到需要时再使用,来消除这种歧义。
问题14:练习题太少了!哪里还能找到更多练习?
我们推荐 https://www.codewars.com/,该网站提供了大量简短的练习题,有助于提升你的综合解题能力和 C++ 实现技巧。而且还很有趣!
完成解题后,你可以将自己的答案与他人的答案进行对比,从而了解其他解法,或发现自己代码中可以改进的地方。
然而,由于这些练习题的答案是预先设定的,解答此类习题并不能真正鼓励你编写高质量的代码,也无法展示不遵循最佳实践时会发生什么。要达到这个目的,没有什么比创建自己的项目更好了。
不妨从小型项目入手——比如一个简单的游戏或模拟程序。随后逐步添加功能。随着项目复杂度的提升,你将逐渐发现代码中存在的各种缺陷。这将帮助你识别代码中哪些部分亟待质量提升。

浙公网安备 33010602011771号