C 语言指针怎么理解?
[toc]
如果是计算机小白的话,对指针是什么都不太了解的话,强烈推荐我之前的一个回答,从一个生活中的故事,抛开程序员思维,但是直觉理解指针的本质:如何向计算机小白解释C语言指针?。
接下来开始上猛药了!
指针提供了动态操控内存的机制,强化了数据结构的支持,且实现了访问硬件的功能。不过,也正是由于指针这种直接操控变量的地址机制,使得它灵活,能减少很多不必要的拷贝,提升程序性能,但难以掌握。因为指针包含的就是内存地址,因此理解指针的关键在于理解C程序如何管理内存。
指针是C++
中一个非常核心的东西,其它的高级语言并不是没有指针,而是将这块屏蔽掉了。比如python
,实例化一个类:class = myclass()
,这里的class
就是一个指针,它指向某个对象,可以为空,也可以修改其指向。尤其是在python
中,如果复制某个数据直接用的=
号,那修改的就是指针,这是非常危险的,复制最好使用copy
,这里还会涉及到深拷贝和浅拷贝,感兴趣的可以自行了解。
指针的定义
指针就是地址,指针变量是一个存放内存地址的变量。指针变量包含的是内存中别的变量、对象或函数的地址。(这里说的对象就是内存分配,比如malloc
中分配的内存)。通常我们说的指针就约等于指针变量。
内存地址是用十六进制表示出来的一串数字编号,是给内存标号的(程序员直接操作的是虚拟内存,而不是真正的物理内存)。在
32
位系统中,这个编号是4byte
(32个bit),64
位系统下是8byte
(64bite)。
指针变量本质还是一个变量,所以这个指针变量存放的是num1的地址还是num2的地址,都是可以改变的,但是num1
的地址和num2
的实际地址是不动的。
与其它变量一样,在使用之前需要对其进行声明,来告诉编译器,这个变量里面存放的是一个地址。然而,指针本身并没有包含所引用数据的类型信息,指针只包含地址。依据这个地址里面存放的数据类型不同,可以定义不同的类型指针:
int* p; // 定义一个整数类型的指针
char* p1; // 定义一个字符类型指针
float* p2; // 定义一个浮点类型指针
注意 :指针指向的是内存地址,那我们为什么需要定义其数据类型呢? 因为指针的大小是固定的,里面存放的是一个变量的首个字节地址,那拿到首地址之后,往后走多少字节才是这个数据呢?这个时候就需要数据类型的辅助。
指针的操作
在了解指针的操作之前,我们首先需要知道并深刻理解如何定义一个指针:
int num = 5; // 定义了一个整数变量num,并赋值为5。
int* p; // 定义了一个指针变量p,指针变量p需要存一个值。
p = # // 指针变量p中存的值被赋值为num的地址。
星号两边的空白符无关紧要。星号将变量声明为指针。这是一个重载过的符号,因为它也用在乘法和解引指针上。
上述代码在int* p
这一步其实就已经给p
赋了一个随机的初始值,或者将其称之为垃圾数据,因为这个刚分配的内存中存储的东西可能是之前存储的任何东西。之后再将num
这块数据的内存地址赋给了p
。也可以在定义指针变量的时候直接给赋上地址值。
直接定义一个不赋值的指针,会使得这个指针变成野指针。在初始化指针的时候,如果不知道给什么值,可以给
NULL
。
int num = 5;
int *p = #
如果你是初学者,这里可能会有一个疑惑:指针前加
*
代表解引用,找到指针指向的内存中的数据。可能这里int *p = #
会有一些同学有一些疑惑,*p
代表解引用,那不应该直接解出个int
类型数据出来嘛,怎么还把地址赋给它了?没错!
*
是解引用运算符&
是取地址运算符*p = &a
这样写是不正确的(除非两种情况:1.p
是指向指针的指针;2. 这时候*p
前面要有类型符,比如int
等,这个时候int* p
表示p
是一个指向int
类型数据的指针。)通常的情况是这样用的int *p = &a;
这一句作用相当于int *p;
,p=&a;
两句,这句话的意思是定义一个int
类型指针p
,然后用a
的地址给p
赋值。所以*
前面加数据类型其实表示的是定义了这种类型的一个指针,而不代表解引用。
int main(){
int num = 0;
int *pi = #
cout << "address of num: " << (int)&num << " num is: " << num << endl;
cout << "address of pi: " << (int)&pi << " pi is: " << (int)pi << endl;
}
上述代码的输出结果为:
address of num: 6422268 num is: 0
address of pi: 6422264 pi is: 6422268
可以看到指针变量pi
里面存的是num
的地址,而这个地址也是需要存在内存中的,因此pi
也是实实在在的一个变量,不过这个变量比较特殊,存别的变量的地址而已。
在虚拟操作系统上显示的指针地址一般不是真实的物理内存地址。虚拟操作系统允许程序分布在机器的物理地址空间上。应用程序分为页(或帧),这些页表示内存中的区域。应用程序的页被分配在不同的(可能是不相邻的)内存区域上,而且可能不是同时处于内存中。如果操作系统需要占用被某一页占据的内存,可以将这些内存交换到二级存储器中,待将来需要时再装载进内存中(内存地址一般都会与之前的不同)。这种能力为虚拟操作系统管理内存提供了相当大的灵活性。程序使用的地址是虚拟地址。操作系统会在需要时把虚拟地址映射为物理内存地址。
间接引用操作符(*)
返回指针变量指向的值,一般称为解引指针。解引用操作符的结果可以用做左值。(左值,是指赋值操作符左边的操作数,所有的左值都必须可以修改,因为它们会被赋值。)
指针操作符
除了上述两种解引和取地址操作符外,指针还有如下操作符:
操作符 | 名称 | 含义 |
---|---|---|
* | 用来声明指针 | |
* | 解引 | 用来解引指针 |
-> | 指向 | 用来访问指针引用的结构的字段 |
+ | 加 | 用于对指针做加法 |
- | 减 | 用于对指针做减法 |
== != | 相当、不等 | 比较两个指针 |
> >= < <= | 大于、大于等于、小于、小于等于 | 比较两个指针 |
(数据类型) | 转换 | 改变指针的类型 |
给指针加上一个整数,其实加的是:整数与指针数据类型对应字节数的乘积,减法类似。如下述代码:
#include<bits/stdc++.h>
using namespace std;
int main(){
int vector[] = {28, 41, 17};
int *pi = vector;
cout << *pi << endl; // 输出 28
pi += 1;
cout << *pi << endl; // 输出 41
}
在做指针算数运算时,需要小心,访问超出数组范围的内存是一个非常危险的操作。并且,没有什么能够保证被访问的内存是有效变量,因此存取无效或无用的地址的情况是很容易发生的。
一个指针减去另一个指针会得到两个地址的差值,这个插值通常没什么用,但是可以判断数组中的元素顺序,当然,这一点也可以用标准的比较操作符来进行比较。指针之间的差值是它们之间相差的“单位”数,差的符号取决于操作数的顺序。
#include<bits/stdc++.h>
using namespace std;
int main(){
int vector[] = {28, 41, 17};
int *p0 = vector;
int *p1 = vector + 1;
int *p2 = vector + 2;
cout << p2 - p0 << endl; // 输出 2
cout << p0 - p1 << endl; // 输出 -1
}
空指针和野指针
-
空指针:指针变量指向内存中编号为
0
的空间。例如:int* p = NULL
。一般用来初始化指针,空指针指向的内存是不可以访问的。NULL
被赋值给指针就意味着指针不指向任何东西。null
概念是指指针包含了一个特殊的值,和别的指针不一样,它没有指向任何内存区域。两个null
指针总是相等的 - 野指针:就是指向一个已删除的对象或者未申请访问受限内存区域的指针。
-
C
语言中的指针可以指向一块内存,如果指针所指向的内存稍后被系统回收(被释放),但是指针仍然指向这块内存,那么此时该指针会变成一个悬空指针。悬空指针在悬空之前是个正常指针,之后所指向的空间被free
或者delete
掉了,就变成了一个悬空指针。 -
指针变量指向非法的内存空间。比如
int *p = (int*)0x1100
,但是事先并未申请这样一个内存空间,就会报错。如果定义一个指针,这个指针变量未初始化的话,这个指针也将会变成一个野指针。
野指针的危害比悬空指针还要可怕,野指针可能指向任意内存字段,也有可能破坏正常的数据。野指针很难被debug
,因此通常在释放内存之后,常常将指针赋值为NULL
。所以很多人都会自己封装一个free
宏,在释放内存的同时将这个指针置NULL
。还有些书籍里面会把这个称作迷途指针,叫法不一样,东西都是一个东西。
指针如果被声明为全局或静态,就会在程序启动时被初始化为NULL
。
void指针
void
指针是通用指针,用来存放任何数据类型的引用。void
指针和别的指针永远不会相等,不过,两个赋值为NULL
的void
指针是相等的。
任何指针都可以被赋给void
指针,它可以被转换回原来的指针类型,这样的话指针的值和原指针的值是相等的。
#include<bits/stdc++.h>
using namespace std;
int main(){
int num = 0;
int* pi = #
cout << "value of pi: " << (int)pi << endl;
void* pv = pi;
pi = (int*)pv; // 从void* 转换回int*
cout << "value of pi: " << (int)pi << endl;
}
上述代码输出结果,表示指针地址是一样的:
value of pi: 6422276
value of pi: 6422276
void
指针只用做数据指针,而不能用做函数指针。
用void
指针的时候要小心。如果把任意指针转换为void
指针,那就没有什么能阻止你再把它转换成不同的指针类型了。
sizeof
操作符可以用在void
指针上,不过我们无法把这个操作符用在void
上,sizeof(void*)
是合法的,但是sizeof(void)
是非法的。
const修饰指针
const
也可以和指针变量一起使用,这样可以限制指针变量本身,也可以限制指针指向的数据。const
修饰指针有三种情况:
- const修饰指针 -- 常量指针:特点是:指针的指向可以修改,但是指针指向的值不可以改。
int a =10;
int b =20;
int* p = &a
const int* p =10;
//*p = 20; //错误,指针指向的值不可以更改。
p = &b; //正确,指针指向可以改。
这里const
修饰的是*p
,所以指针所指向的数据是只读的,我们还是可以通过b
变量来修改其值,只是不能用p
来修改。也就是说:我们不能解引指向常量的指针并改变指针所引用的值,但可以改变指针。但是指针的值不是常量。指针可以改为引用另一个整数常量,或者普通整数。这样做不会有问题。声明只是限制我们不能通过指针来修改引用的值。
- const修饰常量 --指针常量:指针指向不可以改,指针指向的值可以改。
int num = 10
int* const p = #
*p = 20; //正确,指针指向的值可以更改。
//p = &b; //错误,指针指向不可以改。
指针是只读的,也就是p
本身的值不能被修改。把地址&num
赋值给p
之后,就不能再给它赋一个新值&b
。
- const既修饰指针,又修饰常量:指针指向和指针指向的值都不可以改。
const int* const p = 10;
*p = 20; //错误,指针指向的值不可以更改。
p = &b; //错误,指针指向不可以改。
在
C
语言中,单独定义const
变量没有明显的优势,完全可以使用#define
命令代替。const
通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用const
来限制。
- 当一个指针变量
str1
被const
限制时(类似const char* str1
这种形式),说明指针指向的数据不能被修改;如果将str1
赋值给另外一个未被const
修饰的指针变量str2
,就有可能发生危险。因为通过str1
不能修改数据,而赋值后通过str2
能够修改数据了,意义发生了转变,所以编译器不提倡这种行为,会给出错误或警告。- 也就是说,
const char*
和char*
是不同的类型,不能将const char*
类型的数据赋值给char*
类型的变量。但反过来是可以的,编译器允许将char*
类型的数据赋值给const char *
类型的变量。这种限制很容易理解,char *
指向的数据有读取和写入权限,而const char *
指向的数据只有读取权限,降低数据的权限不会带来任何问题,但提升数据的权限就有可能发生危险。
传值与传址、形参与实参
传递指针可以让多个函数访问指针所引用的对象,而不用把对象声明为全局可访问。也能够减少一些没有必要的复制,尤其当涉及大型数据结构时,传递参数的指针会更加高效。如果需要在函数中修改数据的话,用指针传递数据就是一种很好的方式,通过传递一个指向常量的指针,可以使指针传递的数据禁止被修改。
- 传值参数(非指针参数):
#include <iostream>
int inc(int input){
return ++input;
}
int main(int argc, const char * argv[]) {
int num = 10;
printf("the num is %d \n", num);
inc(num);
printf("the num is %d after call \n", num); //num并不会改变,
}
用作函数参数传过去只是赋值。
- 传址: 参数是指针、参数是地址:
将一维数组作为参数传递给函数实际是通过值来传递数组的地址,这样信息传递就更高效,因为我们不需要传递整个数组,从而也就不需要在栈上分配内存。除非数组内部有信息告诉我们数组的边界,否则在传递数组时也需要传递长度信息。如果数组内存储的是字符串,我们可以依赖NUL
字符来判断何时停止处理数组。
#include <iostream>
int inc(int *input){
return ++*input;
}
int main(int argc, const char * argv[]) {
int num = 10;
printf("the num is %d \n", num);
inc(&num);
printf("the num is %d after call \n", num); // num通过地址修改了值
}
将指针传递给函数时,使用之前先判断它是否为空是个好习惯。
将指针传递给函数时,传递的是值。如果我们想修改原指针而不是指针的副本,就需要传递指针的指针。
传递了一个整数数组的指针,为该数组分配内存并将其初始化。函数会用第一个参数返回分配的内存。在函数中,我们先分配内存,然后初始化。所分配的内存地址应该被赋给一个整数指针。为了在调用函数中修改这个指针,我们需要传入指针的地址。所以,参数被声明为int
指针的指针。在调用函数中,我们需要传递指针的地址:
void allocateArray(int** arr, int size, int value){
*arr = (int*)malloc(size * sizeof(int));
if(*arr != NULL){
for(int i=0; i<size; i++){
*(*arr+i) = value;
}
}
}
int* vector = NULL;
allocateArray(&vector, 5, 45);
allocateArray
的第一个参数以整数指针的指针的形式传递。当我们调用这个函数时,需要传递这种类型的值。这是通过传递vector
地址做到的。malloc
返回的地址被赋给arr。解引整数指针的指针得到的是整数指针。因为这是vector
的地址,所以我们修改了vector
。
如果只传递一个指针是不会起作用的。因为将vector
传递给函数时,它的值被复制到了参数arr
中,修改arr
对vector
没有影响。
- 返回指针
我们定义一个函数,为其传递一个整数数组的长度和一个值来初始化每个元素。函数为整数数组分配内存,用传入的值进行初始化,然后返回数组地址:
int* allocateArray(int size, int value){
int* arr = (int*)malloc(size * sizeof(int));
for(int i=0; i<size; i++){
arr[i] = value;
}
return arr;
}
int* vector = allocateArray(5, 45);
for(int i=0; i<5; i++){
printf("%d\n", vector[i]);
}
image
左图显示return
语句执行前的程序状态,右图显示函数返回后的程序状态。vector
变量包含了函数内分配的内存的地址。当函数终止时arr
变量也会消失,但是指针所引用的内存还在,这部分内存最终需要释放。
如果不为数组动态分配内存,而用一个局部数组,就会发生一些错误:
int* allocateArray(int size, int value){
int arr[size];
for(int i=0; i<size; i++){
arr[i] = value;
}
return arr;
}
一旦函数返回,返回的数组地址也就无效了,因为函数的栈帧从栈中弹出了。还有一种方法是把arr
变量声明为static
,static int arr[5]
。这样会把变量的作用域限制在函数内部,但是分配在栈帧外面,避免其他函数覆写变量值。
多级指针和多级指针的解引用
把握一个核心本质,指针就是地址,指针变量就是存放地址的变量。
int num = 9;
int *p = #
int **sp = &p;
int ***ssp = &sp;
上述代码只是个举例示意,多级指针一般并不是这种用法。举个实际的例子:
int arr[3][3] = {{1,2,3},{4,5,6},{7,8,9}};
int **p = arr;
多级指针通常用来作为函数的形参,比如常见的main
函数声明如下:
int main(int argc,char** argv)
因为当数组用作函数的形参的时候,会退化为指针来处理,所以上面的形式和下面是一样的。
int main(int argc,char* argv[])
多级指针的另一种常见用法是,假设用户想调用一个函数分配一段内存,那么分配的内存地址可以有两种方式拿到:
- 通过函数的返回值:
void* get_memery(int size){
void* p =malloc(size);
return p;
}
- 使用二级指针:
#include <iostream>
int GetMemory(int** buf, int size){
*buf = (int*)malloc(size);
if(*buf==NULL) return -1;
else return 0;
}
int main(int argc, const char* argv[]) {
int* p = NULL;
GetMemory(&p, 10);
return 0;
}
上述代码先定义一个指针变量*p
,p
里面存的是一个地址,之后传入函数GetMemory
中的&p
是地址的地址,所以它的类型是int**
。对其一次解引用之后*buf
里面还是存的地址,那存的是谁的地址呢?我们知道p
里面存了一个地址,而&p
就是p
这个地址的地址,把这个值传给了buf
,因此对buf
第一次解引用之后,得到的就是p
的地址。
两次解引用**buf
才能获取到值,也就是*p
的值。
字符串基础
字符串是以ASCII
字符NUL
结尾的字符序列。ASCII
字符NUL
表示为\0
。字符串通常存储在数组或者从堆上分配的内存中。不过,并非所有的字符数组都是字符串,字符数组可能没有NUL
字符。字符数组也用来表示布尔值等小的整数单元,以节省内存空间。
字符串的长度是字符串中除了NUL
字符之外的字符数。为字符串分配内存时,要记得为所有的字符再加上NUL
字符分配足够的空间。NULL
和NUL
不同。NULL
用来表示特殊的指针,通常定义为((void*)0)
,而NUL
是一个char
,定义为\0
,两者不能混用。
声明字符串的方式有三种:字面量、字符数组和字符指针。字符串字面量是用双引号引起来的字符序列,常用来进行初始化,它们位于字符串字面量池中。这里要注意不要把字符串字面量和单引号引起来的字符搞混。
下面是一个字符数组的例子,我们声明了一个header
数组,最多可以持有31
个字符。因为字符串需要以NUL
结尾,所以如果我们声明一个数组拥有32
个字符,那么只能用31
个元素来保存实际字符串的文本。
char header[32];
字符串初始化
有两种方法初始化字符串,这取决于变量是被声明为字符数组还是字符指针,字符串所用的内存要么是数组要么是指针指向的一块内存。
-
初始化操作符初始化
char
数组:
char header[] = "Media Player";
字面量"Media Player"
的长度为12
个字符,表示这个字面量需要13
字节,我们就为数组分配了13
字节来持有字符串。初始化操作会把这些字符复制到数组中,以NUL
结尾。
也可以使用strcpy
函数来初始化数组:
char header[13];
strcpy(header, "Media Player");
-
初始化
char
指针:
动态内存分配可以提供更多的灵活性,当然也可能会让内存存在得更久。
char *header = (char*)malloc(strlen("Media Player")+1);
strcpy(header, "Media Player");
在使用malloc
函数对字符串开辟内存的时候,一定要记得算上终结符NUL
。不要使用sizeof
操作符,而是用strlen
函数来确定已有字符串的长度。sizeof
操作符会返回数组和指针的长度,而不是字符串的长度。
这里特别要注意,如果用字符字面量来初始化char
指针不会起作用,因为字符字面量是int
类型,这其实是尝试把整数赋给字符指针。
char* prefix = '+'; // 不合法
正确的做法是像下面这样用malloc
函数:
prefix = (char*)malloc(2);
*prefix = '+';
*(prefix+1) = 0;
指针与结构体
通常对结构体命名约定以下划线开头:
struct _person{
char* firstName;
char* lastName;
char* title;
unsigned int age;
};
结构体的声明常用typedef
关键字简化:
typedef struct _person{
char* firstName;
char* lastName;
char* title;
unsigned int age;
}Person;
person
的实例声明如下:
Person person;
我们也可以声明一个Person
指针并为它分配内存,如下所示:
Person *ptrPerson;
ptrPerson = (Person*)malloc(sizeof(Person));
ptrPerson->firstName = (char*)malloc(strlen("Emily")+1);
strcpy(ptrPerson->firstName, "Emily");
ptrPerson->age = 23;
使用结构体的话,我们可以采用上述的箭头操作符,或者解引用之后用点操作符,像:
(*ptrPerson).age = 23;
结构体用完之后记得用free
释放内存:
free(person->firstName);