C语言指针的简单理解

192 阅读9分钟

指针的本质是内存地址,其灵活之处在于

  • 可以根据数据的增减,审时度势地调整数据地储存空间(动态内存分配)
  • 参照数据的性质,方便迅速地转换数据的解析方式(数据类型转换)

学习一项技术最好的方式,就是动手实践,下面我们将从取址运算入手,启动指针的学习。

/*
    scanf_error.c
    Show a error case
    BeginnerC
*/
#include <stdio.h>
int main()
{
    int number;
    scanf("%d", number);
    return 0;
}

让我们回顾一个经典的错误。

在这个案例中,我们在为 scanf 函数提供参数的时候,没有使用 & 运算提取 number 的地址,而是直接传入。

由于 number 并没有初始化,因此它实际上就是一个随机值,这会引发一个内存读写错误,如图所示。

解决的办法,往往也相当简单,就是提供出相关的地址,而在这里,我们采用指针方式解决。
在这里插入图片描述

/*
    scanf_right.c
    Use the point to the scanf
    BeginnerC
*/
#include <stdio.h>
int main()
{
    int number;
    int *p = &number;
    scanf("%d", p);
    printf("%d %d\n", number, *p);
    return 0;
}

在这个案例之中,我们首次使用指针,如您所见,指针的语法实际上非常简单

type *var_name;

其中,* 就代表了变量的含义(指针),而 type 则代表了数据的解析方式。

而在之后的 scanf 中,我们将 变量p 中储存的 内存地址进行传入,从而实现了数据的读写。

在最后,我们打印 number 与 *p 的值,在这里,我们指出:

  1. number 就代表 number 本身的数值,其含义就是 *(&number) ,此处 * 运算的意思表示:提取对应内存地址的数据
  2. *p 表示,提取 p 储存的内存地址的数值,因为 p = &number ,所以它等价于 number

回顾 C语言 中的数据类型,我们发现,数据类型的核心,就是对二进制数据设定合理的布局方式与解析方式。

而我们的变量,则根据自身的数据类型,对自身所处的内存逻辑地址进行读写。

以数组为例,在之前我们的分析中,我们都知道,数组是同一类型元素的集合,用于连续地存取数据。

而现在,我们可以以内存视角分析数组:数组就是一片连续的内存区域
在这里插入图片描述

/*
    array_memory.c
    Show the array's essence
    BeginnerC
*/
#include <stdio.h>
void PrintNumberList(int *array, int count)
{
    for (int i = 0;i < count;i++)
    {
        printf("%d ", *(array + i));
    }
    puts("");
}
void GenerateNumberList(int array[], int count)
{
    for (int i = 0;i < count;i++)
    {
        array[i] = i;
    }
}
int main()
{
    int number_list[10] = {};
    int number_list_backup[10] = {};
    double *p = NULL;
    int count_memory = 0;
    GenerateNumberList(number_list, 10);
    for (p = (double*)(void*)number_list;count_memory < sizeof(number_list);p++)
    {
        *(double*)(void*)((char*)(void*)number_list_backup + count_memory) = *p;
        count_memory += sizeof(double);
    }
    PrintNumberList(number_list_backup, 10);
    return 0;
}

这是一个非常绕的例子,但是它很好地说明了许多重要的问题。

我们逐步展开分析。

首先是 PrintNumberList 与 GenerateNumberList 两个函数,他们的意义显而易见,打印数组的值与为数组生成数据。

值得注意的是,它体现出 array[] 等价于 array,事实上,数组名就是数组首地址的开端,所以在这个意义上来看,数组就是指针(都是内存地址)。

其次,我们让一个 double 类型的指针 p 指向了 int 类型的数组 number_list,不要惊讶,在内存地址这个事情上,各种指针都是一致的,区别在于解析方式与尺寸规格

C语言提供了一种通用性指针,void*,它仅仅关注内存地址

而我们也使用了之前提到的 “桥梁" void*,我们将 number_list 先转化为 void* 指针(让后来的变量仅仅关注其内存地址),再在赋值给 p的时候,转化为 double* 指针(表示按照 double 类型处理这一内存地址)

而在循环的判断中,因为我们是复制旧数组的元素,到新数组里面去,因此我们设定了一个 count_memory 变量,表示当前已经复制完成的数据量,并将其与 number_list 的内存总量对比(达到的时候,数据复制完成)

最后,我们指示指针p每次都要自增,这里,我们有必要提及指针自增的真正意义。
在这里插入图片描述

/*
    ptr_plusplus.c
    Show the meaning of pointer plus plus
    BeginnerC
*/
#include <stdio.h>
int main()
{
    int number = 0;
    int *ptr = &number;
    double number_2 = 0;
    double *ptr_2 = &number_2;
    char c = 0;
    char *ptr_3 = &c;
    printf("%u %u %u\n", ptr, ptr++, sizeof(int));
    printf("%u %u %u\n", ptr_2, ptr_2++, sizeof(double));
    printf("%u %u %u\n", ptr_3, ptr_3++, sizeof(char));
    return 0;
}

这个案例体现出两件事情:

  1. 指针自增运算的本质就是内存地址的改变,改变量的大小取决于指针的类型(数据类型决定尺寸规格)
  2. 自增运算将指针地址变小了,这是因为,栈地址自高向低生长

同时,我们也应当看到,通用指针 void* 是不能够自增运算与自减运算的,因为它只关注地址,没有确切的规格尺寸与解析方式
在这里插入图片描述

/*
    void_ptr.c
    Show a case of void*
    BeginnerC
*/
#include <stdio.h>
int main()
{
    int var = 0;
    void *ptr = &var;
    printf("%u %u\n", ptr, ptr++);
    return 0;
}

在这个案例之中,我们使用两种不同的编译器(GCC 与 CLANG)对 void* 自增代码进行编译。

最后出现了完全不一样的结果:在 GCC 中,尽管可以通过编译,但是 void* 只改变了一个字节,而在 CLANG 中,尽管也可以通过编译,但是出现了许多警告,而最后的地址,则是完全就没有改变。

我们将这种情况称之为 UB,也就是未定义行为(结果取决于编译器的选择),在实际情况下,它很容易引发极大的问题。看人

指针的很多事情,就围绕着桥梁 void* 展开,接下来,让我们观察其具有的三种重要用途。

数据类型转换

void* 指针可以很轻松地让我们将存储于一段内存区域的数据,以其它的方式进行解析与看待。

一种典型的用法就是数据的复制,如您所见。

在这里插入图片描述

/*
    memory_copy.c
    Use the pointer to copy the data
    BeginnerC
*/
#include <stdio.h>
#include <time.h>
void GenerateNumberList(int array[], int count)
{
    for (int i = 0; i < count; i++)
    {
        array[i] = i;
    }
}
int main()
{
    int array[1000] = {};
    int array_backup[1000] = {};
    int count_memory = 0;
    unsigned begin, end;

    begin = clock();
    for (unsigned int i = 0; i < 1000000; i++)
    {
        count_memory = 0;
        for (double *p = (double *)(void *)array; count_memory < sizeof(array); p++)
        {
            *(double *)(void *)((char *)(void *)array_backup + count_memory) = *p;
            count_memory += sizeof(double);
        }
    }
    end = clock();
    printf("Time: %u ms\n", (end - begin) * 1000 / CLOCKS_PER_SEC);

    begin = clock();
    for (unsigned int i = 0; i < 1000000; i++)
    {
        count_memory = 0;
        for (char *p = (char *)(void *)array; count_memory < sizeof(array); p++)
        {
            *(char *)(void *)((char *)(void *)array_backup + count_memory) = *p;
            count_memory += sizeof(char);
        }
    }
    end = clock();
    printf("Time: %u ms\n", (end - begin) * 1000 / CLOCKS_PER_SEC);
    return 0;
}

在这个案例之中,我们使用两种不同类型的指针对同一段内容进行反复的复制。

明显看出,在同样的数据量下,double* 比 char* 快了大约 3.1 倍。

其根本的原因,就是在尺寸规格的不同,double 一次复制 8 个字节,而char 仅仅复制 1个字节,前者比后者多复制8倍的数据量。

动态内存分配

void* 通用指针的又一大意义,就在于充当动态内存分配的桥梁。

从解决问题的角度上来看,动态内存分配解决的问题,在于动态变化的数据规模。

  • 自连续型数据视角

一个好的案例,就是实现完成 C语言标准库函数 中的 strdup 函数,它的使用如下所示

在这里插入图片描述

/*
    strdup.c
    Use the strdup function
    BeginnerC
*/
#include <stdio.h>
#include <string.h>
int main()
{
    char string[] = "Hello world";
    char *string_backup = NULL;
    string_backup = strdup(string);
    puts(string_backup);
    return 0;
}

正如你所见,strdup 函数会完整的复制一段字符串到新的区域,它的实际职责,如下所示

  1. 计算旧字符串的长度
  2. 根据旧字符串的长度,分配新内存
  3. 复制旧字符串到新区域中

其中,我们将 步骤一 与 步骤三 以 strlen 与 memcpy 代替,专注于步骤二,进行实现
在这里插入图片描述

/*
    my_strdup.c
    Use the strdup function
    BeginnerC
*/
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/*
    my_strdup
        Achieve the strdup
    Arguemnt
        string
            The string want to duplicate
    Return Value
        The new string
*/
char *my_strdup(const char *string)
{
    char *result = NULL;
    unsigned length = strlen(string);
    if (0 == length)
    {
        return NULL;
    }
    result = (char*)malloc(sizeof(char) * length + 1);
    if (NULL == result)
    {
        return NULL;
    }
    memcpy(result, string, length);
    return result;
}
int main()
{
    char string[] = "Hello world";
    char *string_backup = NULL;
    string_backup = my_strdup(string);
    puts(string_backup);
    free(string_backup);
    return 0;
}

如你所见,我们使用 strlen 计算字符串长度(计算数据规模),用 memcpy 函数实现数据复制。

而关键的地方,在于中间的 malloc 函数以及之后的free函数。

他们是动态数据分配的基石。

malloc 函数会根据传入的数字分配对应尺寸的内存空间,而 free 函数则会释放由 malloc 分配的空间。

在分配失败的时候,malloc会返回常数 NULL,这个值一般情况下,就是 0

值得注意的是,如果不及时释放,会引发内存泄露问题,这会迅速耗尽系统的资源。

同时,我们看到,在前文中,我们提到,数组是一种将同一类型进行整合的数据结构,其本质是一片连续的内存区域

那么,我们能否用同样的办法,对待 malloc 分配而来的内存区域呢?

答案是:可以。

在这里插入图片描述

/*
    malloc_array.c
    Use the malloc to create a array
    BeginnerC
*/
#include <stdio.h>
#include <stdlib.h>
int* GenerateNumberList(int count)
{
    int *result = NULL;
    if (0 >= count)
    {
        return NULL;
    }
    result = (int*)malloc(count * sizeof(int));
    for (int i = 0;i < count;i++)
    {
        result[i] = i;
    }
    return result;
}
void PrintNumberList(int array[], int count)
{
    for (int i = 0;i < count;i++)
    {
        printf("%d\n", array[i]);
    }
}
int main()
{
    int *array = NULL;
    array = GenerateNumberList(10);
    if (NULL == array)
        return -1;
    PrintNumberList(array, 10);
    return 0;
}

在这里,我们用 malloc 根据传入的元素多少创建对应的内存区域。

要记住的是:malloc 分配的内存区域,就是一个一维的线性数组,而且是一个字节一个字节,因此我们在要求 malloc 分配内存的时候,不仅要考虑到元素的多少,而且要考虑到每个元素的规格尺寸

count * sizeof(type)

同时我们这里也体现了一个不好的习惯,就是在使用完成数组以后,没有及时释放掉分配到的内存(尽管程序退出后,这些资源被回收了)。

下面我们就展现这一习惯可能引发的后果。

在这里插入图片描述

/*
    memory_run_out.c
    Show the result of not use free
    BeginnerC
*/
#include <stdio.h>
#include <stdlib.h>
int main()
{
    void *ptr = NULL;
    for (int i = 0;i < 1024 * 1024 * 2;i++)
    {
        ptr = malloc(1024); /* 1KB */
        if (NULL == ptr)
            puts("Failed.");
    }
    getchar();
    return 0;
}

在这里,我们模拟了申请内存却不释放的后果,可以很明显的地发现,这个小程序,占据了 2.2GB 的内存。

在很多场景下这种逐步积累的内存泄露,往往会引发可怕的崩溃。

解决的办法,就是及时释放。

值得一提的是,malloc 分配到的内存,往往是未经初始化的内存,而如果我们希望分配到的内存是经过初始化的,可以使用 calloc 函数。

realloc 函数用于调整内存空间的尺寸。

  • 自非连续型数据的视角

非连续型数据,自我本人的观点看来,就是大量分散的小数据,他们变化频率大,需要做出的响应非常多。

在这里,我们举一个常见的案例。

在这里插入图片描述

在视频播放中,往往有一项功能被称为“A-B区段循环",这项功能的核心就是如下的功能。

  • 将设定的两点中的视频图像与音频循环输出

在我们这里,我们将其简化为如下的模型

在这里插入图片描述

我们做出如下假定

  1. 图片都是连续型数据
  2. AB循环播放就是连续读取这些数据

这就要求,我们对图片进行模拟。

由此,我们制作出如下的程序。

由于这是一个控制台模拟程序,我们就用简单的 0-1 字符数组代替真实场景下面的像素点。

/*
    video.c
    Use the link-table to solve the problem
    BeginnerC
*/
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
typedef struct Image
{
    char *buffer;
    int width;
    int height;
    struct Image *next;
}Image;
void Clear()
{
    system("clear");
}
void OutputImage(Image *image)
{
    for (int i = 0;i < image -> height;i++)
    {
        for (int j = 0;j < image -> width;j++)
        {
            printf("%c ", *(image -> buffer + i * image -> height + j));
        }
        puts("");
    }
}
Image* GenerateImage(int width, int height)
{
    Image *result = NULL;
    int length = width * height;
    result = (Image*)calloc(1, sizeof(Image));
    if (NULL == result)
    {
        return NULL;
    }
    result -> width = width;
    result -> height = height;
    result -> buffer = (char*)calloc(length, sizeof(char));
    if (NULL == result -> buffer)
    {
        free(result);
        return NULL;
    }
    for (int i = 0;i < length;i++)
    {
        result -> buffer[i] = rand() % 2 + '0';
    }
    return result;
}
void FreeImageList(Image *image)
{
    Image *temp = NULL;
    while (image)
    {
        free(image -> buffer);
        free(image);
        temp = image -> next;
        image = temp;
    }
}
void Sleep(int ms)
{
    register unsigned begin = clock();
    while ((clock() - begin) * 1000 / CLOCKS_PER_SEC < ms)
        ;
}
int main()
{
    Image *first = NULL, *last = NULL, *temp = NULL;
    first = (Image*)calloc(1, sizeof(Image));
    if (NULL == first)
        return -1;
    first = GenerateImage(10, 10);
    if (NULL == first)
    {
        return -1;
    }
    last = first;
    for (int i = 0;i < 24;i++)
    {
        temp = GenerateImage(10, 10);
        if (NULL == temp)
        {
            FreeImageList(first);
            return -1;
        }
        last -> next = temp;
        last = temp;
    }
    while (1)
    {
        temp = first;
        for (int i = 0;i < 24;i++)
        {
            Clear();
            OutputImage(temp);
            temp = temp -> next;
            Sleep(30);
        }
    }
    FreeImageList(first);
    return 0;
}

如您所见,这个程序有些复杂,下面,让我们逐一分析他们。

typedef struct Image
{
    char *buffer;
    int width;
    int height;
    struct Image *next;
}Image;

Image 结构定义每一帧的图像,并设计了 next 指针使其可以作为“链表"使用。

“链表"可以被视为数组的一种,但是它的内存地址并不连续,这是因为,链表的每一个节点,都是动态申请内存的。

我们将链表的基本流程分析如下:

假定有三个变量 first,last,temp 用于描述一个链表结构,分别代表 第一个元素,最后一个元素,临时储存。

  1. 第一步:判断第一个元素 first 的状态

    1. 假如第一个元素 first 处于 NULL 状态(链表没有任何元素),那么我们会为这个元素申请内存空间,并将最后一个元素 last 设置为第一个元素,结束整个流程
    2. 否则,进入下一步
  2. 第二步:申请一个元素的内存空间

    1. 申请成功,则将其保存于 temp 中
    2. 否则,失败
  3. 第三步:将 last 设定为新元素的地址,也就是用 temp 的值填充 last

    1. 但是在这之前,我们需要将当下最后元素的“下一个节点"(next)设定为 temp(新元素)

这便是链表的基本流程。

而使用链表的流程也非常简单,只需要从第一个元素开始,依据 next 中的值,一直循环到最后一个节点,就可以完成流程。

我们的 OutputImage,FreeImageList都是这个逻辑。

而 GenerateImage 则是做了两个工作,申请内存空间,填充数据。

可以发现,申请结构体的内存空间与一般类型没有什么不一样,这是因为,结构体的本质就是数据类型的类型,用于描述二进制数据的尺寸、规格、储存方式

所以我们完全可以用 malloc(sizeof(struct struct_name)) 的方式处理他们。

动态数据处理

指针除了用于能够充当内存数据描述的桥梁,以及进行动态内存分配以应对不断变化的数据之外,还能够充当动态数据处理(函数指针)的桥梁 。

在这里,我们有必要指出:C语言中函数就是对于重复性代码的封装,其自身也具有对应的地址(指令起始地址)。

而指针的核心也是地址,那么,我们当然可以将其应用到函数上。
在这里插入图片描述

/*
    function_pointer.c
    Use the function pointer to solve the problem
    BeginnerC
*/
#include <stdio.h>
int Add(int number_1, int number_2)
{
    return number_1 + number_2;
}
int main()
{
    int (*function)(int, int) = Add;
    printf("%u %u %d\n", function, Add, function(256, 256));
    return 0;
}

在这个案例之中,我们定义了一个函数指针,并让他指向了函数 Add。

同时,我们输出了函数指针 function 所储存的地址 与 函数 Add 的地址,并用 function 指针处理数据。

这就说明在C语言函数调用中,函数名本身也是一个指针,指向指令所起始的地址,而参数列表,则承担传入数据的责任。

现在,让我们看第二个案例

在这里插入图片描述

/*
    function_pointer_second.c
    Use the function pointer to solve the problem
    BeginnerC
*/
#include <stdio.h>
int Add(int number_1, int number_2)
{
    printf("%d %d\n", number_1, number_2);
    return number_1 + number_2;
}
int main()
{
    int (*function)(char, char) = Add;
    printf("%u %u %d\n", function, Add, function(1, 1));
    return 0;
}

在这个案例中,我们故意让函数指针的参数列表与原函数不匹配,在这种情况下,函数的参数传递会按照函数指针所设定的参数列表年进行传递。

表面上没有出现问题,是因为(1, 1)没有超出 char 的尺寸规格

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AS1Rl5b8-1683100464617)(https://foruda.gitee.com/images/1677888714771353269/4f59a421_871414.png "1673358131704.png")]

稍加修订,就可以看出问题。

在C语言中,提供了一个函数,叫做 qsort,它的核心作用,就是排序。

在这里插入图片描述

/*
    qsort.c
    Use the qsort to solve the problem
    BeginnerC
*/
#include <stdio.h>
#include <stdlib.h>
int cmp(const void *element_1, const void *element_2)
{
    return *(int*)element_1 > *(int*)element_2;
}
int main()
{
    int array[] = {8, 9, 6, 6, 3, 2, 7, 5};
    qsort(array, sizeof(array) / sizeof(array[0]), sizeof(int), cmp);
    for (int i = 0;i < sizeof(array) / sizeof(array[0]);i++)
    {
        printf("%d ", array[i]);
    }
    puts("");
    return 0;
}

如您所见,qsort 函数要求我们提供函数的地址,并让我们在函数中自己设定比较规则(通过通用指针 void* 实现对各种类型的兼容),从而实现“通用类型排序"。