前言
本文对C语言指针和指针使用时的问题做一个概览性的总结,并对一些值得探讨的问题进行讨论。阅读本文,读者能达到统览C语言指针的目的。以下的讨论只针对32/64位机器。
指针纲领:
什么是指针
要知道什么是指针,就要先了解内存的编址方法。
内存的编址
存储器由一块块的空间(存储单元)组成,为了方便寻找到每一块空间,我们需要对每一个空间进行标识——内存编址。字节(Byte)是讨论内存空间时的基本单位,每个存储单元的大小是一个字节。
对内存进行编址,使得每个内存中的每个字节都有一个特定的编号,这个编号就是该内存单元的地址。
指针和指针变量
指针就是内存的地址。指针变量是存储指针的变量。平常说的指针通常指的是指针变量。
在32位机器上,地址是32个二进制位(bit)组成的二进制序列,需要用4个字节进行存储,所以指针变量的大小是4个字节;同理,在64位机器上,指针变量的大小是8个字节。
二级指针和多级指针
取地址操作符(&)可以拿到一个变量的地址,进而可以将其放到一个指针变量中。指针变量是个变量,本身占用内存空间,并通过存储内存地址另外指向一块内存空间。如果对指针变量进行取地址操作,取出的便是指针变量的地址,若用一个变量存储这个地址,则这个变量便是二级指针变量。通过这个二级指针可以拿到其指向的一级指针。
n级指针变量用来存储(n - 1)级指针变量的地址,这个指针变量就是一个多级指针。
int main()
{
int a = 10;
int* pa = &a;//pa是一个一级指针
int** ppa = &pa;//ppa是一个二级指针
**ppa = 20;//通过二级指针操作数据
return 0;
}
指针类型
指针可以指向不同的数据类型,因此指针具有多种类型。
内置数据类型指针
内置数据类型的指针指向C语言的内置数据类型。
整型指针
指向一个整型空间的指针变量就是整型指针。
需要注意的是,整型变量具有4个字节,而一个指针只能标识一个字节的空间,所以整型指针指向的仅仅是整型变量的第一个字节,对指针进行解引用时,编译器会自行向后取四个字节以完整地拿到这个整型。
void test02(void)
{
int a = 10;
int* pa = &a;
}
字符指针
指向一个字符空间的指针变量就是字符指针。
需要注意的是常量字符串的存储方式,用一个字符指针存储常量字符串时,字符指针存储的仅是第一个字符的地址,且该地址指向的内容不可被修改(常量字符串不能被修改),最好用const
对该指针进行修饰。
void test03(void)
{
const char* p = "hello world!";
}
除了整形指针和字符指针,还有诸如float等其他内置类型的指针,与上述道理相通,不再赘述。
构造数据类型指针
构造数据类型的指针指向C语言的构造数据类型。
数组指针
指向一个数组的指针变量就是数组指针。对数组指针进行解引用,拿到的是该数组的数组名。
结构体指针
结构体指针指向一个结构体。最典型的例子便是FILE*
类型的指针,它指向一个结构体,该结构体记录了某个文件的相关信息。通过这个结构体指针可以对文件进行操作。
V.S.2013环境下的FILE类型声明:
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;
若想了解基本的文件操作,请参考我之前的一篇文章。
除了上述两种构造数据类型指针外,还有位段、枚举和联合的指针,道理有结构体指针相通,不再赘述。若想了解这些构造数据类型,请参考浅谈C语言中的自定义类型。
函数指针
函数的地址
函数具有地址。函数在被准备调用时会在栈区创建函数栈帧,为函数和函数参数创建临时空间,当一切准备工作就绪时,某地址处的函数被调用。
存储函数地址的指针变量就是函数指针。通过函数指针可以找到指向的函数,进而可以调用该函数。
int Add(int x, int y)
{
int sum = x + y;
return sum;
}
void test04(void)
{
int a = 10;
int b = 20;
int ret = Add(a, b);
int(*pf)(int, int) = &Add;//pf是一个函数指针
pf(3, 4);//通过函数指针调用函数、
(*pf)(3, 4);与第13行代码效果相同,*号无实际意义
}
回调函数
回调函数是通过函数指针被调用的函数。如果将一个函数指针作为参数传递给另外一个函数,当函数通过这个函数指针调用指向的函数时,被调用的这个函数就是一个回调函数。回调函数不是实现方直接调用的,而是特定条件或事件发生时由另一方调用的。
通过回调函数,我们可以设计一个可以排序多种数据类型的冒泡排序:
void Swap(char* e1, char* e2, size_t width)
{
//逐字节交换
for (size_t i = 0; i < width; i++)
{
char tmp = *e1;
*e1 = *e2;
*e2 = tmp;
e1++;
e2++;
}
}
//改造冒泡排序
void BubbleSort(void* base, size_t num, size_t width, int (*cmp_fun)(const void*, const void*))
{
for (size_t i = 0; i < num - 1; i++)
{
for (size_t j = 0; j < num - 1 - i; j++)
{
if (cmp_fun((char*)base + j * width, (char*)base + (j + 1) * width) > 0)
{
Swap((char*)base + j * width, (char*)base + (j + 1) * width, width);
}
}
}
}
int cmp_int(const void* e1, const void* e2)
{
return *(int*)e1 - *(int*)e2;
}
void test1(void)
{
int arr[] = { 1,3,5,7,2,4,6,8 };
int sz = sizeof(arr) / sizeof(arr[0]);
BubbleSort(arr, sz, sizeof(int), cmp_int);//回调函数
}
struct Demo
{
int n;
char arr[10];
};
int cmp_str(const void* e1, const void* e2)
{
return strcmp(((struct Demo*)e1)->arr, ((struct Demo*)e2)->arr);
}
void test2(void)
{
struct Demo d[3] = { {2, "zeze"}, {3, "ahah"}, {1, "hehe"} };
BubbleSort(d, 3, sizeof(d[0]), cmp_str);
}
int main()
{
//test1();
test2();
return 0;
}
其中cmp_
就是一个回调函数。
指针类型的意义
指针类型决定了指针与整数运算时,指针移动的步长;
指针类型决定了对指针进行解引用时访问空间的大小。
野指针
是什么
“野指针”就是指向位置不可知(随机的、不正确的、没有明确限制的)的指针。野指针是造成内存错误使用和管理的重要原因。
野指针的成因
指针未初始化。当定义一个指针变量时,其指向的内容是随机的,若直接使用就会造成问题。
指针越界访问。这个问题常见于数组操作中。操作数组时,若不注意数组的大小和范围,就会造成指针越界。
指针指向的空间被释放。返回指向栈区空间的指针,或者free
空间后仍使用该指针,就会造成问题。
如何规避野指针
- 指针初始化
- 小心指针越界
- 指针指向空间释放,及时置NULL
- 避免返回局部变量的地址
- 指针使用之前检查有效性
关于更多野指针的讨论,可以参考我之前的一篇关于内存管理的文章。
指针运算
作为一种变量,指针和整数、指针和指针之间都可以进行运算。两种运算分别有不同的意义。
指针和整数的运算
指针+-整数运算可以实现指针的移动,移动的步长取决于指针的类型。
指针和指针的运算
指向同一块空间的两指针的减法运算的结果的绝对值是两指针之间的元素数目。指针之间的加法运算没有实际意义。
strlen()函数的实现:
size_t my_strlen(const char* p)
{
assert(p);
const char* end = p;
while (*end++);
return end - p - 1;//指针运算
}
指针的关系运算
指针之间可以进行关系运算。指向同一块空间的指针的关系运算常用于控制一些操作的开始或终止。
需要注意的时,ANSI C规定,允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较,但是不允许与指向第一个元素之前的那个内存位置的指针进行比较。
for(vp = &values[N_VALUES-1]; vp >= &values[0];vp--)
{
*vp = 0;
}//不建议这样写
//规范写法:
for(vp = &values[0]; vp <= &values[N_VALUES-1]; vp++)
{
*vp = 0;
}
指针和数组
对二维数组的理解
二维数组的存储空间是连续的,可以将二维数组看做存储数个一维数组的数组,二维数组的每行的元素是一维数组的数组名。二维数组的数组名是第一行的一维数组的地址,即一个数组指针。
指针和数组的联系
数组名是数组的首元素地址;对数组名进行取地址操作,取出的是整个数组的地址,需要用一个数组指针存储。这样我们就可以理解为什么二维数组的数组名是一个数组指针:二维数组的数组名是二维数组首元素的地址,而通过对二维数组的理解可以得知,二维数组的首元素其实是第一行的数组名,对数组名进行取地址操作,取出的是第一行的地址,所以拿到二维数组的数组名就拿到了二维数组第一行的地址。
另外,用sizeof(ArrayName)
计算数组大小时,这里的数组名代表的同样是整个数组。
指针数组
指针数组是存放指针的数组。
数组传参
数组传参时,函数可以用一个数组接收参数,也可以用一个指针接收参数,但是数组本质上传递的是一个指针,该指针保存了数组首元素的地址。要注意选择合适的指针类型接收数组参数。
void Func(int arr[ROW][COL], int row, int col)
{
//用数组接收参数
;
}
void Func(int(*parr)[COL], int row, int col)
{
//用指针接收参数
;
}
指针应用
转移表
函数指针数组常被用作转移表(jump table)。使用转移表可以减少代码冗余,增加代码可读性,是一种良好的设计方案。转移表中的函数必须是同类的函数。
简易计算器:
int add(int a, int b){
return a + b;
}
int sub(int a, int b){
return a - b;
}
int mul(int a, int b){
return a * b;
}
int div(int a, int b){
return a / b;
}
int main()
{
int x, y;
int input = 1;
int ret = 0;
int(*p[5])(int x, int y) = { 0, add, sub, mul, div };//转移表
while (input)
{
printf("*************************\n");
printf(" 1:add 2:sub \n");
printf(" 3:mul 4:div \n");
printf("*************************\n");
printf("请选择:");
scanf("%d", &input);
if ((input <= 4 && input >= 1))
{
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = (*p[input])(x, y);
}
else
{
printf("输入有误\n");
}
printf("ret = %d\n", ret);
}
}
基本数据结构的实现
在C语言中,线性表和二叉树的实现离不开指针,指针将各个结构的各个节点连接起来,进而保证结构的完整性。更高级的数据结构的实现都是建立在此基础上的。
内存管理
C语言内存管理必须要有指针,C程序员通过指针对内存进行布局和使用,离开了指针,程序员面对内存就会手足无措。
作为一把无所不能的菜刀,使用指针管理内存时往往会出现一些问题,常见的内存管理问题和规避方法可以参考我之前的一篇文章。