对于学习C++的人来说,指针是一个绕不过去有比较难以理解的点,特别将它揪出来单独学习。

考虑下面代码,这是一个简单的变量声明;

char x {}; // char 占用一个字节内存

对于上面的代码,简单理解就是当该代码被执行时,程序会将一块内存从 RAM 分配给这个对象。为了举例说明,假设变量 x 被分配到了内存地址 100。每当我们在表达式或语句中使用变量 x 时,程序将访问内存地址 100 处存储的值。

关于变量的好处是,我们不必担心分配了哪些特定的内存地址或需要多少字节来存储对象的值,只需要通过其给定的标识符来引用变量即可,编译器会将此名称转换为适当分配的内存地址。并负责所有的地址管理。

对于引用,这一点也是成立的:

int main()
{
    char x {}; // 假设这被分配了内存地址 140
    char& ref { x }; // ref 是 x 的左值引用(当与类型一起使用时,& 表示左值引用)

    return 0;
}

因为ref充当x的别名,因此每当我们使用ref时,该程序将转到内存地址100以访问该值。同样,编译器会自己管理地址信息,我们不需要操心。


取地址符(&)

默认情况下,变量使用的内存地址并不会暴露给我们,但是可以使用取地址符&来实现饭绘其内存地址。用法很简单:

#include <iostream>

int main()
{
    int x{ 5 };  // 定义一个整型变量 x,并初始化为 5
    std::cout << x << '\n';  // 打印变量 x 的值 (5)
    std::cout << &x << '\n'; // 打印变量 x 的内存地址

    return 0;
}

下面是在我电脑上的输出:

image-20250218193337467

内存地址通常打印出来是十六进制的值,对于使用多个字节内存的对象,取地址操作符(&)将返回对象所使用的第一个字节的内存地址。(如一个数组、结构体、类等)使用 & 操作时,它将返回该对象的第一个字节的内存地址。

由于&符号在c++中的使用很多,因此具有不同的含义,具体需要工具实际使用上下文去判断;

  • 当 & 跟随在类型名后面时,它表示一个左值引用: int& ref;
  • 当在表达式中的单一上下文中使用时,它表示一个地址: std::cout << &x
  • 当在多个表达式中进行运算使用时,它表示位运算符:std::cout << x & y;

解引用操作符(*)

当我们拥有一个地址,便可以使用解引用操作符来访问存储在这个地址上的值;

#include <iostream>

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

    std::cout << *(&x) << '\n'; // 打印变量 x 的内存地址处的值(括号不是必需的,但有助于提高可读性)

    return 0;
}

image-20250218201820622

取地址符&和解引用符*可以作为一个一对反相操作来使用,一个获取地址,一个获取地址所在的值;

但是有没有一种可能,我们获取到某个变量的内存地址,然后通过解引用再去获取这个地址的值似乎有些多余了, 毕竟,如果要获取这个值,为什么不直接用变量来访问呢?所以,接下来该谈论指针了。


指针

指针是将内存地址作为其值的对象。指针是一个对象,它保存一个内存地址(通常是另一个变量的地址)作为其值。这使得我们可以存储其他对象的地址,并在稍后使用该地址。

⚠️我们这里谈论的指针是指原始指针,关于智能指针的内容会在后面的文章中学习。

指定指针的类型(例如 int*)称为 指针类型。就像引用类型是使用 & 字符声明,指针类型是使用 ***** 字符声明:

int;  // 一个普通的 int 类型
int&; // 一个 int 值的左值引用
int*; // 一个指向 int 值的指针(保存一个整数值的地址)

创建一个指针变量:

int main()
{
    int x { 5 };    // 普通变量
    int& ref { x }; // 一个整数的引用(绑定到 x)

    int* ptr;       // 一个指向整数的指针

    return 0;
}

声明指针类型时,最佳的实践是将星号放在类型名称的旁边。

尽管通常不建议在同一行声明多个变量,但如果你这么做,必须将星号(*)与每个变量一起使用。

int* ptr1, ptr2;   // 错误:ptr1 是指向 int 的指针,但 ptr2 只是一个普通的 int!
int* ptr3, *ptr4;  // 正确:ptr3 和 ptr4 都是指向 int 的指针

指针的初始化

像普通的变量一样,默认情况下的指针不会初始化。尚未初始化的指针又称为野指针。指针与普通变量一样,如果没有明确初始化,它们会包含随机的内存地址,这些地址通常是垃圾值。这个垃圾值指向未知的位置,尝试解引用一个野指针将导致程序崩溃或行为不可预测。

int main()
{
    int x{ 5 };

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

    return 0;
}

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

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

#include <iostream>

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

    int* ptr{ &x }; // ptr 保存 x 的地址
    std::cout << *ptr << '\n'; // 使用解引用操作符打印 ptr 保存的地址处的值(即 x 的值)

    return 0;
}

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

#include <iostream>

int main() {
    int x = 10;       // 一个整数变量 x
    double y = 20.5;  // 一个双精度浮点数变量 y

    int* ptr1 = &x;   // 正确:ptr1 是指向 int 类型的指针,指向 x
    double* ptr2 = &y; // 正确:ptr2 是指向 double 类型的指针,指向 y

    // 错误:试图将 int 类型的指针指向 double 类型的变量
    // int* ptr3 = &y;  // 错误:类型不匹配

    // 错误:试图将 double 类型的指针指向 int 类型的变量
    // double* ptr4 = &x; // 错误:类型不匹配

    std::cout << *ptr1 << '\n';  // 输出 ptr1 解引用后的值,即 x 的值
    std::cout << *ptr2 << '\n';  // 输出 ptr2 解引用后的值,即 y 的值

    return 0;
}

另外,禁止使用字面值来初始化指针。

int* ptr{ 5 }; // 不可以
int* ptr{ 0x0012FF7C }; // 不可以,0x0012FF7C 被当作一个整数字面值处理

指针的赋值

我们可以通过两种方式使用指针赋值:

​ 1. 改变指针指向的对象(通过给指针赋一个新的地址)

​ 2. 改变指针指向的值(通过给解引用的指针赋一个新值)

首先,让我们来看一个例子,展示如何改变指针指向不同的对象:

#include <iostream>

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

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

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

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

    return 0;
}

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

现在让我们看看如何使用指针来改变指向对象的值:

#include <iostream>

int main()
{
    int x{ 5 };
    int* ptr{ &x }; // 使用 x 的地址初始化 ptr

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

    *ptr = 6; // 将 ptr 指向地址的对象(x)的值修改为 6(注意这里是解引用 ptr)

    std::cout << x << '\n';    // 打印修改后的 x 的值
    std::cout << *ptr << '\n'; // 打印 ptr 指向地址的值(即 x 的地址)

    return 0;
}

5

5

6

6

在这个例子中,我们定义了指针 ptr,并用 x 的地址初始化它,然后打印 x*ptr 的值(都是 5)。由于 *ptr 返回一个左值,我们可以在赋值语句的左侧使用它,正如我们所做的那样,通过 *ptr = 6; 来改变 ptr 指向的值(将 x 的值改为 6)。接着我们再次打印 x 和 *ptr 的值,以展示值已经按预期更新。

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

#include <iostream>

int main()
{
    int x{ 5 };
    int& ref{ x };  // 引用绑定到 x
    int* ptr{ &x }; // 指针指向 x

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

    ref = 6;  // 修改 ref 所指向的值
    std::cout << x << '\n';     // 打印修改后的 x 的值(6)
    std::cout << ref << '\n';   // 打印修改后的 ref 的值(6)
    std::cout << *ptr << '\n';  // 打印 ptr 指向的值(6)

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

    return 0;
}

因此,指针和引用都提供了一种间接访问另一个对象的方式。它们的主要区别在于,指针需要显式地获取要指向的地址,并且必须显式地解引用指针来获取值。而引用则是隐式地进行地址获取和解引用。

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

​ • 引用必须初始化,而指针不要求初始化(但最好初始化)。

​ • 引用不是对象,而指针是对象

​ • 引用不能重新绑定(不能改变引用指向其他对象),而指针可以改变它们指向的对象

​ • 引用必须始终绑定到一个对象,而指针可以指向空


地址运算符返回指针

值得注意的是,地址运算符(&)并不会将其操作数的地址作为字面值返回(因为 C++ 不支持地址字面值)。相反,它返回一个指向操作数的指针(该指针的值是操作数的地址)。换句话说,给定变量 int x,&x 返回一个 int*,它保存着 x 的地址。

#include <iostream>
#include <typeinfo>

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

    return 0;
}

int

int*

使用 GCC 时,输出结果为 i(表示 int)和 pi(表示指向 int 的指针)。由于 typeid().name() 的结果依赖于编译器,因此你的编译器可能会打印不同的内容,但它们的含义是相同的。


指针大小

指针的大小取决于可执行文件所编译的架构——32位的可执行文件使用32位的内存地址——因此,在32位机器上,指针的大小是32位(即4字节)。而在64位的可执行文件中,指针的大小将是64位(即8字节)。

请注意,这一点与指针所指向的对象的大小无关:

#include <iostream>

int main() // 假设为32位应用程序
{
    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;
}

指针的大小总是相同的。这是因为指针只是一个内存地址,而访问存储器地址所需的位数是恒定的。


悬空指针

和悬空引用类似,悬空指针是一个持有已失效对象地址的指针(例如,因为对象已经被销毁)。

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

#include <iostream>

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

    std::cout << *ptr << '\n'; // 有效:打印 x 的值 5

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

        std::cout << *ptr << '\n'; // 有效:打印 y 的值 6
    } // y 离开作用域,ptr 现在是悬空指针

    std::cout << *ptr << '\n'; // 未定义行为:解引用悬空指针,尝试访问已销毁的对象

    return 0;
}

初始状态:指针 ptr 被初始化为指向变量 x。然后,解引用 ptr 输出了 x 的值 5,这是有效的操作。

进入新的作用域:在新的作用域中,y 被创建并初始化为 6。然后,指针 ptr 被重新赋值为指向 y。此时,解引用 ptr 输出了这是有效的操作。

离开作用域:变量 y 离开了作用域,ptr 指向的内存地址现在不再有效,指针变为悬空指针。

解引用悬空指针:最后,我们尝试解引用悬空指针 ptr,这会导致未定义的行为,因为 ptr 指向的对象已经不再有效。

感谢阅读!