欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《C陷阱与缺陷》学习笔记

程序员文章站 2024-01-29 17:56:22
...

第一章 词法陷阱

笔记本:《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]=0i 将被再次置为0,这就造成了我们看到的死循环…

《C陷阱与缺陷》学习笔记

1 词法分析中的“贪心法”

  • 相同的字符在不同的上下文中含义是不一样的,我们都学过编译原理,词法分析自然也不会陌生,术语 “符号” (token) 指的是程序的一个基本组成单元,其作用相当于一个单词,在C语言中,同一个单词 (token) 通常是无二义性的。
  • 每一个符号应该包含尽可能多的字符,也就是说,编译器将程序分解成符号的 方法是,从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符。需要注意的是,除了字符串与字符常量,符号的中间不能嵌有空白(空白符、制表符和换行符)。例如,==是单个符号,而= =则是两个符号(注意其中的空格符)。

      例如:
            《C陷阱与缺陷》学习笔记

      运行结果如下:
            《C陷阱与缺陷》学习笔记

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;
}

      程序的输出你想不到:

《C陷阱与缺陷》学习笔记

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;
}

      运行结果如下:

《C陷阱与缺陷》学习笔记

4 整形常量的进制

在C/C++中,表示8进制整数需要在最前面加0,如0122,在表示十进制的地方,一定不要用0进行文本格式的对齐

5 字符与字符串

《C陷阱与缺陷》学习笔记

      示例:

#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的别名,Sizeint具有完全相同的含义,Size可以用于类型声明,类型转换等,它和int完全相同

typedef int * Type;
Type A;

上面的例子则是声明了 Type(int *) 的别名,而A是一个指向 int 值的指针

使用 typedef 的目的 or 好处:

  1. 为了使表达式更简洁,例如最开始的例子:(*(void(*)())0)();假如程序里很多地方要用到类型: void(*)() ,我们可以为这种类型起一个别名:typedef void (*A)();,当我们再要定义返回值类型为void类型的函数指针时只用写:A a;,那么最开始的例子就可以写作:(*(A)0)();
  2. 为了隐藏特定类型的实现,强调类型的使用目的
  3. 允许一种类型用于多个目的,同时使得每次使用给类型的目的明确

如:

 typedef int (*Function)(const char *, const char *);

该定义表示 Function 是一种指向函数的指针的类型的别名,要使用这种指针类型时只需直接使用 Function即可,不必每次把整个声明都写出来

void (*Signal(int,void(*)(int)))(int);

typedef void (*HANDLER)(int);
HANDLER Signal(int,HANDLER);

上下两种声明意义是一致的


二、运算符的优先级

《C陷阱与缺陷》学习笔记

结合性和优先级

比如 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

作者:刘依阳


一、指针与数组

四句话:

  1. 数组就是由指针实现的,下标运算就是指针的加运算,二者完全等价使用
  2. 二维数组就是数组元素为数组 ( 指向数组的指针 ) 的数组
  3. 指针加一通常并不是指向下一个内存地址,而是指向原地址所存储数据元结束地址的下一个地址
  4. 一定要记得初始化指针,不要让你的指针成为野指针

二、非数组的指针

两个程序:

#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;
}

《C陷阱与缺陷》学习笔记

注意: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() 运算符下数组指针的特性

三、 《C陷阱与缺陷》学习笔记
《C陷阱与缺陷》学习笔记
不要写出这样的代码:

i=0;
while(i<n){
    x[i]=y[i++];
}

四、 防溢出

五、 main函数要有返回值


第四章 连接

笔记本:《C陷阱与缺陷》

创建时间:2018/4/29 20:47:14                                                                                                                                   更新时间:2018/4/29 20:47:06

作者:刘依阳


一、连接器

《C陷阱与缺陷》学习笔记

连接器的一个作用是处理命名冲突:这里先要明确一个概念—外部对象,外部对象就是指定义在所有函数体之外的对象 ( 和Java的类变量有点像 ) ,例如:
《C陷阱与缺陷》学习笔记

这里又会引出一个 extren 关键字,先来看一下它的用法:
《C陷阱与缺陷》学习笔记

假如一个程序中包含了语句: extern int a; 那么这个程序就必须在别的地方包括语句: int a; 这两个语句既可以在同一个源文件中,也可以位于不同的源文件中,当位于不同的源文件中时,容易发生同名现象,那么就难以处理了,我们应当避免外部变量同名重复定义

二、static修饰符

static 修饰符修饰的外部变量、函数,其作用域都仅限于当前源文件内部,如果一个变量或函数仅供同一个源文件中的其他函数调用,我们就应该将其声明为 static ,避免同名冲突

三、参数类型

《C陷阱与缺陷》学习笔记

四、头文件

《C陷阱与缺陷》学习笔记


第五章 库函数

笔记本:《C陷阱与缺陷》

创建时间:2018/4/29 22:07:27                                                                                                                                   更新时间:2018/4/29 22:07:37

作者:刘依阳


一、返回值类型为整型的 gerchar( )函数

《C陷阱与缺陷》学习笔记

《C陷阱与缺陷》学习笔记

二、读写操作

多数情况下,磁盘文件操作对流的读操作发生在流的开头,写操作发生在流的末尾,写后的流一般不可读 ( 末尾 ) ,而读后的流不适宜写 ( 会覆盖后面内容 ) ,所以读写交替时要用 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指针都是非法的

七、除法运算截断

《C陷阱与缺陷》学习笔记

八、取随机数

rand()%100;//取0到100之间的随机数,不同的版本也不一致

九、首先释放、然后重新分配

《C陷阱与缺陷》学习笔记