💯💯💯
本篇处理的是有关语义误解的问题:即程序员的本意是希望表示某种事物,而实际表示的却是另外一种事物。在本篇我们假定程序员对词法细节和语法细节的理解没有问题,因此着重讨论语义细节。
导言:
由于一个程序错误可以从不同层面采用不同方式进行考察,而根据程序错误与考察程序的方式之间的相关性,可以将程序错误进行划分为各种陷阱与缺陷:
①.词法“陷阱”
②.语法“陷阱”
③.语义“陷阱”
④.连接问题
⑤.库函数问题
⑥.预处理器问题
⑦.可移植性缺陷
本篇重点讲解语义“陷阱”
①.指针与数组
1.1数组的两个注意点:
1.1.1确定数组的大小
数组的声明必须确定数组的大小是多少
int a[3];/
声明a是一个拥有3个整形的数组
struct arr { int p[4]; double x; }b[10];
声明了b是一个拥有10个元素的数组,每个数组元素是个结构体,该结构体中包含了一个拥有4个整形的数组和一个浮点型的变量。
1.1.2获得指向该数组下标为0的元素的指针。
这句话什么意思呢?
其实有关数组的操作,哪怕它们乍看上去是以数组下标进行运算的,实际上都是通过指针进行运算的。
换句话说,任何一个数组下标的运算都等同于一个对应的指针运算,所以我们完全可以依据指针行为定义数组下标的行为。
那获得该下标为0的元素的指针,如果给这个指针加1,就能得到指向该数组中下一个元素的指针。
也就是指针+一个整数得到的还是指针,只不过指针的位置发生改变,注意可不是指针指向的内容发生改变了!
int a[10]={0,1,2,3,4,5,6,7,8,9}; int *p=a; p=p+1; //或者写成p++;
1.2指针与数组之间的真正关系
1.sizeof(数组名),这里的数组名表示整个数组,计算的是整个数组的大小。
sizeof(a)的结果是整个数组a的大小,而不是指向数组a首元素地址的大小。
2.&数组名,这里的数组名表示整个数组,取出的是整个数组的地址
3.除此之外所有的数组名都表示首元素的地址(指针)
这里重点讲解第三个关系
1.2.1数组名表示首元素的地址(指针)
数组名表示首元素的地址
除了a被用作运算符sizeof的参数和&a的情况,在其他所有的情况中数组名a都代表着指向数组a中下标为0的元素的指针。
也就是数组名a代表着数组a中首元素的地址。
所以我们可以这样理解*a也就是数组a中下标为0的元素的引用。
*a=10;
这句代码也就是把首元素的值改成10. 同理,*(a+1_是数组a中下标为1的元素的引用,以此类推,可以推出,*(a+i)即数组a中下标为i的元素的引用;而这种写法是如此的常用,因为它被简记为a[i];
所以我们可以这样理解数组下标与指针之间的关系
a[0] 等同于*(a+0) //数组名a表示首元素的地址,加0还是首元素,对首元素的解引用,访问的就是首元素,而a[0]就是首元素 a[1]等同于*(a+1) //首元素的地址+1表示指向了第二个元素的地址,解引用就等于访问第二个元素,而a[1]就是第二个元素 a[2]等同于*(a+2)//首元素的的地址+2表示指向了第三个元素的地址,解引用等于访问第三个元素,而a[2]就是第三个元素 …… …… …… a[i]等同于*(a+i)//就相当于数组a中下标为i的元素的访问
也正是这一概念让许多C语言新手对数组与指针之间的关系搞得迷迷糊糊的。
实际上,由于a+i与i+a的含义是相同的,因此a[i]和i[a]也具有相同的含义,但不要写成这样,因为不好理解。
1.2.2“二维数组”
我们可以利用"二维数组"来揭示指针与数组之间的关系
int arr[10][20];
这个代码声明arr是一个数组。该数组是拥有10个数组类型的元素。其中每个元素都是一个拥有20个整形元素的数组(而不是一个拥有20个数组类型的元素,每个元素都是一个拥有10个整形的数组)。
根据我们对一维数组的理解,arr总是被转换成一个指向arr数组的起始元素的的指针。也就是首元素的地址。
这里还是提醒一下,该数组仍然遵循上面的第1条
sizeof(数组名)表示的是整个数组的大小,而这个二维数组的整个大小是10 * 20*sizeof(int)
而这个"二维数组",它实际上是以数组为元素的数组。它是一维数组里面又套了一个一维数组。
在一维数组里面,我们可以比较轻松的利用指针来编写操纵一维数组的程序,但是对于二维数组从记法上的便利性来说采用下标的形式才是更好的方式。
如果我们仅仅使用指针来操纵二维数组,那我们就必须深刻的理解指针相关的知识,不然会常常遇到意想不到的bug。
来我们看一下,下面的声明
int a[10]; int arr[10][20]; int *p; int i;
想一想arr[3]代表的是什么?
a[3]表示一维数组a中第4个元素。
那arr[3],对于二维数组arr,是什么意思呢?
我读这个二维数组的方式是这个二维数组有10个数组类型。
10个数组元素,每个数组元素有20个整形元素。
所以arr[3],就代表着这10个数组元素的第4个数组元素。
其实我们还可以这样理解:将二维数组第一个[ ]里看成是行数
第二个[ ]看成列数。
那么有该二维数组有10行20列
arr[0]表示第1行的数组元素
arr[1]表示第2行的数组元素
arr[2]表示第3行的数组元素
arr[3]表示第4行的数组元素
arr[4]表示第5行的数组元素
arr[5]表示第6行的数组元素
arr[6]表示第7行的数组元素
arr[7]表示第8行的数组元素
arr[8]表示第9行的数组元素
arr[9]表示第10行的数组元素
那arr[3]表示的是第4行数组元素。
我们知道每一行的数组元素里面,都有着20个整形元素
所以一个arr[3]的大小就是sizeof(arr[3])==20*sizeof(int)
//这段代码表示什么意思? p=arr[3];
这个语句使得指针p指向了数组arr[3]中下标为0的元素。
为什么是这样呢?
因为你看,arr[3]表示的是数组,该数组里面还有20个元素呢
所以arr[3]表示这个拥有20个整形元素的数组的数组名
数组名代表着什么?
代表着首元素的地址!所以该语句将arr[3]数组的首元素的地址传给了p。
那*arr[3]就是对arr[3]这个数组的首元素的地址的访问了,也就是它那20个元素的首元素。
*(arr[3]+1)这就表示访问第4个数组元素里面的第2个元素 也就是arr[3][1] *(arr[3]+3)这就表示访问第4个数组元素里面的第3个元素==arr[3][2] *(arr[3]+4)这就表示访问第4个数组元素里面的第4个元素==arr[3][3] *(arr[3]+5)这就表示访问第4个数组元素里面的第5个元素==arr[3][4] …… …… *(arr[3]+i)这就表示访问第4个数组元素里面的第i个元素==arr[3][i]
这个语句根据前面的类似的道理,还可以写成下面的这样
*(arr[3]+3)这就表示访问第4个数组元素里面的第3个元素==arr[3][2] 进一步写成这样---也就是将arr[3]下标形式写成指针形式 *(*(arr+3)+3)
我们不难发现,用带方括号的下标形式很明显的要比完全用指针来表达方便多了。
不过还有人经常犯错误,写成下面这样
p=arr;
这个语句是非法,因为arr是一个二维数组,即”数组的数组“
arr表示的是首元素的地址对吧
你想一想,arr首元素是个啥?还是个数组呀!
所以arr首元素的地址是数组的地址,使用arr时,会转化为一个指向数组的指针,而p是一个指向整形变量的指针,可不能将一种类型的指针,赋给另一种类型的指针,这是非法的。
很显然,我们需要一个指向数组的指针来保存arr,上一篇博客我已经较为详细的介绍了该如声明一个变量:按照使用的方式来声明
我们需要的是一个指针,该指针是指向一个数组的,该数组的大小是20
int(*ph)[20]; //我们首先构造出(*ph)这个指针 //这个指针指向的是什么类型的呢?--是int [20]类型的
该语句实现的效果就是*ph是一个拥有20个整形元素的数组
所以ph就是一个指向这样的数组的指针。
所以可以这样写
int arr[10][20]; int(*ph)[20]; ph=arr;
ph也就指向数组arr的第一个元素的地址了,也就是数组arr的10个中有着20个元素的数组的元素之一。
利用上面的"二维数组"可以很好的揭示C语言中数组与指针之间独特的关系,从而更清楚的明白理解这两个概念。
②.非指针的数组
1.在C语言中,字符串常量代表的是一块包括字符串中所有字符还有一个’\0’的内存区域的地址。
2.字符串常量是以空字符’\0’作为结束标志。
3.字符串如果不用数组存储,那必须要有指针来存储。并且该指针指向的是字符串首字符的地址。
我们如果想让两个字符串合并成一个字符串
给定一个想法:
先计算出两个字符串长度,计算总长度多少
利用malloc函数,开辟一个大小为总长度的空间
将两个字符拷贝过去
注意事项:
1.如果利用stren计算字符串长度,请记住最后的结果要加上1,因为strlen遇到’\0’就停止。最后并没有将’\0’计算进去。
2.malloc申请的空间可能失败,需要判断
3.malloc申请的空间,在程序结束之前需要释放
③.作为参数的数组声明
在C语言中,我们虽然没有办法将一个数组作为函数参数之间参过去,因为不知道数组有多大,如果超级大,那操作系统可能无法提供足够的空间。
但如果我们使用数组名作为参数,那么数组名会立刻被转化为指向该数组第一个元素的指针。
数组传参和指针传参
写代码时难免要把数组或者指针传给函数,那函数的参数该怎么设计呢?
3.1一维数组的传参
void test(int arr[])//数组传参,数组接收 {} void test(int arr[10])//跟上面一样 {} void test(int* arr)//数组传参,指针接收 {} void test2(int* arr[20])//数组传参,指针接收 {} void test2(int** arr)//数组传参,指针接收 {} 因为数组名就是首元素的地址,所以数组名传参,可以用数组来接收,也可以用地址来接收,只不过要注意接收的是一级指针还是二级指针。 int main() { int arr[10] = { 0 }; int* arr2[20] = { 0 }; test(arr); test2(arr2); return 0; }
3.2二维数组的传参
void test(int arr[3][5])//ok?二维数组传参,二维数组接收 {} void test(int arr[][])//ok?这个可不行喔 {} void test(int arr[][5])//ok?跟第一个一样
注意点:二维数组传参,函数参数的设计只能省略第一个[ ]的数字。
因为对于一个二维数组,可以不知道到多少行,但不能不知道一行多少个元素。这样才方便运算。
void test(int *arr)//ok?还记得上面说的吗,二维数组的数组名相当于第一行的数组地址所以是类型是数组指针类型的,应该用数组指针来接收 {} void test(int* arr[5])//ok?不行这是个指针 {} void test(int (*arr)[5])//ok?这个可以是数组指针,指针指向一个五个整形的数组 {} void test(int **arr)//ok?不行,这个是二级指针 {} int main() { int arr[3][5] = {0}; test(arr); }
3.3一级指针传参
//用一级指针来接收 void print(int *p, int sz) { int i = 0; for(i=0; i<sz; i++) { printf("%d\n", *(p+i)); } } int main() { int arr[10] = {1,2,3,4,5,6,7,8,9}; int *p = arr; int sz = sizeof(arr)/sizeof(arr[0]); //一级指针p,传给函数 print(p, sz); return 0; }
想一想当函数参数为一级指针时,可以接收上面参数呢?
void test1(int * p) { } //test1函数能接收什么参数? void test2(char* p) { } //test2函数能接收什么参数?
3.4二级级指针传参
void test(int** ptr) { printf("num = %d\n", **ptr); } int main() { int n = 10; int*p = &n; int **pp = &p; test(pp);//pp是二级指针 test(&p);//p是一级指针,&p就应该用二级指针来接收了 return 0; }
当函数的参数为二级指针的时候,可以接收什么参数?
void test(char**p) { } int mian() { char c='b'; char *pc=&c; char**pcc=&pc; char *arr[10]; test(&pc);//指针的地址,需要二级指针接收 test(ppc)//二级指针,二级指针接收 test(arr);//数组名表示首元素地址,每个元素的类型是char类型的指针,然后再取地址,当然可以用二级指针接收。 }
④.避免"举隅法"
什么叫"举隅法",就是以整体代表部分,或者以部分代替整体。
在C语言,我们会遇到常见的"陷阱":混淆指针与指针所指向的数据。
对于字符串的情形,我们更是经常犯错误。
比如:
char *p,*q; p="abc";
我们可能一开始初学时认为,上面的赋值语句将字符串"abc"赋给了p,然而实际上并不是这样,要记住字符串的不同之处。
实际上p的值是一个指向由’a’,‘b’,‘c’,'\0’四个字符组成的数组的起始字符的指针。
如果我们执行下面的语句
q=p;
让p和q同时指向用一块空间,但这个赋值语句并没有将p的数据 赋值给q
只是改变了p原来的指向而已。
所以我们要记住,复制指针并不同时复制指针所指向的数据。
⑤.空指针并非空字符串
在C语言中将一个整数转化为一个指针,最后得到的结果都取决与具体的C编译器实现,。但有个特殊情况,那就是常数0,编译器保证由0转化而来的指针,不等于任何有效的指针。
所以0通常被写成NULL
#define NULL 0;
无论是用常数0还是用符号NULL,效果是一样的。
但是要记住的重点是:
注意
当常数0被转化为指针使用时,这个给指针就不能再被访问,解引用了。
也就是当使用NULL时,就不能再去企图访问这个指针指向的内容了,一旦访问,就会造成非法。
比如下面这样:
char *p=NULL; char *l="abc"; if(strcmp(l,p)==0);
这样就非法访问了,因为库函数,strcmo的使用会查看它的指针参数所指向内存中的内容的操作,因为p为空指针,如果访问它就会非法。
printf(p); printf("%s",p);
以上两个写法也是未定义的。在不同的环境会出现不同的效果。
⑥.边界计算与不对称边界
a[10];在C语言中,数组下标的范围,是0-9,大家应该都不陌生。
而在有些语言中数组下标是1-11,有的是0-10等等。
今天我们就来探究一下C语言这样设计的动机是什么。
6.1 死循环问题
我们先来看一段代码:
int main() { int i; int arr[] = { 1,2,3,4,5,6,7,8,9,10 }; for (i = 0; i < 12; i++) { printf("xiao tao\n"); arr[i] = 0; } return 0; }
这段代码的本意是要将数组a中所有的元素置为0,但却产生了一个出乎意料的"副作用",在for语句中比较部分本来是i<10,却写成了12,因此实际上并不存在的a[10],a[11]都被设置为0,也就是内存中在在数组后面的数据被设置为0。然后最终该程序变成了死循环,下面将会详细的讲解为什么会出现这样的情况,但该部分不是本篇重点,如需知道下面的知识,请跳过。
你觉得该代码有什么问题呢?
1.越界访问 2.死循环
第一个问题越界访问非常容易看出来,数组arr只有10个元素,下标从0-9,而循环12次,肯定越界访问了每次都会打印一个xiao tao,并且把对应的arr[i]置为0。
所以最后答案应该就是打印了12次xiao tao,然后越界访问出现错误,使arr[10]=0,arr[11]=0了但最后答案却不是这样。
答案变成了死循环了,死循环打印xiao tao
6.1.1死循环的原因是什么呢?
这里涉及有关栈空间的知识:
1. 内存空间分为栈区,堆区和静态区 栈区一般存放局部变量,函数参数,函数返回值等 堆区是用来分配动态开辟的空间的 静态区是存放全局变量,static修饰的静态变量等
2.局部变量是在栈区存放的
/
3.栈区的使用习惯: 先使用高地址处的空间 再使用低地址处的空间
4.数组的地址随着下标的增长,地址是由低到高变化的
注意:
该测试是在VS2019 X86环境下进行,其他环境可能不一样,结果也就不一样,不能一概而论。
在变量i与数组之间一定有两个整形空间吗?
答案:不一定。
在VS2019 X86环境下,变量i与数组之间确实空了两个整形变量空间大小。
而在VC6.0环境下,变量i与数组之间没有剩余空间。
在gcc环境下,变量i与数组之间有一个整形空间大小
6.1.2解决方法
可能有的人会这样想将变量i定义在数组的下面这样就不会发生死循环了
我们从栈空间使用方面来看,这样当然可以避免死循环,但是难道我们以后写数组都要把i写在数组的后面吗?
这样只能解决当前的问题,而不能解决根本。不过现在的编译器大多数会自己修改这个死循环问题,比如将变量的i的地址放在数组的下面,在release版本就是这样进行优化不会死循环。
我们一般可能想不到这样本质原因,但我们可以通过调试来解决这个问题
当让i不断的++,当i等于10时将arr[10]置0,当i等于11时将arr[11]置0,然后我们通过调试监视发现arr[12]与i的值相等,这时我们就要想到为什么会死循环了,arr[12]的地址就等于i的地址,将arr[12]修改成0,就等于将i改为0了。
将arr[12]置0,发现i也变成0了
6.1.3总结
其实这道题是在特殊环境下才能实现的,但我们还是要注意的是其中的知识点:
1.栈区的使用习惯
先使用高地址的空间
再使用低地址的空间
2.数组随着下标的增长,地址不断增大
6.2 边界问题
在所有常见的程序设计错误中,最难于察觉的一类是"栏杆错误",也常被称为"差一错误",其实总结起来都是边界问题。
比如100米长的围栏每隔10米就需要一根支撑用的栏杆,那一共需要多少根栏杆呢?
”显而易见“答案是10,不就是100处以10嘛,得到结果10,需要10根栏杆。
这个答案是错误的,正确答案是11根。
仔细想一想:一开始支持这10米长的围栏需要两个跟,两端各一根,然后从第二根开始往后计数,每个10米要一根,最后加起来就是10+1
这个1是一开始没有计算的那根。
6.2.1两个原则和一个编程技巧
为了避免”栏杆错误“,总结以下两个原则:
- 首先考虑最简单情况下的特例,然后将得到的结果往外推。
- 仔细计算边界,绝不能掉以轻心
一个编程技巧:
- 用一个入界点和第一个出界点来表示一个数组范围。
比方说:整数x满足x>=16且x<=48;
求满足整数x的个数是多少呢?
我们可以利用编程技巧将它转变以下
写成x>=16且x<49;
这里的下界就是”入界点“,即包含在取值范围之中,而上界是”出结点“,即不包含在取值范围内。
虽然这样形成了两个不对称(左边带有等于,右边不带有等于),但编程效果是极佳的,因为最后的答案就是上界-下界。
对于像C这样的数组下标从0开始的语言,不对称边界给程序设计代来了许多便利。
为什么呢?
因为这样数组的上界恰是数组元素的个数!
因此如果我们要在C语言中定义一个拥有10各元素的数组,那么0就是数组下标的第一个入界点,而10就是下标中的第一个出界点。
我们经常这样写
int a[10],i; for(i=0;i<10;i++) { a[i]=10; }
而不是这样写:
int a[10],i; for(i=1;i<=10;i++) { a[i]=10; }
是有道理的。所以我们在控制循环时,设置变量的范围是通常是设置为不对称形式,而不设置称对称形式。
6.3 边界访问
ANSIC标准明确允许这样的用法:
数组中实际不存在的”溢界“元素的地址位于数组所占内存之后,这个地址是可以用于进行赋值和比较,但是如果要引用该元素,那就是非法的了。
什么意思呢?
就是数组最后一个元素的后面那块空间地址是允许拿来进行赋值和比较的,但不允许访问。
该准则与上面的”不对称边界“原则是一致的,空间上也形成不对称,但是要记住不能访问!!!.一旦访问就是非法访问了。
⑦.求值顺序
上篇博客我写了关系运算符优先级之间的问题,但本篇讲的是求值顺序,与它并不相同。
C语言只有4各运算符(&& 和|| 和?:;和,)存在着规定的求值顺序。其他运算符对其操作数求值的顺序是未定义的。特别的,赋值运算符并不保证任何求值顺序
1.条件运算符?:;有三个操作数:在a?b:c中,操作数a首先被求值,然后根据a的值再去求操作数b或c的值。
2.而逗号运算符,首先对左侧操作数求值,然后该值被”丢弃“,再对右侧操作数求值
7.1短路求值:&&和||
逻辑与&&逻辑或||存在着短路求值
什么叫短路求值呢?就是首先对左侧操作数求值,只有当需要时才会对右侧操作数进行求值。
3.逻辑与&&: 表达式1&&表达式2 表达式1为假则右边不再进行。
4.逻辑或|| : 表达式1||表达式2 表达式1为真则右边不再进行。
int main() { int i = 0, a = 0, b = 2, c = 3, d = 4; i = a++ && ++b && d++; printf("a = %d\n b = %d\n c = %d\nd = %d\n", a, b, c, d); return 0; } 因为 i = a++ && ++b && d++; a++是后置++,先使用后++,所以a=0,先使用与&&进行配对,然后是假, 所以后面++b, d++都不再进行,但a++,这个还是进行的,所以a用完后还要给a+1, 所以a=1,b=2,c=3,d=4;
int main() { int i = 0, a = 0, b = 2, c = 3, d = 4; i = a++||++b||d++; printf("a = %d\n b = %d\n c = %d\nd = %d\n", a, b, c, d); return 0; } 这里a++(a=0,先使用与||配对为假,再+1,++b,b自增为3与||配对为真,后面的d++不再进行了所以 a=1,b=3,c=3,d=4;
⑧.整数溢出
C语言中存在着两类整数算术运算,有符号运算与无符号运算。在无符号算术中,没有所谓的”溢出“一说:所有的无符号数运算都是以2的n次方为模,这里的n是结果中的位数。
如果算术运算符中一位是有符号,一个是无符号的,有符号的会先转化成无符号数,然后进行运算。
当两个操作数都是有符号数时,才可能发现”溢出“,而且溢出的结果是未定义的。当一个运算的结果发生”溢出“时,做出任何假设都是不安全的。
那如何进行检查两个操作数进行运算时是否”溢出“呢?
一种正确的方式是将a和b都强制转化为无符号整数:
if( (unsigned)a+(unsigned)b>INT_MAX ) exit(1);
这里的INT_MAX是表示最大整数值。
不需要用到无符号算术运算的另一种可行方法是:
if(a>INT_MAX-b) exit(1);
⑨.main函数的返回值
main() { }
函数main什么都没写,与其他任何函数一样,如果没有显示声明返回类型,那么函数返回类型就默认为整形。但是这个程序也没有给出任何返回值。
按理说,这样不会造成什么危害。一个返回值为整形的函数,如果返回失败,实际上是返回了某个”垃圾“整数。只要不用到该整数,就问题不大。
但严格来说,大多数C语言实现是通过函数main的返回值来告知操作系统该函数的执行是否成功。
典型的处理就是main函数返回值为0表示执行成功,返回非0表示执行失败。
#include <stdio.h> int main() { printf("hello world\n"); return 0; }
#include <stdio.h> int main() { int arr[10], i; for (i = 0; i < 11; i++) { arr[i] = 0; } return 0; }
如果一个程序的main函数并不返回任何值,那么有可能看上去执行失败。
所以即使是最简单的C程序也应该像这样编写代码:
#include <stdio.h> int main() { printf("hello world\n"); return 0; }