1 指针的基础概念
指针是 C++ 的核心之一,使用 C++ 语言构建的程序之所以性能强悍,有很大部分原因是体现在使用指针直接操作内存。当然这样的工具是一把双刃剑,错误的指针操作可能会导致程序崩溃或者数据损坏。
指针主要有四个方面的用途:
(1)动态内存分配:使用 new 操作符在堆上分配内存。
(2)传递数据:通过指针传递大型数据对象可以显著提高程序的效率(比如使用指针作为函数参数)。
(3)回调函数:指针可以用于传递函数的地址,函数式编程正是建立在这个功能的基础上。
(4)优化性能:指针可以直接访问内存,避免了一些额外的开销,如复制数据或者查找数据等。
指针本身是一个变量,其值为另一个变量的内存地址,因此,要掌握指针的原理与作用,需要从理解内存地址开始。
1.1 内存地址
内存地址是指计算机内存中存储变量或对象的地址。内存空间大小就是寻址能力,即能访问到多少个地址,比如 32 位机器内存空间大小就是 2^32 = 4294967296
,也就是 4 GB 。每个变量或对象在内存中都有一个唯一的地址,通过该地址可以访问和操作该变量或对象。注意一 个内存地址对应一个字节,以 int 类型的变量为例,其占据 4 个内存地址,其中首个内存地址就是这个变量的地址。
#include <iostream>int main()
{int vals[4]{};printf("val1 address = %p\n", &vals[0]);printf("val2 address = %p\n", &vals[1]);printf("val3 address = %p\n", &vals[2]);printf("val4 address = %p\n", &vals[3]);return 0;
}
上面代码的输出为:
val1 address = 0000005420F6F978
val2 address = 0000005420F6F97C
val3 address = 0000005420F6F980
val4 address = 0000005420F6F984
为了能够说明 1 个 int 类型的变量占据 4 个内存地址,我们在上面的代码中使用占据连续内存的数组来做测试,由这个输出可以看出:数组 vals 的第一个元素所占据的内存地址由 0000005420F6F978
到 0000005420F6F97B
(再往下的一个地址就是第二个元素的首地址 0000005420F6F97C
),刚好是 4 个内存地址,其首个内存地址 0000005420F6F978
就是这个数组 vals 的第一个元素的地址(同时也是这个数组变量 vals 的地址)。
1.2 指针是什么
指针是一种变量,它存储的是其他变量的内存地址。通过指针,我们可以间接地访问和操作存储在内存中的变量。
由这个定义可知,指针既然是一个变量,那么它本身也需要占用内存,即有自己对应的内存地址。如下为样例代码( x64 平台编译):
#include <iostream>int main()
{void* ptr = nullptr;printf("ptr address = %p\n", &ptr);printf("ptr address size = %llu\n", sizeof(ptr));printf("ptr value = %p\n", ptr);return 0;
}
上面代码的输出为:
ptr address = 000000196B5CF678
ptr address size = 8
ptr value = 0000000000000000
其中,指针 ptr 虽然指向的是一个空地址,但是其作为一个变量,依然有自己的内存地址(000000788E9AF638
)。另外,ptr address size = 8
表明使用 x64 平台编译时,指针所占用的内存大小为 8 个字节( 32 位平台编译是 4 个字节),刚好可以保存一个内存地址,这就是指针能够存储其他变量的内存地址的原理。
2 指针的基本使用
指针是一种变量,所以在使用前和其他类型变量一样,也需要定义与初始化:指针变量定义时前面会有一个星号(*)。例如,int *ptr;
意思是定义了一个指向整数的指针。指针变量在使用之前必须被初始化,否则其值是未定义的,这个时候指向的是一个随机的内存地址,对其操作很容易引起程序崩溃。通过在指针变量前加上星号(*)可以访问指针所指向的对象,相当于操作这个对象本体。
2.1 指针的定义与初始化
指针的定义和初始化可以通过以下方式完成:
#include <iostream>int main()
{int val = 1; // 定义一个整型变量 val,并初始化为 1 int *ptr = &val; // 定义一个指向整型的指针 ptr,并将它初始化为变量 val 的地址printf("val address = %p\n", &val);printf("ptr address = %p\n", &ptr);printf("ptr value = %p\n", ptr);return 0;
}
上面代码的输出为:
val address = 00000035076FFCB4
ptr address = 00000035076FFCD8
ptr value = 00000035076FFCB4
其中,指针 ptr 的值等于整型变量 val 的地址。第 6 行 int *ptr = &val;
中的 &
符号是取地址符,用于获取变量的内存地址。基本类型( int 、float 等)、结构体类型( struct )以及类类型( class )的地址获取都需要使用该符号。
2.2 解引用
*
操作符是 C++ 的解引用操作符,用于获取指针所指向的对象,对其操作相当于对指针所指向对象的操作:
指针的定义和初始化可以通过以下方式完成:
#include <iostream>int main()
{int val1 = 1; int *ptr = &val1; *ptr = 2; //该表达式相当于 val1 = 2; int val2 = *ptr; //该表达式相当于 int val2 = val1; printf("val1 = %d\n", val1);printf("val2 = %d\n", val2);return 0;
}
上面代码的输出为:
val1 = 2
val2 = 2
其中,*ptr
在上面程序的运行过程中就是整型变量 val1
,不管是对其做赋值操作(*ptr = 2;
),还是将其用于其他变量的初始化(int val2 = *ptr;
),都相当于直接操作整型变量 val1
自身。 对于结构体和类,一般是使用箭头操作符 ->
来操作对象的成员变量或者成员函数,但是根据前面所描述的解引用概念,使用解引用操作符也可以起到相同作用:
#include <iostream>
#include <string>using namespace std;class Student
{
public:Student() {};Student(string name):m_name(name) {};~Student() {};public:string getName(){return m_name;}private:string m_name;
};int main()
{Student st("zhangsan");Student *ptr = &st;string name1 = st.getName();string name2 = ptr->getName();string name3 = (*ptr).getName(); //使用解引用操作符return 0;
}
注意上面的语句 string name3 = (*ptr).getName();
,同样可以调用对象 st
的成员函数。只是由于 C++ 提供了更方便的箭头操作符 ->
,所以一般我们才不会如此使用。
2.3 指向数组的指针
指向数组与上面章节的指向基本类型( int 、float 等)、结构体类型( struct )以及类类型( class )的使用方式有所不同,数组名本身就是数组的首地址,所以无需做取地址操作,如下为样例代码:
#include <iostream>int main()
{int vals[6] = { 1,2,3,4,5,6 };printf("%p \n", vals);printf("%p \n", &vals);int* ptr = vals;for (size_t i = 0; i < 6; i++){printf("%d ", *(ptr+i));}return 0;
}
上面代码的输出为:
vals address = 000000C03976F8C8
&vals address = 000000C03976F8C8
1 2 3 4 5 6
从上面输出可以看出,数组名 vals
与对数组名取地址 &vals
所得到的内存地址是一样的,所以如果用指针指向某个数组,直接将数组名赋值给指针即可。第 13 行 printf("%d ", *(ptr+i));
中的 *(ptr+i)
是指针的运算,在下面章节会详细讲解。
指针不光可以指向整个数组,还可以指向数组中的某一个元素,如下:
#include <iostream>int main()
{int vals[6] = { 1,2,3,4,5,6 };printf("before modification, vals[2] = %d \n", vals[2]);int* ptr = &vals[2]; //指向的数组第 3 个元素*ptr = 10; //将其所指向的数组第 3 个元素的值修改为 10printf("after modification, vals[2] = %d \n", vals[2]);return 0;
}
上面代码的输出为:
before modification, vals[2] = 3
after modification, vals[2] = 10
注意第 10 行 int* ptr = &vals[2];
这里是指向数组里面的一个整型元素,所以一定要用取地址操作符。
2.4 指向函数的指针
C++中的函数也有地址(调用函数的本质就是跳转到这个函数的地址,然后执行里面的函数体)。因此,可以声明指向函数的指针,并使用这个指针调用函数。指向函数的指针也被称作是函数指针,其定义方式为:
函数返回值类型 (`*` 指针变量名) (函数参数列表);
函数返回值类型:表示该指针变量所指向函数的返回值类型。
指针变量名:表示该指针变量的名称。
函数参数列表:表示该指针变量所指向函数的参数列表。
为了使用方便,一般会用关键字 typedef
来定义函数指针,即:typedef 函数返回值类型 (*
指针变量名) (函数参数列表) 。例如:
typedef int (*ADD)(int,int);
ADD addFunc;
使用这种方式可以目标函数看作为一个类型,然后再用它去定义指针,增强复用性。
对于无参数或者无返回值的函数,需要使用用 void 关键字,例如:
typedef void (*TESTFUNC)(void); //无参数和返回值
2.4.1 指向全局函数的函数指针
以如下代码为例:
#include <iostream>int add(int a, int b)
{int sum = a + b;return sum;
}int main()
{typedef int(*ADDFUNC)(int, int);ADDFUNC f1 = add;int sum1 = f1(1, 2); //直接使用函数名int sum2 = (*f1)(1, 2); //取函数地址printf("sum1 = %d\n",sum1);printf("sum2 = %d\n", sum2);return 0;
}
上面代码的输出为:
sum1 = 3
sum2 = 3
特别注意的是,因为函数名本身就可以表示该函数地址(指针),因此在获取函数指针时,可以直接用函数名,也可以取函数的地址。因此,上面代码中 int sum1 = f1(1, 2);
以及 int sum2 = (*f1)(1, 2);
作用是相同的。
2.4.2 指向对象成员函数的函数指针
以如下代码为例:
#include <iostream>class MyAdd
{
public:MyAdd() {}~MyAdd() {}public:int add(int a, int b){int sum = a + b;return sum;}};int main()
{MyAdd myAddObj;typedef int(MyAdd::*ADDFUNC)(int, int);ADDFUNC f1 = &MyAdd::add;int sum = (myAddObj.*f1)(1, 2);printf("sum = %d\n", sum);return 0;
}
上面代码的输出为:
sum = 3
注意:对象的成员函数属于类,所以其存储位置在对象外的空间中,由所有的类对象共享。因此, MyAdd 类中的 add() 成员函数,不是属于 myAddObj 对象的,而是属于 MyAdd 类。所以使用 &类名::成员函数名
的形式将该成员函数赋给函数指针。
2.4.3 回调函数
回调函数是函数指针的一个重要应用场景,比如在使用 C++ 的容器类时,经常会自定义回调函数用以实现定制化功能。以 vector 的自定义排序为例,代码如下:
#include <iostream>
#include <vector>
#include <algorithm>using namespace std;struct Student
{string id;double score;
};bool compareByScore(Student& stu1, Student& stu2)
{return stu1.score < stu2.score;
}int main()
{vector<Student> students;students.emplace_back(Student{ "s1",98.2 });students.emplace_back(Student{ "s2",97.6 });students.emplace_back(Student{ "s3",92.8 });students.emplace_back(Student{ "s4",95 });students.emplace_back(Student{ "s5",99 });printf("before sort\n");for (size_t i = 0; i < students.size(); i++){printf("%s(%lf) ", students[i].id.c_str(), students[i].score);}printf("\n");sort(students.begin(), students.end(), compareByScore);printf("after sort\n");for (size_t i = 0; i < students.size(); i++){printf("%s(%lf) ", students[i].id.c_str(), students[i].score);}printf("\n");return 0;
}
上面代码的输出为:
before sort
s1(98.200000) s2(97.600000) s3(92.800000) s4(95.000000) s5(99.000000)
after sort
s3(92.800000) s4(95.000000) s2(97.600000) s1(98.200000) s5(99.000000)
其中,函数 compareByScore
便作为一个函数指针的入参传递给函数 sort
。
2.4.4 函数指针和指针函数的区别
函数指针和指针函数是两种不同的编程概念,前者是一个指针,后者是一个函数,除了名字比较容易混淆,实际上是完全不同的概念。
上面内容已经说明了函数指针的含义与作用,指针函数的定义如下:
(1)指针函数本身就是一个函数,其返回的类型是指针。
(2)指针函数用于返回指针类型的值,例如动态分配的对象或数组的指针。
2.5 指向指针的指针
指针可以指向所有数据类型的变量(基本类型、结构体类型、类类型等),而指针自身也是一种变量,所以指针自然也可以指向指针。把指向指针的指针理解透彻,基本上也就能掌握了指针的精髓。如下为样例代码:
#include <iostream>int main()
{int val1 = 1;int *ptr1 = &val1;int **ptr2 = &ptr1;printf("ptr1 address = %p\n", &ptr1);printf("ptr1 address = %p\n", &(*ptr2));printf("ptr2 value = %p\n", ptr2);return 0;
}
上面代码的输出为:
ptr1 address = 000000C4A839F758
ptr1 address = 000000C4A839F758
ptr2 value = 000000C4A839F758
由结果可以看出,指向指针的指针变量 ptr2
保存了指针变量 ptr1
的地址( 000000C4A839F758
)。 其中代码第 10 行 int **ptr2 = &ptr1;
定义了一个指向指针的指针,这里用了两个星号*
,其保存的值就是指针变量 ptr1
的地址。
第 11、 12、 13 行代码尤为重要:
第 11 行代码 printf("ptr1 address = %p\n", &ptr1);
,其中的 &ptr1
是对指针变量 ptr1
做取地址操作。
第 12 行代码 printf("ptr1 address = %p\n", &(*ptr2));
,其中的 (*ptr2)
是对指针变量 ptr2
做解引用操作,再对其做取地址操作,相当于直接对指针变量 ptr1
做取地址操作。
第 13 行代码 printf("ptr2 value = %p\n", ptr2);
,对指向指针的指针取值,直接用其变量名即可。
2.6 创建动态内存
使用指针可以在堆中创建内存空间(先在堆中申请一块内存空间,然后将其首地址返回给一个指针,后面通过该指针便可读写这一块内存),其创建和销毁过程都需要手动控制。 C++ 使用 new 或者 new[] 操作符在堆中创建一块内存空间,使用 delete 或者 delete[] 释放这块申请的内存空间。如下:
int* ptr = new int;
这一行代码定义了一个指向整型的指针变量 ptr
,并且使用 new 操作符在堆中创建一个 int 类型的内存空间,并将该空间首地址返回指针变量 ptr
。
如果想将这个内存空间赋值为 1 ,可以做如下操作:
*ptr = 1;
在使用完这个内存空间后,一定要将其释放(避免内存泄露),并且将指针变量 ptr
赋值 nullptr
(避免悬垂指针,它所指向的内存空间已经被释放):
delete ptr;
ptr = nullptr;
注意释放的操作不能再次执行,如果再做一次 delete ptr;
则会导致程序崩溃。
2.7 指针的运算
指针为什么一定要定义类型(即使无类型,也需要使用 void
做定义),这个要求的一个来源就是指针运算需要按照类型做处理:
#include <iostream>int main()
{int val1 = 1;short val2 = 2;int *ptr1 = &val1;short *ptr2 = &val2;printf("before adding, ptr1 value = %p\n", ptr1);printf("before adding, ptr2 value = %p\n", ptr2);ptr1++;ptr2++;printf("after adding, ptr1 value = %p\n", ptr1);printf("after adding, ptr2 value = %p\n", ptr2);return 0;
}
上面代码的输出为:
before adding, ptr1 value = 0000000BBC54F614
before adding, ptr2 value = 0000000BBC54F634
after adding, ptr1 value = 0000000BBC54F618
after adding, ptr2 value = 0000000BBC54F636
从上面代码运行的结果可以看出:不同类型的指针变量,其运算的步长由其类型确定。 int 类型的指针变量,对其做 ++ 操作后,该变量的值增加了 4 ,指向下一个 int 变量。 short 类型的指针变量,对其做 ++ 操作后,该变量的值增加了 2 ,指向下一个 short 变量。
2.7.1 指针的加减运算
指针的加减运算通常用于对数组的操作,如下为样例代码:
#include <iostream>int main()
{int vals[6] = { 1,2,3,4,5,6 };int* ptr = vals;for (size_t i = 0; i < 6; i++){printf("%d ", *(ptr + i));}return 0;
}
上面代码的输出为:
1 2 3 4 5 6
上面代码的核心语句是 printf("%d ", *(ptr + i));
,其中 ptr + i
是指向数组中的第 i
个元素的地址,再加上前面的星号 *
,则完成了对其的解引用操作,最终获取到了对应数组元素的值。
2.7.2 指针的赋值操作
指针的赋值操作也是一个在开发中常见的操作。其作用是将一个指针的值(这个值是内存中某一个变量的地址)赋给另一个指针。如下为样例代码:
#include <iostream>int main()
{int val1 = 1;int *ptr1 = &val1;int *ptr2 = ptr1;printf("ptr1 value = %p\n", ptr1);printf("ptr2 value = %p\n", ptr2);return 0;
}
上面代码的输出为:
ptr1 value = 0000006FCF3CFBC4
ptr2 value = 0000006FCF3CFBC4
赋值操作后, 指针变量 ptr2
的值就等于指针变量 ptr1
的值。
3 使用指针的注意点
3.1 常量指针与指针常量
常量指针(const pointer)和指针常量(pointer to const)是两个不同的概念,常量指针指的是其指向变量的值不可改变,但是指针本身是可以改变的,可以指向其他变量;指针常量指的是指针本身是常量,其不可以再指向其他变量。
常量指针的样例代码:
const int val1 = 1;
int *ptr1 = &val1; //错误:必须使用常量指针
const int *ptr1 = &val1; //OK
*ptr1 = 2;
指针常量的样例代码:
int val1 = 1;
int val2 = 2;
int const *ptr1 = &val1; //OK
*ptr1 = &val2; //错误:指针本身是常量,其不可以再指向其他变量。
3.2 使用 nullptr
前面章节的代码中,多处使用了 nullptr 关键字,该关键字是在 C++11 标准中引入的,用于表示空指针。在 C++11 及以后的版本中,nullptr 替代了 C++98/03 中的 NULL 或 0 作为空指针的表示。该关键字可以避免函数重载问题,如下为样例代码:
void overLoadFunc(int* val);
void overLoadFunc(int val);int main()
{overLoadFunc( NULL ); // 期待调用 overLoadFunc(int* val); 但实际调用却是 overLoadFunc(int val);
}
上面代码中的 overLoadFunc( NULL );
实际调用的是 overLoadFunc(int val);
。其原因是 NULL 本身就是整数 0 ,因此进入了整型参数的重载函数。
3.2 野指针出现的原因
野指针出现的原因主要有以下三种:
(1)指针变量未初始化。局部指针变量的默认值是一个随机值,如果此时访问该指针则会引起程序崩溃。所以,指针变量在创建的同时应当被初始化,要么将指针设置为 nullptr ,要么让它指向合法的内存( new 出来的对象或者现有的一个对象)。
(2)释放内存后没有将指针设置为 nullptr 。不管是 free 还是 delete 在释放内存时,只是把指针所指的内存给释放掉了,但此时指针的值依然是之前内存空间的首地址。此时访问该指针则会引起程序崩溃。
(3)指针操作超越变量作用范围。栈内存在函数结束时会被释放,如果将其内存地址通过指针返回给调用者,此时再访问则会引起程序崩溃。