美文网首页c++
C语言快速上手(二)

C语言快速上手(二)

作者: leifuuu | 来源:发表于2019-08-07 00:27 被阅读0次

    本文内容来自C语言快速上手系列,该篇内容仅作为笔记使用

    作者:[血色v残阳]

    作者微信公众号: 编程之路从0到1

    image

    预处理

    所谓预处理,就是在办正事之前做一点准备工作。预处理指令都是以#号开头的,这一点很好辨认。

    在之前,我们已经了解过了#include#define这两个指令,实际上预处理指令并不是C语言词法的一部分,它仅仅是写给编译器看的,让编译器在正式编译之前,先帮我们做点小事情。

    声明展开

    头文件中的声明最终都要被拷贝到源文件中,这件事被称为声明展开

    预处理指令

    文件包含

    使用#include指令包含一个指定文件

    宏定义

    使用#define指令定义一个宏
    使用#undef指令删除一个宏

    之前说用#define来定义常量,实际上就是利用宏的预处理,进行字符串替换而已。现在我们就使用gcc命令要验证

    // main.c
    #define PI 3.14
    
    int main(){
        int r = PI *10 + PI*PI;
        return 0;
    }
    
    // gcc -E main.c // 直接在命令行打印预处理结果
    
    输出:
    
    # 1 "main.c"
    # 1 "<built-in>"
    # 1 "<command-line>"
    # 1 "main.c"
    
    
    int main(){
        int r = 3.14 *10 + 3.14*3.14; // 可以很清楚的看到,预处理之后,将所有的PI进行了文本替换。
    }
    

    条件编译

    包含#if#ifdefifndef等,使预处理器可以根据条件确定是否将一段文本包含

    // main.c
    #define PI 3.14
    
    int main(){
        int a = 0;
    #if 0
        int r = PI *10 + PI*PI;
    #endif
        return 0;
    }
    
    // 预编译输出
    
    # 1 "main.c"
    # 1 "<built-in>"
    # 1 "<command-line>"
    # 1 "main.c"
    
    
    int main(){
        int a = 0;
    }
    

    可以看到,当使用条件预处理指令#if时,判断的条件为0,直接就将包裹的代码删除了,实际上在真正的编译之后的程序中 ,根本就不存在这些内容,等同你从来没写过。

    关于预编译指令,需要记住几点

    1. #开头的预处理指令必须顶格写,前面不要有空格
    2. 记住三大类预处理指令的特点,#include指令是声明展开,宏定义是文本替换,条件编译是直接删除代码

    预处理的高级使用

    普通宏

    #define 标识符 替换列表
    #define PI 3.1514
    

    带参数的宏(函数式宏、宏函数)

    #define 标识符(a,b,c,...,d) 替换列表
    #define MAX(x,y) ((x)>(y)?(x):(y))
    

    宏函数注意事项

    max = MAX(i++,j);
    

    如上例,错误的使用宏函数,可能得到预期之外的结果,上例在预处理之后,被替换为如下代码,i会被加两次:

    max = ((i++) > (j) ? (i++) : (j));
    

    关于小括号的注意事项

    1、如果宏替换列表中有运算符号,那么必须将整个替换列表放入小括号中
    #define TOW_PI (2*3.14)

    2、如果宏有参数,那么每个参数在替换列表中出现时,都要放在小括号中
    #define MAX(x,y) ((x)>(y)?(x):(y))

    • 运算符

      宏定义包含两个专用运算符###

      • # 运算符可以用来字符串化宏函数里的参数,它出现在带参数宏的替换列表中。
      #define PRINT_INT(n) printf(#n "=%d\n",n)
      
      PRINT_INT(i/j);
      //宏展开为
      printf("i/j""=%d\n",i/j)
          
      //等价于(C语言相邻字符串字面量会被合并)
      printf("i/j=%d\n",i/j)
      
    • ## 运算符可以将两个记号(如标识符)粘合在一起。
    #define MK_ID(n) i##n
    
    int MK_ID(1),MK_ID(2);
    //宏展开后
    int i1,i2;
    

    实际代码示例:

    #define GENERIC_MAX(type)    \
        type type##_max(type x,type y){ \
            return x > y ? x : y;
        }
        
    //需要float类型的求最大值函数,则可以如下定义
    GENERIC_MAX(float)
    //再定义一个int类型的最大值函数,我们可以量产函数了
    GENERIC_MAX(int)
    
    //float的宏函数展开为
    float float_max(float x,float y){
        return x > y ? x : y;
    }
    

    这样,就可以使用一个宏函数,生成对各种基本类型数据求最大值的max函数了。

    创建包含多条语句的宏

    使用do-while编写多条语句宏是一种C语言的技巧。

    #define ECHO(s)  \
        do{          \
            gets(s); \
            puts(s); \
        }while(0)  
        
    ECHO(str);
    //宏展开后
    do{gets(str);puts(str);}while(0);
    

    预定义宏

    这是编译器预先给我们定义好了的宏,可以直接在代码中使用,例如打印日志时,就可以使用_LINE_宏,将当前代码的行号也输出

    简述
    _LINE_ 当前程序行的行号(十进制整型常量)
    _FILE_ 当前源文件名(字符串型常量 )
    _DATE_ 编译的日期(表示为Mmm dd yyyy 形式的字符串常量)
    _TIME_ 编译的时间(hh:mm :ss形式的字符串型常量)
    _STDC_ 编译器符合C标准,值为1

    关于宏的一些总结

    • 使用宏函数,可以减少函数栈的调用,稍微提升一点性能,相当于C++中的内联的概念,在C99中也实现了内联函数的新特性。缺点是宏展开后,增加了编译后的体积大小。
    • 宏参数没有类型检查,缺少安全机制。
    • 宏的替换列表可以包含对其他宏的调用
    • 宏定义的作用范围,直到出现这个宏的文件末尾
    • 宏不能被定义两次,除非新定义与旧定义完全一样
    • 可以使用#undef 标识符取消宏定义,若宏不存在,则该指令没有作用

    条件编译

    #if#endif

    #define DEBUG 1
    
    
    /* 
    #if和#endif成对出现,
    #if后面跟常量表达式,0为false,反之true。
    当为0时,它们之间的代码在预处理时会被删去 */
    
    #if DEBUG
    printf("this is debug!\n");
    #endif
    

    ​ 需要注意,#if后面的标识符如未被定义过时,则当作值为0处理,因此默认为0时,可以不用定义该宏。

    defined运算符

    #define DEBUG
    
    #if defined DEBUG
    ...
    #endif
    

    ​ 检测其后的标识符是否有定义过,若定义过则返回1,否则返回0

    #ifdef#ifndef

    #ifdef指令用于检测一个标识符是否已经被定义为宏,

    #ifndef则相反,检测一个标识符是否未被定义为宏

    #ifdef 标识符
    
    /* 它等价于以下指令 */
    #if defined 标识符
    

    #elif#else

    这两个指令结合#if使用,相当于C语言中的if…else if…else的用法。这两个指令还可以与#ifdef#ifndef结合使用

    #if 表达式1
    ...
    #elif 表达式2
    ...
    #else
    ...
    #endif
    

    条件编译主要可以用于
    1、需要测试调试代码时,打印更多信息,正式发布时则去除这些代码
    2、跨平台,跨编译器。对于不同平台,可以包含不同的代码,使用不同的编译器特性
    3、屏蔽代码。使用注释符号注释代码时,有一个缺点,注释无法嵌套,即不能注释中间包含注释的代码,使用条件编译则很方便

    • 其他预处理指令

      • #error 指令
        可以用于检查某些编译器属性,当不符合时,提示错误,并终止编译。
      #error 消息
      

    程序结构与作用域

    局部变量

    函数内部声明的变量,只在声明它的函数内有效

    每次调用函数时,生成的局部变量的储存空间可能都是不同的,意即局部变量在函数调用结束后,就会释放,下次调用函数,生成的局部变量又是一个新的

    在函数的形式参数中声明的变量,也都是局部变量

    全局变量

    在所有的函数体之外声明的变量

    全局变量在文件作用域内可见

    编译器会自动将全局变量进行零值初始化。因此在使用时,只需要声明即可。

    如果需要手动指定其值进行初始化,则它只能被常量表达式初始化,使用其他的变量表达式初始化是不合法的。

    static关键字

    除了局部变量和全局变量,C语言中还有静态局部变量和静态全局变量,声明时使用static关键字修饰即代表静态的意思。

    • 修饰全局变量

      1. 静态全局变量,只在声明它的那个源文件中可以访问

      2. 静态全局变量虽然也是在整个程序的生命期中都有效,但它在其他文件中不可见,无法被访问

    • 修饰局部变量

      1. 存储位置不同
      2. 自动初始化为零值
      3. 只能被声明它的函数访问
    • 修饰函数

    static 修饰的函数只能在当前源文件中使用,在其他源文件中无法被访问, 类似private权限修饰符

    C语言函数不能同名, static修饰符可以在一定程度上避免命名冲突

    extern关键字

    • 修饰变量

    全局变量的作用域仅在文件作用域内

    extern int s_global;并不是重新声明变量的意思,它表示的是引用全局变量s_global

    #include <stdio.h>
    // 写在函数外部,表示在当前文件中的任意地方都可以使用s_global
    // extern int s_global;
    
    int main(){
        // 写在函数内部,仅在函数中使用
        extern int s_global;
        printf("s_global = %d\n", s_global);
        return 0;
    }
    
    • 修饰函数

    通常C语言中的函数都使用头文件包含的方式引入声明,但我们也可以使用extern修饰。

    实际上C语言中的函数声明默认都是包含extern的,无需手动指定。

    //以下两种是等价的,无需手动指定extern关键字
    int get_count();
    extern int get_count();
    
    • 小拓展

    有时候我们可能会看到extern “C”这样的声明,请注意,这不是C语言的语法,也不属于C语言。有些C++程序员,经常把C语言和C++语言搞混,实际上这是两种不同的语言,C++也并不是很多人说的那样,完全是C语言的超集,更准确的说法应该是,C++是一种独立的语言,它兼容C语言的绝大多数语法,但并不是百分百完全兼容。C++除了兼容的C语言的语法,另一部分就是它独立的内容。如果不能完全清楚这两种语言的边界,就会发生语法弄混的情况。

    在C++中,当需要调用纯C语言编写的函数时,通常会使用extern “C”声明,表明这是纯C语言的内容。

    头文件的嵌套包含

    所谓嵌套包含,就是指在一个头文件中,还可以使用#include预编译指令,包含其他的头文件。

    // bool.h
    #define Bool int
    #define False 0
    #define True 1
    

    在以上头文件中,我们使用宏定义了新类型Bool,接着编写func.h头文件

    #include "bool.h"
    
    // 声明一个函数,返回值为Bool类型,值可以是False 或者True 
    Bool check();
    

    头文件的保护

    如果一个源文件将同一个头文件包含两次,那么就可能会产生编译错误。因此,在C语言的模块化开发中,一定要避免将同一个头文件包含两次。但是,有时候这种包含不是明显的,而是一种隐式的包含,不易察觉,不知不觉就犯下了错误。

    // h1.h
    #include "h3.h"
    ……
    
    // h2.h
    #include "h3.h"
    ……
    
    // main.c
    #include "h1.h"
    #include "h2.h"
    ……
    

    这样一来,实际上就等同于在main.c中将h3.h头文件include了两次,显然违背了我们上面说的,不能在一个源文件中将同一个头文件包含两次的原则。因为所谓头文件包含,实际上就是将头文件中的声明复制到当前源文件中,那么上例中h3.h一定会被复制两次。

    问题出来了,该如何解决呢?在复杂的大型工程中,头文件被重复包含的问题一定是避免不了的,这个时候就需要我们上一章讲的条件编译知识出来救场了。

    修改h3.h文件, 内容如下:

    // 如果没有定义过_H_H3_ 宏,则定义一个_H_H3_ 宏
    #ifndef _H_H3_
    #define _H_H3_
    
    // 声明的内容 ……
    
    #endif
    

    注意,这里使用#ifndef和#endif将整个头文件中的全部内容包裹起来,然后在#ifndef之后通过#define定义一个宏,这样一来,#ifndef和#endif之间的内容就只会被预编译一次,而不会重复包含。这种机制,被戏称为头文件卫士,或者称为头文件保护。如果对于这种写法不太理解,可以使用上一章介绍的gcc -E命令,生成预编译代码查看,即可明了。

    最后,需特别注意的地方是宏的名字,这里是H_H3,使用头文件包含这种机制时,宏定义的名字一定要独特,避免重复,以免导致各种不可预知的问题。

    通常宏的名字要全部大写,并用下划线来分隔单词或缩写,在这个宏的名称中,最好包含当前头文件的文件名,例如H3。

    结构体

    结构体是一种聚合数据类型,C语言的数组也是一种聚合数据类型,它们显著的区别是,数组是相同数据类型的集合,而结构体可以是不同数据类型的集合。

    结构体的声明与使用

    // 声明一个结构体
    struct student {
        char *name;
        int age;
        char *number;
        char *grade;
    };
    

    结构体声明的一般格式

    struct 标签名 {
        成员变量1
        成员变量2
        ……
    };
    

    结构体被声明之后,就相当于产生了一个新的数据类型,我们可以如下使用:

    #include <stdio.h>
    // 声明一个结构体
    struct student
    {
        char *name;
        int age;
        char *number;
        char *grade;
    };
    
    int main(){
        // 声明结构体变量:stu
        struct student stu;
        // 为结构体中的成员赋值
        stu.name = "zhangsan";
        stu.age = 19;
        stu.number = "A010";
        stu.grade = "18级"; 
        // 访问结构体中各个成员变量的内容
        printf("学生信息:%s,%d,%s,%s\n",stu.name,stu.age,stu.number,stu.grade);
        return 0;
    }
    

    有几个点需要注意:

    1. 使用关键字struct + 标签名 + 一对花括号 + 分号 来声明结构体。在花括号中,声明结构体需要包含的变量,这些变量被称为结构体的成员变量,或成员字段。一定要注意,结尾的分号不能掉!
    2. 在使用时,需将struct + 标签名合起来看做一种新的类型,然后使用我们熟知的数据类型 + 变量名的格式来声明一个结构体变量。例如struct student stu;,这里struct student是一种新类型,stu则是变量名。这里一定要注意,声明结构体和声明结构体变量完全是两回事!
    3. 使用英文句号.来访问结构体中的成员变量,这被称为结构体成员访问符。

    结构体变量的初始化

    以上是通过结构体变量来访问成员变量来逐个进行赋值的,实际上结构体可以在声明的同时进行初始化,这点类似于数组。

    按顺序初始化

    struct student stu={"zhangsan",19,"A010","18级"};
    

    缺省的顺序初始化

    与数组类似的,我们也可以进行缺省的初始化,如下,这样就只会对前两个成员变量赋值,后面省略的变量会被进行零值初始化。

    struct student stu={"zhangsan",19};
    printStudent(stu); // 输出 学生信息:zhangsan,19,(null),(null)
    

    指针变量的零值就是NULL,可以看到省略的最后两个成员变量被赋了零值。

    零值初始化

    前面一直强调,局部变量应当先初始化再使用,当结构体变量做局部变量时,也应当遵循。以上代码示例是没有遵循的,通常人们都喜欢先声明,后面就直接去操作了,忽略了初始化步骤,特别是当我们不确定结构体成员变量的值时,就会先声明放在那,这是不好的习惯,我们应当先做零值初始化。

    int main(){
        // 声明同时对所有成员变量做零值初始化 
        struct student stu={NULL}; // struct student stu={0};
    
        // 初始化之后再去使用,正确!
        stu.name = "zhangsan";
        stu.age = 19;
        stu.number = "A010";
        // stu.grade = "18级";
        
        printStudent(stu);
        return 0;
    }
    

    指定成员初始化

    按顺序初始化是不够灵活的,而且还需要记忆结构体成员变量的顺序,当结构体成员变量比较多时,就有些糟心了。因此C99标准推出了新的语法,指定成员变量名进行初始化

    struct student stu={.age=18, .name="张三"};
    printStudent(stu);
    

    在成员变量名前面加上一个成员访问符.,然后使用=号的形式进行初始化赋值,多个之间用逗号分隔。

    这种新的语法有两个明显的好处

    一是语义化表达,每个值对应哪个成员变量非常清晰;

    二是无序,不用再去关心成员变量声明时的顺序了。与顺序初始化相同的,没有被指定的成员变量,则会被自动的初始化为零值。

    这种结构体初始化方式是我推荐的,它极大的提升了代码可读性,而且这种被称为声明式语法的表达,正是目前其他高级编程语言所流行的趋势。

    结构体与内存

    struct A
    {
        int a;
        char b;
        short c;
    }; // siez is 8
    
    struct A
    {
        char b;
        int a;
        short c;
    }; // siez is 12
    

    编译器为了提升内存访问的性能,它会做一件事,用通俗的话说,它会对结构体分组访问

    通常在用32位来表示int类型的硬件平台上,它会将每四个字节分成一组来进行访问,这样可以提升内存访问效率。

    譬如上面的例子,结构体中有三个变量a、b、c,如果不分组

    正常情况下要向内存一个字节一个字节的读取数据,这样效率会比较低

    但是分组访问就不一样了,假设我们约定4个字节为一组,那么int a正好是四个字节,第一组就访问它,剩下的char b和short c加起来总共3个字节,正好可以凑成第二组,这样一来,三个变量,只需要分两次访问就Ok了,大大减少了对内存的访问次数,提升了性能。

    以上就是C语言中,所谓的结构体内存对齐的概念。带给我们的启示就是,在声明结构体成员变量时,不要随意去排列成员变量的顺序,要有意识的去安排变量的顺序适应内存对齐,这样可以减少结构体占用的内存大小。

    在64位系统里,long类型表示8字节,那么结构体怎么进行内存对齐呢?实际上,上面仅仅是打比方来说明问题,不同的编译器,其结构体内存对齐的规则也不尽相同,并不是简单的仅仅按照4字节来对齐。Windows下的VC编译器,主要按照4字节或8字节来对齐,而Linux下的GCC则使用2字节或4字节来对齐,这个对齐参数被称为对齐模数。

    如果我们不想优化性能,在某些特殊场景下,不希望某个结构体做内存对齐,则可以通过预编译指令进行设置

    // 传入1,指定不做内存对齐,在结束处pack()不传参,恢复内存对齐
    # pragma pack(1)
    struct A
    {
        char b;
        int a;
        short c;
    };
    # pragma pack()
    
    int main(){
        struct A struA = { 0 };
        printf("size is %d\n",sizeof(struA)); // size is 7
        return 0;
    }
    

    # pragma pack(1)# pragma pack()将不希望内存对齐的结构体包裹起来,再次查看打印结果

    结构体与指针

    结构体与数组很像,本质上就是指的一片特定的连续的内存空间,结构体成员就在这边内存空间中按顺序分布。那么所谓结构体指针,也就是指向该结构体的指针,结合结构体内存分布知识可知,这个指针实际上就是保存了结构体空间的初始地址。

    int main(){
        // 声明并初始化一个结构体变量
        struct student stu = {0};
        // 声明一个结构体指针变量,并指向一个结构体
        struct student *p_stu = &stu;
    
        // 通过结构体指针访问成员
        printf("学生信息:%s,%d,%s,%s\n",p_stu->name,p_stu->age,
               p_stu->number,p_stu->grade);
        return 0;
    }
    
    

    事实上,将结构体作为一种新的类型,那么结构体指针与其他类型的指针用法也是相似的,唯一需要注意的地方是,结构体变量访问成员,使用成员访问符.,而结构体指针变量是不同的,它使用一个小箭头->来访问,要注意这两者的区别,万万不能混淆。

    结构体的其他声明方式

    上面的结构体声明方式只是一般方式,除此之外,还有各种怪异的声明方式,大多数是不推荐的,但是要能看懂别人的代码。

    声明结构体同时还声明结构体变量

    int main(){
        // 声明结构体的同时,再声明两个结构体变量a、b
        struct student
        {
            int age;
            char *name;
            char *number;
            char *grade;
        }a,b;
    
    
        // 再声明一个结构体变量c
        struct student c = {0};
        return 0;
    }
    

    还可以在声明结构体并声明结构体变量的同时初始化

    int main(){
        struct student
        {
            int age;
            char *name;
            char *number;
            char *grade;
        }a={0},b={.name="李四"};
    
        printf("%s",b.name);
        return 0;
    }
    

    声明匿名的结构体

    声明结构体时的标签名是可以省略的

    // 声明一个结构体,并省略标签名,同时声明两个结构体变量a、b
    struct
    {
        int age;
        char *name;
        char *number;
        char *grade;
    }a,b;
    

    匿名结构体与有名字的结构体有显著的区别,因为它没有名字,必须在声明的同时声明好需要的结构体变量,后面它是没法再去声明新的结构体变量的。

    这种用法有一个用处,如果我只指定声明一个结构体变量,那么全局就只有一个该结构体变量,后面无法定义新的结构体变量了。

    结构体类型定义

    在结构体的一般声明格式中,当我们声明好一个结构体后,使用的时候还需要将struct关键字+标签名作为一个整体来声明新的结构体变量,如struct student stu;,这样的语法表达非常麻烦。

    实际上在C语言中,结构体声明通常是和另一关键字typedef结合起来使用的。

    // 使用typedef时,省略结构体标签名
    typedef struct{
        int age;
        char *name;
        char *number;
        char *grade;
    } Student;
    
    typedef struct{
        int x;
        int y;
    } Point;
    
    int main(){
        // 声明结构体变量
        Student stu = {0};
        Point point = {10,20};
        return 0;
    }
    

    以上的结构体使用方式,才真正符合我们的编程直觉,看起来更像C++、Java中的类的使用。通常的,我们应该在头文件中用以上方式声明结构体,然后在源文件中包含头文件,使用相应的结构体。

    小拓展
    typedef是一个可以用来定义类型别名的关键字,它并不仅仅是用在结构体声明中

    typedef 旧类型名 新别名;
    
    #define false 0
    #define true 1
    typedef int bool;
    typedef char byte;
    
    int main(){
        bool b=false;
        byte stream[10];
        return 0;
    }
    

    要注意,typedef定义的类型别名后面,一定要跟上分号结束。

    结构体总结

    1. 在声明结构体变量的时候,编译器就为其分配内存空间
    2. 结构体在内存中的分布,是一片连续的内存空间
    3. 结构体指针保存的是结构体在内存空间的起始地址
    4. 结构体的总内存大小并不一定等于其全部成员变量内存大小之和,当存在内存对齐时,可能会多占用一些额外的空间
    5. 结构体变量使用.访问成员,结构体指针使用->访问成员
    6. 声明结构体时,建议结合typedef关键字创建别名
    7. 结构体可以嵌套使用,即将一个结构体作为另一个结构体的成员

    内存管理

    C语言程序加载到内存中,通常可人为划分为栈(stack)、堆(heap)、代码段(text)、数据段(data)、bss 段、常量存储区等区域部分,在这个基础上,人们习惯在逻辑上将C语言程序的内存模型归纳为四大区域。请注意,这四大区域只是逻辑上的划分,实际上对于内存而言,它只是一片连续的存储单元,并不存在什么物理上的区域划分。我们了解C语言内存四区,可以加深对C语言的理解,特别是C语言的内存管理的理解。

    内存四大区域

    • 栈(stack)
      用于保存函数中的形参、返回地址、局部变量以及函数运行状态等数据。栈区的数据由编译器自动分配、自动释放,无需程序员去管理和操心。 当我们调用一个函数时,被称为函数入栈,指的就是为这个函数在栈区中分配内存。

    • 堆(heap)
      堆内存由程序员手动分配、手动释放,如果不释放,只有当程序运行结束后,操作系统才会去回收这片内存。C语言所谓的动态内存管理,指的就是堆内存管理,这也是C语言内存管理的核心内容。

    • 静态全局区
      又被人称为数据区、静态区。它又可细分为静态区和常量区。主要用来存放全局变量、静态变量以及常量。该区域内存,只有在程序运行结束后才会被操作系统回收。被形象的比喻为与整个程序同生共死,也就是说只要程序没有退出,这部分内存数据就一直存在。

    • 代码区
      用于存放程序编译链接后生成的二进制机器码指令。由操作系统管理,程序员无需关心。

    内存分配

    C语言内存分配的三种形式

    1. 静态/全局内存
      静态声明的变量和全局变量都使用这部分内存。在程序开始运行时分配,终止时消失。区别:所有函数都能访问全局变量,静态变量作用域则只局限于定义它的函数内部

    2. 自动内存
      在函数内声明,函数调用时创建(分配在栈中),作用域局限于该函数内部,函数执行完则释放。

    3. 动态内存
      内存分配在堆上,用完需手动释放,使用指针来引用分配的内存,作用域局限于引用内存的指针

    为什么需要在堆上面分配动态内存?
    在前面的章节中,我们一直使用自动内存,也就是栈内存,这并不影响C程序的编写,那么我们为什么还要去使用动态内存,而且还要很麻烦的去手动管理动态内存呢?

    • 栈区的内存大小通常都比较小,具体大小视编译器不同而有所区别,通常可能会在2M大小左右。当我们在处理大文件、图片、视频等数据时,2M显然是不够用的,我们可能需要更大块的内存空间。通常的,堆内存空间大小是没有限制的,只要你电脑的内存条足够大,你就可以向操作系统申请足够大的堆内存空间使用。

    • 栈内存的使用有一定特殊性。通常当函数调用结束后就会退栈,那么函数中的局部变量也就不复存在了。当我们需要一个变量或数组有更长的生命周期时,堆内存是更好的选择。

    • 全局变量虽然有与程序相同的生命周期,但无法动态的确定大小。例如将数组声明为全局数组变量,那么就必须在声明时静态指定数组的长度。假如我们用一个数组来存放会员注册信息,那么我们根本不能在编译时确定数组的具体长度,显然的,我们需要一个可以动态增长的内存区域。不断有新会员注册,那么我们的数组长度也需要增长。

    动态内存管理

    在C语言内存分配的三种形式中,真正能由程序员来控制管理的只有在堆上面分配的动态内存,这也是我们需要关注的重点内容。

    先看一个示例:

    // 定义一个函数,返回局部变量的地址
    int *fn(){
        int i = 10;
        return &i;
    }
    
    int main(){
        // 对fn函数返回的指针进行解引用
        printf("%d",*fn());
        return 0;
    }
    

    以上示例会报错退出,显然的,我们是不能返回一个局部变量的地址的,局部变量在函数调用结束会就会释放,因此在局部变量作用域之外去操作它的地址是非法操作。

    使用动态内存

    #include <stdio.h>
    #include <stdlib.h>
    
    int *fn(){
        // 使用malloc函数,分配动态内存空间,注意包含stdlib.h
        int *p = (int*)malloc(sizeof(int));
        *p = 16;
        return p;
    }
    
    int main(){
        printf("%d",*fn());
        return 0;
    }
    

    运行正常,打印结果:

    16
    

    申请动态内存之后,如果不手动释放,它就会一直存在,直到程序退出。

    可以看到malloc的函数原型 void *malloc(size_t _Size);

    它返回一个void *类型指针,这是一个无类型或者说是通用类型指针,它可以指向任意类型,因此我们在使用它的返回值时,首先做了强制类型转换。

    该函数只有一个无符号整数参数,用来传入我们想要申请的内存大小,单位是字节。上例中我们传入的是一个int类型的大小,通常是4字节。

    需要特别注意,当使用malloc分配动态内存时,如果失败,它会返回NULL指针,因此使用时需判断。

    在使用动态内存时,一定要在用完后记得手动释放内存,否则易造成内存泄露

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    char *String(int len){
        char *s = (char*)malloc(len);
        return s;
    }
    
    int main(){
        char *str = String(100);
        if (str == NULL){
            // 内存分配失败时,返回NULL指针,使用时需先判断分配是否成功
            printf("Not enough memory space!\n");
        }
        
        strncpy(str,"Hi,use dynamic memory space",100);
        printf("%s\n",str);
    
        // 手动释放内存
        free(str);
        return 0;
    }
    

    与动态内存管理相关的主要有四个函数

    函数 功能
    malloc 从堆上分配一块指定大小的内存,并返回分配的空间的起始地址,这里是一个void类型指针,如果系统内存不足以分配,则返回NULL。该函数不会清空所分配的内存空间中的内容,因此可能分配的空间会包含一些随机数据
    calloc 该函数的功能基本与malloc相同,主要的区别是,它分配堆内存时会进行清空,因此内存空间不会包含一些随机数据,当然,相应的,它的性能也略低于malloc,毕竟它多做了一个清理内存的工作。
    realloc 该函数用于重新分配内存大小,其使用情况,较以上两个函数要复杂。 该函数也不会对申请的内存空间进行任何初始化。
    free 该函数用于手动释放以上三个函数所申请的堆内存空间。它的参数是一个指向所分配的动态内存的指针。要注意,该函数只能用来释放以上三个函数申请的堆空间,它们需成对使用,不能用来释放任意内存空间。
    • calloc原型
    void *calloc(size_t _NumOfElements, size_t _SizeOfElements);
    

    第一个参数用来指定元素的个数,第二个参数指定一个元素所占内存大小。malloc函数的参数正好相当于它的两参数的乘积。

    int *array = (int*)calloc(10,sizeof(int));
    
    • realloc原型
    void *realloc(void *_Memory, size_t _NewSize);
    

    它的第一个参数为指向原内存块的指针,第二个参数为重新请求的内存大小。

    当我们使用malloc动态分配了一块内存空间,随着数据的增加,内存不够用时,就可以使用realloc调整原来分配的内存大小。

    int main(){
        // 分配10个元素大小的int数组
        int *arr = (int*)malloc(10*sizeof(int));
        if (arr == NULL){
            printf("malloc:Not enough memory space!\n");
            return -1;
        }
        
        // 将原数组扩展到20个元素大小
        int *newArr = (int*)realloc(arr, 20*sizeof(int));
        if(newArr == NULL){
            printf("realloc:Not enough memory space!\n");
        }else{
           arr = newArr;
        }
        
        // do sometings
    
        // 释放内存
        free(arr);
        return 0;
    }
    

    使用realloc函数需要注意,当原内存空间之后还有足够的内存可分配时,那么就会紧随原内存空间之后扩展空间,这样一来,realloc返回的void指针与指向原内存空间的指针相同;

    如果原内存空间之后没有足够的内存可扩展了,那么就在堆内存中其他的拥有足够空间的地方重新分配空间,并将原内存空间中的数据复制到新空间,只是这样一来,其他地方保存的原内存空间的地址就必须修改为realloc返回的新地址,且原内存空间会被释放,旧地址不可用。

    可以看到,该函数之所以如此复杂,其目的就是为了保证申请的空间都是一片连续的内存空间,而不是碎片化的内存。

    关于realloc的使用总结

    第一个参数 第二个参数 描述
    NULL 欲申请空间大小 功能等同malloc,申请新的堆空间
    NULL 0 等同于free,释放原内存空间
    NULL 比原内存空间小 在原内存空间基础上回收部分内存,相当于缩小空间
    NULL 比原内存空间大 在原内存空间之后扩展,或者在其他位置重新分配更大空间
    • realloc函数功能强大,可以用来申请新的堆空间,释放堆空间,调整原来的堆空间。它一个就能替代其他的三个函数
    • realloc函数如果返回NULL,则表明内存不足,申请新的堆空间或者将原空间调大失败。失败时,它不会对原来的堆空间造成影响

    关于free的使用总结
    当使用free函数释放内存后,指向原堆空间的指针并不会被清理或重置,这意味着指向原空间的指针中仍保存着一个不合法的地址,如果不小心再次使用了这个指针,就会造成无法预知的问题,因此在使用free释放内存后,还应当将原指针重置为NULL

    arr = (int*)realloc(arr, 20*sizeof(int));
    
    // 释放内存
    free(arr);
    // arr指针保存的地址已经不合法,需重置
    arr = NULL;
    

    指针高级用法

    二维数组

    如果数组中的元素也是数组,那么这样的数组就是二维数组,在逻辑上,仿佛有两个维度,实际上在内存中仍然是一片线性的连续的内存空间。

    #include <stdio.h>
    
    int main(){
        // 声明并初始化一个二维数组
        // 第一个[5]表示外层数组的元素个数
        // 第二个[3]表示作为外层数组元素的内层数组的元素个数
        int table[5][3]={
            {11,12,13},
            {21,22,23},
            {31,32,33},
            {41,42,43},
            {51,52,53}
        };
    
        printf("%x\n",table);
        printf("%x\n",&table[0][0]);
        printf("%x\n",&table[0][1]);
        printf("%x\n",&table[0][2]);
        printf("%x\n",&table[1][0]);
        return 0;
    }
    

    二级指针

    所谓二级指针,就是一个指向指针的指针。

    我们知道指针变量是用来保存一个普通变量的地址的,那么如果对一个指针变量取地址,并用另一个变量保存指针变量的地址,这种情况是否存在呢?

    int main(){
        int num = 16;
        // 声明一个一级指针
        int *p = &num;
    
        // 声明一个二级指针,一个指向指针的指针
        int **pp = &p;
    
        printf("p=%x\n", p); // p=22fe4c
        printf("pp=%x\n", pp); // pp=22fe40
        printf("&pp=%x\n", &pp); // &pp=22fe38
        return 0;
    }
    

    可以看到,凡是变量都有地址,即使是指针变量也是有地址的,这种使用两个*来声明的指向指针的变量,就是二级指针。

    一级指针存的是普通变量的内存地址,二级指针则是存的一个一级指针的内存地址。

    image

    在遇到二级指针时,要获取原始变量的值,就需要使用两个*进行解引用,如上例中的**p可获取num的值,如使用一个*解引用,获得的只是指针p的地址而已。

    除了二级指针,自然还可以有三级指针、四级指针等等,三级指针极为少用,四级指针及之后面就没有意义了。除了二级指针有较强的实用意义,其他的基本可以忽略。

    引出了二级指针,有人一定会问,二级指针到底有什么用处,在哪里使用?下面看一个示例

    int main(){
        // 使用二维数组保存英文歌单
        char songs[10][50]={
            "My love",
            "Just one last dance",
            "As long as you love me",
            "Because of you",
            "God is a girl",
            "Hero",
            "Yesterday once more",
            "Lonely",
            "All rise",
            "One love"
        };
    
        for (size_t i = 0; i < 10; i++){
            printf("%s\n",songs[i]);
        }
        return 0;
    }
    

    在C语言中,字符串是用字符数组来表示的,那么字符串数组也必然是一个二维数组,如上。在字符串的章节中讲过,C语言字符串也可以使用char*来表示,那么字符串数组也就可以使用二级指针char **来表示了。

    void printStrings(char **s,int len){
        char **start = s;
        // 使用二级指针来遍历字符串数组
        for (;s < start + len;s++){
            printf("%s\n",*s);
        }
    }
    
    int main(){
        // 声明一个char*类型的数组,它的元素是一个char*指针
        char* songs[10]={
            "My love",
            "Just one last dance",
            "As long as you love me",
            "Because of you",
            "God is a girl",
            "Hero",
            "Yesterday once more",
            "Lonely",
            "All rise",
            "One love"
        };
    
        // 一个指针数组的数组名,实际上就是一个二级指针
        char **p = songs;
        printStrings(p,10);
        return 0;
    }
    

    可以用一句话来解释一级指针和二级指针的使用区别:

    一级指针是用来修改所指向的内存空间中的值,二级指针是用来修改一级指针指向的内存空间

    image

    二级指针的实际运用

    #include <stdio.h>
    #include <string.h>
    
    // 打印字符串数组
    void printStr(char **s,int len){
        char **start = s;
        for (;s < start + len;s++){
            printf("%s\n",*s);
        }
    }
    
    void handleStr(char **buf,int size){
        char **tmp = buf;
        while (buf < tmp + size){
            //遍历字符串数组,如果包含"love",则修改为 520
            if(strstr(*buf,"love") != NULL){
                *buf = "520";
            };
            buf++;
        } 
    }
    
    int main(){
        // 声明一个字符串数组
        char* songs[5]={
            "My love",
            "As long as you love me",
            "Because of you",
            "God is a girl",
            "Hero",
        };
    
        char **p = songs;
        handleStr(p,5);
        printStr(p,5);
        return 0;
    }
    

    打印结果

    520
    520
    Because of you
    God is a girl
    Hero
    

    函数指针

    在上面的内存四区中提到了代码区,而函数就是一系列指令的集合,因此它也是存放在代码区。

    既然存放在内存中,那么就会有地址。

    我们知道数组变量实际上也是一个指针,指向数组的起始地址,结构体指针也是指向第一个成员变量的起始地址,而函数指针亦是指向函数的起始地址。

    所谓函数指针,就是一个保存了函数的起始地址的指针变量

    函数指针的声明

    【返回值类型】 (*变量名) (【参数类型】)
    
    // 分别声明四个函数指针变量 f1、f2、f3、f4
    int (*f1)(double);
    void (*f2)(char*);
    double* (*f3)(int,int);
    int (*f4)();
    

    函数指针的赋值与使用

    当一个函数的原型与所声明的函数指针类型匹配,那么就可以将一个函数名赋值给函数指针变量。

    int count(double val){
        printf("count run\n");
        return 0;
    }
    
    void printStr(char *str){
        printf("printStr run\n");
    }
    
    double *add(int a,int b){
        printf("add run\n");
        return NULL;
    }
    
    int get(){
        printf("get run\n");
        return 0;
    }
    
    int main(){
        // 声明函数指针并初始化为NULL
        int (*f1)(double) = NULL;
        void (*f2)(char*) = NULL;
        double* (*f3)(int,int) = NULL;
        int (*f4)() = NULL;
    
        // 为函数指针赋值
        f1 = &count;
        f2 = &printStr;
        f3 = &add;
        f4 = &get;
    
        // 使用函数指针调用函数
        f1(0.5);
        f2("f2");
        f3(1, 3);
        f4();
        return 0;
    }
    

    函数名同数组名一样,它本身就是一个指针,因此可以省略取地址的操作,直接将函数名赋值给指针

    函数指针的传递

    // 加法函数
    int add(int a,int b){
        return a +b;
    }
    // 减法函数
    int sub(int a, int b){
        return a-b;
    }
    
    // 计算器函数。将函数指针做形式参数
    void calculate(int a,int b, int(*proc)(int,int)){
        printf("result=%d\n",proc(a,b));
    }
    
    int main(){
        // 算加法,传入加法函数
        calculate(10,5,add);
        // 算减法,传入减法函数
        calculate(10,5,sub);
        return 0;
    }
    

    可以看到,将函数指针作为参数传递,可以使得C语言编程变得更加灵活强大。而在Python、JavaScript等编程语言中,当前流行的函数式编程范式,即将一个函数作为参数传入到另一函数中执行,实际上有些古老的C语言中早就能实现了。

    除此之外,C语言还有其他的一些奇技淫巧,虽然看起来实现得不够优雅,但也足以证明C语言无所不能。

    以上在函数的形参中直接定义函数指针看起来不够简洁优雅,每次都得写一大串,实际上还有更简洁的方式,这就需要借助typedef

    // 定义一个函数指针类型,无需起新的别名
    typedef int(*proc)(int,int);
    
    // 使用函数指针类型 proc 声明新的函数指针变量 p
    void calculate(int a,int b, proc p){
        printf("result=%d\n", p(a,b));
    }
    

    函数指针实用小结

    1. 利用函数指针可以实现函数式编程
    2. 将函数指针存入数组中,可以像Java、Python这样,实现函数回调通知机制
    3. 将结构体与函数指针结合,可以模拟面向对象编程中的类。实际上Go语言就是这样做的,Go语言没用类机制,就是使用结构体模拟面向对象编程。

    void*指针

    前面几次提到通用类型指针void*,它可以指向任意类型,但对于void*指针到底是什么没有做深入的探讨。事实上,只有理解了void*指针,才能真正理解C语言指针的本质,才能使用void*指针实现一些奇技淫巧。

    首先思考一个问题,指针仅仅是用来保存一个内存地址的,所有的内存地址都只是一个号码,那么指针为什么还需要类型呢?理论上所有的指针都应该是同一种类型才对呀?

    先写个代码探索一番

    int main(){
        short num = 18;
        
        char *pChar = (char*)&num;
        int *pInt = (int*)&num;
    
        printf("pChar=%x pInt=%x\n",pChar,pInt);
        printf("*pChar=%d *pInt=%d\n",*pChar,*pInt);
        return 0;
    }
    

    分别强制使用char*指针和int*指针来保存short num的地址,打印结果如下:

    pChar=22fe3e ---- pInt=22fe3e
    *pChar=18 ---- *pInt=-29491182
    

    可以看到,保存地址是OK的,但是解引用获取值就会存在问题。

    由此我们基本可以推断一个事实,指针用来保存变量的内存地址与变量的类型无关,任何类型指针都可以保存任何一个地址;指针之所以需要类型,只与该指针的解引用有关

    short是2个字节,char是1个字节,int是4个字节,而指针保存的是第一个字节的地址,当指针声明为short时,编译器就知道从当前这个地址往后取几个字节作为一个整体。

    如果指针没有具体类型,那么编译器根本无法判断应从当前这个字节往后取几个字节。

    如上例,*pInt解引用后结果错误,这就是因为原类型是short2字节,而使用int*指针去解引用会超出short本身的两字节内存,将紧随其后的两字节内存也强制读取了,访问了不合法的内存空间,这实际上是内存越界造成的错误值。

    当我们不确定指针所指向的具体数据类型时,就可以使用void*类型来声明,当我们后续确定了具体类型之后,就可以使用强制类型转换来将void*类型转换为我们需要的具体类型。

    接触过Java等具有泛型的面向对象编程语言的人,可能马上就会联想到泛型,是的,C语言没有泛型,但是利用void*指针的特点,我们可以使用一些技巧来模拟泛型编程。

    再看一个示例

    // 交换两个变量的值
    void swap(int *a,int *b){
        int tmp =*a;
        *a = *b;
        *b = tmp;
    }
    
    int main(){
        int n = 6, l=8;
        swap(&n, &l);
        printf("n=%d  l=%d\n",n,l);
        return 0;
    }
    

    以上swap函数是交换两个int类型变量的值,如果需要交换charshortdouble类型呢?岂不是每一种类型都需要写一个函数吗?像Java这样的编程语言存在泛型,我们可以定义泛型,而不需要在函数声明时指定具体类型,当调用的时候传入的是什么类型,函数就计算什么类型,我们看一下C语言如何实现

    // 交换两个变量的值
    void swap(void* a, void *b, int size){
        // 申请一块指定大小的内存空间做临时中转
        void *p = (void*)malloc(size);
    
        // 内存拷贝函数,拷贝指定的字节数
        memcpy(p, a, size);
        memcpy(a, b, size);
        memcpy(b, p, size);
    
        // 释放申请的内存空间
        free(p);
    }
    
    int main(){
        int n = 6, l=8;
        // 传入int型指针
        swap(&n, &l,sizeof(int));
        printf("n=%d  l=%d\n",n,l);
    
        // 传入short型指针
        short x=10,y=80;
        swap(&x, &y,sizeof(short));
        printf("x=%d  y=%d\n",x,y);
        return 0;
    }
    

    数据结构

    https://blog.csdn.net/yingshukun/article/details/97308805

    相关文章

      网友评论

        本文标题:C语言快速上手(二)

        本文链接:https://www.haomeiwen.com/subject/vvxndctx.html