章节目录

指针简介

本节阅读量:

指针是C++从C继承而来的强大特性,也是许多有抱负的C++学习者陷入困境的地方。然而,正如您很快就会看到的,指针并不是什么可怕的东西。

事实上,指针的行为很像左值引用。但在我们进一步解释之前,先做一个简单的开场。

考虑一个普通的变量,如下所示:

1
char x {}; // char 使用1字节内存

简单来说,当执行该定义生成的代码时,会将一块内存分配给该对象。假设变量x被分配了内存地址140。每当我们在表达式或语句中使用变量x时,程序将访问存储在内存地址140的值。

关于变量的好处是,不需要担心分配了什么特定的内存地址,或者需要多少字节来存储对象的值。只需要通过给定的标识符来使用变量,编译器将该名称转换为适当分配的内存地址。编译器负责寻址工作。

引用也是如此:

1
2
3
4
5
6
7
int main()
{
    char x {}; // 假设x的内存地址是 140
    char& ref { x }; // ref 是 x 的左值引用 (当与类型一起使用时, & 代表左值引用)

    return 0;
}

因为ref充当x的别名,所以每当使用ref时,程序将转到内存地址140来访问该值。同样,编译器负责寻址。


取地址操作(&)

尽管默认情况下变量使用的内存地址不会向我们公开,但确实可以访问到这些信息。运算符(&)返回其操作数的内存地址。这非常简单:

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

int main()
{
    int x{ 5 };
    std::cout << x << '\n';  // 打印变量 x 的值
    std::cout << &x << '\n'; // 打印变量 x 的地址

    return 0;
}

在作者的机器上,上述程序打印:

1
2
5
0027FEA0

在上面的示例中,我们使用操作符(&)来获取分配给x的内存地址,并将该地址打印到控制台。内存地址通常打印为十六进制值,通常没有0x前缀。

对于使用多个字节内存的对象,& 操作符将返回该对象使用的第一个字节的内存地址。


解引用运算符(*)

获取变量的地址本身并不是很有用。

我们可以对地址做的最有用的事情是访问存储在该地址的值。解引用运算符(*)将给定内存地址处的值作为左值返回:

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

int main()
{
    int x{ 5 };
    std::cout << x << '\n';  // 打印变量 x 的值
    std::cout << &x << '\n'; // 打印变量 x 的地址

    std::cout << *(&x) << '\n'; // 打印变量x的存储地址内的值 (括号不是必须的, 只是为了可读性)

    return 0;
}

在作者的机器上,上述程序打印:

1
2
3
5
0027FEA0
5

这个程序相当简单。首先,我们声明一个变量x并打印其值。然后我们打印变量x的地址。最后,我们使用解引用操作符来获取变量x的内存地址处的值(也是x的值),然后将其打印到控制台。

获取变量的内存地址,然后立即解引用该地址以获取值也不是那么有用(毕竟,我们可以只使用变量来访问值)。

但现在我们已经在工具包中添加了操作符(&)和解引用操作符(*),可以准备好讨论指针了。


指针(pointer)

指针是保存内存地址(通常是另一个变量的地址)作为其值的对象。这允许我们存储其他对象的地址以供以后使用。

与使用与号(&)字符声明引用类型很相似,指针类型使用星号(*)声明:

1
2
3
4
int;  // 普通的 int
int&; // int的左值引用

int*; // 指向int的指针 (保存对应的内存地址)

要创建指针变量,我们只需定义具有指针类型的变量:

1
2
3
4
5
6
7
8
9
int main()
{
    int x { 5 };    // 普通变量
    int& ref { x }; // int的引用 (绑定到 x)

    int* ptr;       // 指向int的指针

    return 0;
}

请注意,这个星号是指针声明语法的一部分,而不是解引用操作符的使用。


指针初始化

与普通变量一样,指针在默认情况下不会初始化。尚未初始化的指针有时称为野指针。野指针包含垃圾地址,解引用野指针将导致未定义的行为。因此,您应该始终初始化指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int main()
{
    int x{ 5 };

    int* ptr;        // 未初始化的指针 (包含一个垃圾地址)
    int* ptr2{};     // 空指针
    int* ptr3{ &x }; // 指向变量 x 地址的指针

    return 0;
}

由于指针保存地址,因此当我们初始化指针或将值赋给指针时,该值必须是地址。通常,指针用于保存另一个变量的地址(可以使用操作符(&)的地址来获得)。

一旦有一个指针保存另一个对象的地址,就可以使用解引用操作符(*)来访问该地址上的值。例如:

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

int main()
{
    int x{ 5 };
    std::cout << x << '\n'; // 打印变量 x 的值

    int* ptr{ &x }; // ptr 保存 x 的地址
    std::cout << *ptr << '\n'; // 使用解引用操作获取ptr保存的地址上的值 (ptr保存的是x的地址)

    return 0;
}

这将打印:

1
2
5
5

从概念上讲,您可以这样想上面的代码:

指针示意

指针的名称是ptr,ptr变量对应的地址内保存着x的地址,所以我们说ptr“指向”x。

就像引用的类型必须匹配被引用对象的类型一样,指针的类型必须与所指向的对象的类型匹配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int main()
{
    int i{ 5 };
    double d{ 7.0 };

    int* iPtr{ &i };     // ok: int指针,指向int
    int* iPtr2 { &d };   // not okay: int指针不能指向double
    double* dPtr{ &d };  // ok: double指针,指向double
    double* dPtr2{ &i }; // not okay: double指针不能指向int

    return 0;
}

除了下一课将讨论的一个例外,不允许使用字面值初始化指针:

1
2
int* ptr{ 5 }; // not okay
int* ptr{ 0x0012FF7C }; // not okay, 0x0012FF7C 被认为是int字面值

指针和赋值

我们可以以两种不同的赋值方式来使用指针:

  1. 改变指针所指向的对象(为指针赋值一个新的所指向的地址)
  2. 改变指针指向对象的值(通过解引用操作,为所指对象赋值)

首先,让我们来看一个指针更改为指向其他对象的情况:

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

int main()
{
    int x{ 5 };
    int* ptr{ &x }; // ptr 初始化指向 x

    std::cout << *ptr << '\n'; // 打印ptr指向的值 (x)

    int y{ 6 };
    ptr = &y; // // 将ptr 指向 y

    std::cout << *ptr << '\n'; // 打印ptr指向的值 (y)

    return 0;
}

以上打印内容:

1
2
5
6

在上面的例子中,我们定义了指针ptr,用x的地址初始化它,并解引用指针以打印指向的值(5)。然后,我们使用赋值运算符将ptr保存的地址更改为y的地址。然后,再次解引用指针,以打印所指向的值(现在是6)。

现在,让我们看看如何使用指针来更改所指向的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
    int x{ 5 };
    int* ptr{ &x }; // ptr 初始化指向 x

    std::cout << x << '\n';    // 打印 x 的值
    std::cout << *ptr << '\n'; // 打印ptr指向的值 (x)

    *ptr = 6; // ptr 保存的地址对应的对象 (x) 设置为 6 (注意这里ptr被解引用)

    std::cout << x << '\n';
    std::cout << *ptr << '\n'; // 打印ptr指向的值 (x)

    return 0;
}

该程序打印:

1
2
3
4
5
5
6
6

在这个例子中,我们定义指针ptr,用地址x初始化它,然后打印x和ptr(5)的值。因为ptr返回一个左值,所以我们可以在赋值语句的左侧使用它,这样做是为了将ptr指向的值更改为6。然后,我们再次打印x和*ptr的值,以显示该值已按预期更新。


指针的行为很像左值引用

指针和左值引用的行为类似。考虑以下程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

int main()
{
    int x{ 5 };
    int& ref { x };  // ref 是 x 的别名
    int* ptr { &x }; // ptr 指向 x

    std::cout << x;
    std::cout << ref;  // 使用引用打印 x 的值 (5)
    std::cout << *ptr << '\n'; // 使用指针打印 x 的值 (5)

    ref = 6; // 使用引用修改 x
    std::cout << x;
    std::cout << ref;  // 使用引用打印 x 的值 (6)
    std::cout << *ptr << '\n'; // 使用指针打印 x 的值 (6)

    *ptr = 7; // 使用指针修改 x
    std::cout << x;
    std::cout << ref;  // 使用引用打印 x 的值 (7)
    std::cout << *ptr << '\n'; // 使用指针打印 x 的值 (7)

    return 0;
}

该程序打印:

1
2
3
555
666
777

在上面的程序中,我们创建了一个值为5的变量x,然后创建一个它的左值引用和一个指向x的指针。接下来,使用左值引用将x的值从5更改为6,并且可以通过所有三种方法访问该更新的值。最后,使用解引用指针将值从6更改为7,并再次通过所有三个方法访问更新的值。

因此,指针和引用都提供了间接访问另一个对象的方法。主要的区别是,对于指针,需要显式地获取指向的地址,并且必须显式地解引用指针才能获得值。对于引用,获取地址和解引用隐式发生。

指针和引用之间还有一些其他差异值得一提:

  1. 引用必须初始化,指针不需要初始化。
  2. 引用不是一个单独的对象,指针是。
  3. 无法重新设置引用(更改为引用其他内容),指针可以更改它们所指向的内容。
  4. 引用必须始终绑定到对象,指针可以指向空。
  5. 引用是“安全的”(除了悬空引用之外),指针本身就是危险的。

取址操作返回的是指针

值得注意的是,操作符(&)不会以文本形式返回其操作数的地址。相反,它返回一个指针,该指针包含操作数的地址,其类型是从参数派生的(例如,获取int的地址将返回int指针中的地址)。

我们可以在下面的示例中看到这一点:

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

int main()
{
	int x{ 4 };
	std::cout << typeid(&x).name() << '\n'; // 打印 &x 的类型

	return 0;
}

在Visual Studio上,这打印了:

1
int *

使用gcc,它会打印“pi”(指向int的指针)。由于typeid().name()的结果依赖于编译器,因此编译器可能会打印不同的内容,但它将具有相同的含义。


指针的大小

指针的大小取决于编译可执行文件的体系结构——32位可执行文件使用32位内存地址——因此,32位机器上的指针是32位(4字节)。对于64位可执行文件,指针将是64位(8字节)。请注意,无论所指向的对象的大小如何,均是如此:

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

int main() // 假设是 32-bit 的应用
{
    char* chPtr{};        // char 1字节
    int* iPtr{};          // int 通常4字节
    long double* ldPtr{}; // long double 通常8或者12个字节

    std::cout << sizeof(chPtr) << '\n'; // 打印 4
    std::cout << sizeof(iPtr) << '\n';  // 打印 4
    std::cout << sizeof(ldPtr) << '\n'; // 打印 4

    return 0;
}

指针的大小始终相同。这是因为指针只是内存地址,并且访问内存地址所需的位数是恒定的。


悬空指针

与悬空引用很相似,悬空指针是保存不再有效的对象地址的指针(例如,因为它已被销毁)。

解引用悬空指针(例如,为了打印所指向的值)将导致未定义的行为,因为您正在尝试访问不再有效的对象。

也许令人惊讶的是,语言标准说“无效指针值的任何其他使用都具有实现定义的行为”。这意味着您可以为无效指针分配新的值,例如nullptr。然而,使用无效指针值的任何其他操作(例如复制或无效指针的数学运算)都将产生由实现定义的行为。

下面是创建悬空指针的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

int main()
{
    int x{ 5 };
    int* ptr{ &x };

    std::cout << *ptr << '\n'; // 有效

    {
        int y{ 6 };
        ptr = &y;

        std::cout << *ptr << '\n'; // 有效
    } // y 被销毁, ptr现在是悬空指针

    std::cout << *ptr << '\n'; // 解引用悬空指针,未定义的行为

    return 0;
}

上述程序可能会打印:

1
2
3
5
6
6

但可能不会,因为ptr所指向的对象超出作用域,并在内部块的末尾被销毁,使ptr悬空。


结论

指针是保存内存地址的变量。可以使用解引用操作符(*)来解引用它们,以检索它们所持有的地址处的值。解引用野指针或悬空指针将导致未定义的行为,并可能导致应用程序崩溃。

指针比引用更灵活,也更危险。我们将在接下来的课程中继续探索这一点。


12.5 通过常量左值引用传递函数参数

上一节

12.7 空指针

下一节