C语言之函数(下)

动态内存管理

我们知道,我们之前定义的变量、数组,都是我们事先定义好的,变量一旦被创建,就不能够再更改了。后来,C99标准又增加了变长数组,这一特性提高了我们程序对内存分配的灵活度,可是还是感觉不太灵活。那么有没有办法让它变得更灵活呢?

当然有,而且只需要几个库的函数就能够搞定。而这些库全部都包含在stdlib.h这个头文件中:

  • malloc ——申请动态内存空间
  • free ——释放动态内存空间
  • calloc ——申请并初始化一系列内存空间
  • realloc ——重新分配内存空间

malloc

malloc函数用于申请动态内存空间:

#include <stdlib.h>
void* malloc(size_t size);

malloc函数向系统申请分配size个字节的内存空间,并返回一个指向这块空间的指针。不过,申请的这块空间并没有被初始化,因此上面的数据是随机的(和局部变量一样)。

如果函数调用成功,那么会返回一个指向被申请的内存空间的指针,由于返回的是void类型的指针,所以它可以被转化成任何类型的数据。如果函数调用失败,返回值就是NULL。另外,如果size参数设置成0,那么返回值也有可能是0,但这种情况下并不一定代表调用失败。

//Exmple 01
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int* ptr;
    ptr = (int*)malloc(sizeof(int));
    if (ptr == NULL)
    {
        printf("分配内存失败!\n");
        exit(1);//程序异常退出
    }
    printf("请输入一个整数:");
    scanf("%d", ptr);
    printf("你输入的数据是:%d\n", *ptr);
    return 0;
}

运行结果为:

请输入一个整数:12
你输入的数据是:12

这段代码的意思是,使用malloc函数申请一块int类型的空间,然后使用ptr指针来指向它,然后将用户输入的数据存储在这块空间里面。

不过,malloc函数申请的空间是在堆上,那么这就意味这它不会自动释放,直到程序执行结束。所以在使用完变量之后务必释放内存,否则很有可能造成内存泄漏。

free

释放动态内存空间需要用free函数,函数原型:

#include <stdlib.h>
...
void free(void* ptr);

释放的空间必须要是malloc calloc realloc函数申请的,否则将会导致未定义行为。如果ptr的参数是NULL,那么就不执行任何操作。

这个函数实际上并不会修改ptr参数的值,所以调用后它仍然能够指向原来的地方,只不过变为非法空间罢了。

有人会说了,现代计算机内存都不小,动辄16GB甚至更多,那么这个是否就用不上了呢?

答案当然是否定的。不信你试试下面的这个程序:

//Infinity malloc
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    while (1)
    {
        malloc(1024);
    }
    return 0;
}

你会发现内存占用会飙升。这种情况就叫做内存泄漏。我们申请的空间,在使用完后应该要立即释放,不然很有可能会造成不堪设想的后果。

//Exmple 01 - Edited
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int* ptr;
    ptr = (int*)malloc(sizeof(int));
    if (ptr == NULL)
    {
        printf("分配内存失败!\n");
        exit(1);//程序异常退出
    }
    printf("请输入一个整数:");
    scanf("%d", ptr);
    printf("你输入的数据是:%d\n", *ptr);
    ////释放内存////
    free(ptr);
    return 0;
}

因此,在使用完申请的内存之后应该要手动释放。

我们再来看一个例子:

//Example 02
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int* ptr;
    int num = 123;
    ptr = (int*)malloc(sizeof(int));
    if (ptr == NULL)
    {
        printf("分配内存失败!\n");
        exit(1);
    }
    printf("请输入一个整数:");
    scanf("%d", ptr);
    printf("你输入的整数是:%d\n", *ptr);
    ptr = &num;
    printf("你输入的整数是:%d\n", *ptr);
    free(ptr);
    return 0;
}

结果如下:

请输入一个整数:10
你输入的整数是:10
你输入的整数是:123
HEAP[ConsoleApplication2.exe]: Invalid address specified to RtlValidateHeap( 00D20000, 0096F8E0 )
ConsoleApplication2.exe 已触发了一个断点。

程序被中断了。

刚开始,我们使用malloc函数申请了一段内存,并且只有ptr才知道这块内存的地址。所以后面我们更改了ptr后,这块内存就泄露了。后面我们尝试释放ptr,却发现,现在ptr所指向的变量是一个局部变量,不允许手动释放。这种情况的泄露一定要特别注意。

申请任意尺寸的内存空间

malloc还可以用于申请一块任意尺寸的内存空间。对于后者,由于申请的空间是连续的,所以经常用数组的方式来进行索引。

还记得在《C语言之数组》里我们讲过,Visual Studio和C99之前的编译器是不支持可变长数组的。如果恰好碰到这种环境,怎么办呢?刚好可以用这种方式来曲线救国:

//Example 03
#include <stdio.h>
#include <stdlib.h>
 int main(void)
 {
     int* array;
     int n;
     printf("请输入你要创建的数组元素个数:");
     scanf("%d", &n);
     array = (int*)malloc(sizeof(int) * n);
     for (int i = 0; i < n; i++)
     {
         array[i] = i;
         printf("%d\n", array[i]);
     }
     return 0;
 }

运行结果如下:

//Consequence 03
请输入你要创建的数组元素个数:10
0
1
2
3
4
5
6
7
8
9

多维数组也一样可以(本质上也是线性存储):

//Example 04
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int** array;
    int n, m;
    scanf("%d %d", &n, &m);//n为几个大的数组,m是一个小数组中有几个元素
    array = (int**)malloc(sizeof(int*) * n);//分配行数
    for (int i = 0; i < n; i++)
    {
        array[i] = (int*)malloc(sizeof(int) * m);//分配各个元素
    }
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < m; j++)
        {
            array[i][j] = j;
            printf("%d ", array[i][j]);
        }
        printf("\n");
    }
    return 0;
}

运行结果为:

//Consequence 04
3 4
0 1 2 3
0 1 2 3
0 1 2 3

由于malloc不会初始化申请的内存空间,所以需要字节进行初始化。当然可以写一个循环,像我们刚刚那样,不过还是略显繁琐。

好在,标准库提供了更加高效的函数,包含在string.h头文件中:

  • memset: 使用一个常量字节填充内存空间
  • memcpy: 复制内存空间
  • memmove: 移动内存空间
  • memcmp: 比较内存空间
  • memchr: 在内存空间中搜索一个字符

函数原型如下:

#include <string.h>
void* memset(void* s, int c, size_t n);
void* memcpy(void* dest, const void* src, size_t n);
void* memmove(void* dest, const void* src, size_t n);
int memcmp(const void* s1, const void* s2, size_t n);
void* memchr(const void* s, int c, size_t n);

使用memset函数初始化空间:

//Example 05
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define N 10

int main(void)
{
    int* ptr = NULL;
    ptr = (int*)malloc(N * sizeof(int));
    if (ptr == NULL)
    {
        exit(1);
    }
    memset(ptr, 0, N * sizeof(int));
    for (int i = 0; i < N; i++)
    {
        printf("%d ", ptr[i]);
    }
    putchar('\n');
    free(ptr);
    return 0;
}

运行结果为:

//Consequence 05
0 0 0 0 0 0 0 0 0 0

如果觉得这样太麻烦,那么我们就可以使用calloc来一步到位。

calloc

calloc函数用于申请并初始化一系列内存空间:

#include <stdlib.h>
...
void* calloc(size_t nmemb, size_t size);

calloc函数在内存中动态地申请nmemb个长度为size的连续内存空间(即申请的总空间尺寸为nmemb*size),这些内存空间全部被初始化成0。

如果函数调用成功,会返回一个指向申请的内存空间的指针,由于返回类型是void指针,因此可以被转换成任何类型的数据。如果函数调用失败,返回值是NULL。如果nmembsize参数设置为0,返回值也可能是NULL,但这不一定意味着函数调用失败。

realloc

有时候可能需要对原来分配的空间进行拓展,但是没办法确保两次申请的空间是线性连续的。所以需要先申请一个足够大的空间,再把数据搬运过去。

当然,这样确实可以,但是手动写代码的话感觉有些太繁琐了。使用realloc函数就可以帮我们完成这一系列的操作:

#include <stdlib.h>
...
void* realloc(void* ptr, size_t size);

以下几点是需要注意的:

  • realloc函数将ptr指向的内存空间大小修改为size字节
  • 如果重新分配的内存比原来的大,则旧数据不会发生改变;若比原来小,数据有可能会丢失,慎用!
  • 该函数会移动内存空间并返回新的指针
  • 如果ptr的参数是NULL,那么调用该函数就相当于调用malloc(size)
  • 如果size的参数为0,并且ptr的参数不为NULL,那么调用该函数就相当于调用free(ptr)
  • 除非ptr的参数为NULL,否则ptr的值必须由先前调用的malloc calloc realloc函数返回

下面这个程序,不断接受用户输入整数,直到用户输入-1结束,然后将所有的数据输出:

//Example 06
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int num;
    int count = 0;
    int* ptr = NULL;//这里必须初始化为NULL
    do
    {
        printf("请输入一个整数(输入-1表示结束:)");
        scanf("%d", &num);
        count++;
        ptr = (int*)realloc(ptr, count * sizeof(int));
        if (ptr == NULL)
        {
            exit(1);
        }
        ptr[count - 1] = num;
    } while (num != -1);
    printf("输入的整数分别是:");
    for (int i = 0; i < count - 1; i++)
    {
        printf("%d ", ptr[i]);
    }
    printf("\n");
    free(ptr);
    return 0;
}

运行结果为:

//Consequence 06
请输入一个整数(输入-1表示结束:)5
请输入一个整数(输入-1表示结束:)23
请输入一个整数(输入-1表示结束:)4
请输入一个整数(输入-1表示结束:)51
请输入一个整数(输入-1表示结束:)22
请输入一个整数(输入-1表示结束:)31
请输入一个整数(输入-1表示结束:)1
请输入一个整数(输入-1表示结束:)9
请输入一个整数(输入-1表示结束:)-1
输入的整数分别是:5 23 4 51 22 31 1 9

C语言的内存布局

C语言的内存布局

根据内存由低到高分别做如下划分:

  • 代码段(text segment)
  • 数据段(initialized data segment)
  • BSS段(BSS segment / Uninitialized data segment)
  • 栈(stack)
  • 堆(heap)

代码段

代码段通常用来存放程序执行代码的一块内存区域。这部分区域的大小再程序运行前就已经确定,并且内存区域通常值属于只读。在代码段中,也有可能包含一些只读的常数变量,如字符串常量等等。

数据段

数据段通常用来存放已经初始化的全局变量和局部静态变量。

BSS段

BSS段通常用来存放程序中为初始化的全局变量的一块内存区域。BSS是英文Black Started by Symbol的简称。这个区段中的数据在程序运行前将被自动初始化为0。

前面学习的动态内存管理,实际上就是在这里面进行的。堆里面主要放一些动态的内存段,能够扩展和缩小。

大家平时所听到的堆栈这个词,实际上就是指的栈。栈是函数执行的内存区域,通常和堆共享同一片区域。堆和栈是C语言运行时的重要元素之一。而它们之间也有很大的不同;由程序员手动申请,而由系统自动分配;由程序员手动释放,而由系统自动释放;的生存周期由程序员来决定,并且不同函数之间可以自由访问,而的生存周期由函数调用开始到函数返回时结束,函数之间的局部变量不可以互相访问。

高级宏定义

作为C语言的三大预处理命令之一,宏定义的作用时替换。但是,宏定义就算再复杂,也只是替换不做任何的计算或者表达式求解。

不带参数的宏定义

这种就是我们常见的直接替换:

#define PI 3.14

注意:

  • 为了和普通变量区分,宏的名字一般约定为全大写
  • 宏定义只是简单的替换,且是在编译前就处理好了,所以编译器不会对宏定义的语法进行检查
  • 宏定义的作用范围是从定义开始到程序的结束
  • 可以使用#undef命令中止宏定义的作用域
  • 宏定义允许嵌套(在宏定义中使用已定义的宏)

带参数的宏定义

C语言的宏定义可以带参数。和函数类似,在宏定义里出现的叫做形参,而在调用的时候实际传递的叫做 实参

如:

#define MAX(x, y) (((x) > (y)) ? (x) : (y))

这个就是用来比较x和y哪个更大:

//Example 07
#include <stdio.h>
#define MAX(x, y) (((x) > (y)) ? (x) : (y))

int main(void)
{
    int x, y;
    printf("请输入两个整数:");
    scanf("%d %d", &x, &y);
    printf("%d更大!", MAX(x, y));
    return 0;
}

运行结果如下:

//Consequence 08
请输入两个整数:3 5
5更大!

另外,参数的最外层建议加上一个小括号来确保优先级,不然很可能会出现隐式的bug。

内联函数

由于预编译命令在编译之前就已经处理好了。而函数每次的调用却还要申请栈空间。的确,使用含参宏定义效率确实要更高。

不过,有的时候却会出现一些bug,比如:

//Example 09
#define SQUARE(x) ((x) * (x))
#include <stdio.h>

int main(void)
{
    int i = 1;
    while (i <= 10)
    {
        printf("%d的平方根是%d\n", i-1, SQUARE(i++));
    }
    return 0;
}

结果为:

//Consequence 09
2的平方根是1
4的平方根是9
6的平方根是25
8的平方根是49
10的平方根是81

这是怎么回事呢?

我们刚刚说过,宏定义只是简单的替换。那么SQUARE(i++)最终会被替换为((i++) * (i++)),那么没调用一次宏,就要自增两次。

那么我又想用宏定义,却又向避开这个bug,怎么办呢?

就是调用内联函数:

inline int square(int);

和普通的函数定义一样,只不过在前面加上inline即可。指定一个函数为内联函数,那么系统就会像处理宏定义那样,将整个函数直接在main函数中展开。

不过,内联函数也不是万能的。虽然节省了运行的时间,但是每个地方都要进行替换,实际上也增加了编译的时间。再者,其实现在的编译器也很聪明,对内联函数也有一套像寄存器变量那样的优化机制,并不是所有你声明的内联函数都能够成为内联函数,而有些普通函数也有可能会成为内联函数。

一些小技巧

#和##

在含参宏定义中,#运算符后面应该跟一个参数,预处理器会把这个参数转换成一个字符串:

//Example 10
#define STR(s) # s
#include <stdio.h>

int main(void)
{
    printf("%s\n", STR(TechZone));
    return 0;
}

结果为:

//Consequence 10
TechZone

虽然笔者传入的不是字符串形式,但是#将其变为了字符串,因此可以直接以%s的形式输出。

并且传入字符中,所有的保留字符都会做转义处理,比如\会被替换为\\。存在多个空白字符的时候,会被替换为一个空格。

而##运算符被成为连接运算符,如:

//Example 11
#define TOGETHER(x, y) x ## y
#include <stdio.h>
int main(void)
{
    printf("%d\n", TOGETHER(5, 20));
    return 0;
}

结果为:

//Consequence 11
520

可变参数

之前学习了如何让函数支持可变参数,带参数的宏定义也支持使用可变参数:

#define SHOWLIST(...) printf(#__VA_AGES__)

如:

//Example 12
#define SHOWLIST(...) printf(#__VA_AGES__)
#include <stdio.h>

int main(void)
{
    SHOWLIST(TechZone, HarrisWilde, C);
    return 0;
}

由于在VS2019下无法支持编译,这次的结果使用Linux下的GCC来执行:

//Consequence 12 in GCC
TechZone, HarrisWilde, C

可变参数是允许存在空参数的,如果是空参数,则##前面的逗号也会一起被去掉,避免导致参数数量不一致:

//Example 13
#define PRINT(format, ...) printf(#format, ##__VA_AGES__)

int main(void)
{
    PRINT(num = %d\n, 10);
    PRINT(Hello, world!\n);
    return 0;
}

结果为:

//Consequence 13 in GCC
num = 10
Hello, world!

函数的基础知识到这里就结束了,希望对大家有所帮助!