指针的本质是内存地址,其灵活之处在于
- 可以根据数据的增减,审时度势地调整数据地储存空间(动态内存分配)
- 参照数据的性质,方便迅速地转换数据的解析方式(数据类型转换)
学习一项技术最好的方式,就是动手实践,下面我们将从取址运算入手,启动指针的学习。
/*
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 的值,在这里,我们指出:
- number 就代表 number 本身的数值,其含义就是
*(&number),此处 * 运算的意思表示:提取对应内存地址的数据 - *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;
}
这个案例体现出两件事情:
- 指针自增运算的本质就是内存地址的改变,改变量的大小取决于指针的类型(数据类型决定尺寸规格)
- 自增运算将指针地址变小了,这是因为,栈地址自高向低生长
同时,我们也应当看到,通用指针 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 函数会完整的复制一段字符串到新的区域,它的实际职责,如下所示
- 计算旧字符串的长度
- 根据旧字符串的长度,分配新内存
- 复制旧字符串到新区域中
其中,我们将 步骤一 与 步骤三 以 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区段循环",这项功能的核心就是如下的功能。
- 将设定的两点中的视频图像与音频循环输出
在我们这里,我们将其简化为如下的模型
我们做出如下假定
- 图片都是连续型数据
- 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 用于描述一个链表结构,分别代表 第一个元素,最后一个元素,临时储存。
-
第一步:判断第一个元素 first 的状态
- 假如第一个元素 first 处于 NULL 状态(链表没有任何元素),那么我们会为这个元素申请内存空间,并将最后一个元素 last 设置为第一个元素,结束整个流程
- 否则,进入下一步
-
第二步:申请一个元素的内存空间
- 申请成功,则将其保存于 temp 中
- 否则,失败
-
第三步:将 last 设定为新元素的地址,也就是用 temp 的值填充 last
- 但是在这之前,我们需要将当下最后元素的“下一个节点"(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 的尺寸规格
稍加修订,就可以看出问题。
在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* 实现对各种类型的兼容),从而实现“通用类型排序"。