C语言指针简介
指针在C语言中的地位相当重要,在其他一些面向对象语言(如C++、Java和Python)中,都会有或者类似指针的概念。本文仅是简单介绍C
语言指针的概念以及指针与数组、结构体的区别和联系,而非C
指针教程。有关C
语言中指针的详细解释和学习可以参考经典的图书和指南。
1.指针的概念
指针是一个存储计算机内存地址的变量。指针也有对应的类型(如int
、char
等,也可以是void
),它们的类型只在取值时起作用。
&
号在C中表明取地址,*
号在C指针中有两种含义:
- 声明指针变量。如
int *p = NULL;
声明了一个指针变量p
。 - 取值。如
int a = *p;
表明取出指针p
指向的地址空间的值,并赋值给变量a
。
可以通过下面的示例来简单了解指针的用法。
/* 编译器版本:
* 用于 x86 的 Microsoft (R) C/C++ 优化编译器 18.00.30723 版版权所有(C) Microsoft Corporation。保留所有权利。
*/
#include <stdio.h>
int main(void) {
int *uninit; // int指针未初始化
int *nullptr = NULL; // 初始化为NULL
void *vptr; // void指针未初始化
int val = 1;
int *iptr;
int *castptr;
// void类型可以存储任意类型的指针或引用
iptr = &val;
vptr = iptr;
printf("iptr=%p, vptr=%p\n", iptr, vptr);
// 通过显示转换,我们可以把一个void指针转成
// int指针并进行取值
castptr = (int *)vptr;
printf("*castptr=%d\n", *castptr);
// 打印null和未初始化指针
// printf("uninit=%p, nullptr=%p\n", uninit, nullptr);
// 编译错误:error C4700: 使用了未初始化的局部变量“uninit”
printf("*nullptr=%d\n", nullptr);
// 运行正常,输出0
return 0;
}
/*
输出结果:
iptr=003AFBA8, vptr=003AFBA8
*castptr=1
*nullptr=0
*/
2.内存地址分配
指针可以进行加减运算,可以通过使用&操作符并+1或-1来获取前后两个地址。注意加减操作要结合类型进行计算。例如下面这个例子:
/* 编译器版本:
* 用于 x86 的 Microsoft (R) C/C++ 优化编译器 18.00.30723 版版权所有(C) Microsoft Corporation。保留所有权利。
*/
#include <stdio.h>
int main(void) {
char charvar = '\0';
printf("\nchar charvar = '\\0';\n");
printf("address of charvar = %p\n", (void *)(&charvar));
printf("address of charvar - 1 = %p\n", (void *)(&charvar - 1));
printf("address of charvar + 1 = %p\n", (void *)(&charvar + 1));
// intialize an int variable, print its address and the next address
int intvar = 1;
printf("\nint intvar = 1;\n");
printf("address of intvar = %p\n", (void *)(&intvar));
printf("address of intvar - 1 = %p\n", (void *)(&intvar - 1));
printf("address of intvar + 1 = %p\n", (void *)(&intvar + 1));
return 0;
}
/*
输出结果:
char charvar = '\0';
address of charvar = 002FFBC3
address of charvar - 1 = 002FFBC2
address of charvar + 1 = 002FFBC4
int intvar = 1;
address of intvar = 002FFBB4
address of intvar - 1 = 002FFBB0
address of intvar + 1 = 002FFBB8
*/
在输出中,我们看到地址是16进制的。更值得注意的是,字符的地址前后相差1字节。int 型变量地址前后相差四字节。内存地址的算法、指针的算法、都是根据所引用的类型的大小的。一个给定的类型的大小是依赖于平台的,我们这个例子中的char是1字节,int是四字节。将字符的地址-1是改地址前的地址,而将int型地址-1是该地址前4个的地址。
在例子中,我们是用地址操作符来获取变量的地址,这和使用表示变量地址的指针是一样的效果。
注意:存储&charvar-1
(一个非法的地址因它位于数组之前)在技术上是未特别指出的行为。C的标准已经声明,未特别指出的以及在一些平台存储一个非法地址都将引起错误。
3.指针和数组
C语言的数组表示一段连续的内存空间,用来存储多个特定类型的对象。与之相反,指针用来存储单个内存地址。数组和指针不是同一种结构因此不可以互相转换。而数组变量指向了数组的第一个元素的内存地址。
一个数组变量是一个常量。即使指针变量指向同样的地址或者一个不同的数组,也不能把指针赋值给数组变量。也不可以将一个数组变量赋值给另一个数组。然而,可以把一个数组变量赋值给指针,这一点似乎让人感到费解。把数组变量赋值给指针时,实际上是把指向数组第一个元素的地址赋给指针。可以查看下面的例子:
/* 编译器版本:
* 用于 x86 的 Microsoft (R) C/C++ 优化编译器 18.00.30723 版版权所有(C) Microsoft Corporation。保留所有权利。
*/
#include <stdio.h>
int main(void) {
int numbers[5] = { 1, 2, 3, 4, 5 };
int i = 0;
// 数组变量是常量,不能做下面的赋值
// int *ptr = myarray;
// numbers = ptr
// numbers = numbers2
// numbers = &numbers2[0]
// print the address of the array variable
printf("numbers = %p\n", numbers);
// print addresses of each array index
do {
printf("numbers[%u] = %p\n", i, (void *)(&numbers[i]));
i++;
} while (i < 5);
// print the size of the array
printf("sizeof(numbers) = %lu\n", sizeof(numbers));
printf("length(numbers) = %lu\n", sizeof(numbers) / sizeof(int));
return 0;
}
/*
输出结果:
numbers = 002EFA10
numbers[0] = 002EFA10
numbers[1] = 002EFA14
numbers[2] = 002EFA18
numbers[3] = 002EFA1C
numbers[4] = 002EFA20
sizeof(numbers) = 20
length(numbers) = 5
*/
在这个例子中,我们初始化了一个含有5个int元素的数组,我们打印了数组本身的地址,注意我们没有使用地址操作符&。这是因为数组变量已经代表了数组首元素的地址。你会看到数组的地址与数组首元素的地址是一样的。然后我们遍历这个数组并打印每个元素的内存地址。在我们的计算机中int是四个字节的,数组内存是连续的,因此每个int型元素地址之间相差4。
在最后一行,我们打印了数组的大小,数组的大小等于sizeof(type)乘上数组元素的数量。这里的数组有5个int型变量,每一个占用4字节,因此整个数组大小为20字节。C语言没有直接返回数组长度的函数,只能用整个数组的字节数除以数组的类型字节数。
这里需要注意的是,这里指针需要和数组的元素类型保持一致,除非指针类型为void。
4.指针和结构体
在C语言中,结构体一般是连续的内存区域,但也不一定是绝对连续的区域。和数组类似,它们能存储多种数据类型,但不同于数组的是,它们能存储不同的数据类型。
就像数组一样,指向结构体的指针存储了结构体第一个元素的内存地址。与数组指针一样,结构体的指针必须声明和结构体类型保持一致,或者声明为void类型。
/* 编译器版本:
* 用于 x86 的 Microsoft (R) C/C++ 优化编译器 18.00.30723 版版权所有(C) Microsoft Corporation。保留所有权利。
*/
#include <stdio.h>
int main(void) {
struct measure {
char category;
int width;
int height;
};
// declare and populate the struct
struct measure ball;
struct measure *ballptr;
ball.category = 'C';
ball.width = 5;
ball.height = 3;
ballptr = &ball;
// print the addresses of the struct and its members
printf("address of ball = %p, ballptr = %p\n", (void *)(&ball), (void *)ballptr);
printf("address of ball.category = %p, ballptr->category = %c\n", (void *)(&ball.category), ballptr->category);
printf("address of ball.width = %p, ballptr->width = %d\n", (void *)(&ball.width), ballptr->width);
printf("address of ball.height = %p, ballptr->height = %d\n", (void *)(&ball.height), ballptr->height);
// print the size of the struct
printf("sizeof(ball) = %lu\n", sizeof(ball));
return 0;
}
在这个例子中我们定义了一个结构体measure,然后声明了该结构体的一个实例ball,我们赋值给它的width、height以及category成员,然后打印出ball的地址。与数组类似,结构体也代表了它首元素的地址。然后打印了它每一个成员的地址。category是第一个成员,它与ball具有相同的地址。width后面是height,它们都具有比category更高的地址。同时将ball的地址复制给指向结构体的指针ballptr。对于结构体实例的指针,我们可以通过->
符号访问name变量。也可以同样通过(*ptr).name来访问name变量。
你可能会想因为category是一个字符,而字符型变量占用1字节,因此width的地址应该比开始出高1个字节。从输出来看这不对。 根据C99标准(§6.7.2.1),为边界对齐,结构体可以给成员增加填充字节。它不会记录数据成员,但会增加额外的字节。在实际中,大多数的编译器会使结构体中的每个成员与结构体最大的成员有相同大小,
在我们的例子中,你可以看到char实际上占用4字节,整个struct占用12个字节。都发生了什么?
- struct变量指向struct首元素的地址
- 不要去假设一个结构体的成员相对于另外一个成员有多少内存偏移量,结构体成员之间可能有边界字节,或者编译器也可能将它们放在不连续的内存空间中。使用地址操作符&来获得成员的地址
- 使用sizeof(struct instance)来获得struct的总大小,不能假设它是各个成员大小的大小总和,也许还有填充字节。
5.函数指针
对于函数指针,我们先看一个示例。
/* 编译器版本:
* 用于 x86 的 Microsoft (R) C/C++ 优化编译器 18.00.30723 版版权所有(C) Microsoft Corporation。保留所有权利。
*/
#include <stdio.h>
void sayHello(){
printf("hello world\n");
}
void subtractAndPrint(int x, int y) {
int z = x - y;
/* Simon says 是一个儿童游戏名称,可以wikipedia */
printf("Simon says, the answer is: %d\n", z);
}
int subtract(int x, int y) {
return x - y;
}
// 根据输入执行函数指针
int domath(int(*mathop)(int, int), int x, int y) {
return (*mathop)(x, y);
}
int main(void) {
/* void (*syaHelloPtr)()是一个函数指针,它指向一个不接收参数且没有返回值的函数 */
void (*sayHelloPtr)() = sayHello;
(*sayHelloPtr)();
sayHelloPtr();
/* void (*sapPtr)()是一个函数指针,它指向一个接收两个int型参数但没有返回值的函数 */
void(*sapPtr)(int, int) = subtractAndPrint;
(*sapPtr)(10, 2);
sapPtr(10, 2);
/* void (*subtractPtr)()是一个函数指针,它指向一个接收两个int型参数且返回值类型为int的函数 */
int(*subtractPtr)(int, int) = subtract;
int y = (*subtractPtr)(10, 2);
printf("Subtract gives: %d\n", y);
int z = subtractPtr(10, 2);
printf("Subtract gives: %d\n", z);
/* 函数指针作为参数来传递 */
int b = domath(subtract, 10, 2);
printf("Subtract gives: %d\n", b);
/* 注意,下面的例子都能够运行,即解引用符*和取地址符&用在函数名之前基本上都是多余的 */
void(*add1Ptr)(int, int) = subtractAndPrint;
void(*add2Ptr)(int, int) = *subtractAndPrint;
void(*add3Ptr)(int, int) = &subtractAndPrint;
void(*add4Ptr)(int, int) = **subtractAndPrint;
void(*add5Ptr)(int, int) = ***subtractAndPrint;
// 仍然能够正常运行
(*add1Ptr)(10, 2);
(*add2Ptr)(10, 2);
(*add3Ptr)(10, 2);
(*add4Ptr)(10, 2);
(*add5Ptr)(10, 2);
// 当然,这也能运行
add1Ptr(10, 2);
add2Ptr(10, 2);
add3Ptr(10, 2);
add4Ptr(10, 2);
add5Ptr(10, 2);
return 0;
}
/*
输出结果:
hello world
hello world
Simon says, the answer is: 8
Simon says, the answer is: 8
Subtract gives: 8
Subtract gives: 8
Subtract gives: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
Simon says, the answer is: 8
*/
这里以void (*sayHelloPtr)() = sayHello;
为例,关键字void的作用是说我们创建了一个函数指针,并让它指向了一个返回void(也就是没有返回值)的函数。就像其他任何指针都必须有一个名称一样,这里sayHelloPtr被当作这个函数指针的名称。
我们用*符号来表示这是一个指针,这跟声明一个指向整数或者字符的指针没有任何区别。*sayHelloPtr两端的括号是必须的,否则,上述声明变成void *sayHelloPtr(),*会优先跟void结合,变成了一个返回指向void的指针的普通函数的声明。因此,函数指针声明的时候不要忘记加上括号,这非常关键。
参数列表紧跟在指针名之后,这个例子中由于没有参数,所以是一对空括号()。
将上述要点结合起来,void (*syaHelloPtr)()的意义就非常清楚了,这是一个函数指针,它指向一个不接收参数且没有返回值的函数。
通过上面的例子和分析,我们可以知道:
- 函数名会被隐式的转换为函数指针,就像作为参数传递的时候,数组名被隐式的转换为指针一样。在函数指针被要求当作输入的任何地方,都能够使用函数名。
- 解引用符*和取地址符&用在函数名之前基本上都是多余的。
6.总结
- 指针保存的是地址信息;
- 指针加减要注意指针类型;
- 数组变量能赋给指针,反过来不行。函数中传递数组参数一般选择用指针表示;
- 结构体字节数要考虑是否需要对齐;
- (函数指针我暂时没有用到过,用到在总结它的特点)。