仅记录自己在学习《C语言深度解剖》时的所见所感和所得,如有笔误在所难免。
本文以及之后关于深度剖析类的文章不在详细介绍C语言的基本语法等,而是重点介绍:部分计算机基础知识、最初在C语言的学习中的一些不常见的边缘知识以及加深对于之前学到的部分内容进行更深层次的理解。
本文是的主要目的是去剖析程序与计算机基本结构之间的关联和之前在学习C语言中提到过但没有去深入了解过的一些关键字。
#include
int main()
{
printf("hello world!\n");
return 0;
}
这是很多初学者在学习C语言时所接触的第一个程序,这个程序是通过文本代码编写而成。
这段文本代码通过编译,链接之后的本质上是:将该文本代码转换为二进制的可执行程序(文件):
然后通过双击该可执行文件即可运行:
而编译器的作用就是帮助我们生成可执行程序然后运行它。
知道了编译器的作用之后,那么在windows中双击桌面上的应用程序的本质是什么呢?
桌面上的程序文件等在未打开前都是存放在外存也就是硬盘上,而CPU由于运算速度很快无法直接与硬盘直接进行交互(硬盘存取速度很慢),而内存的存取速度要比硬盘快很多,那么内存就可以作为硬盘的"缓冲存储器",把要处理的数据程序等加载到内存中,等待CPU去访问,以此来提高CPU的访问和处理的效率,这样就可以解决这一速度差异过大的问题,因此规定:所有程序和数据等在运行之前都必须要先从硬盘加载到内存中才能被CPU进行处理。
这时就可以回答上面的问题:双击的目的是打开并将程序加载到内存中去运行。
定义变量的本质是:在程序运行后在内存中的某个位置开辟特定大小的空间,用来保存数据。
如何定义变量:
int main()
{
int a = 10;
double b = 20.0;
//类型名 变量名 = 默认值
char c = 'c';//初始化
c = 'd';//赋值
return 0;
}
初始化与赋值的区别,初始化是在内存开辟了一块空间后就直接把对应的数据放在这块空间中;而赋值是把数据放入这块之前已经在内存中开辟好的空间当中。
为什么定义变量:回答该问题要先清楚计算机的本质:它是为了帮助人类去计算一些复杂和困难的的场景而诞生的,即就是为了计算,计算就需要有数据。
而在实际中,并不是每个时刻都需要计算机去立刻计算某些数据,因此把数据保存在变量中,当到某一时刻计算机需要数据去执行运算时,直接从定义的变量中读取保存过的数据。
换言之:因为有些变量需要暂时保存来等待后续的处理,所以需要定义变量。
声明的本质是:广而告之,即说明该变量已经定义过了,不用在定义了,直接使用就好了。
声明可以有很多次,但是定义只有一次。
auto:在缺省(默认)情况下,编译器默认所有的变量都是auto的,“它很宽宏大量,读者就当不存在吧”
这段是书上对于auto关键字的描述,这种说法是不准确的,只有局部变量默认是auto的,而全局变量则不是。
在说明auto前,再次介绍一下这两种变量的作用域和生命周期。
int main()
{
int a = 10;
{
int b = 20;
printf("%d %d\n", a, b);
}
printf("%d %d\n", a, b);
return 0;
}
运行后会报错:
通过报错信息不难看出,找不到局部变量b,说明b的作用域就在它所在的花括号内部,出了所在的花括号作用域就销毁了,而它的生命周期则是从进入花括号定义时开始,出了花括号被释放后生命周期结束。
局部变量a的作用域则是整个main函数,生命周期也是从定义时开始,main函数调用结束后被释放生命周期结束。
释放是指:所开辟的内存空间被"回收"。
不能把完全认为它的作用域就是它的生命周期,作用域是从空间的维度来看它的作用范围,而生命周期则是从时间的角度来看它"存活时间"的长短。
int g_val = 100;
void test()
{
printf("test: %d\n", g_val);
}
int main()
{
printf("main: %d\n", g_val);
test();
return 0;
}
运行结果:
说明了全局变量具有全局性,即在当前项目中下的任何函数当中都可以使用全局变量,那么它的作用域则是整个工程,生命周期从定义开始,随着整个工程的结束而释放。
注意:变量名冲突
int g_val = 100;
int main()
{
int g_val = 10;
printf("%d\n", g_val);
return 0;
}
输出:
当全局变量与局部变量名字发生冲突时,编译器默认优先使用局部变量。
总结:
作用域:该变量的有效范围
生命周期:该变量从开辟到释放的时间
如何使用:一般在代码块中定义的局部变量,默认才是auto修饰的。
只是局部变量,并不是所有变量都是默认auto修饰。
int main()
{
for (auto int i = 0; i < 5; ++i)
{
printf("i = %d\n", i);
if (1)
{
auto int j = 0;
printf("before: j = %d\n", j);
++j;
printf("after: j = %d\n", j);
}
}
return 0;
}
由于j的作用域只在它的代码块内,所以会循环打印0和1:
其实有没有auto修饰效果都是一样的,如果修饰全局变量会出现什么呢?
auto int g_val = 10;
int main()
{
printf("%d\n", g_val);
return 0;
}
运行:
程序不会报错,但是会报出警告,因此auto是不用来修饰全局变量。
结论:auto很老,基本上已经不会再使用了。
说到register就不得不提到CPU了,CPU是中央处理器,用来负责计算的硬件单元,为了方便进行计算,CPU一般第一步需要去内存中读取数据,那么这就要求CPU需要有一定的数据临时存储能力(并不是马上要计算了才去内存读数据,否则太慢了),所以现代的CPU中都集成了一组叫做寄存器的硬件,用来存放临时的数据。
离CPU越近的器件,速度越快。
可以粗俗的理解为,当前层次的存储硬件都是可以作为下一层存储硬件的缓存,比如说cache是内存的缓存,而内存也可以看作为硬盘的缓存,其根本目的就是要让CPU尽量以最小的成本,来达到最高的效率。
不用关心硬件本身,只需要知道CPU内部集成了一组存储硬件,叫做寄存器组,是CPU为了运算,而必须要有的一组来临时存放数据和运算结果的存储器件。
它存在的本质是:在硬件层面,进一步提高计算机的运行效率。因为CPU不需要从内存中读取数据了。
说了这么多,来了解一下register修饰变量:
register修饰变量的本质是:尽量以高优先级把当前变量存放在寄存器中,使其能够被CPU优先访问到,提高其效率。
尽量:有了register的修饰就一定会把它存放在寄存器中吗,其实并不是,只是建议,到底放不放register说了不算。
那什么样的变量,可以用register来修饰呢?
在被register修饰的变量是否可以取出它的地址呢?
int main()
{
register int val = 10;
printf("%p\n", &val);
return 0;
}
运行:
这里程序会报错,寄存器修饰的变量无法取地址,其实很好理解,该变量是存放在寄存器中的,并不是存放在内存中,只有内存有地址这一概念,所以可以取地址,而寄存器则没有,因此不允许对它进行寻址操作。
其实register关键字不用管,因为编译器已经非常智能了,能进行比人更好的代码优化。
在探究static关键字之前,需要先了解多文件之间的关系。
如上图所示,在test1文件中所定义的函数,能否直接在main文件中调用并执行呢?
答案是可以,但是会有警告。
那么在test1文件中定义一个全局变量后,能否在main文件中打印输出呢?
输出结果:
无法在main文件中直接使用全局变量g_val,而此时可以使用extern关键字在main文件中修饰g_val,有了它的修饰则可以正常使用该全局变量:
这里的extern是一个声明来自外部符号的关键字,只是声明,那么下面这种写法是否有问题呢?
结果是程序报错,因为extern的作用是声明该变量,而声明是不会实际开辟空间的,这里 = 100的含义是赋值或者初始化,对于声明来说没有任何意义,因此在编译过程中直接进行报错,所以所有的变量在声明的时候,不能设置初始值。
深刻理解定义与声明:定义会开辟空间,且定义只有一次,声明不会开辟空间,且声明可以有很多次。
以上说了这么多就是为了引出头文件的概念,那么头文件是干什么用的呢?
先说结论:是为了在组织大型项目结构时,减少其维护成本。
可以发现,在test1中只定义了一个全局变量和一个函数,且只有一个main文件需要使用其中的数据,那么如果test1中定义了很多函数和全局变量,并且有更多的源文件需要使用test1中的数据时,要在这么多的文件中一个个声明吗,显然是不可能的,因为这样越复杂的项目的维护成本会越高。
因此为了解决维护成本高的问题,就可以使用头文件,将所有的函数、全局变量、宏体等等声明全部存放在头文件中,其它所有源文件只需要包含该头文件即可正常使用,且如果需要修改只需要在头文件中修改,如此大大降低了项目的维护成本。
下面是将函数与全局变量的声明存放在头文件中:
如果把变量以及函数前的extern关键字去掉,结果也能正常运行,但是变量声明不带extern会出现二义性,容易被认为是变量的定义,因此为了避免不必要的麻烦,变量声明必须要带extern,而函数则是建议带上extern,因为函数定义后面要有函数体,没有的话编译器就直接当作声明了,但是为了养成好习惯,还是建议带上。
头文件中一般包含:需要用到的C库里的头文件、所有变量的声明、所有函数的声明、#define、typedef和一些结构体联合体等等
因为在头文件中已经包含了用到的C语言库头文件,所以其余源文件只需要包含自己所定义的头文件,而包含头文件的有种约定熟成的方式:
自带的头文件用< >来包含,自定义的头文件用" "包含。
注:为了防止其他文件多次包含该头文件,需要在头文件开头使用#pragam once,这只是一种方法,后面还会介绍
运行结果正常:
上面说了这么多关于多文件的,那么与static这个关键字有什么关系呢?在回答这个问题之前,把上面介绍的多文件方面的知识的总结下来就是:
而在实际的应用场景中,有没有可能,我们不想让全局变量或者函数被跨文件访问,只想在本文件内部被访问呢?
其实是可以的,而这里就要用到static关键字来实现,下面来观察一下被static修饰的全局变量,在其它文件使用并且编译后会出现什么情况:
这里显示链接错误,也就是找不到全局变量g_val,而在本文件内是否可以访问呢?
结果是没问题的,因此可以得出一个结论:
被static修饰的全局变量无法被跨文件直接访问到(可以间接访问,如调用全局变量所在文件中的函数,函数中使用了该全局变量),只能在本文件内被访问。
以上是修饰全局变量的情况,那么static修饰函数会出现怎样的情况呢,结果如下:
出现的链接错误基本与修饰全局变量时无差,如果在本文件内部间接调用该函数:
没有出现错误信息,这里又可以得出一个结论:
static修饰的函数,无法被其它文件直接调用(可以间接嵌套调用,如上),只能在本文件内部被调用。
以上就是static关键字的作用,那么该关键字的作用是什么呢?
简言之static就是为了保障项目的保密性和安全性。因为如果暴露出去的函数接口或者全局变量越多,项目的逻辑实现细节就会被别人知道的越多,也就提高了一定的泄露风险,因此使用static来修饰主要函数,然后把主要函数全部封装在一个总函数内部,对外只暴露出这一个总函数接口,这样就很好的提高了安全性和保密性,对于全局变量也是同理。
static的主要作用介绍完后,接下来需要探讨一下被static修饰的全局变量的作用域和生命周期是否发生了变化。
无论是否被static修饰,全局变量的生命周期都是整个工程,工程何时结束,全局变量何时被释放,因此生命周期并没有发生变化。
而它的作用域,没有被static修饰前,被声明后可以在多个文件中被访问到,此时它的作用域是整个工程,而被修饰了以后,只能在本文件内部被访问到,无法被其它文件直接访问,因此它的作用域发生了变化,从整个工程缩小到本文件内部。
为什么全局变量和函数可以实现跨文件访问?
答案很简单,当维护有一定规模的项目时,就一定会有多个文件,之间也必然会有一定的联系,因此当需要进行数据"交互"时,有了全局变量和函数的存在就大大增加了多文件之间的交互性和共享性,也就降低了其维护成本,换言之即使得程序能够模块化。
static修饰局部变量与修饰全局变量截然不同,局部变量的作用域只能在它所在的代码块中生效,生命周期从进入范围创建后开始,出了作用范围生命周期结束。而被static修饰了以后会发生什么呢,接下来探究其现象。
来看下面一段代码会输出什么:
void fun()
{
int num = 0;
++num;
printf("num = %d\n", num);
}
int main()
{
for (int i = 0; i < 5; ++i)
{
fun();
}
return 0;
}
很明显,结果会输出五个1,因为循环调用五次,每次fun函数后,函数内部会创建局部变量num初始化为0,随后自增1,最后打印出1,函数调用结束后局部变量被释放,随后几次进入函数,都会重新创建局部变量num,因此结果为5个1。
此时如果把num用static关键字修饰了以后会出现什么情况呢?打印出来观察:
为什么与之前的结果不同呢?可以来分析一下:
第一次调用打印1可以理解,而第二次调用却是输出2,也就是在执行++num前,num就是1,并没有执行static int num = 0;
这条语句,接下来几次调用打印的值都是根据上一次的值+1得来的。因此可以推出,被static修饰的局部变量只会被初始化一次,之后不会被再次初始化,且出了它的作用域后该局部变量不会被销毁,依然会存在。
这种结论是否正确可以用指针来证明:
//创建全局指针
int* ptr = NULL;
void fun()
{
static int num = 100;
//把num的地址赋给ptr
ptr = #
}
int main()
{
fun();
//解引用输出ptr的值
printf("%d\n", *ptr);
return 0;
}
一个局部变量,出了它的作用范围会被销毁释放,所以如果num被释放,那么ptr会成为一个野指针,并且它存放的值也会因为空间被释放而变成一个随机值,而如果没有被释放,则ptr依然指向num的地址,且存放的值为100,接下来输出ptr:
结果:
很明显,以上的结论是正确的,而这里又有一个问题需要探讨:被static修饰的局部变量它的作用域和生命周期发生了什么变化?
根据上面的结论可以得出,原本它的生命周期就是从进了函数创建后开始,出了函数生命周期结束,而被static修饰了以后,出了函数不会被销毁,生命周期变长了,而它的作用域也会"变长"吗?接下来证明一下:
程序会报错,因为找不到局部变量num,因此可以说明,即使局部变量被static修饰,它的作用域不会发生变化,还是它自身所在的局部范围。
总结:当一个局部变量被static修饰后,它的生命周期会变长,与全局变量的生命周期相同,而作用域不会发生变化。
那么为什么临时变量具有临时性,以及为什么全局变量却是全局有效呢?
因为涉及到操作系统,目前无法解释清楚,所以简述一下:在计算机中有那么块个空间,该空间从低地址到高地址被分为了很多个层次,从底层开始往上有:代码区,静态数据区,未/初始化全局数据区,堆区,栈区等等。
栈区是用来存放局部或者自动变量等数据,具有局部性。而静态和全局数据区则是用来存放static修饰的局部变量和全局变量等等,具有全局性。
因此当局部变量被static修饰后,本质上是改变了它的存储空间,从栈区改变到了静态区,使得具有了全局属性。
在C语言中,提供给了许多内置类型给用户使用,包括但不限于:
思考两个问题,为什么有这么多的数据类型?以及为什么要根据数据类型来分配内存空间?
先回答第一个问题:由于处理的数据规模和计算场景各有不同,多种的数据类型就可以给提供多样的方式来更合理灵活地满足这些处理需求,本质是用最小的空间成本,解决各种应用的处理场景。
第二:本质是是对内存空间进行合理划分,按需存取,通常任何时刻都不是只有一个程序在运行,而是有多个,如果不按照数据类型合理分配空间,则可能会引起空间的浪费,甚至影响部分程序地正常运行。
总所周知,sizeof的作用是用来求数据类型或者用数据类型定义的变量所占空间的大小:
也许是因为它后面带括号的缘故,使得不少人都觉得sizeof是函数,那么该如何证明sizeof是否为函数呢?
运行代码时,第四个语句报错,显示语法错误,而其它三种语句均可成功打印所占空间大小,因为如果是函数那么它后面的小括号则一定不能省略,而第三个sizeof后省略了小括号还可成功运行,由此可以证明sizeof是关键字或者操作符而非函数。
本篇完,后续文章会继续探究C语言中的关键字。