《Hello C++》4.7 指针的更多用法

现在我们已经初步认识了指针是什么有什么用,也知道了如何用 newdelete 关键字来申请和释放内存。这一小节我们继续往前走,探究一下指针还能做些什么。学完本节,你会发现指针不只是“保存地址”这么简单,它还可以参与运算,可以和字符串一起工作,也可以帮助我们创建运行时才确定大小的结构体。与此同时,我们还要顺便认识三种很重要的内存存储方式:自动存储、静态存储和动态存储

这些内容看起来有点散,但它们其实都围绕着同一个主题:对象到底放在哪里,又在什么时候被创建和销毁。一旦你把这层关系理顺了,后面的数组、结构体、类对象和动态数据,就会更容易理解。

4.7.1 指针运算

指针既然保存的是地址,那么它就不仅能“指向”对象,还能在一定范围内参与运算。最常见的指针运算,就是加减运算。

程序代码4-7-1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

using namespace std;

int main()
{
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;

cout << *p << endl;
++p;
cout << *p << endl;

return 0;
}

这里 p 一开始指向数组第一个元素,++p 之后,它会指向下一个整型元素。注意,这里的“加 1”并不是把地址数值简单地加 1,而是移动到下一个同类型对象的位置。对于 int* 来说,移动一步,通常会跨过一个 int 的大小。

这就是指针运算最重要的地方:指针移动的单位,是它所指向类型的大小。如果你指向的是 int,那就按 int 的步长走;如果你指向的是 double,那就按 double 的步长走。

1
2
3
4
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;

cout << *(p + 2) << endl;

这句代码会输出 30,因为 p + 2 表示移动到第三个元素的位置。

指针运算在遍历数组时很常见。不过初学阶段你可以先记住一条最重要的原则:指针运算通常只适合在同一块连续内存里使用,最典型的就是数组。

4.7.2 指针和数组的关系

前面我们已经多次见过数组。数组和指针之间的关系非常紧密,很多时候它们甚至会让初学者感到有点混乱。

1
2
int arr[3] = {1, 2, 3};
int* p = arr;

这里 arr 在很多表达式中会“退化”为指向首元素的指针,所以 p = arr 是合法的。也正因为如此,我们可以通过指针来访问数组元素:

1
2
cout << p[0] << endl;
cout << *(p + 1) << endl;

这两种写法都能访问数组中的数据。p[0]*(p + 0) 本质上是同一个意思。不过要注意,数组名本身不是普通指针。数组名表示的是整个数组对象,而指针只是一个地址变量。它们看起来很像,但并不完全相同。

4.7.3 指针和字符串

字符串和指针的关系也非常密切。我们先看 C 风格字符串。

1
2
char str[] = "Hello";
char* p = str;

这里 str 是一个字符数组,而 p 指向它的首字符。由于字符数组以 '\0' 结尾,所以我们可以通过指针逐个访问每个字符。

1
2
3
4
5
6
while (*p != '\0')
{
cout << *p;
++p;
}
cout << endl;

这段代码会依次输出 Hello。它的逻辑很简单:只要当前字符不是字符串结束标记,就继续输出并让指针前进一个字符。

指针和字符串最常见的搭配,还有字符串字面量:

1
const char* msg = "Hello C++";

这里 msg 指向的是字符串常量。由于字符串字面量通常不应该被修改,所以指针前面往往会加上 const。这一点很重要,因为如果你试图通过它去修改字符串常量,程序行为可能会出问题。对于 C++ 的 string 类,虽然它不是简单的字符指针,但它内部同样会处理字符数据,所以你在后面学习字符串时,会继续看到指针和字符串之间的联系。

4.7.4 使用 new 创建动态结构

前面我们已经会用 new 创建单个动态变量和动态数组了。其实 new 还可以用来创建动态结构体对象。

程序代码4-7-2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <string>

using namespace std;

struct Student
{
string name;
int age;
};

int main()
{
Student* ps = new Student;

ps->name = "Alice";
ps->age = 18;

cout << ps->name << endl;
cout << ps->age << endl;

delete ps;
ps = nullptr;

return 0;
}

程序代码4-7-2 中,new Student 会在动态内存中创建一个 Student 对象,而 ps 保存的就是它的地址。由于 ps 是指针,所以访问成员时要使用箭头运算符 ->

动态结构的好处是:对象大小和生命周期可以在运行时决定。比如某些对象只在程序运行过程中临时存在,或者它们的数量在运行前并不知道,这时动态创建就很有用。

不过要牢记,既然是用 new 创建的,就要记得用 delete 释放。否则就会产生内存泄漏。

4.7.5 自动存储

自动存储[1]是最常见的存储方式。它通常对应局部变量,也就是在函数或代码块内部定义的变量。

1
2
3
4
void test()
{
int a = 10;
}

这里的 a 就是自动存储对象。它在进入作用域时被创建,在离开作用域时自动销毁。也就是说,你不需要手动释放它。自动存储的优点是简单、安全、易于管理。大多数局部变量都属于这种类型,所以它也是我们最常接触的存储方式。

4.7.6 静态存储

静态存储[2]指的是在整个程序运行期间都存在的对象。最常见的例子就是全局变量和 static 变量。

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

using namespace std;

int g_value = 100;

void test()
{
static int count = 0;
++count;
cout << count << endl;
}

这里的 g_value 是全局变量,属于静态存储,count 虽然定义在函数里,但因为加了 static,它也属于静态存储。它们不会像普通局部变量那样在函数结束后立即销毁,而是会一直保留到程序结束。

静态存储对象的特点是“生命周期长”。这有时很方便,但也要小心它们带来的副作用,因为它们的值可能会在多次调用之间持续保留。

4.7.7 动态存储

动态存储[3]就是通过 new 在运行时申请的内存。前面我们创建动态结构和动态数组时,使用的就是这种方式。

1
int* p = new int(20);

这块内存不属于自动存储,也不属于静态存储,而是程序主动向系统申请来的。它的生命周期由程序员自己控制,所以必须配合 deletedelete[] 正确释放。动态存储的优势在于灵活。你可以在运行时决定申请多大空间、申请多少对象,也可以根据程序的实际需要来延长或缩短对象的使用时间。但正因为它是“手动管理”的,所以也更容易出错。

4.7.8 三种存储方式的区别

这三种存储方式可以简单地理解为:

  • 自动存储:进来创建,出去销毁;
  • 静态存储:程序开始到程序结束都在;
  • 动态存储:运行时申请,程序员手动释放。

如果你把一个程序比作一间房子,那么自动存储就像临时来访的客人,离开房间时自然就走了;静态存储像常住人员,一直都在;动态存储则像你临时租来的房间,用完之后需要自己退还。

再次提醒一下大家,理解这三者的区别非常重要。因为后面不论是结构体、数组、类对象还是函数参数传递,你都很可能反复遇到它们。


  1. 自动存储(automatic storage),通常对应局部变量。 ↩︎

  2. 静态存储(static storage),指生命周期贯穿整个程序运行期的对象。 ↩︎

  3. 动态存储(dynamic storage),指通过 new 在运行时申请的内存。 ↩︎