分享

C语言知识深度汇总(本文仅谈语言,且不适合初学者阅读)

 黄南山 2018-02-26

修正的部分内容的索引放在这里进行说明:
第一次修正:关于自定义类型那里进行了部分内容的修正
第二次修正:
1.对语句部分进行了大程度的修正
2.对数组部分进行了大程度的修正
3.补上了位段的一系列操作
4.对文章中的一些地方进行了小幅度修改(使之更严谨,更容易阅读理解)
第三次修正:
1.增加了文件操作部分的内容
2.对文章中的一些地方进行了小幅度修改。


一、数据类型

我在开头这里提一下c语言标识符和关键字

c语言的标识符不能和标准库中的关键字重名,除此外,它由字母、数字、以及下划线组成,只能以字母或者下划线开头。这里两种命名方法,各举一个例子(这两种没有优劣之别,使用完全看个人习惯):

  • 驼峰命名法:StringLength

  • 下划线命名法: string_length

c99规定c语言的关键字一共有32个,我在这里讲一下typedef和volatile
  • typedef
    这个关键字常用于给类型重命名,eg:typedef char* p_char
    这就代表以后可以用p_char 来代表指向字符的指针类型了。
    它的好处有这两点:
    1、使复杂的声明变得简单,避免出错和提高代码的可阅读性
    2、可以隐藏指针,数组语法
    这里把typedef和#define做一个区别对比:
    他们两个都可以给类型名更改一个名字,但是不同于typedef的是,#define只是简单的符号替换,并没有使一个标识符根本上成为一种已知的类型名( 我这么说你可能听不明白,没关系,我写一段代码,结合我的文字你绝对就理解了 )
    1、

        define p_char char *
        p_char a,b;
    
    • 1
    • 2
    • 3

2、

        typedef p_char char *;
        p_char a,b;
  • 1
  • 2
  • 3

第一段代码中的a是一个指向字符的指针,而b只是一个字符变量
第二段代码中的a、b都是指向字符的指针

还有,#define重命名的类型支持扩展,而typedef重命名的类型不支持扩展,看以下代码:

#define INT int
unsigned INT a,b;//a、b均为无符号整形变量

typedef INT int
unsigned INT a,b;//错误,这就相当于是两个关键字套在一起:char int a,b
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • volatile【定义】:被这个关键字修饰的变量,当编译程序需要获取或者存储这个变量的值时,都从它的地址中来获取,而不要在某些情况下为了优化速度,从临时寄存器中获取(因为若是此时刚好别的程序通过地址更新了这个变量的值,寄存器中的那个值就会过时,从而出现错误)
    我在这里举一个例子:
#include<stdio.h>
int main()
{
    const int num=10;
    int *p=(int *)&num;
    printf("%d ",num);
    *p=20;
    printf("%d\n",num);
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

在c++的编译器上这个输出结果是:

10 10

但是如果把定义num的那行代码改成这种

volatile const int num=10;

那么输出结果就会是我们想要的

10 20

一、基本数据类型(以下描述均在32位cpu环境下)

下面的书写的格式为: 所占位数->所表示范围

  • <字符型>(char):1->(-2^7 ~ 2^7-1)

  • <整形>
    短整形(short)
    signed short: 2->(-2^15 ~2^15-1)
    unsigned short: 2->(0 ~2^16-1 )
    整形(int)
    signed int: 4->(-2^31~2^31-1)//少的那一位是符号位
    unsigned int:4-(0~2^32-1 )
    长整型(long)
    signed long: 4->(-2^31~2^31-1)
    unsigned long: 4->(0~2^32-1 )
    long long(c99新加的类型)
    signed long long: 8->( -2^63 ~ 2^63-1)
    unsigned long long: 8->(0~2^64-1)

  • <浮点型>
    单精度浮点型(float):4-> +/-3.40282e+038 (7~8位有效数字)
    双精度浮点型(double):8-> +/-1.79769e+308 (15~16位有效数字)

    下面我特地讲一下,浮点型和整形在内存中的存储方式

1>整形

这里我们要知道什么是原码、反码和补码:
1)原码:将一个整数转换成二进制形式就是它的原码(即正数和负数的原码相同)
2)反码:负整数的反码就是它的原码除首位的符号位外其他位按位取反,正数的反码就是它的原码
3)补码:负整数的补码就是它的反码加一,正整数的补码就是它的原码

一切整数在内存中都是以它的补码的形式存储的,而读取的时候则转换回原码。这样的存储方式有两个好处
- 简化了硬件电路
- 把加法和减法合并为一种运算

2>浮点型数据在内存中的存储方式

对于32位的浮点数来说(float):最高的一个bit位(示意图如下)是符号位,用1来表示负数,用0来表示正数。接下来的8个bit位用来表示指数,剩下来的23位均用来表示数值。

这里写图片描述

对于64位的浮点数来说(double),大体和32位相同

这里写图片描述

指数位的存储规则

比方说数字12.125,它首先会转换成二进制的数字:1100.001。用指数表示法就是1.100001 x 2^3,又因为所有的数字转换成二进制指数表示法小数点前的数都是1,所以这个数字和小数点就都可以省略了(若是0.1xx怎么办?当然是转换成1.xxx *2^-xx的形式了。。。),然后将截取后的尾数放到尾数位,不满的则补0。指数位置的存储方式是取能表示的数的范围中的中间值(8位范围为0~255,所以中间值就是127),然后给2的指数减去这个数字(读取的时候再加上),然后转换成二进制的形式,然后放到指数的位置。

二、自定义数据类型

struct定义的结构体类型、union定义的联合体(共用体)类型、enum定义的枚举类型

这里提一下关键字typedef,typedef的用法就是给原有的数据类型起一个名字,方便以后的使用。例如:
typedef struct Node
{
int _data;
struct SList *_next;
}Node;
以后再用到上面封装的那个结构体类型时,就不用使用struct Node来定义了,直接用Node来定义可以了,这样一方面时为了减少因为手误而出现的错误,一方面也可以使代码变得简洁。

二、基本语句

1、判断语句

  • if……else
    这里有一个易错的问题:”悬空else”,下面举一个例子说明:下面这段代码给我们由于缩进的原因给我们的直观感觉就是第一个if和下面的else对应为一对,但是实际上是第二个if和else相对应,因为else总是和上面紧接着的if相对应,但是这里的错误往往不易察觉!最有效的避免这种错误的一个方法就是给每一个if和else后面都加上花括号,将要要执行的代码括起来,这样即使出现缩进错误,也不会再出现上面的问题。
if(judge)
    if(judge)
      code block1;
else
    code block2;
/*下面是修改后的代码,当然也不推荐这样子写,缩进最好还是要按照一定的
  原则对齐,下面只是起到一个对照的作用*/
if(judge){
    if(judge){
        code block1;
    }
else{
    code block2;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • switch……case
    这里我强调一下,最好不要用default来表示最后一种情况,否则你既丧失了case语句的自说明功能,又失去了default语句处理意外事件的功能。
    switch(表达式/变量)
    {
        case 常量:代码块或者语句;break;
        ……
        default:代码块或语句;break;                                   
    }   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
switch case语句除了上述用法外,还有一个fall though的用法,下面说明这种用法

可以用好几个情况对应一种事件的发生,例如1~5都代表工作日,而如下代码就表示了这种用法的使用方式。事实证明,fall though在某些特定情况下还是非常有用的。。。

switch(day)
{
case '1':
case '2':
case '3':
case '4':
case '5':printf("今天是工作日!\n");break;
case '6':
case '7':printf("今天是休息日!\n");break;
default:
    break;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

循环语句

  • while(条件表达式)……
  • do……while(条件表达式)
  • for(零条或多条语句;判断语句;零条或多条语句)

for语句相对于while语句的好处在于,它的使用可以使代码变得紧凑,且部分变量的增减位于头部,一目了然,不易出错。
do_while语句的作用和while语句类似,不同之处在于它至少要执行一次花括号内的代码块。

三、操作符

对于操作符来说,主要就三点内容:优先级(网上有详细的15级)、结合性、是否控制求值顺序(&&,||……)

这里提一下左值和右值

左值:标识了一个可以存储结果值的地点。
右值:即一个指定的值
在使用右值的地方都可以使用左值,但是相反则不一定

复杂表达式的求值是由三个因素决定的:操作符的优先级操作符的结合性,以及操作符是否控制求值顺序,相邻两个操作符究竟哪个先执行取决于他们的优先级,若是优先级相同,则再看结合性。这里我强调一下,优先级的那个表最好还是背过,因为虽然自己可以在所有要执行的运算两边加上括号,以避免优先级的问题,但是不代表别人也会那样,而背过那个表,就会避免很多不必要的麻烦。

PS:笔者是见得多了,用得多了就记住了,这个记忆方法网上还是有很多的,觉得吃力的读者可以去学习学习

四、数组

1.概念:一组类型相同的数据的集合,在内存地址上从低到高依次排列(也就是说,定义数组时在内存上的栈空间申请一段连续的空间)

2.分类:一维数组和多维数组

(注意:这个维数可不是空间上的维数,所有类型的数组在内存上都是呈线性排列的,读者千万别被和谭书上类似的说法给误导了)

3.初始化

  • 一维数组的初始化
    1.在定义数组时对数组元素赋以初值;
    2.可以只给一部分元素赋值
    3.想使一个数组中全部元素值为0,可以写成:a[10]={0};
    需要注意 int a[10] = {1}; 并不能把数组初始化为全1,只是将第一位初始化为1,后面全部都是0滴.
4.在对全部数组元素赋初值时,可以不指定数组长度。
  • 多维数组的初始化(以二维数组示例)

1.分行给数组元素赋值:int a[3][4]={{1,2,3,4},{5,6,7,8},{,9,10,11,12}};
2.将所有元素按照顺序写在一个花括号内:int a[3][4]={1,2,3,4,5,6,7,8,9,10,11,12};
3.可以给部分元素赋值(c11规定)
4.如果给全部元素赋值,第一个方括号内可以省略数值,但是第二个必须写上数值:int a[][4]={{1,2,3,4},{5,6,7,8},{,9,10,11,12}};

  • 字符数组的初始化(举栗子)
    第一种:char arr[常量可写可不写,看需求]=”I am a bug”//g后面会自动加上\0
    第二种:char arr[同上]={‘a’,’b’}
    ( 若是中括号内的数值等于(或者没有数值)后 面赋值的元素个数,则b后面不会自动加上\0,若是大于赋值的元素个数,则其他的元素缺省为\0 )
    注意:数组只能在定义的时候一次给多个元素赋值,以后一次只能给一个元素赋值

4.定义:类型+标识符+[常量]/[(常量)][常量]……

5.使用

这里需要注意的地方有以下几点: 1)数组名只有在:sizeof()里面,还有取地址的时候代表整个数组的长度,其他时候都代表数组首元素的首地址 2)数组在使用的时候可以采用下标的方式(eg:arr[3]),也可以采用解引用的方式(eg:*(arr+3))

6.(和指针的区别)

指针和数组是什么关系呢?答案是:一点关系都没有
这里主要论述一下二者的不同

1>在多文件编程中的声明上来说,在A文件中定义为数组,在B文件中声明为指针是会出错的,定义为指针,声明……也一样会出错。 2>从举藕法来说,两个指针指向同一个字符串,更改一个指针的内容是会改变另一个指针指向的内容,但是两个数组若是都存放同一个字符串,更改一个数组的内容对另一个数组的内容并没有影响。 这是因为两个指针指向的字符串内容存储在字符常量区,而数组中存放的内容则存储在内存上的栈区。 3>看代码
char arr[10];
char *arr_n=NULL;
arr_n=arr;
printf("%d %d",sizeof(arr),sizeof(arr_n));
  • 1
  • 2
  • 3
  • 4
  • 5

输出结果为:10,4

因为数组名放在sizeof里面代表整个数组,而指针的名字永远代表一块地址,而地址在内存中永远占4个字节(windows32位平台下)

7.(柔性数组(和结构体))

柔性数组是什么呢?它定义在结构体的最后一个成员,但是它的前面至少要有一个成员。使用方法如下:
#define NUM 10
int main()
{
struct text{
    int arr;
    int sum[];
}ce;

struct text *p = (struct text *)malloc((sizeof(ce)+10 * sizeof(int)));
for(int i = 0; i < NUM; i++)
{
    p->sum[i] = i;
}
for (int i = 0; i < NUM; i++)
{
    printf("%d\t", p->sum[i]);
}

free(p);    
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
柔性数组的优点主要有这两点:
  • 1、方便内存释放,避免因为用户不知情的情况而导致内存泄露
  • 2、减少内存碎片,提高访问速度

八.锯齿形数组

在实际操作中,我们常常需要将一些长度不等的字符串保存在一个二维数组中,你大概会这么做:

char dst[3][10]={"I","love","knowledge"};
  • 1

这样做确实达成了我们的要求,但是这样操作明显有两个缺点:
1.你为了能够存放下所有的字符串,定义的二维数组第二维大于最大的那个字符串长度,但是对于相对很短的字符串的存储来说就浪费了大量的空间,比方上例中的存储 I 的那块空间
2.我们在实际操作中有时候并不知道需要多大的空间来存储要存储的数据,这样你这只能尽可能的分配更大的空间来达成我们的需求,这样以来,既有极大的不安全性,又有可能浪费大量的空间
为了避免这种问题,我们可以这样做>

char *dst[3]={"I","love","knowledge"};
  • 1

这种操作就被称为“锯齿形数组”,由于 [] 的优先级高于 *,所以dst先和 [] 结合形成一个数组,然后数组里面存放的元素就是指针,指向要存储的字符串。

五、函数(function)

1.概念:实现某一个功能的一段代码块

2.原型(我把一些包括返回值类的注意事项都写在这个条目下)

作用:给编译器声明函数的信息,使函数不用重复定义就可以直接使用
模板: 返回值类型 函数名{参数列表}
注意事项:
1、返回值类型若是没有则写void,否则会缺省为整形类型。这里若是遗忘显式注明返回值类型,而恰好函数返回值不是整形(eg:float)则会出现问题
2、函数名的命名规则见开始的标识符命名规则
3、参数列表的格式:类型名称+变量名称(可写可不写,但是建议写上,因为这可以起到帮助程序员理解函数的作用!),……
若是没有参数传递,则注明void

3.函数调用原理(引出栈帧)

由于在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机提供了一种被称为栈(在调用的时候形成栈帧)的数据结构来支持参数传递。
栈是一种先进后出的数据结构,它由栈顶、栈底、以及一个指针构成。栈底通常不变,变的都是栈顶的位置。函数调用在传入参数的过程中,会按照一定的顺序将参数压入栈中(push),栈顶指针随之移动,指向最新的栈顶,当调用结束时,又将参数从栈顶弹出(pop)。
函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在堆栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改堆栈,使堆栈恢复原装。

栈溢出的原理:函数在调用的时候就会给定一块栈帧空间,而如果操作使得数组的大小超出了给定的空间大小,从而覆盖掉函数的返回值地址,函数返回错误就会出现段错误。eg:你定义了一个具有10个元素的数组,但是在赋值的时候却超过了10个元素,这时候,多余的数据就会覆盖函数的返回值地址,这就是所谓的栈溢出。

4.调用约定

常见的调用约定有:stdcall(pascal调用约定)cdecl(c调用约定)

在参数传递中,有两个很重要的问题必须得到明确说明:

  • 当参数个数多于一个时,按照什么顺序把参数压入堆栈
  • 函数调用后,由谁来把堆栈恢复原装

而调用约定就是用来解释说明这两个问题的。

5.库函数相关(常见的函数)

这里我简单的说明一下这些函数的作用,具体的实现网上都有,需要的朋友可以去查

1)str家族

  • 函数原型:int strcmp(char *string1,char *string2);
    函数说明:这个函数用于比较两个字符串的大小,若string1>string2,函数返回一个大于零的数;若string1=string2,函数返回零,若string1 < string2,函数返回一个小于零的数。 ( 若第一个字符串的长度小于第二个字符串的长度,也认为第一个string1 < string2)

    这里要注意两个字符串相等的时候

  • 函数原型:char *strcat(char *target,char *source)
    函数说明:这个函数用于将字符串source连接在字符串target的后面,

  • 函数原型:char *strcpy(char *target,char *source)
    函数说明: 这个函数用于将字符串source复制在字符串target的后面,若是字符串target的长度比source的长度长,则在复制过去的source后面自动加上\0,即覆盖以前的字符串,但是相对应的,要是……比……短,则不会在后面加上\0,这样用%s输出的时候就会出错!!!

  • 函数原型:size_t strlen(char *arr)
    函数说明:这个函数用于求字符串的长度,需要注意的是函数的返回值是一个无符号类型的整数,也就是说,需要注意向下面这种情况:

    if (strlen(dst) - strlen(src) < 0)
    {
        printf("OK!\n");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    恐怕这句“OK!”哪种情况都是无法输出的,因为strlen()的返回值是一个无符号类型整数,而两个无符号类型整数运算的结果也是一个正整数,所以……所以如果有需要,则要将strlen的返回值强制类型转换成int型

  • 函数原型:char *strchr(const char *dst,int cc)
    函数说明:从字符串dst中查找字符cc(注意这里是整形,用的时候要用单引号括起来),函数返回第一次找到cc的位置,若未找到,则返回NULL,类似的还有strrchr(这个函数是从后向前找)

  • 函数原型:char *strstr(const char *dst,const char *src)
    函数说明:从字符串dst中查找字符串src若找到,则返回第一次找到src的起始位置

  • 函数原型:char *strpbrk(const char *dst,const char *src)
    函数说明:从字符串dst中查找字符串src中任意一个字符,函数返回第一次找到的任意一个字符在dst中的位置

  • 函数原型:char *strncpy(char *dst,const char *src,size_t num)
    函数说明:将字符串src中的num个字符拷贝到dst中,覆盖其原来的字符串,若是num>sizeof(src),则其他位补\0,若是小于,则将num位拷贝过去(注意,没有在结尾加上\0哦)

  • 函数原型:char *strncat(char *dst,const char *src,size_t num)
    函数说明:将字符串src的前num个字符添加到dst字符串的末尾,并且自动在结尾加上一个\0,(注意:它不管有没有超出dst的范围,这点不同于strncpy)

  • 函数原型:int strncmp(const char * str1,const char *str2,size_t num)
    函数说明:比较两个字符串前num个字符的大小

  • 函数原型: size_t strspn(const char *src,const char *dst)
    函数说明:函数返回字符串src前面和字符串dst中相同字符的个数,与此类似,还有一个函数strcspn(返回不相同的个数)

  • *函数原型:char *strtok(char *str1,const char *str2)
    函数说明:它从字符串str1中隔离各个单独的称为标记的部分,并且丢弃分隔符。
    这个函数还是相当重要的,我在这里写一段代码来说明一下它的使用方法:

    char target[] = "I Lo\nve you Ch\nina for\fever!";
    char *token;
    for (token=strtok(target, arr);\ 
    token != NULL;token = strtok(NULL, arr))
    {
        printf("%s", token);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

结果:I Love you China forever!

这个函数若用于将一串字符中想要去掉的一些字符拿走,将这些字符前后的字符连起来,则可以封装成一个函数,需要用的时候直接将字符串作为参数传进去还是很方便的!

2)mem家族

  • 函数原型:void *memcpy(void *dst,void const *src,size_t length)
    函数说明:大体和strncpy作用相同,不同的是这个函数会连\0一起拷贝过去,而不会终止于\0,(需要注意的是函数未定义当dst和src在内存中重叠的情况)

  • 函数原型:void *memmove(void *dst,void const *src,size_t length)
    函数说明:将src中的内容移动到 dst中,此函数可以处理当dst和src在内存中重叠的情况

  • 函数原型:void *memcat(void *dst,void const *src,size_t length)

  • 函数原型:void *memcmp(void *str1,void *str2,size_t length)

  • 函数原型:extern void *memset(void *buffer, int c, int count)
    函数说明:这个函数用于将某块内存全部置c,一般常用于清空数组。buffer多为指针或者数组,c为要设置的数,count为所置内存的字节数。
    例如:char buffer[100];
    memset(buffer,0.sizeof(buffer);

3) 字符分类函数和字符转换函数

这里我列举几个常用的函数

  • isdigit:判断是否为数字(十进制),是则返回真
  • isspace:判断是否为空白字符,同上
  • isalpha:判断是否为字母“a~z””A~Z”,同上
  • isupper:判断是否为大写字母,同上
  • islower:判断是否为小写字母,同上
    • isalnum:判断是否为数字或字母,同上
    • tolower:把大写字母转换为小写字母
    • toupper:与上相反

4)I/O家族(具体放到文件那里讲)

printf,scanf……

7.不定参数(它其实是由宏实现的,但是它的用法又体现在函数这里)

详情参见我的另一篇博客:函数可变参数

六、预处理

1.预处理过程

  1. 预编译阶段(^.i):这个阶段主要是处理预处理指令,包括#difine定义的宏常量等
  2. 编译阶段(^.s):这个阶段就是对代码进行词法分析,语法分析,语义分析,符号汇总
  3. 汇编阶段(^.o):形成符号表,将c代码转变成汇编代码,再转换成二进制机器码,从而形成可执行程序。
  4. 链接阶段:合并段表,符号表的合并以及符号表的重定位

2.宏

这里易出现的问题有:带歧义的宏、宏的副作用(i++和i+1)

按照命名约定,定义宏名时通常为大写字母。

下面做一下函数和宏的优缺对比

1、从代码量上来说,函数是将一段代码块封装成一条代码(即函数调用),所以它的使用可以减少一个项目中的代码量。但是宏替换每一次都会将一段代码插入到项目代码中,从而增加项目代码量。所以除非宏非常短,否则就使用函数来代替宏的功能
2、从执行速度上来说,由于函数的使用存在额外的返回/调用开销,所以在这点上,宏普遍比函数快
3、从参数类型上来说,函数的参数必须声明为已知类型,也就是说,它的使用一定程度上受到了参数类型的限制。还有函数的参数不可以传递类型。而宏是与类型无关的,而这个特性既是优点,也是缺点(不严谨)。
4、从操作符优先级上来说,由于函数只在函数调用时求值一次,所以它不会存在由于邻近操作符优先级的问题而产生不可预料的结果。
5、函数没有宏的歧义性
6、宏无法调试,因为它在预编译阶段就已经完成了操作

这里说明一下#和##的作用

“#”:把一宏参数变成对应的字符串
    #define PRINT(FORMAT,NUMBER)    printf(" the value of "  #NUMBER  "is"  FORMAT "\n",NUMBER);
    //这里利用了相邻的字符串自动连接在一起的特性(c99)    
    int i=0;
    PRINT("%d",i+3);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

result:the value of i+3 is 3

“##”:将两个位于它两边的标识符连接在一起形成一个新的符号(当然,连接后的新标识符必须合法),它还允许宏定义从分离的文本片段创建标识符(示例如下)
define  ADD_NUM(NUM,TAG)sum##TAG+=NUM
ADD_NUM(10,1);
  • 1
  • 2
  • 3
  • 4

上面这段代码的结果是给sum1加上10

3.条件编译

条件编译的作用

  • 原来除了注释掉代码外,还可以这样!!!(调试性编程)
    eg:有些调试性的语句只要进行选择性的编译便能够实现它的功能而且不用删除这些语句

这是一种做法:

    #include<stdio.h>
    #define DEBUG 
    main()
    {
        int arr[10];
        for(int i = 0;i < 10;i++)
        {
            arr[i]=i;
            #ifdef DEBUG
            printf("arr[%d]=%d\n",i,arr[i]);
            #endif
        }
        return 0;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

这是另一种做法:

    #include<stdio.h>
    #define DEBUG 1   //不需要调试的时候就把DEBUG设置为0
    main(){
        int arr[10];
        for(int i=0;i <10;i++){
            arr[i]=i;
            if(DEBUG){
            printf("arr[%d]=%d",i,arr[i]);
            }
    }
        return 0;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 防止头文件重复引入

在文件的开头加条件编译指令:

#ifndef FUNCTION
#define FUNCTION

代码块(函数声明,定义的宏,定义的类型……)

#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

七、指针

1.概念:内存中字节的编号即为指针

2.分类:

  • 指向基本类型的指针(eg:int*, float *……)

  • 指向自定义类型的指针(eg:结构体指针……)

  • 指向函数的指针

  • 指向指针的指针

3.运算

两个指向相同数组的指针相减的值是他们相差的元素个数,指针不可以相加。
指针变量名+n代表指针指向向前跳n个指向元素类型的偏移量

4.深度剖析

我在这里举几个复杂声明的例子来说明问题(附解析)

<1>

void (* signal ( int ,void( *) (int) ) )( int );
这是一个什么东西!?别晕,听我讲。
看到这种复杂的声明,我们首先从标识符入手,它的标识符是signal,左边是星号,右边是括号,但是由于括号的优先级高于星号的优先级,所以signal首先被解释为一个函数,既然是函数,那么signal后面紧接着的那个括号内肯定就是参数了,第二个参数是一个指针,指向一个返回值为空,具有一个整形参数的函数,然后再来看signal的返回值。首先我们可以看到它的返回值是一个指针,指向什么呢?这时你可以把signal左边的括号去掉,就剩下了 void 空缺(int),这时就一目了然了,指针的指向就是一个返回值为空,有一个整形参数的函数。
这时我们总结一下,signal就是一个函数,这个函数有两个参数,一个整形,一个指向(返回值为空,具有一个整形参数的函数)的函数指针,返回值为一个(返回值为空,具有一个整形参数的函数)!
一般碰到这种情况我们可以这样来简化声明:将重复出现的类型用typedef关键字进行重新命名,然后再声明就会变得简单多了,比方说上面这个:
typedef void(*p_fun)(int);//更改名称
p_fun signal(int p_fun)l;//重新声明

<2>

int main()
{
    int arr[2][5]={1,2,3,4,5,6,7,8,9,10};
    int *ptr1=(int *)(&arr+1);
    int *ptr2=(int *)( *(arr+1));
    printf("%d    %d\n",*(ptr-1), *(ptr2-1));
    return 0;
}
//输出什么?
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

我们来看,取arr的地址加1代表将数组的地址整个向后偏移一个数组长度,然后再强转成int * ,此时的地址类型由数组变成了整形,再赋值给ptr1。ptr-1,根据指针运算,减一就代表减一个类型的步长,它的类型是整形,也就是此时ptr1由指向arr数组后面那数组大小的地址的开始向前偏移一个整形大小,那么它此时就指向arr数组的末尾元素,解引用的结果就是10。第二个,(arr+1),从数组那里我们可以知道,数组名大多数情况下都代表数组首元素的地址,这里数组arr的首元素是一个一维数组,给它加一,就相当于偏移了一个一维数组的长度,此时它的地址就是数组arr第二个元素,对它解引用,翻译成另一种你容易理解的形式就是arr[1],而这代表第二个元素的地址,再将其转换成int *,输出的时候减一就相当于向前偏移一个整形的长度,所以第二个输出结果为5

3>

//这个例子貌似在网上广为流传,我在这里做一个解析
int main()
{
char *c[]={"ENTER","NEW","POINT","FIRST"};
char **cp[]={c+3,c+2,c+1,c};
char ***cpp=cp;
printf("%s\n",**++cpp);
printf("%s\n",* -- *++cpp+3);
printf("%s\n",*cpp[-2]+3);
printf("%s\n",cpp[-1][-1]+1);
return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

c是一个指针数组,cp是一个二级指针数组,cpp是一个三级指针。第一个输出:先对cpp进行自加一,此时它指向cp[1],而cp[1]又指向c+2,所以对其两次解引用的结果是:POINT。第二个输出:cpp前面的操作符的优先级都高于加法操作符的优先级,所以我们先看前面,先对cpp自加1,此时它指向cp[2],对cpp一次解引用得到c+1,然后再自减1得到的是c,对其解引用再加3,它的输出是:ER。第三个输出:cpp[-2]存放的是c+3的地址,对其解引用得到的结果加三也就是:ST。第四个输出:cpp[-1][-1]的意思翻译成另一种形式就是 * ( *(cpp-1)-1 ),也就是c+1,然后对解引用结果再加1,所以输出结果就是:EW。

5.高级应用:

转换表
回调函数(参见qsort()函数的使用)

八、结构体

1.概念:将一堆类型相同的数据或者类型不相同的数据放在一(注意不能放函数,这里不同于c++)

2.结构体大小(内存对齐->原因?方法?)

说到结构体的大小,就不得不提一下内存对齐

内存对齐(性能原因):数据结构中内存需要对齐到自然边界上,原因在于由于CPU访问未对齐的内存需要两次访问,而访问对齐的内存只需要一次,提高了效率
内存对齐的规则
1。结构体中的第一个成员实际操作(后面有解释为什么是实际操作)上不需要内存对齐,也就是说它在与结构体偏移量为0的地址处。

2。其他成员需要对齐到对齐数的整数倍的地址处。(对其数:编译器默认的一个对齐数字和该成员本身大小的较小值< Linux下:4 Windows下:8>)

3。结构体的总大小等于成员最大对齐数的整数倍(包括第一个成员,这就是前面为什么是实际操作上)

4。如果是嵌套结构体的结构体求大小,内部结构体对齐到它成员的最大对齐数的整数倍处,而外部结构体的大小则是所有成员的最大对其数的整数倍(包括内部的结构体的最大对其数)。

3.联合体和位段

  • 1>原理及注意:联合体和结构体的定义很相似,不同之处在于联合体内部的成员是共享一段内存的,而结构体是分别享有一段内存,( 这里联合的大小至少要大于最大的一个成员的字节数)这里需要注意:如果联合内部的成员字节数相差太大的话,就会造成一定程度上的内存浪费,所以我在这里建议将联合体内部的成员声明为指针,这样就能最大程度上的节省内存。(如果你的程序里面有1万个联合体……)
    2>用法:如果你需要使指定内存区域在不同时刻具有不同类型的值,则使用联合会是一个好的方法
    3>联合还可以用于判断大小端

当数值的低位段存储在内存的低地址处,这种计算机模式被称为小端模式,反之则被称为大端模式。(这里列举一种用联合体判断大小端的方法)

int main()
{
    union{ int i=1; char a; }text;
    text.i=1;
    if(text.a==1){
    printf("小端模式\n");
    }else{
    printf("大段模式\n");
    }
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 位段

位段的概念:
位段的声明和普通的结构类型相同,但是它的成员是一个或者多个bit位的字段。这些不同长度的字段实际上存储在一个或者多个整形变量中。(概念看不懂不要紧,往下看,最后学会位段的使用就行)

位段的声明:
从它的概念中我们就可以看出,位段必须声明为int,signed int或unsigned int类型,其次,在成员的后面加上冒号,冒号后面为位段所占的位数。(这里我强调一下,最好将位段显式声明为signed /unsigned ,不要为了省事而只写一个int,因为只声明为一个int,它被解释为unsigned 还是signed事要根据编译器决定的!)
下面我举一个栗子:

struct Text{
    unsigned data1: 6;
    unsigned data2: 7;
    unsigned data3: 19;
}
  • 1
  • 2
  • 3
  • 4
  • 5

使用位段的理由:
1.最大的好处在于,它可以节省存储空间,尤其是需要成千上万个这种结构的时候!
2.位段可以很方便的操作一个整数的部分内容。(常见于操作系统的设计代码)

4.枚举

定义示例1:enum num{a,b,c};
a=0,b=1,c=2 //这里默认第一个成员的值为 常量0,后面依次递增
定义示例2:emnm num{a,b=12,c}
a=0,c=13 //相信不说你也应该看明白了吧

5.注意事项

  • 向函数传递结构参数的效率非常低下,通常可以采用传递指向结构的指针的方法

  • 位段在本质上是不可移植的

  • 联合在实现变体记录(内存中的某个特定区域在不同时刻具有不同类型的值)的时候非常有用

  • 联合变量初始化的时候必须与第一个变量的类型相匹配

九、内存管理(分配的内存都在堆区)

1.malloc

函数原型:void *malloc(size_t size);
常见使用示例:
//给p分配了一段10个字节大小的空间
int *p=NULL;
p=(int *)malloc(10 *sizeof(int));

malloc的实现原理涉及到内存池的概念

2.calloc

函数原型:void *calloc(size_t num_elements,size_t element_size)
区别与malloc的是,它将分配的字节中的内容全部初始化为0;

3.realloc

函数原型:void *realloc(void *pointer,size_t new_size);
作用:更改已经分配的内存空间大小

4.free

函数原型:void free(void *pointer)
作用:释放已经分配的空间

5.野指针

所谓野指针即没有确定指向的指针,这时候若是对其解引用操作就会出现操作异常失败。野指针通常出现在使用free释放一块内存后,忘记将指针指向NULL,而后面却对其解引用操作。

5.注意事项

  • 注意每一段申请的内存在使用完毕后都要使用free来释放掉,否则,就会导致内存泄露的问题

  • 进行内存分配的时候要对函数的返回值进行检查,eg:

    int *p=NULL;
    if(p=(int *)malloc(10 *sizeof(int))==NULL)
    {
        perror("error");
        exit(1);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这里要注意realloc的返回值有三种可能:其一为原内存后面空闲内存的大小足够新分配的内存,所以直接返回原内存的首地址;其二为原内存后的闲置内存不够分配,所以重新找了一块空间分配,而原来的内存则丢失掉;其三为新找了一块内存还放不下,所以返回NULL,而原来的内存而也会丢失掉,所以在使用realloc的时候,事先准备一个指针来接受新分配的内存,若是分配成功,则赋值给原来的指针,否则就报错,这样原来分配的内存就不至于丢失。

十、文件操作

1.打开文件和关闭文件
“打开”函数原型:FILE *fopen(char *filename, char *mode);
“打开”函数说明:第一个参数filename是要打开的文件路径(包含文件名称),第二个参数mode是指对文件操作的权限,一般常用的权限有:

r: 以只读的形式打开文件,文件必须存在
w: 以只写的形式打开文件,若是文件已经包含内容,则覆盖其内容,若是文件不存在,则在该路径创建目标文件并写入内容。
a: 以追加的形式打开文件,若是文件已经包含内容,则在其后面继续添加。若是文件不存在,则在该路径创建目标文件并写入内容。
b: 二进制文件,和上述三个操作联合使用,eg:rb,wb。
t: 文本文件,可省略不写。

“关闭”函数原型:int fclose(FILE *fp);
“关闭”函数说明:文件正常关闭时,fclose() 的返回值为0,如果返回非零值则表示有错误发生。
注意:基于防御性编程的原则,通常要对打开文件文件操作进行检验,一般的格式可以为下(打开当前目录下的文件filename):

FILE *fp;
if(fp=fopen("filename","rb") == NULL)
{
    printf("Error on open D:\\demo.txt file!");
    getch();
    exit(1);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

最后一定要记得对打开的文件使用fclose()进行关闭。

2.以字符形式进行文件操作
涉及的两个函数的原型
读:int fgetc(FILE *fp)
写:int fputc(int c,FILE *fp)
说明:两个函数若是读取/写入成功,则返回读取的字符数目。若是失败,返回EOF(具体是哪个数值要看编译器是怎么规定的,一般为-1)
在文件内部有一个位置指针,用来指向当前读写到的位置,也就是读写到第几个字节。在文件打开时,该指针总是指向文件的第一个字节。使用fgetc(fuptc) 函数后,该指针会向后移动一个字节,所以可以连续多次使用fgetc(fputc)读取(输出)多个字符。
注意:还有两个常见的文件操作函数:feof(FILE *fp)ferror(FILE *fp)
feof函数用来判断文件操作指针是否到达文件的末尾,若是,则返回非零值,否则返回零。ferror函数用来判断文件操作是否出错,是则返回非零值,否则返回零值。
3.以字符串的形式文件操作
读取函数原型:char *fgets(char *dststr,int n,FILE *fp)
函数说明: dststr 为字符数组,n 为要读取的字符数目,fp 为文件指针。
函数返回值,读取成功时返回字符数组首地址,也即 str;读取失败时返回 NULL;如果开始读取时文件内部指针已经指向了文件末尾,那么将读取不到任何字符,也返回 NULL。
注意:
1.fgets() 遇到换行时,会将换行符一并读取到当前字符串,然后结束本次读取操作。这点和gets()不同,gets()会自动忽略换行符。还有,在设置fgets的接收参数时,要将其空间设置为需要接收的文件字节数加一,这是因为fgets每次读取的字符个数为n-1,最后一个字符默认置为NULL。假如目标文件中目标行的字符数目大于n-1,则fgets函数读取n-1个字符后结束本次操作,下次读取继续从上次未读完的行读取字符。

2.fgets函数不能用于读取二进制文件的内容!

输出函数原型:int fputs(char *str,FILE *fp)
str 为要写入的字符串,fp 为文件指针。写入成功返回非负数,失败返回EOF。

4.格式化读写文件操作
读取函数:int fscanf(FILE *fp,char *format,…)
写入函数:int printf(FILE *fp,char *format,…)

函数说明:这两个函数和scanf和print唯一的区别就是多了一个文件指针,可以对文件进行操作,值得一提的是,如果如下这样修改fscnaf和fprintf的参数,则这两个函数将变为scanf和printf。
fscanf(stdin,char *format,…) | fprintf(stout,char *format,…)

5.随机读写文件操作
什么是随机读写文件呢?顾名思义,就是可以在文件的任意位置进行文件读写。要实现这个操作的关键就在于怎样按照想法移动文件内部的位置指针,称为文件的定位
实现文件定位函数有:rewind()和fseek()
void rewind(FILE * fp);
说明: 将文件内部位置指针放到文件的开始位置
void fseek(FILE *fp,long offset,int orign);
说明: offset为移动的距离
orign为开始移动的位置,规定有三种:
SEEK_SET (0): 文件开始的位置
SEEK_CUR(1): 当前位置
SEEK_END(2): 文件结尾的位置
**注意:**fseek函数通常用于二进制文件的操作


PS:这是作者的脑力劳动成果,希望广大网友转载可以注明出处

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多