跳转到内容

指针

指针是 C++ 中非常重要的一种数据类型,它专门用来存储其它变量的内存地址,通过指针,我们可以直接访问和操作存储在内存中的数据。

在讨论指针前,我们先来看看 C++ 中变量的内存地址这个概念。

内存地址

在 C++ 中,所有的变量、数据、甚至代码都是存储在内存中的,在这里我们不讨论具体的内存模型是怎么样的(想要深入了解可以自行查阅资料,我在后面也会给出一些参考资源),我们现在主要关心的是变量所在的内存地址

假设我们有一个变量 a,在变量的前面加上取地址符号 & &a 会得到这个变量所在的内存地址。

#include <iostream>
using namespace std;
int main() {
int a = 1, b = 2, c = 3;
cout << "a的地址:" << &a << endl;
cout << "b的地址:" << &b << endl;
cout << "c的地址:" << &c << endl;
return 0;
}

运行该程序,输出结果为:

a的地址:0x7fff282891bc
b的地址:0x7fff282891c0
c的地址:0x7fff282891c4

你运行的结果应该大致看起来和我差不多,但具体的地址肯定不同,这是当程序在创建变量时由系统自行分配的。

另外,你每次运行程序时,都会得到不同的地址,这是因为每次运行程序都会重新创建变量,因此分配的内存地址也应该也是不同的。

这里的地址 0x7fff282891bc 看起来有点奇怪,有数字又有字母,实际上,前缀0x 代表十六进制,后面的一串数字则是由十六进制表示的实际内存地址。

细心的同学还会发现,这里三个变量的地址刚好差 4,实际上是指 4 个字节(byte),因为 int 变量刚好是占 4 个字节。

在 C++ 中,符号 & 根据上下文的不同有多种含义。

  • 作为取地址运算符,例如:&a,表示取变量 a 的地址
  • 作为按位与运算符,例如: a & b,表示对变量 a 和 b 进行与运算
  • 用于声明引用的分隔符,例如:int& ref,表示声明了一个引用,在函数参数中用得较多

数组与地址

数组与指针是两种完全不同的对象,但又有着密不可分的关系,接下来我们来看看它们之间有哪些共同和不同的地方。

一个数组的名字,实际上就是一个指针,该指针指向该数组存放的起始地址,也就是数组第一个元素所在的地址,这一点使得数组与指针之间产生了密切的联系。

例如,有如下代码:

int q[5] = {1, 2, 3, 4, 5};
cout << q << endl;
cout << &q[0] << endl;

q&q[0] 的输出结果是完全相同,因为他们代表的是同一个元素的地址。

【TODO】

  • 地址的连续性
  • 二维数组越界访问如何处理

指针变量

在 C++ 中,指针是一种特殊的变量,它是专门用来存储变量的内存地址的。直接看例子容易明白。

#include <iostream>
using namespace std;
int main() {
int a = 5;
// 定义指针
int* pointer_a;
// 为指针赋值
pointer_a = &a;
cout << "a: " << a << endl;
cout << "&a: " << &a << endl;
cout << "pointer_a: " << pointer_a << endl;
return 0;
}

这里定义了一个指针变量 pointer_a,该变量存储了变量 a 的地址。程序的运行结果如下:

a: 5
&a: 0x7ffc0e3f045c
pointer_a: 0x7ffc0e3f045c

可以看到,这里指针 pointer_a 的值与变量 a 的地址是完全一致的。

在定义指针变量时,除了正常的变量名称,需要在前面加上 * 表明这是一个指针变量,* 的位置可以加在变量名前,也可以紧跟在变量类型后面。

int *pointer_a; // 位置1: 加在变量名前
int* pointer_a; // 位置2: 紧跟在变量类型后

推荐使用位置2,紧跟在变量类型后,这样就不会误解,以为 *pointer_a 这个整体是变量名,指针变量名是不包含 * 的。

实际上,point_a*pointer_a 代表的完全不同的意思,在指针变量名前加星号,如 *pointer_a,是直接获取该指针中存储的地址所指向的值

// 通过指针访问变量
cout << "pointer_a 指向的值: " << *pointer_a << endl;

下面的示意图显示了普通变量 a ,a 的地址 &a,以及指针变量 pointer_a 这几者之间的关系。

更改指针变量所指向的值

既然指针变量存储的是变量的地址,我们就可以通过这个地址去修改变量的值。

下面是一个简单的例子:

#include <iostream>
using namespace std;
int main() {
int a = 5;
int* pointer_a;
pointer_a = &a;
// 更改指针变量所指向的值
*pointer_a = 8;
cout << "a: " << a << endl;
cout << "&a: " << &a << endl;
cout << "pointer_a: " << pointer_a << endl;
cout << "pointer_a 指向的值: " << *pointer_a << endl;
return 0;
}

这里我们添加了一行,通过指针去修改它所指向的值,可以发现,原始的变量 a 的值也被修改了。

需要注意的是,指针变量也有大小,它是指针本身在内存中占用的空间,而不是它所指向的数据的大小,别弄混淆了。指针的大小是由操作系统和编译器决定的,通常在 32 位系统上是 4 个字节,在 64 位系统上是 8 字节。

指针与函数

在前面讲解函数的章节中,我们已经看到函数的参数传递分为值传递和引用传递两种方式。

引用传递实际上就是将实参变量的地址传递给函数,当时讲到一个交换两个变量的函数 swap,大家还记得吗?

void swap(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}
swap(a, b); // 调用函数

在上面的代码中,我们通过引用传递实现了两个变量的交换。

既然指针又是存储内存地址的变量,我们也可以通过传递指针变量的方式来实现同样的功能。

void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
swap(&a, &b); // 调用函数

两者实现的效果一样,不同的是函数参数的定义和调用函数的方式有所区别,通过这两种不同的写法,可以更好地帮助理解指针与内存地址之间的关系。

指针的运算

如果指针 pp 指向数组元素 ee,那么

  • 表达式 p+ip + i 为指向元素 ee 向后 ii 个位置的元素的指针
  • 表达式 pip - i 为指向元素 ee 向前 ii 个位置的元素的指针

例如下面的代码:

int q[5] = {1, 2, 3, 4, 5};
int* p = &q[2];
cout << *p << endl; // 输出 3
p = p + 2;
cout << *p << endl; // 输出 5
p = p - 1;
cout << *p << endl; // 输出 4

前面有讲到,数组名本身就是数组所在的内存地址,所以我们可以使用 数组名 + i 来遍历数组中的元素。如下面的代码所示:

int q[5] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) {
cout << *(q + i) << endl; // 利用指针运算来遍历数组元素
}

动态分配内存

也可以使用指针来动态分配内存,例如:

// 动态分配了一个 int 类型的内存空间
int* p = new int;
// 将值 10 存储到刚刚分配的内存空间中
*p = 10;

这里的 new 运算符用于动态分配指定类型的内存空间,将分配的内存空间地址存储在指针变量 p 中,然后使用 *p 来设定该地址所指向的值。

需要注意的是,在使用完动态分配的内在后,需要使用 delete 运算符去释放对应的内存空间。

// 释放动态分配的内存空间
delete p;

动态创建数组对象

上面是单个对象动态创建,同样的方式也可以用于动态创建数组,下面是一个动态创建数组的完整示例:

#include <iostream>
using namespace std;
int main() {
int size;
cin >> size;
// 动态创建含有 size 个元素的数组对象
int* p = new int[size];
// 继续读入元素到数组
for (int i = 0; i < size; i++) {
cin >> p[i];
}
// 测试:遍历输出数组每一个元素
for (int i = 0; i < size; i++) {
cout << p[i] << " ";
}
// 释放动态分配的内存空间
delete[] p;
return 0;
}

可以发现,使用 new 运算符动态创建数组,数组元素的个数可以不用是常量(这里是通过外部读入的),也就是说数组元素的个数是在运行时确定的,相比普通的数组创建来说有更高的灵活度。

另外,销毁数组是用 delete[] 运算符,别忘了 [] 符号。

使用指针的一些常见错误

指针可以直接操作内存地址,这是 C++ 强大的功能之一,但也正是因为它的强大与灵活,一旦没使用好,很容易造成程序崩溃、内存泄漏或逻辑错误等问题。在使用时要特别小心。

这里介绍几种使用指针时的常见错误用法。

错误 1

// 访问未初始化或无效的指针
int a, *p;
a = 5;
*p = a + 5;
cout << a << endl;

修改后:

int a, *p;
a = 5;
p = &a;
*p = a + 5;
cout << a << endl;

错误 2

// 同一行声明多个指针
int* p1, p2;
int a = 5;
int b = 3;
p1 = &a;
p2 = &b; // 出错

运行代码,会遇到 int*int 的类型转换错误。

当我们想在一行定义两个同类型的指针,初学者容易这样写,这是一个常见的声明指针的错误。

修改后:

int *p1, *p2; // 通过分开定义来修正错误
int a = 5;
int b = 3;
p1 = &a;
p2 = &b;

错误 3

int a = 5;
int* p = &a;
*p++; // 出错
cout << *p << endl;

写这段代码的本意是希望将 p 指针指向的值增加 1,但运行该程序得到的结果可能是一个很大或很小的,错误的整数。

修改后:

int a = 5;
int* p = &a;
(*p)++; // 修改的语句
cout << *p << endl;

小结

C++ 中的指针是一种强大的工具,它们提供了直接访问和操作内存的能力,使用得当,可以极大的简化一些编程任务的执行,但如果未使用好,容易造成一些不易调试的内存访问问题。因引在使用指针时需要谨慎,多积累一些内存管理的技巧,养成良好的编码实践习惯。