开篇语
本节内容相对来说比较笼统一些,但是也是非常非常非常重要的一节内容,毕竟一个程序猿不能只会写bug,你写一堆bug谁来修呢?所以通过调试去排查错误的能力是一个优秀的程序猿必须要掌握的技能,而且不能仅仅是掌握,更要精通,本节内容总体来说只有两大部分,一是学习调试的方法,会有举例实际去感受一下,二是在写代码就要考虑的一些问题,养成良好的写代码习惯,防患于未然。
什么是bug?
bug一词在英文中本意有臭虫,虫子的意思,当年世界上第一台计算机发生故障就是由于一个虫子而引起,后来将bug一词引申义解释为影响计算机程序里的错误,我们常听到的 debug也就是“除虫”,修复bug的意思。
调试是什么?有多重要
提起调试就不得不想起一句话,一名优秀的程序员也是一名优秀的侦探。当然不是指的办理案件那种,仅仅针对于代码而言。
每一次调试都是一次推理破案的过程。
看上面这张有意思的图,是否像极了你,这种我们统称为迷信式调试,可能我们现在写的代码不长,几行几十行的代码,你凭肉眼这儿改改那儿改改最后也能改对,但是这种其实是非常不合理的。要是放在一个大的项目中可能几千上万行代码,怎么办。所以我们要掌握调试的方法,绝对不是这种迷信式调试。
调试是什么?
调试(Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。
调试的基本步骤
- 发现程序错误所在
- 以隔离、消除的方式对错误进行定位
- 确定错误产生的原因
- 提出错误的解决方法
- 修正错误处,重新测试
这是我们笼统的一些步骤,当然最终还是要去程序中切身体验才行。后面会有举例。
debug和release的介绍
不知道你是否有注意到我们的IDE会上面有一个选项debug或者release,
debug刚才我们已经解释了是除错的意思,release这个单词是有释放的意思。所以debug就是调试版本,通常是由我们程序员来写代码的版本,包含大量的调试信息,经过不断的修复bug;release版本是我们最后要发给用户的版本,release版本是没有包含调试信息的,不能进行调试的,release版本通常会对代码进行一定的优化。
总结:Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用。
我们也可以来看一下,这两种版本会有什么样的区别。
假设我们就这样同一段代码,分别在debug和release版本下看一下生成的可执行程序有什么区别。
可以看到,release版本优化掉的内容还是蛮多的。当然这里仅仅是从大小的角度来看的区别。
我们怎么看release是否对代码进行了优化呢?
我们来看一个稍有难度的示例:
示例一
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i <= 12; i++)
{
arr[i] = 0;
printf("hehe\n");
}
return 0;
}
这段代码其实是个有很大问题的代码,我们一眼就可以看出数组越界了,并且越界的不仅仅是一点,这段代码我们在debug版本下运行一下结果其实是死循环的打印hehe,而在release版本下则不会死循环的打印。这就是release版本对代码进行优化的证明。
但是这里还涉及到一个知识点,就是我数组虽然越界了,但是为什么会发生死循环这个结果呢?
我们可以调试看一下,提前感受一下调试的魅力,
首先你可以看到一个比较大的错误点就是当数组发生越界的时候,你有没有发现这段代码把数组越界越到的空间都给改成0了,它连越界访问到的空间是哪里,干什么的都不知道就给改成0了,这种行为是多么危险的一种行为。所以写代码一定做好数组越界的检查。其次你可能发现了,当我们访问到arr[12]时候一改为0连带着i也改为0了,所以我们就有疑问,难道越界访问到的这块空间正好就是i这块空间吗?这是一个巧合吗?
这里就要引出一个知识点了,我们通过下图来理解。
当然了,不同编译器的环境不同,可能也会有所差异,64位栈区的使用习惯是先使用低地址,在vs2022或2019,32位环境下数组越界预留空间是2个整型大小这些内容在计算机组成原理应该会学到,这里就不作深究,主要是理解一下弄清楚这个现象即可。
Windows环境调试介绍
备注:在Linux环境下调试使用的是gdb,并且比较有挑战性。
这里我们就只做Windows环境下的介绍了。
环境准备
环境准备呢其实就是将我们的IDE切换到debug版本底下,否则是无法进行调试的。
学会几个常用快捷键
首先我们要记住这几个,
F5,启动调试,直接来到下一个断点处,要配合断点来使用,否则直接按F5和Ctrl+F5是同样的效果。Ctrl+F5,直接执行,不调试。我们平时直接都是Ctrl+F5运行。
F10,逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
F11,逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最常用的)。
F9,创建断点和取消断点,断点的重要作用,可以在程序的任意位置设置断点。这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。
当前我们写的代码还不是很长,大概率就是几十行,所以一般都是直接调试,但是还是要学会习惯使用断点,养成好习惯到以后写项目,就是非常自然的一个过程。
调试的时候查看程序当前信息
这里就是重头戏了,我们调试的目的就是查看代码中的各部分信息,从而去检查出错误,最后进行修正。
查看临时变量的值
当我们调试起来后可以在调试->窗口->监视中任意打开一个即可,在监视窗口中可以输入你想要查看的变量,通过一步步调试来查看变化,当出现不是你想要的结果时就找到问题所在的地方了。提醒:注意一定是调试起来之后再去调试窗口里去找。否则没有监视内存等选项。
查看内存信息
同上所述,只要打开内存窗口即可,也是四个都可以。
查看调用堆栈
调用堆栈一般是用来查看函数之间的调用关系,通过调用堆栈,可以清晰的反应函数的调用关系以及当前调用所处的位置。
例如这个,是一层一层的函数调用才调用了我们的main函数,当我们代码下面再调用函数,在这里还会在往上再添加函数,这里还有一个数据结构中一个小知识点就是栈这种排序方式,我们可以画图来解释:
这时候你有没有发现我们通过调用堆栈去看这些函数排序方式的时候对递归的理解又加深了呢?所以再次证明了这种底层的逻辑对我们学习编程是非常有帮助的。
查看汇编信息
同样的方式也可以去查看汇编代码。
查看寄存器信息
查看寄存器信息。
多多动手,尝试调试,才能有进步
- 一定要熟练掌握调试技巧。
- 初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。
- 我们所讲的都是一些简单的调试。以后可能会出现很复杂调试场景:多线程程序的调试等。
- 多多使用快捷键,提升效率。
调试示例
题目:实现代码:求 1!+2!+3! ... + n! ;不考虑溢出。
首先我们根据我们正常的思路写出代码:
#include<stdio.h>
int main()
{
int i = 0;
int n = 0;
int ret = 1;//保存每个阶乘的结果
int sum = 0;//保存最终的和
scanf("%d", &n);
for (i = 1; i <= n; i++)
{
int j = 0;
for (j = 1; j <= i; j++)//计算每个阶乘
{
ret *= j;
}
sum += ret;
}
printf("%d\n", sum);
return 0;
}
很正常的思路写下来,看起来挺正确的对不对,但是我们去验证一下结果,
1!+2!+3!结果应该是9,但是却得到了15,哪里错了呢?
不要着急,我们调试看一下:
当我们去观察每一个可能出现问题的元素时,发现ret是用来求阶乘的,但是求每一个阶乘之前ret的值并不是1,而是上一次乘之后的结果,这里我们就找到问题了,在每一次求阶乘之前我们只要让ret重新赋值成1就可以了。所以修改后的代码:
这就是一个我们使用调试来排查错误的一个示例,由于博客只能以图片文字形式来传达,有一定局限性,大家一定要自己动手去感受,一定要动手!一定要动手!一定要动手!
如何写出好(易于调试)的代码
什么是优秀的代码
- 代码运行正常
- bug很少
- 效率高
- 可读性高
- 可维护性高
- 注释清晰
- 文档齐全
写出好的代码常用的技巧
- 使用assert
- 尽量使用const
- 养成良好的编码风格
- 添加必要的注释
- 避免编码的陷阱。
- 添加必要注释
assert作用以及使用
assert翻译叫做断言,凭借我们这么多年学中文大致也能猜测出来这是个什么词,大致就和预测的意思差不多。
assert的作用就是断言一下,通常我们在不想让某个元素为什么值的时候使用它,如果这个元素是这个值了,程序就会给报出警告。
我们举例来示范一下:
#include<stdio.h>
#include<assert.h>
void test(int* pa)
{
//断言pa不能为空指针
assert(pa != NULL);
}
int main()
{
int* pa = NULL;
test(pa);
return 0;
}
我们来运行一下看效果:
这里甚至把你断言的什么,在哪个文件哪个文件里,代码哪一行都告诉你了,所以说这个还是非常好用的。
const的作用以及使用
我们之前就已经了解过const定义的常变量,用const修饰的变量本身还是个变量,但是有了常属性,也就不能被修改了。
#include<stdio.h>
int main()
{
const int i = 10;
i = 20;
return 0;
}
可以看到,我们这样去修改const修饰的变量是不可以的,这是我们之前对const的了解。
这次我们更加细致的了解一下,const的作用和使用方法。
我们今天要学的是const修饰指针时的两种情况,
#include<stdio.h>
int main()
{
const int* i = 10;
i = 20;
printf("%d", i);
return 0;
}
当你用const修饰时发现i可以改了,并且并没有报错。而你去修改*i时,会报错:
另外一种情况是const放在 * 后面:
#include<stdio.h>
int main()
{
int* const i = 10;
*i = 30;
return 0;
}
你又会发现,*i可以改了,但是i不可以改,会报错:
所以综合上述两种情况,我们可以总结一下:
1.const修饰指针变量时放在 左边本质是限制的指针指向的内容 i,所以i可以改, *i不可以改;2.const修饰指针变量时放在 右边边本质是限制的 i,所以指针指向的内容i可以改, i不可以改;
这里还有一个有趣的例子来帮助强化一下记忆:
当然了,这里仅仅是简单的举个例子,没有不良导向啊。
编程常见的错误类型
编译型错误
简单来讲就是语法错误,直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误。
例如:函数名是test而写成了Test。编译器提示找不到test函数。
运行时错误
这个就是我们最常见的错误了,一般这种错误是由于逻辑或者写代码时各种因素写错,这种错误一般不好发现,一定要耐心的去调试发现问题,然后去修正。
优秀代码应该注意的几处地方示例
题目:模拟实现strlen函数
#include<stdio.h>
#include<assert.h>
int my_strlen(const char* str)//此处const修饰
{
assert(str != NULL);//断言指针不能为空指针
int count = 0;
if (*str != '\0')
{
str++;
count++;
}
return count;
}
int main()
{
char arr[] = "abcdef";
int len = my_strlen(arr);
printf("%d\n", len);
return 0;
}
如果我们之前写这个代码可能是不会写这两处的,但是之后希望大家能够多注意这些小的地方。
题目:模拟实现strcpy
#include<stdio.h>
#include<assert.h>
//库函数的strcpy返回的是目标空间的起始地址
char* my_strcpy(char* des, const char* source)//const修饰不需要改变的量
{
assert(des && source);//断言不是空指针,确定指针有效性
char* ret = des;
while (*source != '\0')
{
*des++ = *source++;
}
*des++ = *source++;//把最后的\0也要拷贝过去
return ret;
}
int main()
{
char arr1[] = "hello";
char arr2[] = "*********";
my_strcpy(arr2, arr1);
printf("%s\n", arr2);
return 0;
}
好了,关于调试部分到这里就结束了,说一千道一万这种东西也只能告诉你方法,真正还是要多调试,多去发现问题,初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。再次强调:调试很重要!!!调试很重要!!!调试很重要!!!
看代码的三种境界:
- 第一层:看代码是代码;
- 第二层:看代码不是代码,是内存;
- 第三层:看代码还是代码;
最后的话
学校也因为疫情放假了,虽然自己在家也很无聊,很闷,但是也希望自己在家能够自律一些,不再像以前一样摆烂,也希望看到这儿的你也一起坚持下来。
最后的鸡汤:
大学四年也好,三年也罢。如白驹过隙,一瞬即逝。珍惜自己的时间,珍惜自己的所爱,每天积极地去面对生活,加油!