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 的第一个元素所占据的内存地址由 0000005420F6F9780000005420F6F97B (再往下的一个地址就是第二个元素的首地址 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 个元素的值修改为 10
	printf("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)指针操作超越变量作用范围。栈内存在函数结束时会被释放,如果将其内存地址通过指针返回给调用者,此时再访问则会引起程序崩溃。

02-04 08:43