指针详解
这段时间在看 Linux
内核,深觉C
语言功底不扎实,很多代码都看不太懂,深入学习巩固C
语言的知识很有必要。先从指针开始。
什么是指针
C
语言里,变量存放在内存中,而内存其实就是一组有序字节组成的数组,每个字节有唯一的内存地址。CPU
通过内存寻址对存储在内存中的某个指定数据对象的地址进行定位。这里,**数据对象是指存储在内存中的一个指定数据类型的数值或字符串,它们都有一个自己的地址,而指针便是保存这个地址的变量。**也就是说:指针是一种保存变量地址的变量。
前面已经提到内存其实就是一组有序字节组成的数组,数组中,每个字节大大小固定,都是 8bit
。对这些连续的字节从0
开始进行编号,每个字节都有唯一的一个编号,这个编号就是内存地址。示意如下图:
这是一个 4GB
的内存,可以存放 2^32
个字节的数据。左侧的连续的十六进制编号就是内存地址,每个内存地址对应一个字节的内存空间。而指针变量保存的就是这个编号,也即内存地址。
为什么要使用指针
在C
语言中,指针的使用非常广泛,因为使用指针往往可以生成更高效、更紧凑的代码。总的来说,使用指针有如下好处:
1)指针的使用使得不同区域的代码可以轻易的共享内存数据,这样可以使程序更为快速高效;
2)C
语言中一些复杂的数据结构往往需要使用指针来构建,如链表、二叉树等;
3)C
语言是传值调用,而有些操作传值调用是无法完成的,如通过被调函数修改调用函数的对象,但是这种操作可以由指针来完成,而且并不违背传值调用。
如何声明一个指针
声明并初始化一个指针
指针其实就是一个变量,指针的声明方式与一般的变量声明方式没太大区别:
int *p; // 声明一个 int 类型的指针 p
char *p // 声明一个 char 类型的指针 p
int *arr[10] // 声明一个指针数组,该数组有10个元素,其中每个元素都是一个指向 int 类型对象的指针
int (*arr)[10] // 声明一个数组指针,该指针指向一个 int 类型的一维数组
int **p; // 声明一个指针 p ,该指针指向一个 int 类型的指针
指针的声明比普通变量的声明多了一个一元运算符 “*”
。运算符 “*”
是间接寻址或者间接引用运算符。当它作用于指针时,将访问指针所指向的对象。在上述的声明中:p
是一个指针,保存着一个地址,该地址指向内存中的一个变量; *p
则会访问这个地址所指向的变量。
声明一个指针变量并不会自动分配任何内存。在对指针进行间接访问之前,指针必须进行初始化:或是使他指向现有的内存,或者给他动态分配内存,否则我们并不知道指针指向哪儿,这将是一个很严重的问题,稍后会讨论这个问题。初始化操作如下:
/* 方法1:使指针指向现有的内存 */
int x = 1;
int *p = &x; // 指针 p 被初始化,指向变量 x ,其中取地址符 & 用于产生操作数内存地址
/* 方法2:动态分配内存给指针 */
int *p;
p = (int *)malloc(sizeof(int) * 10); // malloc 函数用于动态分配内存
free(p);
// free 函数用于释放一块已经分配的内存,常与 malloc 函数一起使用,要使用这两个函数需要头文件 stdlib.h
指针的初始化实际上就是给指针一个合法的地址,让程序能够清楚地知道指针指向哪儿。
未初始化和非法的指针
如果一个指针没有被初始化,那么程序就不知道它指向哪里。它可能指向一个非法地址,这时,程序会报错,在Linux
上,错误类型是Segmentation fault(core dumped)
,提醒我们段违例或内存错误。它也可能指向一个合法地址,实际上,这种情况更严重,你的程序或许能正常运行,但是这个没有被初始化的指针所指向的那个位置的值将会被修改,而你并无意去修改它。用一个例子简单的演示一下:
#include "stdio.h"
int main(){
int *p;
*p = 1;
printf("%d\n",*p);
return 0;
}
这个程序可以编译通过,但是运行的话会报错,报错信息如下:
[root@hly_centos learn]# gcc -o point point.c
[root@hly_centos learn]# ./point
Segmentation fault
[root@hly_centos learn]#
要想使这个程序运行起来,需要先对指针p
进行初始化:
#include "stdio.h"
int main(){
int x = 1;
int *p = &x;
printf("%d\n",*p);
*p = 2;
printf("%d\n",*p);
return 0;
}
这段代码的输出结果如下:
[root@hly_centos learn]# gcc -o point point.c
[root@hly_centos learn]# ./point
1
2
可以看到,对指针进行初始化后,便可以正常对指针进行赋值了。
NULL
指针
NULL
指针是一个特殊的指针变量,表示不指向任何东西。可以通过给一个指针赋一个零值来生成一个NULL
指针。
#include "stdio.h"
int main(){
int *p = NULL;
printf("p的地址为%d\n",p);
return 0;
}
/***************
* 程序输出:
* p的地址为0
***************/
可以看到指针指向内存地址0
。在大多数的操作系统上,程序不允许访问地址为0
的内存,因为该内存是为操作系统保留的。但是,内存地址0
有一个特别重要的意义,它表明该指针指向一个不可访问的内存位置。
指针的运算
C
指针的算术运算只限于两种形式:
指针+/-
整数 :
可以对指针变量p
进行 p++
、p--
、p + i
等操作,所得结果也是一个指针,只是指针所指向的内存地址相比于p
所指的内存地址前进或者后退了i
个操作数。用一张图来说明一下:
在上图中,10000000
等是内存地址的十六进制表示(数值是假定的),p
是一个int
类型的指针,指向内存地址 0x10000008
处。则p++
将指向与p
相邻的下一个内存地址,由于int
型数据占4
个字节,因此p++
所指的内存地址为 1000000b
。其余类推。不过要注意的是,这种运算并不会改变指针变量p
自身的地址,只是改变了它所指向的地址。
指针-
指针
只有当两个指针都指向同一个数组中的元素时,才允许从一个指针减去另一个指针。两个指针相减的结果的类型是 ptrdiff_t
,它是一种有符号整数类型。**减法运算的值是两个指针在内存中的距离(以数组元素的长度为单位,而不是以字节为单位),因为减法运算的结果将除以数组元素类型的长度。**举个例子:
#include "stdio.h"
int main(){
int a[10] = {1,2,3,4,5,6,7,8,9,0};
int sub;
int *p1 = &a[2];
int *p2 = &a[8];
sub = p2-p1;
printf("%d\n",sub); // 输出结果为 6
return 0;
}
指针与数组
在C
语言中,指针与数组之间的关系十分密切。实际上,许多可以用数组完成的工作都可以使用指针来完成。一般来说,用指针编写的程序比用数组编写的程序执行速度快,但另一方面,用指针实现的程序理解起来稍微困难一些。
指针与数组的关系
我们先声明一个数组:
int a[10]; // 声明一个int类型的数组,这个数组有10个元素
我们可以用 a[0]
、a[1]
、...
、a[9]
来表示这个数组中的10
个元素,这10
个元素是存储在一段连续相邻的内存区域中的。
接下来,我们再声明一个指针:
int *p; // 声明一个int类型的指针变量
p
是一个指针变量,指向内存中的一个区域。如果我们对指针p
做如下的初始化:
p = &a[0]; // 对指针进行初始化,p将指向数组 a 的第 1 个元素 a[0]
我们知道,对指针进行自增操作会让指针指向与当前元素相邻的下一个元素,即*(p + 1)
将指向 a[1]
;同样的,*(p + i)
将指向a[i]
。因此,我们可以使用该指针来遍历数组a[10]
的所有元素。可以看到,数组下标与指针运算之间的关系是一一对应的。而根据定义,**数组类型的变量或表达式的值是该数组第1
个元素的地址,且数组名所代表的的就是该数组第1
个元素的地址,**故,上述赋值语句可以直接写成:
p = a; // a 为数组名,代表该数组最开始的一个元素的地址
很显然,一个通过数组和下标实现的表达式可以等价地通过指针及其偏移量来实现,这就是数组和指针的互通之处。但有一点要明确的是,数组和指针并不是完全等价,**指针是一个变量,而数组名不是变量,它数组中第1
个元素的地址,数组可以看做是一个用于保存变量的容器。**更直接的方法,我们可以直接看二者的地址,并不一样:
#include "stdio.h"
int main(){
int x[10] = {1,2,3,4,5,6,7,8,9,0};
int *p = x;
printf("x的地址为:%p\n",x);
printf("x[0]的地址为:%p\n",&x[0]);
printf("p的地址为:%p\n",&p); // 打印指针 p 的地址,并不是指针所指向的地方的地址
p += 2;
printf("*(p+2)的值为:%d\n",*p); // 输出结果为 3,*(p+2)指向了 x[2]
return 0;
}
结果如下:
[root@hly_centos learn]# gcc -o point point.c
[root@hly_centos learn]# ./point
x的地址为:0x7ffe02b98a60
x[0]的地址为:0x7ffe02b98a60
p的地址为:0x7ffe02b98a58
*(p+2)的值为:3
可以看到,x
的值与x[0]
的地址是一样的,也就是说数组名即为数组中第1
个元素的地址。实际上,打印&x
后发现,x
的地址也是这个值。而x
的地址与指针变量p
的地址是不一样的。故而数组和指针并不能完全等价。
(笔者注:上述输出结果是在 centos7 64bit
的环境下使用 gcc
编译器得到的,可以看到地址是一个12
位的十六进制数,转换成二进制是48
位,也就是说寻址空间有 256TB
,但是笔者的电脑只有 8GB
内存,猜测是不是由于 linux
系统开启了内存分页机制,这里寻址的是虚拟地址?
另外,在Windows下使用 vs2015
编译运行的话,则输出结果是一个 8
位的十六进制数,也就是32
位二进制,寻址空间为 4GB
)
指针数组
指针是一个变量,而数组是用于存储变量的容器,因此,指针也可以像其他变量一样存储在数组中,也就是指针数组。 指针数组是一个数组,数组中的每一个元素都是指针。声明一个指针数组的方法如下:
int *p[10]; // 声明一个指针数组,该数组有10个元素,其中每个元素都是一个指向int类型的指针
在上述声明中,由于[]
的优先级比*
高,故p
先与[]
结合,成为一个数组 p[]
;再由int *
指明这是一个int
类型的指针数组,数组中的元素都是int
类型的指针。数组的第i
个元素是 *p[i]
,而 p[i]
是一个指针。由于指针数组中存放着多个指针,操作灵活,在一些需要操作大量数据的程序中使用,可以使程序更灵活快速。
数组指针
数组指针是一个指针,它指向一个数组。声明一个数组指针的方法如下:
int (*p)[10]; // 声明一个数组指针 p ,该指针指向一个数组
由于()
的优先级最高,所以p
是一个指针,指向一个int
类型的一维数组,这个一维数组的长度是 10
,这也是指针p
的步长。也就是说,执行p+1
时,p
要跨过1
个int
型数据的长度。数组指针与二维数组联系密切,可以用数组指针来指向一个二维数组,如下:
#include "stdio.h"
int main(){
int arr[2][3] = {1,2,3,4,5,6}; // 定义一个二维数组并初始化
int (*p)[3]; // 定义一个数组指针,指针指向一个含有3个元素的一维数组
p = arr; // 将二维数组的首地址赋给 p,此时 p 指向 arr[0] 或 &arr[0][0]
printf("%d\n",(*p)[0]); // 输出结果为 1
p++; // 对 p 进行算术运算,此时 p 将指向二维数组的下一行的首地址,即 &arr[1][0]
printf("%d\n",(*p)[1]); // 输出结果为5
return 0;
}
指针与结构
简单介绍一下结构
结构是一个或多个变量的集合,这些变量可能为不同的类型,为了处理的方便而将这些变量组织在一个名字之下。由于结构将一组相关的变量看做一个单元而不是各自独立的实体,因此结构有助于组织复杂的数据,特别是在大型的程序中。声明一个结构的方式如下:
struct message{ // 声明一个结构 message
char name[10]; // 成员
int age;
int score;
};
typedef struct message s_message; // 类型定义符 typedef
s_message mess = {"tongye",23,83}; // 声明一个 struct message 类型的变量 mess,并对其进行初始化
----------------------------------------------------------------------------------
/* 另一种更简便的声明方法 */
typedef struct{
char name[10];
int age;
int score;
}message;
可以使用 结构名.成员 的方式来访问结构中的成员,如下:
#include "stdio.h"
int main(){
printf("%s\n",mess.name); // 输出结果:tongye
printf("%d\n",mess.age); // 输出结果:23
return 0;
}
结构指针
结构指针是指向结构的指针,以上面的结构为例,可以这样定义一个结构指针:
s_message *p; // 声明一个结构指针 p ,该指针指向一个 s_message 类型的结构
p = &mess; // 对结构指针的初始化与普通指针一样,也是使用取地址符 &
C
语言中使用->
操作符来访问结构指针的成员,举个例子:
#include "stdio.h"
typedef struct{
char name[10];
int age;
int score;
}message;
int main(){
message mess = {"tongye",23,83};
message *p = &mess;
printf("%s\n",p->mess); // 输出结果为:tongye
printf("%d\n",p->score); // 输出结果为:83
return 0;
}
指针与函数
**C
语言的所有参数均是以“传值调用”的方式进行传递的,这意味着函数将获得参数值的一份拷贝。**这样,函数可以放心修改这个拷贝值,而不必担心会修改调用程序实际传递给它的参数。
指针作为函数的参数
传值调用的好处是是被调函数不会改变调用函数传过来的值,可以放心修改。但是有时候需要被调函数回传一个值给调用函数,这样的话,传值调用就无法做到。为了解决这个问题,可以使用传指针调用。**指针参数使得被调函数能够访问和修改主调函数中对象的值。**用一个例子来说明:
#include "stdio.h"
void swap1(int a,int b) // 参数为普通的 int 变量
{
int temp;
temp = a;
a = b;
b = temp;
}
void swap2(int *a,int *b) // 参数为指针,接受调用函数传递过来的变量地址作为参数,对所指地址处的内容进行操作
{
int temp; // 最终结果是,地址本身并没有改变,但是这一地址所对应的内存段中的内容发生了变化,即x,y的值发生了变化
temp = *a;
*a = *b;
*b = temp;
}
int main()
{
int x = 1,y = 2;
swap1(x,y); // 将 x,y 的值本身作为参数传递给了被调函数
printf("%d %5d\n",x,y); // 输出结果为:1 2
swap(&x,&y); // 将 x,y 的地址作为参数传递给了被调函数,传递过去的也是一个值,与传值调用不冲突
printf("%d %5d\n",x,y); // 输出结果为:2 1
return 0;
}
指针函数
指针函数: 顾名思义,它的本质是一个函数,不过它的返回值是一个指针。其声明的形式如下所示:
ret *func(args, ...);
其中,func
是一个函数,args
是形参列表,ret *
作为一个整体,是 func
函数的返回值,是一个指针的形式。
下面举一个具体的实例来做说明:
文件:pointer_func.c
# include <stdio.h>
# include <stdlib.h>
int * func_sum(int n)
{
if (n < 0)
{
printf("error:n must be > 0\n");
exit(-1);
}
static int sum = 0;
int *p = ∑
for (int i = 0; i < n; i++)
{
sum += i;
}
return p;
}
int main(void)
{
int num = 0;
printf("please input one number:");
scanf("%d", &num);
int *p = func_sum(num);
printf("sum:%d\n", *p);
return 0;
}
上例就是一个指针函数的例子,其中,int * func_sum(int n)
就是一个指针函数, 其功能十分简单,是根据传入的参数n
,来计算从0
到n
的所有自然数的和,其结果通过指针的形式返回给调用方。
以上代码的运行结果如下所示:
[root@hly_centos learn]# gcc -o point_func point_func.c
[root@hly_centos learn]# ./point_func
please input one number:100
sum:4950
如果上述代码使用普通的局部变量来实现,也是可以的,如下所示:
文件:pointer_func2.c
# include <stdio.h>
# include <stdlib.h>
int func_sum2(int n)
{
if (n < 0)
{
printf("error:n must be > 0\n");
exit(-1);
}
int sum = 0;
int i = 0;
for (i = 0; i < n; i++)
{
sum += i;
}
return sum;
}
int main(void)
{
int num = 0;
printf("please input one number:");
scanf("%d", &num);
int ret = func_sum2(num);
printf("sum2:%d\n", ret);
return 0;
}
本案例中,func_sum2
函数的功能与指针函数所实现的功能完全一样。
[root@hly_centos learn]# gcc -o point_func point_func.c
[root@hly_centos learn]# ./point_func
please input one number:100
sum:4950
不过在使用指针函数时,需要注意一点,相信细心地读者已经发现了,对比func_sum
和func_sum2
函数,除了返回值不一样之外,还有一个不同的地方在于,在func_sum
中,变量sum
使用的是静态局部变量,而func_sum2
函数中,变量sum
使用的则是普通的变量。
如果我们把指针函数的sum
定义为普通的局部变量,会是什么结果呢?不妨来试验一下:
文件:pointer_func3.c
# include <stdio.h>
# include <stdlib.h>
int * func_sum(int n)
{
if (n < 0)
{
printf("error:n must be > 0\n");
exit(-1);
}
int sum = 0;
int *p = ∑
for (int i = 0; i < n; i++)
{
sum += i;
}
return p;
}
int main(void)
{
int num = 0;
printf("please input one number:");
scanf("%d", &num);
int *p = func_sum(num);
printf("sum:%d\n", *p);
return 0;
}
执行以上程序,发现仍然能得到正确的结果:
[root@hly_centos learn]# gcc -o point_func point_func.c
[root@hly_centos learn]# ./point_func
please input one number:100
sum:4950
可如果我们把main
函数里面稍微改动一下:
int main(void)
{
int num = 0;
printf("please input one number:");
scanf("%d", &num);
int *p = func_sum(num);
printf("wait for a while...\n"); //此处加一句打印
printf("sum:%d\n", *p);
return 0;
}
我们在输出sum
之前打印一句话,这时看到得到的结果完全不是我们预先想象的样子,得到的并不是我们想要的答案。
[root@hly_centos learn]# gcc -o point_func point_func.c
[root@hly_centos learn]# ./point_func
please input one number:100
wait for a while...
sum:0
为什么会出现上面的结果呢?
其实原因在于,一般的局部变量是存放于栈区的,当函数结束,栈区的变量就会释放掉,如果我们在函数内部定义一个变量,在使用一个指针去指向这个变量,当函数调用结束时,这个变量的空间就已经被释放,这时就算返回了该地址的指针,也不一定会得到正确的值。上面的示例中,在返回该指针后,立即访问,的确是得到了正确的结果,但这只是十分巧合的情况,如果我们等待一会儿再去访问该地址,很有可能该地址已经被其他的变量所占用,这时候得到的就不是我们想要的结果。甚至更严重的是,如果因此访问到了不可访问的内容,很有可能造成段错误等程序崩溃的情况。
因此,在使用指针函数的时候,一定要避免出现返回局部变量指针的情况。
那么为什么用了static
就可以避免这个问题呢?
原因是一旦使用了static
去修饰变量,那么该变量就变成了静态变量。而静态变量是存放在数据段的,它的生命周期存在于整个程序运行期间,只要程序没有结束,该变量就会一直存在,所以该指针就能一直访问到该变量。
因此,还有一种解决方案是使用全局变量,因为全局变量也是放在数据段的,但是并不推荐使用全局变量。
函数指针
与指针函数不同,函数指针的本质是一个指针,该指针的地址指向了一个函数,所以它是指向函数的指针。
我们知道,函数的定义是存在于代码段,因此,每个函数在代码段中,也有着自己的入口地址,函数指针就是指向代码段中函数入口地址的指针。
其声明形式如下所示:
ret (*p)(args, ...);
其中,ret
为返回值,*p
作为一个整体,代表的是指向该函数的指针,args
为形参列表。其中p被称为函数指针变量 。
关于函数指针的初始化与数组类似,在数组中,数组名即代表着该数组的首地址,函数也是一样,函数名即是该数组的入口地址,因此,函数名就是该函数的函数指针。
因此,我们可以采用如下的初始化方式:
函数指针变量 = 函数名;
下面还是以一个简单的例子来具体说明一下函数指针的应用:
文件:func_pointer.c
#include <stdio.h>
int max(int a, int b)
{
return a > b ? a : b;
}
int main(void)
{
int (*p)(int, int); //函数指针的定义
//int (*p)(); //函数指针的另一种定义方式,不过不建议使用
//int (*p)(int a, int b); //也可以使用这种方式定义函数指针
p = max; //函数指针初始化
int ret = p(10, 15); //函数指针的调用
//int ret = (*max)(10,15);
//int ret = (*p)(10,15);
//以上两种写法与第一种写法是等价的,不过建议使用第一种方式
printf("max = %d \n", ret);
return 0;
}
上面这个函数的功能也十分简单,就是求两个数中较大的一个数。值得注意的是通过函数指针调用的方式。
首先代码里提供了3
种函数指针定义的方式,这三种方式都是正确的,比较推荐第一种和第三种定义方式。然后对函数指针进行初始化,前面已经提到过了,直接将函数名赋值给函数指针变量名即可。
上述代码运行的结果如下:
[root@hly_centos learn]# gcc -o func_point func_point.c
[root@hly_centos learn]# ./func_point
max = 15
调用的时候,既可以直接使用函数指针调用,也可以通过函数指针所指向的值去调用。(*p)
所代表的就是函数指针所指向的值,也就是函数本身,这样调用自然不会有问题。
为什么要使用函数指针?
那么,有不少人就觉得,本来很简单的函数调用,搞那么复杂干什么?其实在这样比较简单的代码实现中不容易看出来,当项目比较大,代码变得复杂了以后,函数指针就体现出了其优越性。
举个例子,如果我们要实现数组的排序,我们知道,常用的数组排序方法有很多种,比如快排,插入排序,冒泡排序,选择排序等,如果不管内部实现,你会发现,除了函数名不一样之外,返回值,包括函数入参都是相同的,这时候如果要调用不同的排序方法,就可以使用指针函数来实现,我们只需要修改函数指针初始化的地方,而不需要去修改每个调用的地方(特别是当调用特别频繁的时候)。
回调函数
函数指针的一个非常典型的应用就是回调函数。
什么是回调函数?
回调函数就是一个通过指针函数调用的函数。其将函数指针作为一个参数,传递给另一个函数。
回调函数并不是由实现方直接调用,而是在特定的事件或条件发生时由另外一方来调用的。同样我们来看一个回调函数的例子:
文件:callback.c
#include<stdio.h>
#include<stdlib.h>
//函数功能:实现累加求和
int func_sum(int n)
{
int sum = 0;
if (n < 0)
{
printf("n must be > 0\n");
exit(-1);
}
for (int i = 0; i < n; i++)
{
sum += i;
}
return sum;
}
//这个函数是回调函数,其中第二个参数为一个函数指针,通过该函数指针来调用求和函数,并把结果返回给主调函数
int callback(int n, int (*p)(int))
{
return p(n);
}
int main(void)
{
int n = 0;
printf("please input number:");
scanf("%d", &n);
printf("the sum from 0 to %d is %d\n", n, callback(n, func_sum)); //此处直接调用回调函数,而不是直接调用func_sum函数
return 0;
}
上面这个简单的demo
就是一个比较典型的回调函数的例子。在这个程序中,回调函数callback
无需关心func_sum
是怎么实现的,只需要去调用即可。
这样的好处就是,如果以后对求和函数有优化,比如新写了个func_sum2
函数的实现,我们只需要在调用回调函数的地方将函数指针指向func_sum2
即可,而无需去修改callback
函数内部。
以上代码的输出结果如下:
[root@hly_centos learn]# gcc -o call_back call_back.c
[root@hly_centos learn]# ./call_back
please input number:10
the sum from 0 to 10 is 45
回调函数广泛用于开发场景中,比如信号函数、线程函数等,都使用到了回调函数的知识。