C++助教问题汇总1

周四实验课遇到的高频编程问题,简单汇总了一下。

如何进行断点调试

程序不动了是迭代次数太多了吗?

遇到这种情况先考虑是不是死循环了。
检查你的循环跳出条件,打断点,监视那些关键变量的变化是否符合预期。

断点调试是你强大的工具

程序出现运行时Bug(和令代码无法通过编译的Bug相区分)时,常用的调试手段有两种。

第一种是 打日志(log)

直接把关键变量的值打印出来

第二种是 断点(breakpoint)调试

首先在想要停下的行打上断点,这样调试运行时到此处会自动停下。

在该行打上断点

编译选项要选Debug,如果处在Release状态下运行,断点调试将被忽略。

点击“本地Windows调试器”开始调试。运行后程序将停在第一个断点处,黄色箭头(红色断点的上面)指示当前程序所执行到的位置。黄色箭头位于第7行,表示前面的都执行过一遍了,而当前所在行(第7行)还未执行。

开始调试

调试中断时,鼠标放在变量上面能显示它当前值,或者看下方的“局部变量”表也行。因为第7行还未执行,此时变量i的值为0。

点工具栏上面的“逐语句(F11)”,可以让程序往前执行一行。边上的“逐过程(F10)”其实也行,它们的区别在于遇到对函数的调用时,是跳进去继续追踪,还是等它跳出来再追踪。
连续点击“逐语句(F11)”,令程序连续执行好几步,这个过程中你可以观察变量i的值的变化情况。

单步执行

逐语句运行时,每走一行,就停下来给你观察现场,并等待你决定接下来做什么。

把断点去掉以后点“继续”,程序就会继续不停地运行,也就是死循环,因为没有输出所以看起来就是黑窗口上只有光标在闪。

不想调试了就点那个方形的“停止调试”,程序就将结束。

结束调试

inf,NaN是什么意思

inf,NaN是浮点数(float,double)数据类型中非常特殊的两个取值。

inf是无穷大(infinity)的缩写,对一个正数除0.0就会得到inf,对负数除0.0会得到负的inf,或者当试图用double存储一个超过其表示范围的数,比如说1e99999,也会导致它变成inf)

NaN是非数字(Not A Number)的缩写,对负数开平方,对负数取对数,0.0除以0.0,0.0乘inf, inf除以inf等错误都会得到NaN

当你的浮点运算产生上述不合法运算时,程序不会直接报错,而是使你的运算结果变为这种特殊值。这种特殊值会在运算过程中不断传递,污染后续运算 (NaN加减乘除任何数还是NaN)。

当打印这两种特殊的数值时,控制台(console)会显示inf-nan(ind),如果你使用的是Visual Studio。或者你使用的是Dev C++等基于g++编译器的IDE,则显示为infnan。这只是显示方式上的区别!

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <cmath>
using namespace std;

int main() {
double x = 1.0;
double zero = 0.0;

cout << (x / zero) << endl; // inf
cout << (-x / zero) << endl; // -inf

cout << (sqrt(-x)) << endl; // nan
cout << (log(-x)) << endl; // nan
cout << (zero / zero) << endl; // nan

double inf = x / zero; // nan

cout << (zero / zero) << endl; // nan
cout << (zero * inf) << endl; // nan
cout << (inf / inf) << endl; // nan

return 0;
}

Visual Studio运行结果

x^y不是x的y次方吗

在C++里不是。

C++ 的^运算符不是次幂运算符,这是数学符号习惯带来的第一个常见误会,下面的这个示例代码可以最直接地证明这一点。

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;

int main() {
int x = -1;
int y = 2;

cout << (x ^ y) << endl; // 3, not 1
return 0;
}

尽管代码可以通过编译并正常运行,但^在C++中是“异或运算符”,属于位运算符的一种(详细可参考这篇博客)。C++不存在次幂运算符,不过你可以调用cmath库中的pow()指数函数来做到这一点。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <cmath>
using namespace std;

int main() {
int x = -1;
int y = 2;

cout << pow(x, y) << endl;
return 0;
}

单独cpp文件不能编译运行吗

为什么Visual Studio打开单独cpp文件不能运行,但交作业只需单独cpp文件?

这个问题解释起来有些复杂……
一言以蔽之,Visual Studio试图让“多文件联合编译”这个问题变得简单,但反而令“单文件编译”变得繁琐。

多文件联合编译是C++的常态

尽管刚开始这门课的时候,大家编写的C++程序只放在一个文件里,但在工程上,一个完整的C++程序项目,代码分散地放在数百个文件里是非常正常的事情。

这是我大三时编译原理实践的放C++代码的目录结构。编译时,需要将所有这些文件里的代码合并到一起编译,才能产生一个程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
sly
├── CMakeLists.txt
├── include
│ └── sly
│ ├── Action.h
│ ├── AnnotatedParseTree.h
│ ├── AttrDict.h
│ ├── ContextFreeGrammar.h
│ ├── FaModel.h
│ ├── FileAnalyzer.h
│ ├── LrParser.h
│ ├── Production.h
│ ├── RegEx.h
│ ├── SeuLex.h
│ ├── SeuYacc.h
│ ├── Stream2TokenPipe.h
│ ├── TableGenerateMethod.h
│ ├── TableGenerateMethodImpl.h
│ ├── Token.h
│ ├── def.h
│ ├── sly.h
│ └── utils.h
└── src
├── Action.cpp
├── AnnotatedParseTree.cpp
├── AttrDict.cpp
├── ContextFreeGrammar.cpp
├── FaModel.cpp
├── LrParser.cpp
├── Production.cpp
├── RegEx.cpp
├── SeuLex.cpp
├── SeuYacc.cpp
├── Stream2TokenPipe.cpp
├── TableGenerateMethod.cpp
├── TableGenerateMethodImpl.cpp
├── Token.cpp
├── sly.cpp
└── utils.cpp

你肯定会想到,为了让编译器知道哪些文件需要参与编译,当然要有个地方存储这样一个索引。对于Visual Studio来说,你所新建的这个“项目”(Project)就主要做了这件事。
正是因为“项目”索引了该工程所涉及的所有代码文件,才使得Visual Studio得以提供跨文件的代码智能提示功能(intellisense),一键的“编译并运行”功能,智能跳转功能以及许许多多便捷功能,使得它荣获“宇宙第一IDE”之美称。

在业界,大部分时候一个单独的cpp文件都是不完整的(你的程序必须包含一个且最多一个main()才能编译运行),所以Visual Studio被设计成了,在任何时候都需要先有一个项目(其中指明了存在哪些代码文件)才能编译运行。哪怕你的确只有一个cpp文件,你也需要先建立一个项目,再一步步正确地把代码文件添加进去。

对于使用Dev C++的同学,自然不会被这个问题困扰,因为和Visual Studio默认多文件联合编译相反,Dev C++默认进行单文件编译。你打开的是哪个文件,就编译运行的是哪个文件。如果只是经常写一些单文件的C++代码,那么无需每次都繁琐地建立工程文件,无疑是Dev C++的一大优势。

我还是要提醒一句,Visual Studio的编译器(msvc)和Dev C++的编译器(g++)的表现是不完全一样的,尤其是对变量未赋初值的处理方式是不同的。曾经就有同学习惯了g++贴心的变量初值自动置零,而忘记手动赋初值,最后在考场上被msvc狠狠制裁,丢了好几分。

构建系统(build system)不取决于打开了哪些文件

Visual Studio是一款IDE(Integrated Development Environment, 集成开发环境),这意味着它集成了C++的构建系统(也就是你点击“编译”时所运行的系统),能从代码文件生成可执行程序。而构建系统的基础,则在于如何找到生成最终程序所需的各个源代码文件。
构建系统也会随着你添加、删除项目中的代码文件而需要动态地变化。

但是什么叫做“项目中的代码文件”呢,是看它是否出现在了编辑器(也就是屏幕占比最大的中间那块编辑区域)里吗?不是的,对于Visual Studio来说,在“解决方案资源管理器”里看到的树状结构才是它认为的“项目中的代码文件”。

“解决方案资源管理器”中,引用、外部依赖项、头文件、源文件下面的文件,都参与构建

当你点击“编译并运行”时,你在编译的不是面前的这个代码文件,而是这个项目
你面前的这个代码文件,不一定在当前项目里。同时,这个项目也不一定只包括了你面前的代码文件。
要想知道项目到底包含了哪些文件,一切都以“解决方案资源管理器”里的树状结构为准。

总之,Visual Studio所做的并不是“把打开的代码文件一起联合编译”,而是“按照项目索引的代码文件进行联合编译”。你爱打开几个文件打开几个文件,构建系统都不会变,除非你更改“解决方案资源管理器”里的文件结构。

“解决方案资源管理器”中可以看到, 当前打开的AttrDict.cpp并不在该项目中,编译运行并不会带上它

单独打开cpp文件的迷惑之处

搞清楚上面那点,其实就足够了,下面的有点偏吐槽向。

在Visual Studio的设想下,开发者可以在一个项目内,对每个代码文件进行编辑,修改后编译并运行,一切都是如此美好。

然而有些迷惑人的地方在于,Visual Studio又占据了系统中cpp文件的默认打开方式。当你双击一个cpp文件时,这个cpp文件中的代码会弹出在Visual Studio里。此时你能看到这些代码的语法高亮,能够编辑修改它们,并且这个选项卡看起来和其他的没有什么区别。

安装完Visual Studio后cpp文件的默认打开方式通常就变成了Visual Studio

最前面这个AttrDict.cpp看起来很好,它并不在当前项目里,你看得出来吗(看不出来)

按理说,我们只关心自己项目里的代码文件,既然当前打开的这个代码文件不属于我的项目,那按理说应该进入一个“临时浏览/编辑”模式,在显示方式上和项目内的代码文件做明显区分,方便我意识到它不在我项目内才对。

然而Visual Studio并没有这么做!哈哈!全都长得一模一样!

更本质的原因是,Visual Studio作为一款全能型的IDE,既包揽了编辑器的功能,又承接了维护构建系统的功能,但却只有一套IDE的界面。
当开发者打开项目内文件并编辑时,不仅需要语法高亮,修改了代码文件,同时也对构建系统产生影响,此时Visual Studio是作为IDE在工作。
但当开发者编辑项目外文件时,只是需要语法高亮,修改了代码文件,但不对构建系统产生影响,此时Visual Studio仅作为编辑器(editor)工作,这时界面上所有构建系统相关的按钮都变成了干扰视听的混淆项。

我认为规避这一问题的最好方法,就是始终从项目中打开cpp文件,而不通过cpp文件进入Visual Studio,并且将cpp文件的默认打开方式绑定到真正的编辑器上,比如notepad++,VS Code或者Sublime之类。

C++构建系统的原理

当然,我的这些解释有点太过拘泥于IDE的特性,而不是在聊C++本身了。如果你的好奇心依然未被满足,想搞明白关于C++单文件/ 多文件编译的更本质一些的原理,我建议你了解一下g++,也就是从命令行编译运行C++的流程。

g++是干什么用的 学C++一定要用这个么? - 知乎

Compiling C++ programs with g++

兴许你会开始对makefile和Cmake感兴趣。

Dev C++中编译时使用的g++命令及参数

Visual Studio中使用的cl命令及参数

C++逗号相关问题

我的评价是水很深。

逗号不是逻辑上的“并且”

把逗号当作逻辑上的“并且”(and)是数学符号习惯带来的第二个常见误会,下面是一个错误的例子:

1
2
3
if (x > 0, x < 10) {
// ...
}

正确的做法是使用逻辑运算符&&

逗号间隔的变量声明初始化是独立赋值

逗号还有一个常见误解是用在变量声明里.

1
int x, y, z;

这固然没什么问题,但如果还要赋初值的话。

1
int x, y, z = 10;

请记住此时xy并没有变成10,上述代码等价于

1
2
3
int x;
int y;
int z = 10;

逗号运算符通常属于奇技淫巧

还有一种是作为逗号运算符使用,也称“顺序求值运算符”,用于将表达式“串联”起来,返回最后一个表达式的值。
常见于算法竞赛不择手段的压缩行数。

包括但不限于以下用法:

1
for (int i = 0; i < 10; i++) a += 1, b += 2, c += 3;
1
2
3
4
int a;
while (cin >> a, a != 0) {
// ...
}

前者是一种畸形的、邪恶的、错误的美学追求,后者有一定的实用性但需要你把握得住。

如果你觉得你把握得住,试试看下面几个例子能不能迷倒你。

1
a, b, c = 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <cstdio>
using namespace std;

int main()
{
int x;
int result;

result = scanf("%d", &x), x * x;
cout << result << endl;
return 0;
}

感谢阅读!
封面来自电影《飞驰人生》截图。


C++助教问题汇总1
http://example.com/2022/10/01/cpp_tutor_1/
作者
Lee++
发布于
2022年10月1日
许可协议