《C陷阱与缺陷》学习笔记
第一章 词法陷阱
笔记本:《C陷阱与缺陷》
创建时间:2018/4/23 22:06:21 更新时间:2018/4/24 08:36:21
作者:刘依阳
导读
有的人说,学习一门语言,就要知道他的底层原理,动手能力不着急,有的人说,学习一门语言最重要的是要会动手,要有项目实战经验,要知道一些API和框架,要我说,这二者缺一不可。
有的人大一C语言C++学得特别好,善于从上帝视角看待C++程序脚本,沉迷于命令行中字符串的输入输出,编码能力得到了很大的提升。但人的精力和耐力是有限的,没有需求支撑、没有项目支撑,迟早有一天我们会因为学习压力、生活压力、工作压力而*抛弃我们最爱的底层原理和OnlineJudge,在这之后,之前学得再扎实的语言语法也会渐渐被我们忘掉大半…
还有的人,沉迷于做项目,项目很大很复杂,能做出来确实很了不起,但其实这之中有大量的重复编码,我们自以为学了很多,其实也只是做了一个苦力活。如果我们只是会做项目,只是知道怎么写,而不知道为什么这么写,不知道项目里每一个类底层都干了些啥,编译器和运行环境为我们做了些啥,那我们写出来的只会是死代码,遇到一个新的问题,自己仍然不会用底层知识去理解去创造自己解决方案,这样的码农迟早要被飞速进步的互联网行业所淘汰,与培训机构出来的 “两年经验” 无异…
因为自己以前走过弯路,所以开始学习之前,还是要拿这段话来提醒一下自己…
进入正题:
有的程序虽然简单,但如果我们不注意,就很容易发生不可预料的错误,即使一些很有经验的程序员,也常常容易忽视这些问题。例如下面这段代码:
#include<iostream>
#define N 5
using namespace std;
main(){
int i;
int a[N];
for(int i=0; i<=N; i++)
cout<<(a[i]=0)<<endl;
}
乍一看只是FOR循环中的 “判定表达式” 多了一个等号,在Java中这将产生数组越界异常 “java.lang.ArrayIndexOutOfBoundsException” ,而在C语言中则没有这类运行时异常,程序照常执行,但这里发生了死循环。
其实这正是C语言指针的一个弊端,指针作为C语言中强大的工具,我们能通过它直接对某一内存地址中的值进行读写,在这里,变量 i 的内存地址恰好分配到了 a[9] 的下一个内存地址 a[10] ,每一次 i=10 时进入循环后,将执行 i=a[10]=0 ,i 将被再次置为0,这就造成了我们看到的死循环…
1 词法分析中的“贪心法”
- 相同的字符在不同的上下文中含义是不一样的,我们都学过编译原理,词法分析自然也不会陌生,术语 “符号” (token) 指的是程序的一个基本组成单元,其作用相当于一个单词,在C语言中,同一个单词 (token) 通常是无二义性的。
- 每一个符号应该包含尽可能多的字符,也就是说,编译器将程序分解成符号的 方法是,从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符。需要注意的是,除了字符串与字符常量,符号的中间不能嵌有空白(空白符、制表符和换行符)。例如,==是单个符号,而= =则是两个符号(注意其中的空格符)。
例如:
运行结果如下:
2 =不同于==
- C语言中 “=” 是赋值运算符 “==” 是关系运算符,用于两个数进行比较…,例:
#include<iostream>
using namespace std;
int main(){
int x,y=5;
cout<<(x=y)<<(x==y)<<x<<y<<endl;
//尽量多用括号、空格、转义符等明确语义
cout<<(x==y)<<endl;
return 0;
}
程序的输出你想不到:
3 &和|不同于&&和||
&:按位与,优先级高于&&,也可作为逻辑与,作为逻辑与时没有短路机制...
|:按位或,优先级高于||,也可作为逻辑或,作为逻辑或时没有短路机制...
&&:逻辑与,短路机制...,返回布尔值(0或1)
||:逻辑或,短路机制...,返回布尔值(0或1)
示例:
#include<iostream>
using namespace std;
int main(){
int x,y=5;
//&:按位与,优先级高于&&
//|:按位或,优先级高于||
cout<<(x&y)<<" "<<(x|y)<<" "<<(!x)<<" "<<(x^y)<<" "<<(x>y)<<" "<<(x>>y)<<endl;//C语言没有>>>
//&&:逻辑与
//||:逻辑或
x=y=5;
cout<<(x==5&&y!=5)<<(x==5||y!=5)<<endl;
//&:也可作为逻辑与,作为逻辑与时没有短路机制...
//&&:逻辑与,短路机制...,返回布尔值(0或1)
//注意以下两种的区别
x=y=5;
cout<<(x!=5&x++==5)<<x;
cout<<(x!=6&&x++==6)<<x<<endl;
x=y=5;
cout<<(x!=5&x++==5)<<x<<(x!=6&&x++==6)<<x<<endl;
//|:也可作为逻辑或,作为逻辑或时没有短路机制...
//||:逻辑或,短路机制...,返回布尔值(0或1)
//注意以下两种的区别
x=y=5;
cout<<(x==5|x++==5)<<x;
cout<<(x==6||x++==6)<<x<<endl;
x=y=5;
cout<<x<<y<<endl;
cout<<(x==5|x++==5)<<x<<(x==6||x++==6)<<x<<endl;
return 0;
}
运行结果如下:
4 整形常量的进制
在C/C++中,表示8进制整数需要在最前面加0,如0122,在表示十进制的地方,一定不要用0进行文本格式的对齐
5 字符与字符串
示例:
#include<iostream>
using namespace std;
int main(){
unsigned int value1 = 'tag1';
unsigned int value2 = 'cd';
char value3 = 'abcd';
cout<<value1<<" "<<value2<<" "<<value3<<endl;
printf("\n\n"+'\n');//0
printf("\n\n\n"+'\n');//1
printf("\n\n\n\n"+'\n');//2
//printf('\n');这一句编译报错
return 0;
}
输出:
1952540465 25444 d
`@Mingw runtime failure:
Process returned 0 (0x0) execution time : 0.307 s
Press any key to continue.
注释掉//2处输出:
1952540465 25444 d
@Mingw runtime failure:
Process returned 0 (0x0) execution time : 0.254 s
Press any key to continue.
注释掉//2和//1处输出:
1952540465 25444 d
ingw runtime failure:
Process returned 0 (0x0) execution time : 0.321 s
Press any key to continue.
可以看见每一种输出都不一样...,很诡异吧?
6 小结
括号和空格以及转义符很重要,我们平时应多用,不要在cout<<输出语句中作表达式运算,容易出错,main函数记得要写返回值,牢记词法分析的贪心法…
———
第二章 语法陷阱
笔记本:《C陷阱与缺陷》
创建时间:2018/4/29 12:31:46 更新时间:2018/4/29 12:31:58
作者:刘依阳
想要知道语法陷阱,必须要先知道语法的规则
一、第一个例子:
(*(void(*)())0)();
乍一看可能有点难于理解,但只要我们仔细去分析,表达式结构也并不复杂
根据括号必须成对,以及贪心法的匹配规则可以得到:
( * ( void ( * ) ( ) ) 0 ) ( );
( * ( void ( * ) ( ) ) 0 ) ( );
( * ( void ( * ) ( ) ) 0 ) ( );
相信童鞋们很快理解了,其中:
void ( * ) ( )
指的是函数指针的类型声明,这里声明了一个指向返回值类型为void的函数的函数指针,函数指针指向的函数的地址为0地址,最后:
( * 0 ) ( ) ;
调用了函数指针所指向的那个函数,也就是内存中首地址为0处所存储的那个函数体
这里要说一下函数指针和指针类型返回值,借用谭文波同学的例子:
float *g(),(*h)();
因为 ( ) 结合的优先级高于 * ,第一个声明float *g()
也就是一个返回值类型为指向float值的指针的函数 ,而第二个则是声明一个函数指针h,这个函数指针h所指向的函数的返回值类型为float
还有指针数组和指向数组的指针
指针数组:
int * A[3];
—— 声明了一个大小为3的数组A,数组中的元素都是 int 类型的指针指向数组的指针:
int (*A)[3];
—— A是一个指针,指向一个长度为3、元素类型为 int 的指针
取值:* 和取址:&
PS:关于起别名typedef
:
typedef int Size;
将Size
定义为int
的别名,Size
和int
具有完全相同的含义,Size
可以用于类型声明,类型转换等,它和int
完全相同
typedef int * Type;
Type A;
上面的例子则是声明了 Type
是 (int *)
的别名,而A是一个指向 int
值的指针
使用 typedef
的目的 or
好处:
- 为了使表达式更简洁,例如最开始的例子:
(*(void(*)())0)();
假如程序里很多地方要用到类型:void(*)()
,我们可以为这种类型起一个别名:typedef void (*A)();
,当我们再要定义返回值类型为void
类型的函数指针时只用写:A a;
,那么最开始的例子就可以写作:(*(A)0)();
- 为了隐藏特定类型的实现,强调类型的使用目的
- 允许一种类型用于多个目的,同时使得每次使用给类型的目的明确
如:
typedef int (*Function)(const char *, const char *);
该定义表示 Function
是一种指向函数的指针的类型的别名,要使用这种指针类型时只需直接使用 Function
即可,不必每次把整个声明都写出来
void (*Signal(int,void(*)(int)))(int);
typedef void (*HANDLER)(int);
HANDLER Signal(int,HANDLER);
上下两种声明意义是一致的
二、运算符的优先级
结合性和优先级
比如 a = b = 3;
这个表达式,就应该是 a = ( b = 3 );
而不是 ( a = b ) = 3;
,优先级就更好理解了
结合性的记忆方法:
1、单目运算符中除了自加和自减这两种依赖于顺序的运算符外,其他的全部都是自右向左结合的
2、双目运算符都是自左向右结合的
3、三目运算符是自右向左结合的
4、赋值运算符是自右向左结合的
三、要注意的的小坑
典型错误:
if(a[i] > max);
max = x[i];
典型错误:
if(x[0] < 0)
return
logrec.date=x[0];
典型错误:
switch(Expression){
case value1:process1;
case value2:process2;
case value3:process3;
}
典型错误:
struct logrec{
int date;
int time;
int code;
)
main(){
//...do something
}
典型错误:
main(){
getch;
}
练习 2 - 1
C语言允许初始化列表中出现多余的逗号,例如:
int days[] = { 31, 28 ,31 , 31,};
为什么这种特性是有用的?
答:保证每一行、每一个数组元素在语法上的相似性,自动化的程序设计工具才能够更方便地处理很大的初始化列表
第三章 语义陷阱
笔记本:《C陷阱与缺陷》
创建时间:2018/4/29 15:28:52 更新时间:2018/4/29 15:28:57
作者:刘依阳
一、指针与数组
四句话:
- 数组就是由指针实现的,下标运算就是指针的加运算,二者完全等价使用
- 二维数组就是数组元素为数组 ( 指向数组的指针 ) 的数组
- 指针加一通常并不是指向下一个内存地址,而是指向原地址所存储数据元结束地址的下一个地址
- 一定要记得初始化指针,不要让你的指针成为野指针
二、非数组的指针
两个程序:
#include<stdio.h>
#include<string.h>
#include <cstdlib>
int main(){
char *s="abcd",*t="efgh",*r;
r= (char *)malloc((strlen(s)+strlen(t)+1));
if(!r){
printf("%s\n","Fuck ! ,How did you go wrong ?");
exit(1);
}
strcpy(r,s);
strcat(r,t);
printf("%s\n",r);
/* 一段时间之后记得释放内存 */
free(r);
return 0;
}
#include<stdio.h>
#include<string.h>
#include<iostream>
using namespace std;
int main(){
char *p="KING-ERIC",*q;
char a[]="KING-ERIC",*b,c[10];
string x="KING-ERIC",y;
q=p;
b=a;
//c=a;数组不可直接赋值给数组
y=x;
//q[4]=' ';//存放在常量区,不可修改
b[4]=' ';
y[4]=' ';
printf("%c\t%s\t%d\t%d\t%d\t%d\n",p[4],q,sizeof(p),sizeof(q),strlen(p),strlen(q));
printf("%c\t%s\t%d\t%d\t%d\t%d\n",*(a+4),b,sizeof(a),sizeof(b),strlen(a),strlen(b));
printf("%c\t%c\t\t%d\t%d\t%d\t%d\n",x[4],y[4],sizeof(x),sizeof(y),x.size(),y.size());
//string不是C语言内置数据,不能用printf输出
printf("%c\t%s\t\t%d\t%d\t%d\t%d\n",x[4],x,sizeof(x),sizeof(y),x.length(),y.length());
cout<<x<<endl<<y<<endl;
return 0;
}
注意:char *a="KING-ERIC" 与 char a[]="KING-ERIC"
的区别:
1、字符串存放的内存区域不同:前者存放在常量区,不可修改,后则存放在栈中,可以修改
2、变量 a
存放的内容不同:前者存放的是一个地址,而后者存放的是字符串 "abcdef"
,因此使用 sizeof
它们的结果是不同的,分别是 4 和 10
3、此外,关于new分配的对象数组的情形:因为是内存区中的修改,所以也是可以实现修改字符串的
三、作为参数的数组声明
将数组作为参数传到函数里,C语言会自动的将作为参数的数组声明转换成相应的指针声明,所以在传数组的时候只需要写数组名,不需要写大小
int strlen(char s[]) 等同于 int strlen(char *s) //都是将首地址传了进去
要注意的地方:
一、 空指针并非空字符串:NULL( 同nullptr )是个好东西,给一出生的指针一个安分的家~~~,它仅仅代表空值,也就是指向一个不被使用的地址(访问时输出 (null) 或没有输出)
在C语言中,NULL和0是完全相同的,但是为了明确语义,NULL用于指针和对象,0用于表示数值,在不同的语言中,NULL并非总是和0等同
二、 sizeof()
运算符下数组指针的特性
三、
不要写出这样的代码:
i=0;
while(i<n){
x[i]=y[i++];
}
四、 防溢出
五、 main函数要有返回值
第四章 连接
笔记本:《C陷阱与缺陷》
创建时间:2018/4/29 20:47:14 更新时间:2018/4/29 20:47:06
作者:刘依阳
一、连接器
连接器的一个作用是处理命名冲突:这里先要明确一个概念—外部对象,外部对象就是指定义在所有函数体之外的对象 ( 和Java的类变量有点像 ) ,例如:
这里又会引出一个 extren
关键字,先来看一下它的用法:
假如一个程序中包含了语句: extern int a;
那么这个程序就必须在别的地方包括语句: int a;
这两个语句既可以在同一个源文件中,也可以位于不同的源文件中,当位于不同的源文件中时,容易发生同名现象,那么就难以处理了,我们应当避免外部变量同名重复定义
二、static修饰符
被 static
修饰符修饰的外部变量、函数,其作用域都仅限于当前源文件内部,如果一个变量或函数仅供同一个源文件中的其他函数调用,我们就应该将其声明为 static
,避免同名冲突
三、参数类型
四、头文件
第五章 库函数
笔记本:《C陷阱与缺陷》
创建时间:2018/4/29 22:07:27 更新时间:2018/4/29 22:07:37
作者:刘依阳
一、返回值类型为整型的 gerchar( )函数
二、读写操作
多数情况下,磁盘文件操作对流的读操作发生在流的开头,写操作发生在流的末尾,写后的流一般不可读 ( 末尾 ) ,而读后的流不适宜写 ( 会覆盖后面内容 ) ,所以读写交替时要用 fseek()
等重新定位到一个可读或可写的位置,或调用 fflush()
,这是指导性原则,本质上只能算是半双工,最好的办法就是简单化:读写打开不同的文件句柄,相关函数都会加锁,不要同时读写
FILE * fp;
struct record rec;
//... ...
//从fp读结构体rec,每次读一个
while (fread((char *)&rec, sizeof(rec), 1, fp) == 1)
{
/* 对rec执行某些操作 */
if (/* rec 必须被重新写入 */)
{
fseek(fp, -(long)sizeof(rec), 1);
/*因为要重新将rec写入到fp,要对文件指针向前回溯,所以是fseek的第二项是负数,后面的1是文件指针当前位置,表明回溯起点是文件中rec的尾部*/
fwrite((char *)&rec, sizeof(rec), 1, fp);
/*rec写入缓冲区,等待写入。*/
fseek(fp,0L,1);
/*之所以要调用fseek,因为fwrite的数据只是写入到了缓冲区,而fseek函数中调用了fflush(因版本而异),这样才将缓冲区的内容输入写进fp。*/
/*其实这个指令看似什么也做,但是其使得磁盘文件中的数据改变了,并且使文件可以正常读取了*/
}
}
三、缓冲区与内存分配
有一种技术叫做Copy On Write
:
Copy on write (COW) is an optimization strategy that avoids copying large sized objects.
In a lot of real world programs, a value is copied to another variable and often is never written to. In most languages other than C++, all large sized objects are actually references. When you copy an object, all you copy is a pointer (shallow copy semantics). In such languages, COW is implemented at the language/runtime level, and not in the standard library.
In C++, copies are deep copies by default (value semantics), thus assigning large structures and strings are expensive, because the entire data is duplicated.
To avoid this, one can make a system where a copy is always shallow, but when you modify a copied object, the underlying object is duplicated, and then the changes are applied to the new copy.
因此 C 语言实现通常都允许程序员进行实际的写操作之前缓存输出数据,这种控制能力一般是通过库函数 setbuf
实现的,如果 buf
是一个大小适当的字符数组,那么 setbuf (stdout, buf) ;
语句将通知输入/输出库,所有写入到 stdout
的输出都应该使用 buf
作为输出缓冲区,直到 buf
缓冲区被填满或者程序员主动调用 fflush
( 对于由写操作打开的文件,调用 flush
将导致输出缓冲区的内容被实际地写入该文件 ) 时 buf
缓冲区中的内容才实际写入到 stdout
中。缓冲区的大小由系统头文件 <stdio.h>
中的 BUFSIZ
定义
四、使用errno检测错误
很多库函数,特别是那些与操作系统有关的,当执行失败时会通过一个名称为
errno
的外部变量,通知程序该函数调用失败,errno
是记录系统的最后一次错误代码,它是一个定义在errno.h
中的int
值查看错误代码
errno
是调试程序的一个重要方法,不同的值表示不同的含义,可以通过查看该值推测出错的原因,就像Windows
里的错误代码一样在调用库函数时,我们应该首先检测作为错误提示的返回值,确定程序执行已经失败,然后再检查
errno
,来搞清楚出错原因:
//调用库函数
if(返回的错误值)
检查errno
五、库函数signal
所有 C 语言实现中都包括有 signal
库函数,作为捕获异常事件的一种方式:
#include<signal.h>
#include<stdio.h>
void sighandler(int);
int main()
{
signal(SIGINT, sighandler);
while(1)
{
printf("开始休眠一秒钟...\n");
sleep(1);
}
return(0);
}
void sighandler(int signum)
{
printf("捕获信号 %d,跳出...n", signum);
exit(1);
}
按 CTRL + C 键跳出程序可以看到捕获的信号
第六章 预处理器
笔记本:《C陷阱与缺陷》
创建时间:2018/4/29 23:45:31 更新时间:2018/4/29 23:45:41
作者:刘依阳
一、宏定义中的空格
#include<stdio.h>
//#define f (x) ((x)-1)不调用的时候没事,调用的话编译不通过
#define f(x) ((x)-1)
int main(){
printf("%d\t%d\n",f(5),f (5));//输出4 4
return 0;
}
可以看到,程序中宏调用中的空格无所谓,但是放到宏定义中就有问题了
二、宏不是函数
#include<stdio.h>
#define abs(x) x>=0?x:-x
//#define abs(x) (((x)>=0)>(x):-(x))//宏定义应当严格书写括号
int main(){
int a=5,b=10;
printf("%d\t%d\t%d\n",abs(5),abs(5)+2,abs(a-b));//输出5 5 -15
return 0;
}
三、宏不是语句
以断言 ( 调试程序时常用 ) 为例:
#define assert(e) if(!e) assert_error(_FILE,_LINE_)
if(x > 0 && y > 0)
assert(x > y);
else
assert(y > x);
展开后变为:
if( x > 0 && y > 0)
if(!(x > y))
assert_error("foo.c",37);
else
if(!(y > x))
assert_error("foo.c",39);
断言assert宏正确的写法是:
#define assert(e) ((void)((e))||_assert_error(__FILE__,__LINE__)))
y=distance(p,q);
assert(y>0)
x=sqrt(y);
四、宏不是类型定义
#define T1 struct foo *
typedef struct foo *T2;
T1 a, b;//声明1
T2 a, b;//声明2
分析可得到:
声明1等价于:struct foo *a, b; 一个是指向结构体foo类型的指针变量a,一个是结构体foo类型的变量b,显然与我们的预期不符
声明2等价于:struct foo *a; 和 struct foo *b;
五、宏的原理就是直接替换
在编译阶段之前,程序中的宏调用便会全部替换为宏定义
第七章 可移植性缺陷
笔记本:《C陷阱与缺陷》
创建时间:2018/4/30 0:30:56 更新时间:2018/4/30 0:31:01
作者:刘依阳
一、应对C语言标准变更
C语言在众多的硬件以及系统平台上都有相应的实现,这使得C程序可以方便的在不同的平台之间移植,但C语言的每个版本之间还是存在一些细微差别,为了解决这一问题,ANSI 国际标准化组织制订了 ANSI C 标准
二、标识符名称的限制
目前的C语言的标识符是对大小写敏感的,但是在以前的版本中ANSI C标准是保证必须能够通过前6个字符区分不同的外部名称,而且无视大小写,所以编译器禁止使用与库函数同名的标志,即使大小写不同也不行!
三、整数的大小
//字符长度受硬件特性影响
short
int
long int//历史遗留问题
long
四、有符号整数和无符号整数
整数的有无符号对程序的运行至关重要,它决定着一个八位字符的取值范围是从-128到127还是从0到255,而这一点又反过来影响到程序员对哈希表或转换表等的设计方式
五、移位运算符
两个问题:
在向右移位时,空出的位是由0填充,还是由符号位的副本填充? 答:符号位
移位操作的位数的取值范围是什么? 答:大于或等于0,小于被移位对象的位数
六、内存位置零
NULL指针并不指向任何对象,因此,除非是用于赋值或比较运算,出于其他任何目的使用NULL指针都是非法的
七、除法运算截断
八、取随机数
rand()%100;//取0到100之间的随机数,不同的版本也不一致
九、首先释放、然后重新分配
上一篇: 一个注解引发的血案
下一篇: 一个空格引发的“*“