C生万物 | 程序员必备实用调试技巧分享

举报
烽起黎明 发表于 2023/11/30 11:33:42 2023/11/30
【摘要】 一名出色的程序员 = 优秀的Coding技巧 + 扎实的调试基本功

一、前言

本文,我们就来讲讲如何去进行调试,对于一名优秀的程序员来说,除了要熟练写业务逻辑外,还要学会如何去调试代码,这是至关重要的!

🔰 那我们该如何去调试哪些东西呢?使用什么去调试呢?如何调试呢?
—— 让我们带着上面这些问题一起进入调试的学习:book:

二、什么是Bug?

首先来讲讲我们要调试的是哪些东西,调试又称作【DeBug】,那这个Bug又是什么?

==“1949 年 9 月 9 日,我们晚上调试机器的时候,开着的窗户没有纱窗,机器闪烁的亮光几乎吸引来了世界上所有的虫子。果然机器故障了,我们发现了一只被继电器拍死的飞蛾,翅膀大约 4 英寸。”==

  • 上面是计算机编程的先驱 —— 【格蕾丝·霍普】在一次的机器调试中所遇到的现象,它也是发现Bug的第一人
  • 这导致计算机错误的飞蛾,也是第一个计算机程序错误

在这里插入图片描述
注:参考资料

三、调试是什么?有多重要?

1、导学引入

调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。

所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径:mag:

  • 还记得当年我的老师曾经和我讲 “一名优秀的程序员是一名出色的侦探”,我觉得说得很正确,在日常中我们总会写很多的代码,但是呢不一定每个逻辑都能写对,这个时候就需要我们去进行调试了,==每一次调试都是尝试破案的过程==

顺着这条途径顺流而下就是犯罪( ‵▽′)ψ,逆流而上,就是真相🕵️‍

可是在日常的工作中很多人又是怎么写代码的呢?

在这里插入图片描述
下面还有《神秘的程序员们》这部漫画里的一组图片,让我们看到了一个【迷信式调试】的程序员,我们应该杜绝这种行为!

在这里插入图片描述

2、调试的基本步骤

好,接下去就让我们正式地来学习调试吧:computer:

首先我们要了解的是调试的基本步骤

  1. 发现程序错误的存在
  2. 以隔离、消除等方式对错误进行定位
  3. 确定错误产生的原因
  4. 提出纠正错误的解决办法
  5. 对程序错误予以改正,重新测试
  • 我们都说【万事开头难】,想要去通过调试解决一个问题,那就需要先找到这个问题,怎么找呢?谁去找呢?这里一共会有三种人:

首先第一个的话就是程序猿本猿🐒 我们会尽量地做到让自己的代码不出问题,减少Bug

在这里插入图片描述

第二个的话就是软件测试人员:man: 它们的任务就是尽量多得找出程序中的Bug,保证产品在上线的时候不会出现问题

在这里插入图片描述

那最后一个就是在使用软件的客户🤵,因为软件做出了来就是为了给客户用的,我们会根据客户的反应来不断完善所研发出来的产品
在这里插入图片描述
那么在找出了问题之后,接下去我们要做的就是去做一系列的排查了,这也是我们本文所要学习的内容

3、Debug和Release的介绍

接下去呢,我们再来说说有关【Debug】和【Release】的区别

🔰 Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序

🔰 Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用

  • 接下去我们就到VS中来观察一下

Debug版本

在这里插入图片描述
Release版本

在这里插入图片描述

  • 当我们在不同的版本下去执行程序的时候,当前工程目录下就会生成对应的文件夹

在这里插入图片描述

  • 而且对于不同的版本来说,它们所形成的可执行程序大小也各不相同,可以看到优化后的Release版本体积小了很多

在这里插入图片描述

int main()
{
	char* p = "hello bit.";
	printf("%s\n", p);
	return 0;
}
  • 我们还可以通过汇编来进行观察,便可以发现相较于Debug版本而言,Release在底层做了许多的优化工作

在这里插入图片描述

四、Windows环境下VS调试介绍

好,清楚了调试的基本技巧之后,我们正式地来介绍一下在Windows中的VS下该如何去进行调试

1、调试环境的准备

  • 我们在准备进行调试的时候,一定要让自己处于Debug模式下,如果是其他模式的话是无法进行调试的

在这里插入图片描述

2、学会快捷键

  • 在调试这一块,我们一般会去使用一些快捷键来进行操作,这样在调试的过程中就可以专注于代码本身,而不是去关注进入下一步要点击哪个按键,下图中我框出来的几个就是比较常用的

在这里插入图片描述
F5

启动调试,经常用来直接跳到下一个断点处。

F9

创建断点和取消断点。熟知这个快捷键,我们便可以在程序的任意位置设置断点,由调试者本身来进行操控,做到得心应手。

F10

逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。

F11

逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最长用的)。

Ctrl + F5

开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。

👉想知道更多快捷键?点我👈

3、调试的时候查看程序当前信息

清楚该如何去进行一步步地调试之后,如果我们在调试的过程中想要查看一些当前程序的相关信息呢?该如何去进行查看

  • 立马到VS中来看一下,在点击【调试】⇒ 【窗口】里面有很多的内容可供我们去进行查看,不过呢这些窗口都是需要再调试起来之后才可以的

在这里插入图片描述

  • 我们可以看一下,如果没有调试起来点击【窗口】出现的就只会是一些简答的窗口,有些窗口我们就看不到了,所以一定要调试起来!!!

在这里插入图片描述
💬 接下去呢,就让我们去学习一下这些相关的窗口该如何使用:book:

3.1 查看临时变量的值

  • 首先的话就是去看看我们在调试过程中所需要查看的临时变量的值,在这之前呢我们要先开启【窗口】中的【监视】,随便点开哪一个都是一样的

在这里插入图片描述

  • 接下去,开始调试起来,我们就可以在【监视】窗口中敲入当前程序的变量名然后去观察它们在调试过程中所发生的变化,不仅可以增加也可以删除,读者可以自行去试试,这里不做演示了

在这里插入图片描述

  • 然后我们调试起来,观察右侧的值发生了怎样的变化🎇

在这里插入图片描述

当然,除了【监视】窗口可以看这些内容之外,我们还可以通过【自动窗口】【局部变量】这些来进行查看

在这里插入图片描述

  • 可以看到,我们在使用【自动窗口】进行调试的时候,发现里面的变量会随着程序的执行而发生变化,却不会是一直固定的几个值,无法进行自行输入和删除,所以我们若是像看一些变量的时候就不到了

在这里插入图片描述

  • 再来看看【局部变量】又是怎样的

在这里插入图片描述

  • 如果有看过 函数栈帧 的同学应该就可以知道局部变量在出了作用域之后就销毁了,所以当我们在进行调试的时候窗口中只会显示当前函数栈帧中的变量,出了当前函数的作用域之后就销毁了

在这里插入图片描述

综上所述,我们在进行调试的时候还是尽量选择【监视】窗口来进行观察,因为在里面我们可以任意地增、删变量,任何东西都可以由调试者来进行控制,而不是由VS来进行控制

3.2 查看内存信息

  • 接下去我们再来看下一个功能 ⇒ 【内存】

在这里插入图片描述

  • 首先你要知道的是如何精准地找到某个变量的地址,也就是在上面这个框中输入【& + 变量名】即可

在这里插入图片描述

  • 接下去我们就可以去看看当前的变量在内存中到底是怎么存放的,例如这个变量a在内存中左侧即为其地址,中间就是他的值,0a即为10的十六进制表示法,由于VS是小端存放,所以我们看到的样子是倒着放到的,如果不太懂得同学可以看看 数据在计算机内部的存储 一文,里面有讲到大小端的相关知识
  • 那么右侧的就是一些参考信息,这一块可以不用理会

在这里插入图片描述

3.3 查看调用堆栈

  • 如果读者看过 C/C++内存分布 的话就可以知道在内存中存在着一个区域叫做【栈区】,也可以叫做【堆栈】

在这里插入图片描述

  • 为什么叫做堆栈呢?仔细看右侧的这个结构是不是和下面的函数布局main()test1()test2()test3()存在一些关系呢,就像是一个个地堆了上去
void test3()
{
	printf("hello debug\n");
}

void test2()
{
	test3();
}

void test1()
{
	test2();
}

int main(void)
{
	test1();
	return 0;
}
  • 不过,这么来看我们还看不到一些再底层的细节,此时我们就可以右击选择下面的这个【显示外部代码】,此时我们又可以看到多出来一些东西

在这里插入图片描述

  • 可以看到,在main函数的下面我们又看到了一些类似于main函数的东西,到这里如果你看过 函数栈帧创建和销毁 的话

在这里插入图片描述

  • 这里可以带读者通过调试来看看这个main函数的底层调用究竟是怎样的

在这里插入图片描述

3.4 查看汇编信息

  • 有的时候呢我们还需要查看一些底层的汇编代码,此时就可以通过【窗口】 ⇒ 【反汇编】进行查看

在这里插入图片描述

  • 当然你也可以调试起来后在右击选择【转到反汇编】,博主一般都是用的这种方法,比较方便一些

在这里插入图片描述
💬 当然对于一些反汇编的知识这里就不讲了,有兴趣的同学可以去了解一下

3.5 查看寄存器信息

  • 如果有学习过汇编语言或者是《计算机组成原理》的同学应该可以知道寄存器是 中央处理器(CPU) 很重要的一个部件,用来存放一些临时的变量,我们在调试汇编的时候可以发现用得比较多的有 eaxecxedxebpesp这些

在这里插入图片描述
💬 对于上述的调试小技巧,希望大家要熟练掌握,初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试

💬 我们现在所讲的只是一些简单的调试,等到你慢慢地进行深入学习后,可能会出现很复杂调试场景:多线程程序的调试、网络问题的调试等,此时光使用VS内置的调试器来进行调试可能就不够了,大家还要再去学习一些像【Windbg】、【SPY++】、【Dependency Walker】等等。张哥是C++软件调试专家,致力于C++软件调试方面的培训和教学,读者如果感兴趣的话可以多去看看他的文章 dvlinker主页

4、多多动手,尝试调试,才能有进步

清楚了调试的基本手法后,我们立马来尝试一下该怎样一步步地去进行调试

  • 下面是我们要进行调试的代码
void test()
{
	int a = 0;
	int n = 0;
	scanf("%d %d", &a, &n);
	// 计算
	int sum = 0;
	int i = 0;
	int k = 0;
	for (int i = 0; i < n; i++)
	{
		k = k * 10 + a;
		sum += k;
	}
	// 打印
	printf("%d\n", sum);
}
  • 首先我们在这一行打上一个断点,按F9或者直接使用鼠标点击即可

在这里插入图片描述

  • 然后按下F5直接运行到断点处开始调试,但是呢可以看到命令窗口却等待我们进行输入,一看代码原来有一个scanf在等待我们进行输入

在这里插入图片描述

  • 在输入具体的数值之后便发现右侧的断点处出现了调试的箭头,此时我们就真正地开始调试了

在这里插入图片描述

  • 然后我们在循环处打上一个断点开始做调试的工作,此时再按下【F5】便可以运行到下一个断点处

在这里插入图片描述

  • 那此时我在循环外面又打了一个断点,请问此时我再度按下【F5】的时候也会运行到下一个断点处吗

在这里插入图片描述

  • 可以看到并没有运行到这个打印语句处,而是一直处于循环,这就是因为这个循环还没有到达结束条件,所以它是不会退出的,屡次按【F5】只会强制进入下一次循环,我们通过去看这个i的变化就可以看出来

在这里插入图片描述
💬 那有同学问:这该怎么办呢?有没有什么好的办法?

  • [x] 办法当然是有的,只需要将这个循环内部的断点给取消掉即可,此时再去按【F5】的时候就可以运行到下一断点处了

在这里插入图片描述

以上即为我们在学习了基本的调试技巧后所呈现的调试详解,继续看下去,还有更精彩的内容等着你哦😘

五、Linux环境下GDB调试介绍

请移步博主Linux基础入门篇之 调试器GDB的详细教程,带你感受在Linux环境下该如何进行调试

六、经典案例分析【步步调试教学】

接下去,博主就通过一步步的调试来带读者如何去排查问题、解决问题

1、问题代码段1 —— 阶乘之和

先来看一道C语言中比较基础的题目,求解阶乘的和,通过调试来观察为何会出现问题,如觉得已经会了的读者可以直接看第二道题

  • 先上代码。逻辑很简答,首先输入n表示,表示n个阶乘之和,然后在内部循环中求出每一个数的阶乘,计算所得进行累加,最后便有了【阶乘之和】
int main()
{
	int sum = 0;	//保存最终结果
	int n = 0;
	int ret = 1;	//保存n的阶乘

	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
	{
		for (int j = 1; j <= i; j++)
		{
			ret *= j;
		}
		sum += ret;
	}
	printf("%d\n", sum);

	return 0;
}

简单一些,计算1! + 2! + 3!的阶乘之和。

  • 首先看到我们进入了内存循环,此时i = 1, j = 1,内部的循环只会执行一次,求出1! = 1

在这里插入图片描述

  • 此时的sum便为1

在这里插入图片描述

  • 接着进入【2!】的计算,内部循环会执行2次,此刻i = 2, j = 1

在这里插入图片描述

  • 此时算出ret = 2,即【2! = 2】,累加到sum中,此时sum == 3

在这里插入图片描述

  • 接下去计算【3!】,内部循环会执行3次,3!应该要为6
  • 此刻i = 3, j = 1注意观察此时的ret为2

在这里插入图片描述

  • j == 2时ret进行了一次累乘,值便为4

在这里插入图片描述

  • j == 3时继续进行累乘,此时ret为12

在这里插入图片描述

  • 此时跳出循环开始累加【3!】的结果,此时sum == 15,结果错误❌

在这里插入图片描述

相信在认真看了我步步分析之后,你一定可以清楚为什么会出错❓

  • [x] 原因就在于每次在计算下一个数的阶乘时,上一次累乘之后的ret没有进行一个重置,便导致在计算下一个阶乘的时候重复累乘

在这里插入图片描述

  • 这样运算结果就正确了,1! + 2! + 3! = 9

在这里插入图片描述

💬 上面只是热热身,让读者熟悉一下该如何去进行调试排查。下面这个才叫做【真正的问题】

2、问题代码段2 —— 越界的危害

① 发现问题

好,我们来看这个代码段,首先请你给出它最终的运行结果💻

  • 相信很多同学都认为这段代码最后的结果是程序报错,因为一眼就看出了for循环的边界条件有问题,导致产生了数组访问越界
  • 如果你是这么想的,那我要这么告诉你:你是个正常人😀,我问了我身边的朋友,第一时间就觉得这一定是一个越界错误,不过它的运行结果并不是你想的那样,而是一个死循环😵
int main()
{
	int i = 0;
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	for (i = 0; i <= 12; i++)
	{
		arr[i] = 0;
		printf("hehe\n");
	}
	return 0;
}

在这里插入图片描述


② 分析问题

看了上面的这些结果,相信很多读者都非常诧异(・∀・(・∀・(・∀・*)。究竟是为什么呢?我们一起先来分析一下:mag:

  • 通过调试来进行观察,首先我们进入循环,初始化数组

在这里插入图片描述

  • 然后通过动图先将数组的合法10个元素修改为0,这里的快捷操作是F9设置断点 + F5运行到下一断点处

在这里插入图片描述

  • 但是可以看到,循环的终止条件为i == 12,那此刻循环就会继续执行,那你就会想arr[10]这个位置不是越界非法的嘛,为什么访问不会报错呢?

在这里插入图片描述

  • 首先我们来讨论一下arr[10]这个位置,为什么可以访问到?
  • 从下图可以看出,在内存中对于一个数组而言是连续存储的,数组后面的这些空间其实也是存在arr数组之后,它们都存在于main函数的函数栈帧中,对于函数栈帧来说,一块块存储空间都是紧密相连的,所以要想访问数组之后的空间也是可以访问到,==只是在我们的意识中他确实是存在越界访问的行为==
  • 可能还是有同学不理解,举个简单的【例子】:假设你现在在一家酒店里,和家里人一起出来旅游,找了酒店中三个连在一起的房间住一晚上。你们旁边呢还有很多房间,都是连在一起的,那这个时候你可以闯入别人的房间🏠吗?虽然行为上是非法的,但是呢又是可以做得到的,只是会被人打一顿而已。这就可以理解为【数组的越界访问】
    在这里插入图片描述
  • 但是越界访问也就算了,难不成真的可以修改没分配内部空间的值吗,我们来看看

在这里插入图片描述

  • 可以看到,这个位置上的值确实是发生了一个修改Σ(っ °Д °;)っ,而且没有报出错误,继续循环。其实对于编译器来说,有时候的越界访问不报出错误是正常的,因为它有时候确实检查不到,就和交警不可能查到每一个醉酒的司机是一个道理,不然为什么有这么多车祸呢🚗
  • 非但是可以修改第10个位置的元素,后面第11个它也进行了修改。虽然这已经见怪不怪了

在这里插入图片描述

  • 可是呢,到了第12个位置的时候,却出现了奇怪的事情,这块地址上的数竟然不是一个随机值,我们都知道面对未初始化的数据都是一个无符号整型(unsigned int),上面也看到过,是一个很大的负数,可是呢这个位置上的数确是12,而且刚好是i == 12这个边界的位置

在这里插入图片描述

  • 此时当我再执行arr[i] = 0时间,就发生了这样的事👇此时的【i】和【arr[12]】两个值竟然同时发生了变化

在这里插入图片描述

  • 那有同学就更加差异了,这是为啥呀?????

在这里插入图片描述

③ 思考问题【⭐堆栈原理⭐】

分析了问题出现的地方,接下去就让我们通过堆栈的内存布局和原理来分析一下为何会出现这样的情况

  • 刚才看到了当程序运行完arr[12] = 0时arr[12]和【i】这两个位置的值一起发生了变化,那就会思考它们会不会是一样的呢?
  • 接着分别在调试的时候取出它们的地址就可以发现确实是同一块空间【我第一次看到这个结果的时候也感觉很惊奇!】

在这里插入图片描述

  • [x] 其实就可以想到,对于变量i,应该是位于数组结束位置的后两位位置,这样才会在越界访问数组的时候导致访问到【i】,然后在修改这块块空间中的值时将循环变量【i】的值做了修改,那也就使得【i】永远到不了13,那也就不会跳出这个循环,会一直循环下去

在这里插入图片描述

  • [x] 变量【i】辛辛苦苦地通过循环加到了13,眼看前面的路就要走完了,但是呢你又给他拽回了起点,那也就只好重新开始。可是呢一次也就算了,你就是和它过不去,就站在13这个位置上,每次看【i】一到13就把他拖回起点,这也就使得它永远都过不去了 —— 血海深仇❣

在这里插入图片描述


当然就通过这样的方式来看还是了解不到在内存中它们究竟发生了什么,就下去我就通过画内存图的方式带你一探究竟:mag:

  • 内存布局呢分为【堆区】【栈区】【静态区】三大块,对于像arr数组变量i这些都属于局部变量,对于局部变量来说都是存放在【栈区】中的。也就是我们说过的函数栈帧它就是在栈区开辟空间的
  • [x] 栈也可以称做为【堆栈】,它的使用习惯是:==先使用高地址,再使用低地址==。这一点很重要,是理解的关键所在!
  • 通过创建两个变量来进行观察就可以发现先创建的变量就会先创建的变量就会现在栈中为其开辟空间,因此可以看到变量a的地址是比变量b的地址来得大的

在这里插入图片描述

  • [x] 下面我画的这张图其实就是内存中栈区的真正模样,也就是从上往下进行生长,上面是高地址,下面是低地址。因此一进到main函数的函数栈帧中时,就会先为变量【i】开辟一块空间,接着可能就会空出几个位置再为arr数组开辟十个元素的空间
  • [x] 可是呢有同学就会疑问,为什么要空出几个位置,而不是直接紧随其后就在变量【i】后面为其分配10块空间呢?这一点我们到后面再议👇
    在这里插入图片描述
  • [x] 我们都知道对于数组的下标来说是从低到高进行变化的,也就是从0 ~ 9,那对于数组的地址是如何变化的呢?我们通过VS来看看

在这里插入图片描述

  • [x] 通过打印数组中每个元素的下标就可以发现数组中==每个元素的地址是由低到高进行一个变化的==。这一点也很重要,是理解的关键所在!

然后再去看上面这张图你就可以知道为什么在越界访问数组的时候会访问到先创建出来的变量【i】了

👉虽然变量【i】是先创建出来的,先开辟的空间;而数组arr是后创建出来的,后开辟的空间。不过呢,因为数组的下标和每一个元素的地址都是从低到高进行一个变化的又因为堆栈的使用习惯是:先使用高地址,再使用低地址,所以当数组在进行向后访问的时候,就有可能找到变量【i】,就有可能把【i】覆盖掉,就有可能把这个循环变量改成意想不到的值,导致循环的结束条件永远都不会成立,永远都是真,这也就导致了死循环产生👈

你,明白了吗👀


最后的话再来解释一下为什么开辟了变量【i】的栈帧空间后要空出几个位置才为数组arr开辟空间,而且刚好是两个这么巧呢?

  • 其实这不是我瞎说的,也不是我能决定的,而是取决于编译器。
    • VC6.0这个很老的编译器中,其实在局部变量的栈帧空间开辟中是不会再创建多余空间的;
    • gcc这个Linux环境下的编译器中,创建的局部变量之间会空出一个整型,也就是4个字节
    • 但是在VS 2013/2019/2022这些编辑器中,中间都会空出两个整型,也就是8个字节
  • 所以这段代码其实你在不同编译器下去运行虽然都是死循环,但是死循环的临界点和循环的这个范围都是不同的。例如在Linux下的gcc去编译运行的话i = 11就会发生死循环,具体的有兴趣可以去试试

④ 解决问题【DeBug与Release】

好,上面我们通过一系列的问题排查和思索,最终发现了问题所在,那现在就来更正一下这个问题

  • 其实如何更正你已经可以想到了,那就是把对于变量【i】的定义放到数组定义的后面,这样数组在进行越界访问的时候就不会访问到后面的【i】了
  • 不过可以看到,终于是出现了大家一开始想到的越界访问的情况

在这里插入图片描述

  • 不过这种做法可是不对的,数组越界访问应该是我们要避免的一个问题,所以真正要做出修改的应该是循环中访问数组的结束条件

在这里插入图片描述

  • 接下去我们再来看一个神奇的事情,对于代码在编译器中的运行环境我们可以知道有【DeBug】和【Release】两个版本,我将会出现死循环的这中定义方式放在两个不同的版本下进行了运行,查看变量i和数组的边界地址
  • [x] 然后便发现【Relsase】版本对变量i的地址做了一个优化,使其变到了数组arr的前面,==这样在数组向后进行越界访问的时候就不会发生覆盖造成同时修改了==

在这里插入图片描述
在这里插入图片描述

希望在看了我上述对这个问题的讲解之后,今后在碰到类似的问题也可以照常去分析排查:mag:

上面这题就是来自西安的一家公司叫做【Nice】,这是它们在2016年的校招笔试题

  • 可以看到,题目中问题的是在Linux环境底下的运行结果是怎样的,所以我们还需要熟悉Linux的环境,毕竟C/C++程序员日常的开发环境基本都是在Linux环境下的,读者可以自行去分析一下这道题目看看是否真的掌握了💡

在这里插入图片描述

七、Coding技巧 —— 如何写出优秀的代码

接下去我们来说一说该如何去写出一份优秀的代码,而不是像上面那样的问题代码段

1、代码素养先知

通常优秀的代码都具有以下几个特征:

  1. 代码运行正常bug很少
  2. 效率高
  3. 可读性高、可维护性高
  4. 注释清晰、文档齐全

常见的Coding技巧:

  1. 使用assert
  2. 尽量使用const
  3. 添加必要的注释
  4. 避免编码的陷阱

2、学会使用【const】,辨析常量指针和指针常量

本小节我们来说说大家很困惑的两个东西👉【常量指针】与【指针常量】

💬引言须知

  • 首先来看看下面这段代码,首先我定义了一个变量num为10,然后又对其进行了一个赋值修改,打印出来之后就是修改之后的值【相信这是最基本的认识】
int main(void)
{
	int num = 10;
	num = 20;

	printf("num = %d\n", num);
	return 0;
}

在这里插入图片描述

  • 但若是我可以修改num值的话,别人也可以修改了,这就没有了安全性。所以我想给它加上一把锁🔒使得它无法被修改,这里介绍一种C语言中的关键字【const】,这个我在初识C语言也有说到过,若是我们在定义变量的时候在前面加上一个const做修饰,此时这个变量就会变成【常量】
  • 这个就和Java中的final关键字是一个道理,若是加上了这个关键字做修饰之后,就要在定义的时候对其进行一个初始化,并且后面不能去修改它的值
const int num = 10;
  • 可以看到,在加上const常进行修饰之后,这个变量就无法被修改了,若是有人想要去修改的话编译器就会报出警告⚠

在这里插入图片描述


以上均为引言,接下去我们来说说有关【常量指针】和【指针常量】之间的区别

< 常量指针 >

介绍与分析

  • 上面看到,因为在定义num的时候前面加上了const常的修饰,就使得它变成了一个常量,无法被修改,在指针初阶章节,我有介绍过可以将一个指针进行解引用去修改这个指针所指向那块地址的值
int* p = &num;
*p = 20;
  • 可以看到,确实可以对其进行一个修改

在这里插入图片描述

  • 那此时这个num的安全性就又降低了,所以我想再做制裁🗡,使得指针也无法对其解引用进行一个修改
  • 那么又需要使用上面所说的const修饰符,也是和修饰num一个道理,只需要在前面加上一个【const】作为修饰即可
const int* p = &num;
  • 可以看到,此时我们通过指针解引用的方式也无法对其进行修改❌

在这里插入图片描述

  • 虽然是不可以通过指针解引用去修改这个指针所指向的值,但是可以去修改这个指针的指向,令其重新指向一个变量的地址,这是合法的
const int num = 10;
//num = 20;
int num2 = 20;

const int* p = &num;
//*p = 20;		//err
p = &num2;
  • 不过原理还是一样的,我们无法通过这个指针进行解引用去修改它所指向的值

在这里插入图片描述
:book:小结一下

  • [x] 总结:对于常量指针而言,是将【const】放在*的左边,表示的是指针所指向的内容不能通过指针来修改,但指针变量本身可修改
  • [x] 口诀:常量指针所指向的是一个常量,不能修改;但是指针本身不是常量,可以修改

< 指针常量 >

知道了什么是【常量指针】,接下去让我们来看看什么是【指针常量】

介绍与分析

  • 刚才我们将const放在*的左边,现在我们换个地方,将它放在*的右边试试
int* const p = &num;
  • 此时若再去做这两步操作的时候你就会发现和【常量指针】完全不同,可以通过指针解引同去修改指向的值,但是无法再次修改指针的指向
*p = 20;		
p = &num2;		//err

在这里插入图片描述

:book:小结一下

  • [x] 总结:对于指针常量而言,是将【const】放在*的右边,表示的是指针变量本身的指向不能修改,但是指针指向的内容可以通过指针来修改
  • [x] 口诀:指针常量这个指针本身就是一个常量,不能修改;但是指针所指向的内容不是常量,可以修改

❤一份凉皮所引发的故事

可能还是有同学对它们之间的关系不太理解。没关系,我们通过一个生活中的场景来介绍一下

在这里插入图片描述

  • 现在这里有三行代码,有一个常量num指针p里面保存了它的地址,还有一个常量num2
  • 我们假设这个指针p为一个女孩,num为一个男孩,他是这个女孩的男朋友。有一天男孩陪女孩去逛街,女孩看到路边有人在卖凉皮,所以就想要男孩给他买一份凉皮吃,可是呢男孩身上只有【10块钱】,若是给女朋友买了凉皮自己就没钱用了,于是说:“不行,不给你买,凉皮有什么好吃的😕”

在这里插入图片描述

  • 于是这个时候女孩就==生气了==,就对男孩说:“一份凉皮都不舍得给我买,还算是我男朋友吗?分手!”,于是看另一个男孩还不错,就想去找另一个男孩【他身上有100块钱】
  • 于是这个时候男孩就不乐意了,好不容易追到的女朋友(不是靠钱),怎么能说分手就分手呢,不能分。此时它就做了一个动作:在这个操作符[*]的前面加上了const作为修饰符,我们来回顾一下前面的知识

在这里插入图片描述

  • [x] 这里的*p = 0就相当于是指针通过解引同让num = 0,那指的就是让男孩变得身无分文;这里的p = &num2指的就是重新修改指针p的指向,使其指向另一个值的地址。👉这就是【常量指针】

  • 此时男孩意识到事情的严重性,那个男的身上这么有钱,万一被它拐走了。想了想还是去给她买吧,一份凉皮罢了,就和女孩说:“行行行,给你买,但是你不可以换男朋友”。此时他就又做了一个动作:在这个操作符[*]的后面加上了const作为修饰符,去掉了前面的const

在这里插入图片描述

  • [x] 同理,这里的*p = 0就相当于是指针通过解引同让num = 0,那指的就是让男孩变得身无分文;这里的p = &num2指的就是重新修改指针p的指向,也就是换一个男朋友。👉这就是【指针常量】

建议广大女性读者选择第二种男朋友,若是想下面这样的,就直接分手吧

  • [*]的前后都加上了const修饰符,那么既无法通过指针去修改所指向的值,也无法修改指针的指向,虽然这使代码变得非常安全,但在还是没有这个必要╮(╯▽╰)╭
  • 要想一个男朋友连吃的都不给你买,而且还不准你换男朋友,强行霸占你🔨这种情况还是赶紧分手吧!

在这里插入图片描述

最后再来总结一下

  • [x] 对于【常量指针】而言,是将const放在[*]左边的,指针所指向的内容不能通过指针来修改,但指针变量本身可修改
  • [x] 对于【指针常量】而言,是将const放在[*]右边的,指针变量本身的指向不能修改,但是指针指向的内容可以通过指针来修改

3、模拟实现库函数strcpy之梅开n度

清楚了一些常用的Coding技巧后,我们就要把它用起来,接下去我们再来模拟实现一下【strcpy】这个C语言中的库函数吧

【梅开一度】:观察库函数strcpy()的实现

首先我们先来观察一下库函数 strcpy 去实现字符串拷贝的功能

  • 首先来看看文档中对这个函数是如何描述的
    在这里插入图片描述
  • 清楚了这个库函数的功能之后,我们到VS中来看看使用代码如何实现
  • 可以看到,首先去定义出两个字符串:第一个str1为目标字符串,初始化均为x是为了在调试的时候方便查看是否拷贝成功;第二个str2为源头字符串
int main(void)
{
	char str1[10] = "xxxxxxxxx";
	char str2[] = "hello";

	strcpy(str1, str2);

	printf("%s\n", str1);
	return 0;
}
  • 进入调试进行观察可以发现,两个字符串已经初始化完成,准备进行拷贝

在这里插入图片描述

  • 然后看到如下就完成了字符串的一个拷贝工作,会连带\0一起拷贝过去。所以对于目标字符串我没有初始化为0就是为了看出拷贝完成的工作

在这里插入图片描述


【梅开二度】:模仿实现strcpy()

好,看完了库函数的实现之后,我们考虑自己去进行一个实现

  • 通过定义出一个my_strcpy()的函数,设置形参为两个字符指针,用于接收主函数传入进来的两个字符串的起始地址
void my_strcpy(char* dst, char* src)
  • 对于数组的函数名来说就是首元素地址,所以直接传入数组名即可
my_strcpy(str1, str2);
  • 写代码前我们来看一下字符串拷贝的原理,也就是获取到srcdst两个指针所指向的字符,然后进行一一拷贝,直到*src == '\0’为止

在这里插入图片描述

  • 所以对于一个字符的拷贝就可以这样去写
*dst = *src;
  • 但是拷贝完一个字符之后还要拷贝后面的字符,这就是通过字符串指针去进行一个后移的操作,便可以进行继续拷贝

在这里插入图片描述

  • 最后当这个*src == '\0'的时候,便结束拷贝,跳出循环。此时我们还有最后一个'\0'还没有拷贝过去,继续执行一次*dst = *src即可

==代码展示==

void my_strcpy(char* dst, char* src)
{
	while (*src != '\0')
	{
		*dst = *src;
		src++;
		dst++;
	}
	*dst = *src;
}

==运行结果展示==

在这里插入图片描述


【梅开三度】:优化简练代码

看完了上面这段代码,你认为就结束了吗?其实对于这种代码来说是不够简练的,我们来继续进行一个优化

  • 对于while循环内部的判断,我们知道是一个逻辑表达式,而对于'\0'来说就相当于与【假】,所以当*src != '\0'的时候就会一直循环,就为【真】。所以我们可以直接改成*src,当其碰到'\0'的时候就会跳出循环停止拷贝
while (*src)
  • 第二处可以优化的就是循环内部的一个拷贝的过程,因为在每一次拷贝完成之后两个字符指针就会进行一个后移,此时我们可以对它们进行一些合并。
  • 因为对于后置++来说是先执行++之前的,所以赋值完成之后再++就刚好可以达到一个后移的效果
*dst++ = *src++;

来看一下代码的优化后的逻辑,其实它还可以再进行一个优化:dragon:

while (*src)
{
	*dst++ = *src++;
}
*dst = *src;
  • 通过仔细观察库函数strcpy()的描述后就可以发现,其实它在拷贝结束之后也是存在返回值的,返回的就是拷贝完成之后的目标字符串

在这里插入图片描述

  • 因此我们可以将拷贝的逻辑也放到循环的条件判断中去,不需要在最后继续拷贝'\0',随着*dst++ = *src++的不断执行,最后将src中的\0拷贝到了dest中,此时while()循环中的条件就变成了\0,会自动跳出循环,此时【src】和【dst】也已经遍历结束
  • 所以代码就被简化成了下面这样👇
while (*dst++ = *src++)
{
	;
}

==运行结果展示==

在这里插入图片描述


【梅开四度】:assert()断言拦截

经过上面的众多优化,你一定觉得可以了,确实已经是够简洁了,但是呢却缺乏安全性🛡

  • 我们是模拟实现字符串的拷贝,将str2中的字符串拷贝到str1中,那就是要源头字符串中有内容才可以拷贝,但若是我将这个str置为NULL然后传进去呢,会发生什么?
char* str2 = NULL;

在这里插入图片描述

  • 通过运行可以看到,运行的时候报出了[空指针异常],因为在函数内部现在要执行*src,也就是解引用的操作,我们知道对于空指针来说是不能解引用的,因此这里就出现问题了,表示我们的程序考虑地不够严谨
  • 此时就可以使用到一样东西叫做【断言】,可以去看看官方文档 👉assert

在这里插入图片描述

assert(src != NULL);
  • 若是加上了这句assert断言,那么编译器在运行的时候就会报出对应的错误信息,括号里面要写上的就是==出错的对立面==,若是当src != NULL时,便不会执行这个断言,只有当src传入进来是NULL的时候才会触发这个断言
  • 当然为了方便也可以写成这样👉assert(src);只有里面的表达式expression为真的时候才会执行,为假的时候便不会执行
  • 也可以给dst加上断言,防止它传入进来也为NULL,👉assert(dst);

那么这两个断言的逻辑就可以转换为只有当srcdst均为非空的时候程序才正常执行,只要有一方为空便报出错误,那便将它们做一个合并,就可以想到使用我们在操作符章节讲到过的【逻辑与】

assert(dst && src);

在这里插入图片描述


【梅开五度】:const修饰常量指针

看完了上面的这些,那你一定会觉得这个这个代码非常严谨了吧,但是不要高兴得太早,还有问题😮

假设一个公司的程序员,它现在就在模拟实现一个字符串strcpy(),也想到了断言这一步,然后吃饭去了。和朋友一起到楼下酒吧喝了两杯,然后呢回到公司之后继续写业务,要知道此时他喝醉了:beer:

while (*src++ = *dst++)
{
	;
}
  • 于是呢他就将代码写成了上面这样,将目标字符串dst中的内容拷贝到了原字符串src中,此时虽然在拷贝的过程中不会出现什么问题,可是呢在运行的时候就会出现【变量str周围的堆栈已损坏】,也就是【str1】中的这些“xxxxxxxxx”若是拷贝到str2中是存不下的,这就出现问题了

在这里插入图片描述

  • 那么上述的这个程序员的操作其实是在修改源头字符串src那我们要将原字符串拷贝到目标字符串中,原字符串肯定不能修改,所以这个时候就要使用到【const常】了。此时我们可以在char* src的前面加上一个const作为修饰,此时若是这个喝醉酒的程序员把拷贝的字符串反了,==编译时期就会直接报出错误==

在这里插入图片描述

  • 此时对于src来说就叫做【常量指针】,它所指向的内容是不可以修改的,但是它的指向是可以修改的,若是不太清楚可以看看这篇文章👉常量指针与指针常量

可能有同学说,就这么一个小小的const也这么讲究,那我要和你说:我们写业务逻辑就是要严谨,你永远不可能知道用户下一秒会做什么。加上了const之后使得我们的代码更具有健壮性💪防止源头被修改,也就可以扼杀一个运行时错误❌


【梅开六度】:还可以有返回值哦🚀

最后的话再进行一个完善也就是我们前面说到过的有关这个strcpy()函数还具有一个返回值,也就是char*,返回的是【dst】拷贝后的内容

在这里插入图片描述

  • 因为我们是进行一个模拟,所以为了尽量和原本的内容保持一致,我们也要将这个返回值加上,这个很简单,只需要在一开始的时候保存一下src原字符串即可
char* ret = src;
  • 最后将其返回即可
return ret;

那么官方要加上这个char *的目的是什么呢?从下面的printf语句其实就可以看出是为了实现一个【链式访问

  • 什么是链式访问呢?也就是将一个函数的返回值作为另一个函数的参数,设想若是这个函数的返回类型是void的话,那么它还能不能放在这里呢
printf("str1 = %s\n", my_strcpy(str1, str2));
  • [x] 以下便是整体代码展示
char* my_strcpy(char* dst, const char* src)
{
	assert(dst && src);

	char* ret = src;
	while (*dst++ = *src++)
	{
		;
	}
	return ret;
}
int main(void)
{
	char str1[10] = "xxxxxxxxx";
	char str2[] = "hello";

	printf("str1 = %s\n", my_strcpy(str1, str2));
	return 0;
}

到这里,我么的模拟实现就算是真正完成了,相信在跟着我一步步地这么思考下来,一点点地做修改,完成代码。回顾整个流程。相信你的逻辑思维一定得到了提升,更加严密💪


💬 其实上面的这道题也是出自于一本书《高质量C/C++编程》,有兴趣的同学可以私信我,发你电子书:book:

在这里插入图片描述

👴梅开n度后的忠告👴

为何以梅开n度作为小标题,一方面除了【吸睛】之外,其实也在反映我们的程序人生:walking:

  • 其实做我们程序员这一行,20%在写业务逻辑,但是80%在调BUG,修BUG,但其实这都是你的代码问题导致的,若是我们在第一次写代码的时候就将问题考虑得很仔细、很周全,其实是可以减轻很多负担的
  • 当别人五点已经下班的时候,你还在吭哧吭哧修BUG,也就造成了【996】的现象,若是不想变成这样,那就提高你的代码质量吧!

八、编程常见的错误

好,最后我们再来看看在编写程序的过程中所遇到的一些常见错误

1、编译型错误

类型描述:

  • 【编译型错误】通常指的是在程序编译过程中出现的错误,这类错误往往是由于程序员在代码中违反了编程语言的语法或语义规则导致的。
  • 举个最简单的例子,我们在写完每一条语句的时候都会去写一个分号,那若是没有写上的话报出的就是 ==编译型错误==

在这里插入图片描述
分类须知:

  • 常见的编译型错误主要有以下几种
    • 语法错误:代码中存在违反语言语法规则的地方,如括号不匹配、缺少分号等
    • 类型不兼容错误:表达式或赋值中的数据类型不兼容,如把整型赋值给字符串等
    • 未声明标识符:使用了未在前面声明过的变量、函数等标识符
    • 重定义标识符:两个地方定义了同名的变量、函数等标识符
    • #include文件不存在:代码包含的头文件在指定路径下未找到
    • 函数参数错误:函数调用时,参数的个数或类型与函数原型不匹配

解决方案:

==直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单==

2、链接型错误

类型描述:

  • 【链接型错误】通常指的是程序在链接过程中出现的错误,链接是将编译产生的各个对象文件和所需的库文件合并,生成最终的可执行程序的过程
  • 一样举个最简单的例子,我们日常在写项目的时候都会去进行分文件编写,此时就需要去包含所需的头文件,这一块涉及 编译链接 相关知识,读者可以去看看

在这里插入图片描述

  • 此时我将Add函数的定义给去除,然后再去编译发现是没有问题的,因为其不是编译型错误

在这里插入图片描述

  • 不过呢,去运行起来就会出现了一个叫做 error LNK2019,它就是VS2019下的链接错误所报出来的问题

在这里插入图片描述

分类须知:

  • 常见的链接型错误主要有以下几种
    • 未定义的符号引用错误:对象文件或代码引用了未定义的函数或全局变量。这通常是因为没有把定义该符号的对象文件或库链接进来。
    • 重复的符号定义错误:两个对象文件定义了同名的全局变量或函数,链接器不知道使用哪一个
    • 类型不兼容错误:试图链接类型不兼容的函数。例如参数类型不匹配
    • 库文件丢失:代码需要链接的指定库文件在链接过程中没有找到
    • 入口点丢失:可执行程序缺少入口点(main函数)的定义
    • 其他链接参数错误:如库文件或链接脚本指定的路径错误等

解决方案:

==看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误==

3、运行时错误【条件断点】

类型描述:

  • 【运行时错误】通常指的是程序在运行过程中出现的错误。这类错误不能在编译时发现,只有在程序实际运行时才会出现
  • 马上,我们也来举一个例子,就是使用指针来访问数组元素
void test3()
{
	int a[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(a) / sizeof(a[0]);
	int* p = a;

	for (int i = 0; i < sz; i++)
	{
		p[i]++;
		printf("%d ", *(p + i));
	}
	printf("\n");
}
  • 那正常来说我们在访问时候是不会有问题的

在这里插入图片描述

  • 但此时呢我将for循环的结束条件做了一个修改,此时你如果对边界条件敏感一些的话就可以知道这一定会造成越界的行为
for (int i = 0; i <= sz; i++)
  • 此时我在这里设置一个条件断点,好让下一步直接运行到越界的位置,这也是一个排查的技巧

在这里插入图片描述

  • 然后我们按下F5开始调试,再按一次F5运行下一次循环,此时就可以发现程序出现了问题,如果你经验足的话一眼就能看出是越界所引发的问题

在这里插入图片描述

  • 为了更好地体现这是一个【运行时错误】,我们直接 ctrl + F5 不执行调试开始运行,就发现VS直接给我们报出了Error,相信很多同学在写代码的时候都有遇到的过这个情况

在这里插入图片描述

分类须知:

  • 常见的运行时错误主要有以下几种
    • 除零操作错误:代码中进行了除以零的非法操作,导致运行时报错
    • 数组下标越界:试图访问数组中不存在的元素,索引超出了数组范围
    • 空指针访问:对一个空指针进行了访问操作,如解引用
    • 读写越界:向内存中写入的数据大小超过了缓冲区的大小限制
    • 动态内存访问错误:对未分配或已释放的动态内存进行访问
    • 无效的类型转换:将一种数据类型强制转换为不兼容的类型
    • 浮点数溢出:浮点数运算结果超出表示范围
    • 栈溢出:递归调用层数过多或栈空间占用过大
    • 资源泄漏:程序未成功释放分配的内存、文件句柄等资源

解决方案:

==借助调试,逐步定位问题。最难搞==

以上就是我们在写代码的时候遇到的常见错误,只有清楚各个错误产生的原因,才能在调试的时候迅速精准地定位到问题的所在

九、总结与提炼

最后来总结一下本文所学习的内容:book:

  • 在文章的一开始,我们首先了解了什么是Bug,接着开始慢慢了解什么是调试,该如何去一步步调试以及清楚了在什么环境下在能进行调试
  • 本文主要介绍的是在Windows环境下的VS中进行调试,这也是大多C/C++程序员学习的集成开发环境,在准备好调试环境后,我们要开始学习 快捷键 的使用,我们再来温故一下
    • 【F5】可以启动调试
    • 【F9】可以创建断点和取消断点
    • 【F10】可以逐过程调试
    • 【F11】可以逐语句调试
    • 【Ctrl + F5】可以开始执行不调试
  • 除了要熟知快捷键外,调试的时候查看程序当前信息,才可以在调试的过程中观察变量的相关信息来做出更好地判断。在熟悉了基本的调试手法后,我们立马就通过一个小案例学习了一下
  • 除了要熟知Windows下如何进行调试,Linux平台下的调试技巧也是不可或缺,虽说GDB比较恶心🤢但用熟了的话也不乏是一项技能呢
  • 步步调试教学分析两个问题代码段,是否感受到了Debug给我们带来的便捷之处,有了它妈妈👩再也不用担心我在编程的时候遇到问题了
  • 除了要会调试之外,我们还需要有一个好的Coding技巧,如果可以在第一次写代码的时候就写出尽量完美无缺的代码,那么在调试排错的时候就可以很轻松不少,所以我们一定要养成一个基本的代码素养,写出优秀的代码
  • 最后我们又聊到了在编程的时候所会碰到了三种错误,分别是【编译型错误】、【链接型错误】、【运行时错误】,类型有很多,我们清楚最常见的几种即可。在排查问题的时候就能够得心应手了🖐

最后,希望大家都做一个有心人,在日常编码的过程中积累排错经验,才能在遇到复杂问题时临危不惧、得心应。感受您对本文的阅读:rose::rose::rose:

在这里插入图片描述

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。