Skip to content

Commit

Permalink
03.00 -> 03.05
Browse files Browse the repository at this point in the history
  • Loading branch information
ficapy committed Jan 5, 2021
1 parent 82311d6 commit ef8e059
Show file tree
Hide file tree
Showing 6 changed files with 1,000 additions and 0 deletions.
89 changes: 89 additions & 0 deletions 03DebugC++程序/3.1 语法和语义错误.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 语法和语义错误



软件错误是普遍存在的。制造它们很容易,而发现它们却很难。在本章中,我们将探讨查找和清楚C++程序内的错误有关的话题,包括学习如何使用集成开发环境中的集成调试器。

虽然调试工具和技术并不是C++标准的一部分,但是学会在你编写的程序中发现和消除错误是成为一个成功的程序员的极其重要的一部分。因此,我们会花一点时间来介绍这个主题,这样随着你编写的程序越来越复杂,你诊断和补救问题的能力也会提高。

如果你有使用其他编译语言调试程序的经验,那么其中的大部分内容你都会很熟悉。

# 语法和语义错误

编程是很有挑战性的,而且C++是一种有一些怪异的语言。把这两者放在一起,就会有很多方法来作死。错误一般分为两类:语法错误和语义错误(逻辑错误)。

当你写出的语句按照C++语法是无效的时候,就会出现`语法错误`,这包括缺少分号、使用未声明的变量、小括号和大括号不匹配等等错误。例如,下面的程序包含了不少语法错误

```c++
#include <iostream>

int main()
{
std::cout < "Hi there"; << x; // 无效的操作符(<),多余的分号,未定义的变量
return 0 // 语句结尾缺少分号
}
```

幸运的是,编译器一般会捕捉到语法错误,并产生警告和错误,所以你很容易发现并修复问题。然后只需要再编译一次,直到把所有的错误都解决掉。

一旦你的程序编译正确,让它实际产出你想要的结果可能有些棘手。当一条语句再语法上是有效的,但是却没有达到程序员的意图的时候,就会发生`语义错误`

有时候,这些会导致你的程序崩溃,比如下面的程序在处以0的情况下

```c++
#include <iostream>

int main()
{
int a { 10 };
int b { 0 };
std::cout << a << " / " << b << " = " << a / b; // 除以0没有被定义
return 0;
}
```

更多的时候,这些只会产生错误的值或者行为:

```c++
#include <iostream>

int main()
{
int x;
std::cout << x; // 使用未初始化的变量会导致未定义的结果
return 0;
}
```

或者

```c++
#include <iostream>

int add(int x, int y)
{
return x - y; // 函数要做的是加,写成了减
}

int main()
{
std::cout << add(5, 3); // 预想的是8,结果是2
return 0;
}
```
或者
```c++
#include <iostream>
int main()
{
return 0; // 函数在这个地方返回
std::cout << "Hello,World!"; // 这条语句永远不会被执行
}
```

现代编译器在检测某些常见语义错误(比如使用未初始化变量)方面已经做的越来越好。然而,在大多数情况下,编译器将无法发现大多数这类情况,因为编译器的设计是为了执行语法,而不是搞明白你的意图。

在上面的例子中,错误是相当容易发现的。但是在大多数程序中,语义错误并不容易通过目测代码来发现。这时候,调试技术就可以派上用场了。
63 changes: 63 additions & 0 deletions 03DebugC++程序/3.2 调试流程.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 调试流程



比如说你写了一个程序,但是它不能正常工作 -- 代码能正常编译,但是当你运行它的时候,却得到了一个不正确的结果。你一定是在某个地方出现了语义错误。如何才能找到它呢?如果你一直在遵循最佳实践,写一点代码,然后进行测试,你可能已经知道你的错误在哪里了。或者呃逆可能完全没有头绪。

所有的bug都源自于一个简单的前提:一些你认为正确的东西,并不正确。实际上要找出这个错误在哪里是很有挑战性的。在本节中,我们将概述调试程序的一般流程。

因为我们还没有涉及到那么多的C++主题,所以我们这一节的示例程序会非常基础。这可能会使我们在这里展示的一些技术显得过度。但是,请记住,这些技术是为了用于大型的、更复杂的程序而设计的,在这样的环境中(这也是你最需要它们的地方)会有更大的用处。

# 调试的一般步骤

一旦发现问题,调试问题一般包括五个步骤:

1. 找出问题的根本原因(通常是哪行代码无法工作)
2. 确保你了解问题发生的原因
3. 确定你将如何解决这个问题
4. 修复问题
5. 重写测试确保问题已经被解决,没有出现新的问题

我们在这里用一个现实生活中的比喻。比如说,有一天晚上,你去冰箱里面的制冰机取冰。你把被子放到饮冰机下面,按了一下,然后....什么也没出来。呃,你发现了毛病,你会怎么做?你可能会开始调查,看看能否找出问题的根本原因。

`找出根本原因`:既然你听到了制冰机送冰的声音,很可能不是送冰装置本身的问题。于是你打开冰柜,检查冰盘。没有冰块。这就是问题的根本原因吗?不,这只是症状。经过进一步的检查,你确定制冰机似乎没有制冰。是制冰机的问题还是其他原因?冰箱仍然是冰的,水管也没有阻塞,其他一起似乎都在工作,所以你断定根本原因是制冰机不工作。

`了解问题`:在这个案例中,问题很简单,坏掉的制冰机不制冰

`确定修复方案` :在这一点上,你有几个选择来解决这个问题。你可以解决这个问题(从商店买冰袋)。你可以尝试进一步诊断制冰机,看看是否有可以维修的部件。你可以买一个新的制冰机,然后把它安装在现有的位置。或者你可以买个新的冰箱。

`修复问题`:一旦制冰机到了,你就把它安装好

`重新测试`: 重新接通电源并等待一夜后,你的新制冰机开始制冰,没有发现新的问题。

现在让我们把这个过程应用到上一节课的程序中

```c++
#include <iostream>

// 将两个数字相加
int add(int x, int y)
{
return x - y; // 应该相加,却执行了减法
}

int main()
{
std::cout << add(5, 3) << '\n'; // 应该得到8,结果得到2
return 0;
}
```
这段代码有一点很好:错误非常明显,因为错误的答案会通过输出语句打印到屏幕上面。这位我们的调查提供了一个起点。
`找到根本原因`: 在第11行,我们可以看到我们正在传递参数的字面量(5和3),所以那里没有出错的余地。由于函数add的输入是正确的,但是输出不是。很明显,函数add一定是产生了错误的值。函数add中唯一的语句是返回语句,这一定是罪魁祸首。我们已经找到了问题,现在我们知道了我们的关注点在那里,注意到我们在做减法而不是加法。
`理解问题`:在这种情况下,很明显为什么会产生错误的值 -- 我们使用了错误的操作符
`确定修复方案`:我们只需要将operator - 改成 operator +。
`修复问题`:将operator- 改成 operator+,重新编译程序。
`重新测试`:在实施改变后,重写运行程序,我们得到了正确的结果8。对于这个简单的程序来说,这就是所有需要的测试。
这个例子很简单,但它说明了诊断任何程序时要经历的基本过程。
85 changes: 85 additions & 0 deletions 03DebugC++程序/3.3 调试策略.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 调试策略

在调试程序的时候,大多数情况下,你的绝大多数时间会花在试图找到错误发生的地方。一旦找到了问题,剩下的步骤(修复问题和验证问题是否被修复)相比之下往往是微不足道的。

在本节中,我们将开始探讨如何查找错误。

# 通过代码检查发现问题

比如说,你已经注意到了一个问题,你想追踪这个问题的原因。在许多情况下(尤其是在小的程序当中),我们可以迅速地找到问题的近似点。

考虑以下程序片段:

```c++
int main()
{
getNames(); // 告诉用户输入名字
sortNames(); // 使用字母序排序它们
printNames(); // 打印排序后的名字
return 0;
}
```

如果你希望这个程序按照字母顺序打印名字,但是它却以相反的顺序打印,那么问题可能出现在sortNamess函数上。如果你能把问题缩小到一个特定的函数上,你可能只需要看一下代码就能发现问题。

然而,随着程序越来越复杂,通过代码检查发现问题也变得越来越复杂。

首先,要看的代码更多。在一个长达数千行的程序中,查看每一行代码都会花费很长的时间(更不要说这很无聊)。第二,代码本身往往十分复杂,可能出错的地方很多。第三,代码的行为可能不会给你很多线索,让你知道哪里出了问题。如果你写了一个推荐股票的程序,但是实际上它什么也没有输出,你可能不会有太多的线索,不知道该从哪里开始寻找问题。

最后,bug可能是由错误的假设引起的。几乎不可能直观的发现由错误假设引起的bug,因为你很可能在检查代码时做出同样的错误假设,而没有注意到这个错误。那么如果我们通过检查代码无法发现问题,我们该如何发现呢?

# 通过运行程序发现问题

幸运的是,如果我们无法通过检查代码发现问题,我们可以采取另外一种途径:我们可以观察运行时的行为,并试图从中诊断出问题。这种方法可以概括为:

1. 相出如何重现问题
2. 运行程序并收集信息,以缩小问题所在的范围。
3. 重复前面的步骤,直到找到问题为止

在本章的其他部分,我们将讨论这种方法。

# 重现问题

发现问题的第一步,也是最重要的一步,就是要能够重现问题,原因很简单:除非你能观察到问题的发生,否则极难发现问题。

回到我们的饮冰机的比喻 -- 假设有一天你的朋友告诉你,你的饮冰机坏了。你去看了一下,它正常工作。你会如何诊断呢?这将是非常困难的。然而,如果你能真正的看到饮冰机不正常工作,那么你就可以开始更有效的诊断它为什么不工作。

如果一个软件问题是非常明显的(比如,程序每次运行到同一个地方都会崩溃),那么重现问题可能是简单的。然而,有时候重现一个问题非常麻烦。问题可能只会发生在某些计算机上面,或者在特定的情况下(比如,当用户输入某些值的时候)。在这种情况下面,搞一个重现步骤是很有帮助的。`重现步骤`是一个清晰和精确的步骤列表,可以遵循这些步骤,以高度的可预测性导致问题再次发生。我们的目标是能够尽可能的使问题重现,这样我们就可以反复运行我们的程序,寻找线索来确定问题的原因。如果能够100%重现,那是最理想的,但是低于100%的重现也是可以的。一个只有在50%的时间内发生的问题只是意味着诊断问题需要花费两倍的时间,因为有一半的时间程序不会表现出问题,因此不会贡献任何御用的诊断信息。

# 专注于问题

一旦我们可以合理的重现问题,下一步就是找出问题在代码中的位置。根据问题的性质,这可能很容易,也可能很困难。为了举例说明,假设我们不知道问题到底在哪里。我们如何找到它呢?

打个比方就能很好的解决这个问题。让我们来玩个游戏吧。我要请你们猜一个1到10之间的数字。对于你的每一次猜测,我会告诉你是太高还是太低,还是正确。这个游戏的例子可能是这样的。

```text
You: 5
Me: Too low
You: 8
Me: Too high
You: 6
Me: Too low
You: 7
Me: Correct
```

在上面的游戏中,你不需要每一个数字都猜就能找到我所想的数字。猜测的过程中,考虑从每次猜测中获得信息,你就只需要猜几次就能“锁定”正确的数字(如果你使用最佳策略,你总是能在4次或者更少的猜测中找到我在想的数字)。

我们可以使用类似的过程来调试程序。在最糟糕的情况下,我们可能不知道错误在哪里。但是,我们知道,问题一定是在程序开始到表现出第一个我们能观察到错误之间的代码的某一个地方。这至少排除了在第一个观察到错误之后执行的部分。但是这依旧覆盖了很多代码。为了诊断问题,我们将对问题进行一些有根据的猜测,目的是快速锁定问题的所在。

通常情况下,不管是什么原因导致我们注意到这个问题,都会给我们一个初步的猜测,这个猜测接近于实际问题的所在。比如,如果程序在应该写入数据的时候没有将数据写入到文件,那么问题可能就出在处理写入文件的代码中的某个地方。那么我们可以使用类似hi-lo的策略来尝试隔离问题的实际所在。

比如:

- 如果在我们的程序中的某一点,我们可以证明问题还没有发生,这就类似于收到一个"太小"的hi-lo结果 -- 我们知道问题一定是在程序后面的某个地方。比如,如果我们的程序每次都在同一个地方崩溃,而我们可以证明程序在某一点上没有崩溃,那么崩溃代码一定是在后面
- 如果在我们的程序中的某一点,我们可以观察到与问题相关的不正确的行为,那么这就类似于收到了一个“太大”的hi-lo结果,我们知道问题一定是在前面。比如,假设一个程序打印了某个变量x的值,你本以为它会打印值2,但它却打印了8。变量x的值一定是错的。如果,在程序执行过程中的某个点,我们可以看到变量x的值已经是8,那么我们就知道问题一定在这个点之前发生了。

hi-ho的类比并不完美 -- 我们有时候也会将一整块代码删除,而没有获得任何关于实际问题是在这一点之前还是之后的信息(TODO 有些不通顺)

我们将在下一节中展示这三种情况的例子。

最终,有了足够的猜测和一些好的技巧,我们就可以找到导致问题的确切原因了!如果我们做了错误的假设,这将帮助我们发现,当你排除了其他因素之后,唯一剩下的一定是导致问题的原因。那就只需要了解原因了。

你想使用什么样的猜测策略取决于你自己 -- 最好的策略取决于它是什么类型的bug,所以你可能会想尝试许多不同的方法来缩小问题的范围。随着你在调试问题方面经验的积累,你的直接将引导你。

那么,我们该如何“猜测”呢?有很多方法可以做到这一点。我们将从下一节开始从一些基础的方法开始,然后我们将在这些方法的基础之上,在以后的章节中探讨其他方法。
Loading

0 comments on commit ef8e059

Please sign in to comment.