参考书籍:C 缺陷与陷阱(人民邮电出版社)
参考博客:https://blog.csdn.net/weixin_40186813/article/details/126048321?spm=1001.2014.3001.5506
一、词法 “陷阱”
1) = 不同于 ==
2) & 和 | 不同于 && 和 ||
3)“贪心法”(大嘴法)
持续找寻能组成一个符号的最长字符串
除了字符串与字符常量,符号中间不能嵌入空白(空格符、制表符、换行符),如:
a—b 《==》 a-- -b 《!=》 a- --b
/* 直至遇见 */,再次遇见/*并不管
4)警惕将十进制写出八进制
5)区分好单引号与双引号
练习1-1:
int main()
{
int a = /* /*/0*/**/1;
printf("a:%d\n",a);
return 0;
}
练习1-4:
int main()
{
int a = 1,b=1,c;
c = a+++++b; //根据大嘴法原则,这里不符合语法标准,报错:常量不能做左值
printf("c:%d\n",c);
return 0;
}
二、语法“陷阱”
1)理解函数声明
( ( void()() )0 )();
构造这类表达式规则:按照使用的方式来声明
任何C变量的声明都由两部分组成:类型 + 一组类似表达式的声明符
对表达式求值(即表达式的返回值)应该匹配该类型
例如: 匹配规则(就近原则)
1、float f,g; 2、float ((f)); 3、float ff(); 4、float *pf;
5、float *g(),(*h)(); 6、 ( *( void(*)() )0 )();
pf:一个指向浮点数的指针
g:一个指针函数,该函数返回值类型为【指向浮点数的指针】
h:一个函数指针,该指针所指向的函数的返回值类型为【浮点类型】
一个给定类型的变量 =变换成=》 类型转换符(去掉声明中的变量名和末尾分号)
例如:float (h)(); ==》 ( float ()() )
一个指向返回值为浮点类型的函数指针 ==》 一个"指向返回值为浮点类型的函数指针"的类型转换符
开始分析表达式:符合ANSIC标准
假设fp默认初始化为0(不提倡,代价为多声明一个"哑"变量)
"哑"变量:虚拟变量,0或者1,适用于离散型变量水平数较小时使用,3个及以内
将不能够定量处理的变量量化,考察定性因素对因变量的影响
①(*fp)() 可简写为 fp() *((*fp)()) 可简写为*(fp()) ,再简写为*fp()
②找一个恰当的表达式替换 fp
运算符 * 必须要一个函数指针来做操作数,这样经 * 作用后才能作为函数被调用
假设:
( * 0)() ==》 (void ( * )())0 将其代替为fp(函数指针那个fp)即可得到那个表达式
对0作类型转换,转换后的类型大致描述为:指向返回值为void类型的函数指针
体现出了 typedef的好处:typedef void ( * func)(); ( * (func)0)();
相同类型问题如下:
sighandler_t signal(int signum, sighandler_t handler);
SIG_IGN,SIG_DFL,自定义信号起始地址
typedef void (*HEADLER)(int);
HEADLER signal(int,HEADLER);
//上下等价
void (*sfp)(int); //sfg is int class
void sigfunc(int n)
{
/*特定信号处理部分*/
}
void (*signal(int,void(*)(int)))(int);
2)运算符的优先级问题
①记住优先级
1、任何一个逻辑运算符的优先级低于任何一个关系运算符
2、移位运算符的优先级比算术运算符要低,但是比关系运算符高
②加小括号
3)注意作为语句结束标志的分号
if、while后面需要注意是否加分号
4)switch语句
注意贯穿效果
5)函数调用
C语言规则:在函数调用是即使函数不带参数,也应该包括参数列表,举例:f()与f
6)"悬挂"else引发的问题
即 else 与最近的 if 想匹配,就近原则
练习2-2:
某些程序设计语言改为在第n行中使用某些指示标志,以表示第n+1行代码应该被当做同一个语句的一部分,
例如:UNIX系统的shell(如bash、ksh、csh等)在代码行的结尾使用字符\来作为指示标志,表示下一行
代码行是同一个语句的一部分。sprintf或者sscanf等等。
三、语义"陷阱"
1)指针与数组
两点C语言数组注意事项:
1、C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确定下来,可仿真多维数组
(C99标准允许变长数组,GCC中也实现了变长数组,但两者细节不完全一致)
2、对于一个数组,我们只能
①确定该数组的大小
②获得指向该数组下标为0的元素的指针
其他看上去虽然是以数组下标进行运算的,实际上都是通过指针进行的,即
(任何一个数组下标运算都等同于一个对应的指针运算,因此我们可以依据指针行为定义数组下标行为)
备注:程序猿应该具备将数组运算与对应的指针运算融会贯通的能力,思考时能自由切换,毫无阻塞如:int cale[12][31];
声明了cale是一个数组,该数组拥有12个数组类型的元素,其中每个元素都是一个拥有31个整形元素的数组
重点:
如果cale不是用于sizeof的操作数,而是用于其他的场合,那么cale总是被转换成一个指向cale数组的起始元素的指针!!!
3、行指针 int (ap)[31];
声明了ap是一个拥有31个整形元素的数组,即ap就是一个指向这样的数组的指针
拓展:
int cale[21][31];
int (*mon)[31];
mon = cale;
mon将指向数组cale的第一个元素,也就是数组cale的12个有着31个元素的数组类型元素之一
int (*mon)[31];
int cale[12][31];
for(int month=0;month<12;month++)
{
for(int day = 0;day<31;day++)
cale[month][day] = 0;
}
//上下等价,特别的,下面那个例子符合ANSIC标准
for(mon = cale;mon<&cale[12];mon++)
{
int *day;
for(day = *mon;day<&(*mon)[31];day++)
*day= 0;
}
2)非数组的指针
字符串常量:
一块包括字符串中所有字符以及一个空字符(‘\0’)的内存区域的地址
1、合并两个字符串,strcpy和strcat,以及malloc等库函数、C99可在分配动态数组
int b;
scanf("%d",&b);
int c[b];//C99的动态数组、环境:ubuntu 20.04
char *s = "hello";
char *t = "world";
char *r = malloc(strlen(s)+strlen(t)+1);
strcpy(r,s);
strcat(r,t);
free(r);
3)作为参数的数组声明
1、数组作为函数参数 《=等价=》 对应指针声明 代码自助式char s[]
因此C语言会自动地将作为参数的数组声明转换为相应的指针声明,即char s[] 等价
char *s
char hello[] = "hello";
printf(" :%s\n",hello);
printf("&:%s\n",&hello[0]);
4)避免"举隅法"
举隅法:
以含义更宽泛的词语来代替含义相对较窄的词语,或者相反。(局部、整体相互代替)
这充分的描述了C语言中一个常见的"陷阱":混淆指针与指针所指向的数据
备注:复制指针并不同于复制指针所指向的数据!(共享内存的实现、除去cache缓存)
ANSIC标准中禁止对 string literal 作出修改,试图修改字符串常量的行为都是未定义的
5)空指针并非空字符串
#define NULL 0
备注:当常数0被转换为指针使用时,这个指针绝对不能被解除引用
解除引用:我知道你这块空间的地址,我可以通过地址 访问和修改 该块空间里面的内容,修改 便称之为 解引用,反之则是 未解引用
char *p = NULL;
if(p == (char *)0)
{
printf("NULL 等价 0.\n");
}
if(strcmp(p,(char *)0) == 0)
//strcmp的实现会包括查看它的指针参数所指向内存中的内容的操作
//null argument where non-null required (argument 2)
{
// printf("如果p是一个空指针,即 \
使printf(p)和printf('%s',p) \
的行为也是未定义的.且不同计算机效果不同\n");
}
6)边界计算与不对称边界
//验证该编译器环境是否是按照内存地址递减的方式给变量分配内存
// int i,j;
// printf("i addr:%p \nj addr:%p\n",&i,&j);
//i addr:0x7ffcb6a3fe6c
//j addr:0x7ffcb6a3fe70
//属于地址递增,本环境验证不了,据说VC6.0分配内存地址是递减的,有兴趣的可以去验证验证
int i,tt[3];
for(i = 0;i<=3;i++)
{
printf("tt[%d] addr:%p\n",i,&tt[i]);
}
printf("i number:%d\n",i);//本意分配内存地址是递减的话,此时i应该为0
printf("i addr:%p\n",&i);
#if 1
int str[10];
for(int i = 1; i <= 10; i++)
str[i] = 0;//本意是设置数组a中所有元素为0,实际却陷入死循环
//将a[10]设置为0,即内存中在数组a之后的一个字的内存被设置为0
// 如果用来编译这段程序的编译器按照内存地址递减的方式来给变
//量分配内存,则内存中数组a之后的一个字实际上是分配给了整型变量i
//即先&a[0]~&a[9],再是&i
#endif
/*
运行结果:
tt[0] addr:0x7fff46490364
tt[1] addr:0x7fff46490368
tt[2] addr:0x7fff4649036c
tt[3] addr:0x7fff46490370
i number:4
i addr:0x7fff4649033c
*/
备注:在所有常见的程序设计错误中,最难以察觉的一类便是**“栏杆错误”(差一错误)**
避免"栏杆错误"两个通用原则:
①首先考虑最简单情况下的特例,然后将得到的结果外推(自底向上,动态规划)
②仔细计算边界,绝不掉以轻心
举例:假定整数x满足边界条件x>=16且x<=37,那么此范围内x的可能取值个数有多少?
根据原则一,我们考虑最简单情况下的特例,
1、这里假定整数x的取值范围上界与下界重合,即x>=16且x<=16,显然合理的x取值只有1个整数,
即16。所以当上界与下界重合时,此范围内满足条件的整数序列只有1个元素
2、再考虑一般的情形,假定下界为1,上界为h。如果满足条件“上界与下界重合”,即1=h,亦即h-1=0。
根据特例外推的原则,我们可以得出满足条件的整数序列有h-1+1个元素。
在本例中,就是37-16+1,即22。
“入界点"以及"出界点”,左闭右开,即将x>=16且x<=37 等价于 x>=16且x<38
不对称之美,也许从数学上而言并不优美,但对程序设计的简化效果却足以令人吃惊:
①取值范围大小就是上界与下界之差,38-16的值是22,恰恰是不对称边界16和38之间所包括的元素数目
②如果取值范围为空,那么上界等于下界。这是第一条的直接推论。
③即使取值范围为空,上界也永远不可能小于下界
所以,判断语句不妨将 i<=9 改成 i<10 。
另一种考虑不对称边界的方式:
把上界视作某序列中第一个被占用的元素,而把下界视作序列中第一个被释放的元素
数组不对称边界示意图:
【可用区域 | 已占用区域 | 可用区域】
| |
"入界"下界 "出界"上界
注意:当处理各种不同类型的缓冲区时,这种看待问题的方式就特别有用!!!
例如:
某个函数功能:将长度无规律的输入数据送到缓冲区,每当这块内存被填满时,就将缓冲区内容写出
缓冲区声明样式如下:
#define N 1024
static char buffer[N];
设置指针变量,将它指向缓冲区当前位置:
static char *bufptr;
问题:对于指针bufptr,我们应该把重点放在哪个方面呢?
选择一:让指针bufptr始终指向缓冲区中最后一个被占用的字符(指向入界点)
选择二:让指针Bufptr指向缓冲区第一个未占用的字符(指向出界点)【看图】
备注:充分考虑"不对称边界"的偏好设置"不对称边界"惯例:*bufptr++ = c;
含义:这个语句把输入字符 c 放到缓冲区,然后指针bufptr递增1,又指向缓冲区中第1个未占用的字符
(类比 先自增和后自增的区别 ++p=a; || p++=a;)
提升: 指针bufptr 与 &buffer[0] 相等时,缓冲区存放的内容为空
初始化时声明缓冲区为空:
bufptr = &buffeer[0]; 即 bufptr = buffer;
任何时候缓冲区中已存放的字符数都是 bufptr - buffer,因此我们可以通过将这个表达式与N作比较,来判断缓冲区是否已满
当缓冲区全部"填满"时,表达式bufptr - buffer == N,即可得缓冲区未占用字符数为:N - (bufptr-buffer).
void bufwrite(char *p,int n)
{
while(--n>0)
{
if(bufptr == &buffer[N])//遵循"不对称边界"原则
//等效 if(buffer > &buffer[N - 1])
// 虽然引用一个并不存在的元素没有意义,但是引用其地址确有意义,
//并且该地址还真是存在,且无论分配内存是递增还是递减都可
// ANSIC标准明确允许这种用法,数组中实际不存在的"溢界"元素的地
//址位于数组所占内存之后,这个地址可进行赋值和比较,但不能被引用
flushbuffer();
*bufptr++ = *p++;
}
}
//弊端:该程序绝大部分开销来自于每次迭代都要进行的两个检查
//1、判断循环计数器是否到达终值
//2、判断缓冲区是否已满
//导致结果:一次只能转移一个字符到缓冲区
//优化:使用memcpy函数(内存拷贝函数),达到一次转移一批字符的目的
void *memcpy(char *dest,const char *src,size_t k)
{
while(--k > 0)
*dest++ = *src++;
}
void bufwrite(char *p,int n)
{
while(n > 0)
{
int k,rem;
if(bufptr == &buffer[N])
flushbuffer();
rem = N - (bufptr - buffer);//缓冲区剩余的空间大小
k = n > rem ? rem : n;//判断是否还存放的下,存放不下则存满为止
memcpy(bufptr,p,k);
bufptr += k;//指针偏移到第一个未占用的字符处(出界点)
p += k;
n -= k;
}
}
//计算rem的两种方法:
//①缓冲区总的字符数 - 已占用的字符数,即 N - (bufptr - buffer).
//②直接计算这个空余部分的大小:
//由于bufptr始终指向第一个未占用字符(入界点)
//而 buffer+N ,即&buffer[N]指向这个区间的终点(出界点)
//则缓冲区在可用字符数 (buffer + N) - bufptr.
//编写这样的代码的原则:特例外推法和仔细计算边界(栏杆错误)
#endif
//打印函数:print()、flush()
//显示本页的当前位置:printnum()
//打印换行符:printnl()
//打印分页符,另起新的一页:printpage()
//每一行必须以换行符结束,包括最后一行,从左到右充填每个输出行
//要求编写的程序如下:
//该程序按一定顺序生成一些整数,并将这些整数按列输出
//即程序的输出可能包含若干页的整数,每页包括NCOLS列,每列包括NROWS个元素
//程序生成的整数是按列连续分布的,而不是按行分布的
//备注:该题关注的重点应该放在与技术有关的特性方面
#define BUFSIZE (NROWS * (NCOLS-1))
static int buffer[BUFSIZE];
static int *bufptr = buffer;
void print(int n)
{
if(bufptr == &buffer[BUFSIZE])
{
//打印当前行所有元素,行号加1,本页完则新起一页
int *p;
for(p = buffer+row;p < bufptr;p += NROWS)
//row:行号,且buffer[row]必定存在,属于第1列
//buffer + row 等价 &buffer[row]
printnum(*p);
//打印行的最后一个元素(该元素未在缓冲区中)
printnum(n);
printfnl(); //打印换行符,新起一行
if(++row == NROWS)
{
printpage();//打印分页符,新起一页
row = 0;
bufptr = buffer;//重置指针 bufptr
}
}else
*bufptr++ == n;
}
void flush()
{
int row;
int k = bufptr - buffer; //计算缓冲区中剩余项的数目
if(k > NROWS)
K = NROWS;
if(k > 0)
{
for(row = 0;row < NROWS; row++)
{
int *p;
for(p = buffer+row;p < bufptr;p += NROWS)
printnum(*p);
printnl();
}
printpage();
}
}
7)求值顺序
&& 和 || 短路原则
? : 先求?前,后求?后
,:先求,前,丢弃该值,再求,后(函数参数列表例外)
8)逻辑与 && ,逻辑或 || ,逻辑非 ! 按位与 &, 按位或 | ,按位异或 ^
&&:bool类型,如 a && b,若a为真,则求b,b真则真;若a为假,则全假
|| :bool类型,如 a || b,若a为真,则全真;若a为假,则求b,b真则真
! :取反
& : 同真为真
| :同假为假
^ : 相同为假,不同为真
9)整数溢出
有符号算术运算可能导致溢出
10)为函数 main 提供返回值
若并未显示声明返回类型,那么返回类型默认为整型。
当一个返回值为整型的函数返回失败,实际上会隐含地返回一个"垃圾"整数,不能被使用。
练习3-1:假定对于下标越界的数组元素,取其地址是非法的,那么3.6节的bufwrite程序应该如何写呢?
bufwrite程序实际上隐含了这样一个规定:
即使在缓冲区完全填满时,bufwrite程序也仍可以返回,并留待下一次bufwrite函数被调用时再刷新。如果指针变量bufptr不能指向缓冲区以外的位置,棘手。
那我们该如何指示缓冲区已满这种情形呢?
①避免在缓冲区已满时从bufwrite函数返回,需要把最后进入缓冲区的字符作为特例处理
②除非我们已经知道指针p指向的并不是某个数组的最后一个元素,否则,我们必须避免对 p 进行自增操作(地址不能自增的缘由之一),可通过在循环的每次迭代中增加一次额外的测试来做到这一点
void bufwrite(char *p,int n)
{
while(--n>0)
{
if(bufptr == &buffer[N-1])
{
*bufptr = *p;//防止生成非法地址 &buffer[N]
flushbuffer();
}else{
*bufptr++ = *p;
}
if(n>0)
{
p++;
}
}
}
void bufwrite(char *p,int n)
{
while(n>0)
{
int k,rem;
rem = N - (bufptr - buffer);
k = n > rem ? rem : n;
memcpy(bufptr,p,k);
//void *memcpy(void *dest,const void *src,size_t n);
if(k == rem)
{
flushbuffer();
}else
{
bufptr += k;
}
n -= k;
if(n) //判读本次迭代是否为循环的最后一次迭代,避免对p进行递增操作
{
p += k;
}
}
}
③重复整个循环
练习3-3:
参考链接:https://blog.csdn.net/weixin_40186813/article/details/126048321?spm=1001.2014.3001.5506
// 假定待搜索的元素为x,如果x存在于数组中的话,那么我们假定它在数组中的
//下标为k.(0<=k<n)
//假定low和high是不对称的边界的界点,即low <= k <= high,则mid为判断界点
int *bsearch(int *t,int n,int x)
{
int low = 0,high = n;
while(low < high)
{
int mid = (low+high)/2;
if(x < t[mid])
high = mid;
else if(x > t[mid])
low = mid + 1;
else
return t+mid;
}
return NULL;
}
//优化: int mid = (low+high)/2; ==》int mid = (low+high)>>1;
//这样做提高了程序运行效率,累加器不适合除法
//版本2:减少寻址运算,创建一个备份记录临时变量值,不要重新再计算
//可将t+mid的值存储在一个局部变量中,类似备忘录(自顶向下、动态规划)
int *bsearch(int *t,int n,int x)
{
int low = 0,high = n;
while(low < high)
{
int mid = (low+high)>>1;
int *p = t + mid;
if(x < *p)
high = mid;
else if(x > *p)
low = mid + 1;
else
return p;
}
return NULL;
}
//版本3:进一步减少寻址运算,原因是在很多机器上下标运算都要比指针运算慢
//可将程序中用到下标的地方更改为指针的形式
int *bsearch(int *t,int n,int x)
{
int *low = t,*high = t + n;
while(low < high)
{
int *mid = low + ((high-low)>>1);
if(x < *mid)
high = mid;
else if(x > *mid)
low = mid + 1;
else
return t+mid;
}
return NULL;
}
//采用对称边界来表达,整齐好看
int *bsearch(int *t,int n,int x)
{
int low = 0,high = n - 1;
while(low < high)
{
int mid = (low+high)>>1;
if(x < t[mid])
high = mid - 1;
else if (x > t[mid])
low = mid +1;
else
return t+mid;
}
return NULL;
}
//对称边界、指针形式
//需单独讨论 n = 0
int *bsearch(int *t,int n,int x)
{
int *low,*high;
if(n == 0)
return NULL;
else{
low = t;
high = t+n-1;
}
while(low < high)
{
int *mid = low + ((low+high)>>1);
if(x == *mid)
return mid;
else if (x > *mid)
low = mid + 1;
else
high = mid - 1;
}
return NULL;
}
四、连接
1)什么是连接器
连接器:能够理解机器语言和内存布局,可连接多个源文件,某些C语言实现提供了一个称为lint的程序,可捕获大量的C语言相关的错误,善用lint程序.
编译:把C源程序"翻译"成对连接器有意义的形式,这样连接器便可"读懂"C源程序了。
典型的连接器:(驱动:字符设备、块设备、网络设备)外部对象,static由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行.
工作图谱:
目标模块和库文件---》【连接器】---》载入模块
对每个目标模块中的每个外部对象,连接器都要检查载入模块,看是否已有同名的外部对象,如果没有,连接器就将该外部对象添加到载入模块中,如果有,连接器便开始处理命名冲突。
2)声明与定义
外部变量只能定义一次,extern为显示声明,不加static的,却被引用了的文件为隐式声明
3)命名冲突与static修饰符
对于命名冲突,需要善用static修饰符,无论是函数还是变量
4)形参、实参与返回值
考虑变量的声明周期与作用域,最好对其生命过程有个简单的了解
函数内部需要外部的数据时,一般要通过参数传递;
函数外部需要内部的数据时,一般要通过返回值传递。
函数分类:
计算类函数(参数和返回值)、功能型函数(参数)、混合类(遵循上述两条原则)
5)检查外部类型
方式一:
源程序①:包含外部变量n的声明:extern int n;
源程序②:包含外部变量n的定义:long n;
假定两个语句都不在任何一个函数体内,则n为外部变量
那么,这便是一个无效的C程序,因为同一个外部变量名在两个不同的文件中被声明为不同类型。
然后,大多数C语言实现却不能检测出这种错误
当这两个源程序运行时,会发生的情况如下:
1、C语言编译器足够“聪明”,能够检测到这一类型冲突。
编程者将会得到条诊断消息,报告变量n在两个不同的文件中被给定了不同的类型。
2、读者使用的C语言实现对int类型的数值与long类型的数值在内部表示上是一样的。
尤其是在32位计算机上,一般都是如此处理。在这种情况下,程序很可能正常工作,就好像 n 在两个文件中都被声明为long (或int)类型一样。本来错误的程序因为某种巧合却能够工作,这是一个很好的例子。
3.变量n的两个实例虽然要求的存储空间的大小不同,但是它们共享存储空间的方式却恰好能够满足这样的条件:赋给其中一个的值,对另一个也是有效的。这是有可能发生的。
例如:
如果连接器安排int类型的n与long类型的n的低端部分共享存储空间,这样给每个
long类型的n赋值,恰好相当于把其低端部分赋给了int类型的n。本来错误的程序因为某种巧合却能够工作,这是一个比第2种情况更能说明问题的例子。
4.变量n的两个实例共享存储空间的方式,使得对其中一个赋值时,其效果相当于同时给另一个赋了完全不同的值。在这种情况下,程序将不能正常工作.
备注:保证一个特定名称的所有外部定义在每个目标模块中都有相同的类型,这是程序员的责任。
特别的:
以下引用也不太合法,两者不能以以一种合乎情理的方式共存!!!
字符数组:char filename[] = “/etc/passwd”;
字符指针:extern char* filename;
方式二:
忽略声明函数的返回类型,或者声明了错误的返回类型
6)头文件
规则:每个外部对象只在头文件声明
练习4-1:
给long类型的foo赋值为37,short类型的foo也得到37:可能性1:long类型和short类型被实现为同一类型;
可能性2:两者共享相同的内存空间
推论:运行该程序的硬件是一个低位优先的机器
反之则是高位优先
五、库函数
建议:尽量使用系统头文件
1)返回整数的 getchar 函数
getchar 函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF.
EOF:一个在头文件stdio.h中被定义的值,不同于任何一个字符
2)更新顺序文件
不能同时操作读写指针
3)缓冲输出与内存分配
程序输出两种方式:①即时处理方式②暂存缓冲区,缓冲区满后处理
例如:setbuf(stdout,buf);
需要注意变量,程序的生命周期
4)使用errno检测错误
先检测作为错误指示的返回值,确定程序执行已经失败,在检查errno,来搞清楚出错原因。
5)库函数signal
头文件:#include <signal.h>
函数:signal(signal type,handler function);
未完待续,待到毕设结束再来…