本期介绍🍖
主要介绍:C语言中一些大家熟知知识点中的盲区,这是第二期。
文章目录
- 1. 一维数组在内存中的存储方式
- 2. sizeof计算数组元素的个数
- 3. 二维数组
- 3.1 概念
- 3.2 二维数组在内存中的存储
- 3.3 初始化省略行,但不能省略列
- 4. 变长数组
- 5. 函数
- 5.1 形参和实参
- 5.2 数组传参的本质
- 5.3 函数的声明和定义
- 6. 对程序员的一种保护
- 7. static和extern关键字
- 7.1 局部变量
- 7.2 全局变量
- 7.3 函数
- 8. VS实用调试技巧
- 8.1 什么是bug、debug?
- 8.2 Debug和Release模式
- 8.3 VS调试快捷键
- 8.4 监视和内存观察
- 8.5 一个典型调试代码
- 9. 函数递归
- 9.1 什么是递归?
- 9.2 递归的限制条件
- 9.3 案例
- 9.4 滥用递归所带来的问题
1. 一维数组在内存中的存储方式
下面看个案例:有一个char型的数组arr有10个元素,打印数组每个元素的地址。
如上图所示,地址是以十六进制的形式打印在屏幕上的。可以发现相邻两个地址之间差值为1,且地址随数组下标的增长而增长。所以得出结论:一维数组在内存中其实是连续存放的,且数组随着下标的增长,地址是由低到高变化的。
2. sizeof计算数组元素的个数
sizeof
操作符是用于计算类型或变量所占内存空间的大小,单位:字节(byte)。那数组占用的空间该怎么计算呢?使用sizeof(数组名)
就可以计算整个数组所占空间的大小,这里的数组名
代表整个数组。如下所示:
对于一个未知数组,该怎么计算出它元素的个数呢?可以先计算出整个数组的大小,再计算出数组中一个元素的大小,两者相除就可以得到数组元素的个数,sizeof(数组名)/sizeof(数组元素)
。如下所示:
3. 二维数组
3.1 概念
二维数组是具有相同属性元素的集合,该集合中每个元素都由两个下标组成,X轴下标和Y轴下标。如下图所示:
3.2 二维数组在内存中的存储
二维数组在内存中其实是连续存放的,占用了一块连续的内存空间。并不会像上图所示的那样,是一块二维的空间。验证如下,创建一个char类型的二维数组,并按行打印每一个元素的内存地址。
所以二维数组在内存中的布局,不是二维的而是一维的,如下所示。为了更方便理解,我们可以把二维数组理解为存放一维数组的数组。由下图还可知,二维数组在初始化时,一定的按行存放的,因为这是一块连续的空间,从低地址向高地址逐个存放。
3.3 初始化省略行,但不能省略列
在学习定义和初始化一个二维数组的时候,大家一定被告知过不能省略二维数组的列号,但行号省不省无所谓。那这是为什么呢?根本原因是由于二维数组在进行初始化的时候是按行来进行存放的,所以这时列数就显得格外重要了。什么意思呢?举个例子:
4. 变长数组
在C99标准之前,C语言在创建数组的时候,数组大小的指定只能使用常量、常量表达式。这样的语法限制,让我们创建数组就不够灵活,有时候数组大了浪费空间,有时候数组小了不够用。于是在C99标准中,给了变长数组(variable-lengtharray,简称VLA)的新特性,允许我们可以使用变量指定数组大小。
变长数组的根本特征,就是数组长度只有运行时才能确定,所以变长数组不能初始化。它的好处是程序员不必在开发时,随意为数组指定一个估计的长度,程序可以在运行时为数组分配精确的长度。代码如下:
#include<stdio.h>
int main()
{
int k = 0;
scanf("%d", k);
int arr[k];
}
注意:变长数组的意思是数组的大小是可以使用变量来指定,而不是数组的大小是可变的,变长数组的大小一旦确定就无法再更改了。
5. 函数
5.1 形参和实参
在函数的使用过程中,函数的参数被分为形参和实参。实参顾名思义表示实际传递给函数的参数,是真实存在的。形参顾名思义表示形式参数,如果函数没有被调用的话,形参只是形式上存在不会向内存申请空间;只有当函数被调用时,才会开辟空间。
当函数被调用时,实参会向形参进行数据的传递,让函数内部可以用到外部的数据。那假如改变函数内部的形参,那外部的实参会发生变化吗?如下所示:
可以看出,函数内部的形参无论怎么改变都不会影响到外面的实参。因为形参和实参的存储空间不是同一块,相互之间并不影响。可以这么理解:形参就是实参的一份临时拷贝。如下证明,形参的地址与实参的地址完全不同。
5.2 数组传参的本质
大家在编程的时候,难免会将数组作为参数传递给函数,在函数内对数组进行操作。而数组的传参方法,就是将数组的数组名作为参数传递给函数,如下所示。
int main()
{
int arr[10];//arr数组
my_print(arr);//数组传参
}
值得注意的是,数组传参并不会把整个数组作为参数传递过去,而只是将数组首元素的地址作为参数传递过去。而C语言中规定:数组名代表的就是数组首元素的地址。
那有人就有疑惑了,不是说形参是实参的一份临时拷贝吗?按理来说,传递数组也应该拷贝一份啊。那是因为这样做太浪费空间了,假如数组有10000个元素呢,那这样还传递整个数组并不现实。而传递数组首元素地址的方法,既可以间接的访问整个数组(数组在内存中是连续存放的,知道了首元素的地址和数组元素的个数,就能访问整个数组了),又不太浪费存储空间。
证明如下,形参arr2内存放的是一个占用4个字节大小的地址,且该地址与数组arr1首元素的地址一模一样,所以可知数组传参是在访问同一块内存空间。而形参int arr2[]
不要看它的类型是数组,本质上arr2其实其实是一个指针变量,用于存放地址的。
5.3 函数的声明和定义
如果函数的定义出现在函数的调用之后,就必须在调用之前引用一个函数的声明,不然编译器将会报警告:函数未定义。如下所示,那为什么呢?
这是因为编译器在编译代码前会先从上往下的进行一次扫描,当扫到函数调用的时候,编译器愣住了。它不认识这个函数啊从来没有见过,于是给你一个警告。然后接着往下扫描,直到扫到函数的定义,编译器这时才幡然醒悟:奥!其实这个函数是存在的,只不过是放到了后面。可之前已经报了警告,还能收回来?当然不能。所以这就是为什么该程序在编译时会报警告但该程序还能正常运行起来的原因。
想要消除这个警告,只需要在调用函数之前引用该函数的声明,这样就可以避免警告了,如下所示。值得注意的是:函数声明仅仅只是告诉你有那么个函数,但并没有仔细的说明该函数到底长啥样啊;而定义才是真正说明这个函数的。
在初学C语言的时候,大家会觉得把所有代码全都写到一个源文件中最为方便,但实际上并不是这样的。思考一下,如果公司里有一个项目,这个项目交给一个团队去完成,而团队里的所有成员都只在一个源文件里面写代码。这样必然会导致,一个人在写代码的时候,其余人是不能写代码,效率是极其低下。正确可靠的代码风格需要考虑以下两个方面。
协作性: 从协作的角度来思考,每个人都应把代码写在自己的文件中(因为这样就能实现公司所有成员同时在对该程序进行编写),等到所有成员都完成了手头上分配的任务,再拼装起来就可以高效的完成工程。
模块化: 从模块化的角度来思考,应使得每个文件都拥有自己独立的功能,把不同的功能按模块化分开来(就譬如:实现权限的放一个文件,实现文件操作的放一个文件,实现数据库操作的放一个文件……)。这样做就可以使得在阅读这个程序时更易读取,在维护这个代码时更加的方便。
所以一个良好的代码风格,会把实现不同功能的函数放到不同的原文件中,然后把所有源文件中的函数的函数声明放到同一个头文件中。只需引用头文件,不同源文件中的函数就可以交叉调用了(因为函数是具有外部链接属性的)。如下所示。
6. 对程序员的一种保护
从前有一个程序员A,他业余时间写出了一个非常牛逼的游戏引擎的代码。接着现在程序员A想把自己写的代码卖给一家游戏公司B。所以A就把他所写的游戏引擎的所有代码一股脑的发给了B公司,结果发生B公司找几个他们公司里优秀的程序员把A的代码复刻一份出来,然后说这是我们公司内部编写出来的游戏引擎,并且向法院起诉A偷窃公司核心机密。这时A悔的肠子都青了。
那该如何避免发生A程序员这种情况呢?只要能让B公司能使用到A所写的程序,但无法获取其中的源代码。也就是说我A只出售使用权,但不卖源代码。那怎么才能在不暴露源代码的前提下,使得别人能够使用该代码的功能呢?
方法:把源代码编译成静态库,然后再把头文件和静态库一起卖个别人,这样别人就只能使用而不能窃取了。因为源代码编译成静态库后,静态库里的东西已经是二进制代码了,是看不懂的。这样就能起到的保护程序员原创代码不被抄袭的风险。
下面演示如何编译静态库:
现在假设写了一个实现加法的源代码 ADD.c
和 ADD.h
,如下所示。
第一步,右击“ADD项目”的名字,然后会跳出来一个窗口,接着点击“属性”。
第二步,执行完上一步将会跳出如下窗口,接着点击“常规”,然后点击“配置类型”,然后将“应用程序”改成“静态库”就ok了。
第三步,按住快捷键:ctrl+f5(开始编译),但会提示错误不过没啥大关系,因为生成的这个ADD.lib的文件不是应用程序所以运行不起来,这个 .lib 文件就是ADD的静态库。
下面打开看看这所谓的静态库里面存放的到底是什么。
下面演示如何使用静态库文件:
第一步,先找到之前的静态库lib文件,和ADD的头文件。并把它两拷贝到sum的文件里头。
第二步,在编译器中添加add的头文件。
第三步,使用库函数#pragma comment(lib, "ADD.lib")
导入静态库,编译执行成功。
7. static和extern关键字
7.1 局部变量
static
关键字是静态修饰符,extern
关键字是外部声明符。在第一期中讲述过一个概念,局部变量的作用域是局部所在的范围,生命周期是进作用域开始出作用域结束。当局部变量被static
修饰后,其生命周期变为了整个工程。
举例如下,当test()
函数没有被static
修饰时,临时变量x
是随着函数的创建而创建销毁而销毁的,所以无论多少次重复调用test()
函数打印结果都是4。当test()
函数被static
修饰后,静态变量x
就不随着函数的销毁而销毁,它会一直存在直到整个工程结束而销毁。所以这就是为什么每次调用test()
函数,x
任然保留上一次调用完的值,打印结果为4、5、6。
生命周期改变的本质,其实是改变了变量的存储区域。如下图所示,局部变量原本是存储在栈区中的,后来被static
修饰后存储到了静态区,于是静态变量就拥有了与全局变量一样的生命周期。但值得注意的是,被静态修饰后作用域是不发生改变的。
7.2 全局变量
全局变量的作用域是整个工程,也就是说全局变量是可以跨文件被使用的。但需要注意的是,使用外部符号前需要用extern
进行外部声明,不然在源文件被单独编译的时候,编译器会识别不了这个外部符号,会报警告。举例如下:
全局变量能够文件使用并不是因为extern
关键字,extern
仅仅只是起到了声明的作用,就是告诉编译器存在这么个变量。真正起决定性作用的是外部链接属性,这个属性全局变量默认是拥有的。
使用static
修饰全局变量,会使得全局变量的外部链接属性变为内部链接属性,也就是说被静态修饰的全局变量只能在当前文件中使用,其他文件无法使用。如果使用,会出现链接性错误。举例如下:
7.3 函数
函数也是默认拥有外部链接属性的,当被static修饰的时候,与全局变量一样,外部链接属性会变成内部链接属性,此时的函数无法跨文件使用。举例如下:
8. VS实用调试技巧
8.1 什么是bug、debug?
bug本意是“昆虫”或“虫子”,现在⼀般是指在电脑系统或程序中,隐藏着的一些未被发现的缺陷或问题,简称程序漏洞。
“Bug”的创始人格蕾丝·赫柏(Grace Murray Hopper),她是⼀位为美国海军工作的电脑专家,1947年9月9日,格蕾丝·赫柏对Harvard Mark II设置好17000个继电器进行编程后,技术⼈员正在进行整机运行时,它突然停止了工作。于是他们爬上去找原因,发现这台巨大的计算机内部⼀组继电器的触点之间有⼀只飞蛾,这显然是由于飞蛾受光和热的吸引,飞到了触点上,然后被高电压击死。所以在报告中,赫柏⽤胶条贴上飞蛾,并把“bug”来表示“⼀个在电脑程序里的错误”,“Bug”这个说法⼀直沿用到今天。
当发现程序中存在问题,并去寻找解决这个问题的过程就被叫做调试,又称:debug(消灭bug的意思)。调试⼀个程序,首先是承认出现了问题,然后通过各种手段去定位问题的位置,可能是逐过程的调试,也可能是隔离和屏蔽代码的方式,找到问题所的位置,然后确定错误产生的原因,再修复代码,重新测试。
8.2 Debug和Release模式
在VS上编写代码的时候,经常能够看到Debug
和Release
两个选项,如下图所示。
- Debug
通常称为调试版本,它生成的可执行程序包含调试信息,并且不做任何优化,便于程序员调试。这样编译产生的是debug 版本的可执行程序,其中包含调试信息,是可以直接调试的。 - Release
通常称为发布版本,它生成的可执行程序往往进行了各种优化,使得程序在代码大小和运行速度上都是最优的。这个版本是用户使用的,也是测试开发人员测试的版本,无需包调试信息。
8.3 VS调试快捷键
- F9(创建断点和取消断点)
F9可以在程序的任意位置设置断点,配合F5就可以使得程序执行到想要的位置暂停执行,接着就可以使用F10、F11细致的执行观察代码。当然还可以设置条件断点,该断点只有满足条件时才暂停执行。 - F5(启动调试)
经常用来直接跳到下一个断点处,一般是与F9配合使用。 - F10(逐过程)
通常用来处理一个过程,一个过程可以是一次函数调用,也可以是一条语句。 - F11(逐语句)
就是每次只执行一条语句,与F10不同的在于,F11可以使程序的执行逻辑进入函数内部,更加细致的观察函数内部的具体执行逻辑。 - Ctrl+F5(开始执行不调试)
如果你想让程序直接运行起来而不调试就可以直接使用。
8.4 监视和内存观察
在调试过程中,如果想观察代码执行过程中,上下文环境中变量的值,就可以打开监视窗口,输入你想观察的对象进行观察。打开监视窗口步骤:【开始调试】 -> 【菜单栏】 ->【调试】->【窗⼝】->【监视】,打开任意⼀个监视窗口,输⼊想要观察的对象,如下图所示。
在调试过程中,如果想要更加直观的观察观察变量在内存中的存储情况,可以打开内存窗口进行观察。打开内存窗口步骤:【开始调试】 -> 【菜单栏】 ->【调试】->【窗⼝】->【内存】,如下图所示。
8.5 一个典型调试代码
在VS2022、X86、Debug的环境下,编译器不做任何优化的话,下面代码执行的结果是啥?
#include<stdio.h>
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;
}
如上图所示,在vs2022的x86的Debug模式下执行改代码,导致的结果使死循环打印hehe。大部分人看到这个代码,第一反应就是数组越界访问了,但大家有没有想过为什么越界访问会导致死循环?如下图所示。
由于局部变量i
和数组arr
存放在内存空间的栈区中,而栈区的存储布局默认是先使用高地址在使用低地址的,所以变量i
存放的位置是高于arr
数组的,如上图所示。又因为随着数组下标的增长,地址是由低到高变化的。当数组越界访问后,是有一定概率会越界访问到变量i
的地址空间,将i
的值改为0,导致死循环。
9. 函数递归
9.1 什么是递归?
递归是一种解题的思路:将大且复杂的问题层层转化为一个与原问题类似,但规模较小的子问题来求解,直到子问题不能再被拆分,递归就结束了。所以递归的思考方式就是把大事化小的过程。
在程序中函数递归的实现,就是让函数自己调用自己,下面举个递归的例子:
#include<stdio.h>
int main()
{
printf("hehe\n");
main();
return 0;
}
当运行该代码后,会疯狂打印“hehe”
,直至报错:Stack overflow
(栈溢出)。一直不停打印"hehe"
是因为当main函数自己调用自己后没法停下来,只能无限递归下去。而大家要知道每一次函数的调用都在内存的栈区中开辟一块空间,用于存放函数调用过程中的信息,这块空间被称为:函数栈帧空间。因为栈区空间是有限的,不停的调用main()函数,不停的开辟空间,栈区必然会在某个时刻被完全占满,这就是所谓的栈溢出,如下图所示。
9.2 递归的限制条件
递归在书写的时候,必须满足2个条件:
- 递归中存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用后越来越接近这个限制条件。
9.3 案例
(1) 求n的阶乘
一个正整数的阶乘,是所有小于等于该数的正整数的积,故n的阶乘就是1~n的数字累积相乘。注意:0的阶乘为1,自然数n的阶乘记作n!。
解题思路:这样一个问题该怎么大事化小呢?由于5! = 5*4*3*2*1
,又由于4! = 4*3*2*1
,那么5!
是不是可以写成5*4!
,这样不就把求5!的问题转化成求4!的问题了,问题类型不变,但问题的规模变小了。如下所示:
公式:n! = n*(n-1)!
5! = 5*4!
5! = 5*4*3!
5! = 5*4*3*2!
5! = 5*4*3*2*1!
5! = 5*4*3*2*1*0!
就这样层层转化,直至转化为求0!,递归便不需要再继续下去了。由此可以得到递归的限制条件,当n == 0
时结束递归,递归公式如下:
代码如下:
#include<stdio.h>
int Factorial(int n)
{
if (n == 0)
return 1;
else
{
return n * Factorial(n - 1);
}
}
int main()
{
int n = 0;
while (scanf("%d", &n) == 1)
{
int back = Factorial(n);
printf("%d! = %d\n", n, back);
}
return 0;
}
画图推演:
(2) 顺序打印整数的每一位
输入个整数m,按照顺序打印整数的每一位。假设输入:1234
,输出:1 2 3 4
。解题思路:假设Print()
函数顺序打印整数的每一位,那么Print(1234)
可以转化为,先Print(123)
再printf("%d ", 4)
,如下所示:
Print(1234)
Print(123) printf("%d ", 4);
print(12) printf("%d ", 3);
print(1) printf("%d ", 2);
printf("%d ", 1);
代码如下:
#include<stdio.h>
void Print(int n)
{
if (n > 9)
{
Print(n / 10);
}
printf("%d ", n % 10);
}
int main()
{
int n = 0;
while (scanf("%d", &n) == 1)
{
Print(n);
printf("\n");
}
return 0;
}
9.4 滥用递归所带来的问题
每一次函数的调用都会在内存中开辟一块栈帧空间,用于存放函数调用时的数据。如果函数不返回,函数对应的栈帧空间就一直会被占用。所以当函数调用中存在递归的话,每一次递归调用都会开辟属于自己的栈帧空间,直至递推结束开始回归,才逐层释放栈帧空间。所以如果采用函数递归的方式完成代码,递归的层数太深,就会浪费太多栈帧空间,甚至会引起栈溢出的情况。
如下代码所求n的阶乘,当n为10000时,就会开辟10000份的栈帧空间,如果不开辟出这10000份的栈帧空间,该代码将无法执行完成。
int Fact(int n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
如此简单的问题,但开销却这么巨大,用递归来实现是不是太没这个必要了。换个思路用迭代来实现呢?代码如下:
int Fact(int n)
{
int i = 0;
int ret = 1;
for(i = 1; i <= n; i++)
{
ret *= i;
}
return 0;
}
事实上,我们看到的许多问题是以递归的形式进行解释,这只是因为它比非递归的形式更加清晰,但是这些问题的迭代实现往往比递归实现效率更高。
当⼀个问题非常复杂,难以使用迭代的方式实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。
这份博客👍如果对你有帮助,给博主一个免费的点赞以示鼓励欢迎各位🔎点赞👍评论收藏⭐️,谢谢!!!
如果有什么疑问或不同的见解,欢迎评论区留言欧👀。