【C深度剖析】关键字大全详解 - 2

时间:2021-03-08 01:08:53


1. void关键字

void也是一个数据类型,即空类型,通常用在声明一个无返回值的函数或者函数的无参列表等。
数据类型的作用之一是可以用来定义变量,在内存当中开辟一定大小的空间然后存放数据,读取数据时,在以自身的数据类型来解释该数据,这是对于数据类型的部分理解。
问题是void类型可以定义变量吗?

1.1 void a

用void来定义变量:

int main()
{
	void a;
	return 0;
}

【C深度剖析】关键字大全详解 - 2

编译时报错,由此说明用void是无法来定义变量的。

vs环境下void类型的大小:
【C深度剖析】关键字大全详解 - 2
void类型所占空间大小为0个字节,而定义变量是要首先根据数据类型开辟空间然后存放数据,因为void大小为0,所以没法开辟空间来定义变量。
在linus环境下void类型所占空间大小为1一个字节,但同样无法用它来定义变量。

为什么void无法用来定义变量:

上面提到过,定义变量的本质是开辟空间,而void作为一个空类型,大小并不明确,理论上来说是不应该开辟空间的,即使开了空间,也仅仅作为一个占位符来看待。
所以既然无法开辟空间,那么也就无法作为正常变量来使用,既然无法使用,编译器就干脆直接禁止用它来定义变量。

1.2 void作为函数返回值类型

来看下面两组代码:

//1.
void test()
{
	printf("1\n");
	return 1;
}
int main()
{
	test();
	return 0;
}
//2.
void test()
{
	printf("1\n");
	return 1;
}
int main()
{
	int ret = test();
	return 0;
}

可以发现,两组代码中两个函数的返回类型都是空,意味着不需要返回值,但是却加上了return语句返回一个值,区别是代码1没有用变量来接收其返回值,而代码2用了一个变量来接收其返回值。
经过执行,代码1可以顺利执行,只不过会有警告:
【C深度剖析】关键字大全详解 - 2
代码2则会直接报错:
【C深度剖析】关键字大全详解 - 2
由此可以得出结论:

  1. void可作为一个标识符,来让用户明确该函数不需要返回值
  2. 告知编译器,void类型无法接收其返回值

如果把void省略掉会发生什么:

test()
{
	printf("1\n");
	return 1;
}
int main()
{
	int ret = test();
	printf("%d\n", ret);
	return 0;
}

运行结果:
【C深度剖析】关键字大全详解 - 2
去掉后程序却可以正确执行,且无警告。

因为赋值操作符两端的操作数类型要保持一致,且该程序并无警告,因此可以推断出,当函数省去返回值类型时,默认返回类型为int

1.3 void作为函数形参

同样先看一组代码:

int test(void)
{	
	return 1;
}
int main()
{
	int ret = test(1,2,3,4);
	return 0;
}

形参列表为void,也就是空,但是调用函数时却给它传了几个参数,程序运行后,进行了警告但并无报错:
【C深度剖析】关键字大全详解 - 2

不同环境或许结果有所不同

此时这里void的作用是:告知用户或者编译器,该函数是不需要任何参数的,不用给其传参。

1.4 void作为指针

int main()
{
	void* ptr = NULL;
	return 0;
}	

void类型无法用来定义变量,但是却可以作为指针类型,原因是指针的大小是明确的,且只与计算机的逻辑结构有关系,32位系统指针大小为4个字节,64位下为8个字节,所以是可以用来作为指针类型。

void*指针是否可以赋值给其它类型的指针?或者反之会有什么结果

int main()
{
	void* ptr = NULL;
	double* x = NULL;
	int* y = NULL;

	x = ptr;
	y = ptr;

	ptr = x;
	ptr = y;
	return 0;
}

上面提到过,赋值操作符两端的操作数类型要保持一致,而在代码中double和int类型的指针分别被赋值了void类型的指针,同样把void类型的指针依次赋值了double和int类型的指针,运行结果会怎样呢?
【C深度剖析】关键字大全详解 - 2
运行成功,并且连一个警告都没有,根据结果可以得出两个结论:

  1. void*可以被任何类型的指针接收
  2. void*同样也可以接收任意指针类型(常用)

目前见过的使用void*的函数有:
【C深度剖析】关键字大全详解 - 2

【C深度剖析】关键字大全详解 - 2
诸如此类…
很多函数接口设计为void*本质上是为了使得函数能够接收或者被接受各种类型指针的参数,作为一个通用接口,简化接口设计,进行数据处理时只需要将void*强制类型转换为特定的类型即可。

1.4.1 void* 能否进行加减运算

指针±1说的是它的步长,即跳过几个字节单位,取决于具体的数据类型,如int*+1跳过4个字节,char*+1跳过1个字节…

而void*是否可以±1取决于实现环境,在vs环境下,由于把void类型的大小看作是0,所以±1等于什么事情都没发生,因此编译器在对void*进行此类操作时直接报错处理。

int main()
{
	void* ptr = NULL;
	ptr++;
	ptr--;
	return 0;
}	

【C深度剖析】关键字大全详解 - 2

而Linux环境下,void类型大小为1,所以它进行±1时是可以正常执行,不会报错。

本质是不同环境对于void类型大小的规定是不同的,因此在有些方面对于void类型的处理会有些许区别。

1.4.2 void*能否解引用

int main()
{
	int a = 10;
	void* ptr = &a;
	*ptr = 20;
	return 0;
}	

如上代码,对与一个void*类型的指针解引用,结果为:
【C深度剖析】关键字大全详解 - 2
结果为报错,其实不难理解,在上面说过void类型因为大小不明确,所以是无法来开辟空间来存放数据的,那么对其解引用得到的是个void类型的变量,既然连空间都没有,那数据是如何存在呢?所以对其解引用本身就是个错误操作,因此不能对void*指针解引用。

2. return关键字

在探究return关键字前,需要先了解一些函数调用时的相关知识,以便之后更深入的了解return。

2.1 函数栈帧的简单理解

如何正确理解下列代码:

char* show()
{
	char str[] = "hello world";
	return str;
}
int main()
{
	char* ret = show();	  
	printf("%s\n", ret);
	return 0;
}	

表面上看是在show函数中定义一个数组,用一串字符串对其初始化,最后把字符串的首元素地址返回到主函数中,将其打印到屏幕上,但是结果是正如我们所说的吗?
【C深度剖析】关键字大全详解 - 2
结果显示的是一串乱码,原因并不难理解,调用函数是在栈区上开辟空间,同样数组也是在栈区开辟的,由于在栈区上的空间具有临时性,当函数调用结束或者返回后,对其开辟的空间也一并销毁了。本质上返回的空间地址已经是一个野指针了,由于那块空间不在属于程序,那么其中的数据可能被其它内容所覆盖,所以打印时的结果并不是所想的那样。

销毁并不是真的销毁空间,而是把那块空间回收了

以上是对于这段代码比较浅显的理解,要真正搞懂它其实并不容易,在解释之前,先看一个例子:

  • 在网上花了十几分钟下载了一部电影,看完后因为存储空间不够用了想要把它删除,删除可能花了几秒钟。

那么问题来了,是计算机中是如何删除数据的,删除的数据是确确实实从计算机的存储介质中去掉了吗?

下载的过程是将电影的数据内容转换为二进制序列后依次写入到硬盘当中,如果删除数据后要将那块空间的内容清空,则需要重新在磁盘中写入一些二进制序列对内容进行清空处理,那么删除所需要的时间应该与下载的时间是保持一致的,可现实情况并非如此,一般几秒钟即可删除完毕。
由此可见,计算机中删除数据数据并非是从存储介质中真正的清除数据,而是使用某种方法来设置这块空间的数据无效即可,以很小的代价来完成"删除"功能,当再次需要存放数据时,发现该数据块空间中是无效的内容,因此可以直接进行覆盖写入,如此一来有两个好处:

  1. 提高计算机删除数据的效率
  2. 硬盘或者一些其它外部存储介质是有读写寿命的,有利于减少读写次数,延长读写寿命

以上面所说的为基础之后,回来理解上面那段代码的真正含义:
在程序运行时操作系统会提供一个虚拟地址空间供程序使用,前面提到过,调用函数需要预先在栈区上为其开辟足够大小的空间,而为函数开辟的空间就叫做函数的栈帧,而在函数中局部变量的定义也是在该栈帧空间中进行二次分配空间,那么除了栈区,还分为以下几部分:
【C深度剖析】关键字大全详解 - 2
而这段代码只涉及栈区空间的内容,因此将栈区单独领出来解释:
【C深度剖析】关键字大全详解 - 2

栈区的使用习惯是先使用高地址空间,在使用低地址,程序运行时从main函数开始执行,因此需要先给main函数分配空间,并定义变量接收show函数返回值,紧接着调用show函数,为其开辟空间,并为str数组二次开辟空间存放字符串数据,当show函数调用结束后,返回数组空间的起始地址,然后show函数的栈帧空间随之被销毁。
但main函数中的ret依然保存着那块空间的起始地址,此时虽然空间不在了,那么ret指向的那块空间中字符串数据还是原来的吗?换句话说,那块空间中的内容被清空了吗?调试发现:
【C深度剖析】关键字大全详解 - 2
在没有执行printf函数前,那块地址中的字符串还是原样,并没有被清空或者修改。

【C深度剖析】关键字大全详解 - 2
紧接着,执行完printf函数后,ret所指向的空间中的数据变成了乱码后输出到屏幕上。
在有了栈帧的理解其实也不难明白,原因是printf本质也是一个函数,所以在调用它后也需要为其开辟空间,之前show函数调用结束后的空间被释放,而printf开辟空间的位置就是之前为show函数所开辟的位置,又因为释放空间不会清空数据,因此当printf函数中又执行了一系列操作,就把之前空间中留下的数据覆盖了,所以进行打印时,那块空间中存放的是一串乱码:
【C深度剖析】关键字大全详解 - 2
以上就是对于本段代码正确的理解,与上面提到的删除数据的原理类似。

  • 不要返回栈空间上的地址

函数可以调用其它函数,也能被其它函数调用,所以main函数也是有其它函数调用,但本文不详细探讨,只需要知道有函数调用main函数就可以了

到这里还有一个小问题,系统在帮函数开辟空间时如何知道该函数所需要的空间大小是多少呢?
在编译期间,编译器会对函数进行检测,通过检测其中的数据类型所定义的变量等,来预估出该函数所需要的大概空间是多少。这就是为什么sizeof可以用来求数据类型或者变量的大小,因为编译器认识,这些数据类型关键字就是给编译器作为预估一个函数的空间大小来用的,即使没有真的被执行,也能给函数开辟足够大小的空间供函数使用。

此时再次理解为何局部变量具有临时性:首先,函数在调用时需要预先在栈区开辟空间,称为函数栈帧,其次,局部变量是在函数内或者函数内代码块中定义的变量,但总之是在函数栈帧空间中进行二次开辟空间存放数据,当函数调用结束后,栈帧空间被销毁,既然大空间都被销毁了,在大空间中开辟的小空间等也都一并被销毁了,那么局部变量就无法在被使用,因此具有临时性。

2.1 return x

先来看如下代码:

int GetNum()
{									    
	int x = 0x11223344;
	return x;
}
int main()
{
	int ret = GetNum();
	printf("%x\n", ret);
	return 0;
}		

输出:
【C深度剖析】关键字大全详解 - 2
结果正常输出,可是上面说过,局部变量具有临时性,函数调用结束后空间被销毁变量也就无法正常使用,那么主函数中是如何接收到x的值呢?解决这个问题需要从汇编的角度去分析:
【C深度剖析】关键字大全详解 - 2
此时调用0D213BBh位置的GetNum函数:
【C深度剖析】关键字大全详解 - 2
这里定义变量x,然后把数据存放到dword ptr [x]这块空间中,下一步返回x,实际上是把dword ptr [x]空间中的数据存放到eax中,这里的eax为寄存器的一种。

【C深度剖析】关键字大全详解 - 2
GetNum函数调用结束后,虽然空间被销毁了但是寄存器中依然保存了变量x的值,因此回到主函数中,通过寄存器将数据放入dword ptr [ret]这块空间中,此时的ret中就存放了GetNum函数的返回值。

小结:函数的返回值是通过保存在寄存器中的方式,返回给函数的调用方

3. const关键字

3.1 修饰只读变量

const的修饰变量的作用是让变量具有只读属性,无法被直接修改

int main()
{
	const int x = 10;
	x = 20; //err
	return 0;
}		 

运行直接报错:
【C深度剖析】关键字大全详解 - 2

const int x = 10;
int const x = 10;
两种写法等价

虽然无法直接修改,但可以使用其它方式间接修改,如使用指针访问:

int main()
{
	const int x = 10;
	int* ptr = (int*)&x;
	printf("before %d\n", x);
	*ptr = 20;
	printf("after %d\n", x);
	return 0;
}

输出结果:
【C深度剖析】关键字大全详解 - 2
因此可以发现,const修饰的变量并非真的无法被修改的常量。既然可以被修改,那const修饰变量的意义是什么?

  1. 即使可以间接修改,但是无法被直接修改,因此可以告知编译器,如果后续检测到有直接修改const修饰的只读变量的操作时直接报错
  2. 告知其他阅读或者修改你代码的人,这个变量不要修改,天然含有一种"自描述"的作用

也就是说const这个关键字,并不是真的对一个变量是否能被修改起到一个强制性约束,而是在语法层面上的一个弱约束。

3.2 修饰数组

const修饰数组与修饰变量效果极为类似,也是使得数组中的元素无法被直接修改,具有只读属性:

int main()
{
	const int arr[] = { 1,2,3,4,5 };
	arr[0] = 0;
	arr[1] = 0;
	arr[2] = 0;
	arr[3] = 0;
	arr[4] = 0;
	return 0;
}

代码会直接报错,比较简单没什么可以说的。

3.3 修饰指针

const修饰指针无非就是如下四种情况:

int main()
{
	int a = 10;
	int b = 20;
	//1.
	const int* p = &a;
	//2.
	int const* p = &a;
	//3.
	int* const p = &a;
	//4.
	const int* const p = &a;
	return 0;
}	

接下来分别解释其区别:

  1. const int* p = &a;
    【C深度剖析】关键字大全详解 - 2
    第一种写法中,const的限制作用是:无法通过解引用去修改指针变量p中存放的地址所指向空间中的数据,但是可以修改p的中存放的地址,也就是它的指向可以被修改。
  2. int const* p = &a;
    第二种写法与第一种写法的作用写法完全等价,只是两种不同的写法而已。
  3. int* const p = &a;
    【C深度剖析】关键字大全详解 - 2
    第三种写法中,const的限制作用则发生了改变,这里的作用是:可以通过解引用去修改p变量中保存的地址所指向的空间中的数据,但是无法修改p的中保存到地址,换言之,p的指向无法修改,只能存放a的地址,无法指向其它变量的地址。
  4. const int* const p = &a;
    【C深度剖析】关键字大全详解 - 2最后一种写法所表示的含义就更加明显了,左右两边都被const所修饰,因此:既无法通过解引用去修改p中存放的地址所指向的变量,也无法修改p中保存的地址。

小结:注意观察const的位置,如果在*号的左边,则限制的是p指向的变量无法修改,如果是在*号的右边,则限制的是它 保存的地址/指向 无法修改。

补充概念:&是取出变量或者其它变量的最低的地址!

关于const修饰指针还有一个小概念:

int main()
{
	int a = 10;
	
	//1.
	const int* p = &a;
	int* q = p;

	//2.
	int* q = p;
	const int* p = &a;
	return 0;
}		 

这两段代码,只是顺序不一样,但是第一种经过运行后,编译器会发出警告或者报错,而第二种则可以顺利通过运行。关于这种现象有这么一种说法:
当一个类型限制非常严格的变量赋值给另一个类型限制不怎么严格的变量,编译器会发出警告甚至报错,而把一个类型限制不怎严格的变量赋值给另一个类型限制非常严格的变量则无事发生。
我的理解是第一种写法,是将强约束类型的变量赋值给一个非强制约束类型的变量可会造成一些不安全的情况发生;而反过来则使得其加上强制性约束后会更加安全。

第一种好像是先建立了一座城墙,然后把城堡建立在了城墙之外,而第二种则是先建立了城堡,然后在其周围建立了一座城墙。
虽然发动强攻都可以将其攻破,但显然第二种会更安全。

3.2 修饰函数参数

如下代码:

void print(char* pstr)
{
	printf("%s\n", pstr);
}
int main()
{
	char str[] = "hello world!";
	print(str);
	return 0;
}	

代码功能很简单,调用print函数打印字符串数据。其实这段代码基本没什么问题,但是从预防性和安全性的角度来说,可以将函数参数加上const来修饰。
因为函数的功能就是很简单的输出语句,不需要对参数的内容做出任何修改,加上const后可以很好的预防后续发生修改形参的行为,如果发生则进行报错提醒我们,以此提高代码的安全性。

void print(const char* pstr)
{
	printf("%s\n", pstr);
}

3.3 修饰函数返回值

修饰返回值与修饰函数参数其实含义极为相似。

const int* getNum()
{
	static int val = 10;
	return &val;
}
int main()
{
	int* ret = getNum();
	//...
	return 0;
}	

如上,被const修饰后的函数返回值,由ret来接收,此时虽然返回值被const修饰,但依然可以被修改。
显然这种做法很危险,违背了const的意义,因此为了保证返回值不可以被直接修改,需要在接收返回值的变量类型前也加上const来提高安全性。

const int* ret = getNum();

一般内置类型返回,加const无意义。const修饰的返回值普遍是指针类型。

4. volatile关键字

4.1 简单理解volatile

volatile是不稳定的、易变的意思。很多人在之前的C语言的学习中压根就没见过这个关键字,不知道它的存在。即便知道,也基本上没怎么用过它。
它与const一样,是一种类型修饰符,用它修饰的变量表示可以被某些编译器位置的因素修改,比如操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

这是相关的概念,通俗点说就是:目前的编译器都有对变量代码自动优化的功能,如果不希望被编译器优化,想要达到可以稳定访问内存的目的时,则可以考虑使用volatile关键字。

编译器是如何进行优化的呢?下面是一段死循环的代码:

int pass = 1;
//volatile int pass = 1;
int main()
 {
	while (pass)
	{

	}
	return 0;
}	
int main()

分别从汇编的角度分别来看 没有被volatile关键字修饰的变量和被volatile关键字修饰的变量,这两者底层是怎么运行的。

无volatile修饰:
【C深度剖析】关键字大全详解 - 2
有volatile修饰时:
【C深度剖析】关键字大全详解 - 2
以上就是对于该关键字的基本理解,认识并且知道它大概是什么作用即可。

其它问题:const volatile int val = 10;这两个关键字一起修饰变量时会冲突吗?
明确的是,const修饰变量的含义即使得该变量无法被直接修改,也就是不可以进行写入;而volatile修饰变量的含义是,需要读取该数据时,必须要去内存中读取。一个是写,一个是读,其实二者本质上并没有什么关系,因此不会产生冲突,编译器不会发出警告。


本篇完。

由于知识水平有限,如有错误在所难免~