需要预先了解:


1. &取地址符

& 是取地址符,用于获取变量的内存地址。大多数的数据类型往往占用不止一个字节的空间, & 取到的就是其中第一个字节的地址。

#include <iostream>

int main()
{
    int a = 10;         // 创建一个 int 类型的变量 a
    std::cout << "a 的地址: " << &a << std::endl;
    std::cout << "地址的大小: " << sizeof(&a) << " 个字节" << std::endl;

    return 0;
}

编译并运行代码后,看到这样的输出:

a 的地址: 0x378cfffe2c
地址的大小: 8 个字节

1.1 地址大小与 CPU 架构的关联

  • 对于x86_32架构的CPU,其地址总线为32位,对应地址的大小是32个二进制位,也就是4个字节。
  • 对于x86_64架构的CPU,其地址总线是64位,对应地址的大小是64个二进制位,也就是8个字节。

这里的输出的地址只有 10 个十六进制位,但是地址的大小是 8 个字节,也就是 8 * 8 = 64 个二进制位。说明地址是一个 64 位的值,但是前 24 位都是 0,被省略掉,所以我们只看到了后面 40 个二进制位,也就是 10 个十六进制位。

如果想看完整的输出,可以调用 <cstdio> 的 printf 函数,使用 %p 格式化输出地址:

#include <cstdio>

int main()
{
    int a = 10;                        // 创建一个 int 类型的变量 a
    printf("a 的地址:%p\n", &a);       // 输出 a 的地址
    printf("地址的大小:%lu\n", sizeof(&a));

    return 0;
}

编译并运行后,得到如下输出:

a 的地址:000000697edffd7c
地址的大小:8

2. 指针

指针是一种变量,它存储的值是另一个变量的内存地址。

声明指针时,语法为类型* 指针名;,例如:

int* int_ptr;            // 声明一个指向 int 类型的指针
double* double_ptr;      // 声明一个指向 double 类型的指针
char* char_ptr;          // 声明一个指向 char 类型的指针

可以通过**取地址符&来获取变量的地址,通过解引用操作符***来访问指针所指向的变量:

#include <iostream>

int main()
{
    int a = 5;
    int* ptr = &a;          // ptr 是指向 a 的指针

    std::cout << "a 的值: " << a << std::endl;
    std::cout << "ptr 的值: " << ptr << std::endl;   
    std::cout << "ptr 指向的值: " << *ptr << std::endl;   

    return 0;
}

2.1 指针的注意事项

1. 声明指针时的 * 号只作用于它右侧最近的一个标识符,标记这个变量是一个指针变量。因此当同时声明多个变量的时候,需要给每个变量前都加一个星号:

#include <iostream>

int main()
{
    int a = 5, b = 6;
    int *pa = &a, *pb = &b;         // 正确写法
    // int *pa = &a, pb = &b;       // 错误写法,编译器会报错
    // 这里的 pb 是一个 int 类型的变量,而不是一个指针

    return 0;
}

2. 无论什么变量类型,它的指针的大小都是固定的,与地址的大小一样。也就是 32 位系统下是 4 个字节,64 位系统下是 8 个字节。

我们对 int 类型变量 a 取地址,&a 的类型就是 int*,它的大小是 8 个字节;对 char 类型变量 b 取地址,&b 的类型就是 char*,它的大小也是 8 个字节。虽然指针的类型不同,但是它们的大小都是一样的。

不同类型的指针大小一致,内容都是对应的变量之间的地址,我们可以按照以下方式来进行类型转换,例如将 int* 转换为 char*,我们就可以按照 char 的方式来访问 int 类型变量的每一个字节:

#include <iostream>

int main()
{
    std::cout << "int 类型变量的大小: " << sizeof(int) << " 字节" << std::endl;
    std::cout << "char 类型变量的大小: " << sizeof(char) << " 字节" << std::endl;

    int a = 0x41414141;             // 0x41 是 ASCII 码中 'A' 的十六进制表示
    char* pchar = (char*)&a;        // 将 int* 转换为 char*
    std::cout << "pchar 指向的值: " << *pchar << std::endl;

    return 0;
}

编译运行后,得到以下输出:

int 类型变量的大小: 4 字节
char 类型变量的大小: 1 字节
pchar 指向的值: A

2.2 指针的运算

指针可以进行加法和减法运算,指针的加法和减法会根据指针所指向的数据类型来进行计算。

对于某种类型 Type 的指针 Type* ptr,当我们进行 ptr + k 的操作时,指针会向前移动 k 个 Type 类型的元素,也就是移动 k * sizeof(Type) 个字节。

#include <iostream>

int main()
{
    std::cout << "int 类型变量的大小: " << sizeof(int) << " 字节" << std::endl;
    std::cout << "char 类型变量的大小: " << sizeof(char) << " 字节" << std::endl;

    int a = 0x41424344;             // 0x41 是 'A',0x42 是 'B',0x43 是 'C',0x44 是 'D'
    int* p_int = &a;                // 定义一个 int* 指针,指向 a
    char* p_char = (char*)&a;       // 定义一个 char* 指针,指向 a

    // 输出指针的值
    std::cout << "p_int 和 p_char 的值" << p_int << " " << p_char << std::endl;
    
    // 输出指针加 1 后的值
    std::cout << "加1后:" << p_int + 1 << " " << p_char + 1 << std::endl;
    
    // 输出指针加 2 后的值
    std::cout << "加2后:" << p_int + 2 << " " << p_char + 2 << std::endl;

    // 输出指针减 1 后的值
    std::cout << "减1后:" << p_int - 1 << " " << p_char - 1 << std::endl;

    std::cout << *(p_char + 0) << " " << *(p_char + 1) << " " << *(p_char + 2) << " " << *(p_char + 3) << std::endl;

    return 0;
}

2.3 空指针

空指针表示该指针不指向任何有效的内存地址,有NULL和nullptr两种表达方式。但是在很多编译器中,对于NULL的实现只是简单的定义为整数0,而nullptr有自己的类型std::nullptr_t,是更安全、更建议的方式。

int* ptr1 = NULL;
int* ptr2 = nullptr;

3. 二级指针与多级指针


3.1 二级指针

二级指针是指向指针的指针。它的声明语法为 类型** 指针名;,即比一级指针多一个 * 号:

int a = 10;
int* p = &a;        // 一级指针:存储变量 a 的地址
int** pp = &p;      // 二级指针:存储指针 p 的地址

内存关系图

假设 a 在地址 0x1000,p 在地址 0x2000,pp 在地址 0x3000:

二级指针示例

  • pp 存的是 p 的地址 (0x2000)
  • p 存的是 a 的地址 (0x1000)
  • a 存的是实际数据 10

通过二级指针访问原始数据

需要 两次解引用 才能拿到最底层的值:

#include <iostream>

int main()
{
    int a = 10;
    int* p = &a;
    int** pp = &p;

    // 三层值的关系
    std::cout << "a 的值:             " << a << std::endl;      // 10
    std::cout << "*p 的值:            " << *p << std::endl;     // 10
    std::cout << "**pp 的值:          " << **pp << std::endl;   // 10

    // 三层地址的关系
    std::cout << "&a (a 的地址):      " << &a << std::endl;     // 0x1000
    std::cout << "p 的值 (即 &a):     " << p << std::endl;      // 0x1000
    std::cout << "&p (p 的地址):      " << &p << std::endl;     // 0x2000
    std::cout << "pp 的值 (即 &p):    " << pp << std::endl;     // 0x2000

    // 修改原始数据
    **pp = 20;
    std::cout << "修改后 a = " << a << std::endl;               // 20

    return 0;
}

*pp 就是 p(一级指针),**pp 就是 *p 也就是 a。


3.2 多级指针的一般规律

指针级别 声明语法 存储内容的类型 解引用到底层数据
一级 T* p; T 的地址 *p
二级 T** pp; T* 的地址 **pp
三级 T*** ppp; T** 的地址 ***ppp
n 级 T + n 个 * T+(n-1)个* 的地址 * 重复 n 次

4. new 与 delete


4.1 new 运算符

new运算符用于向操作系统申请一块足够容纳目标类型大小的堆内存,并会在刚分配好的内存上执行对应类型的构造函数,完成对象的初始化,可以有以下初始化方式:

  • 默认初始化:new 变量类型; 对于内置类型值为随机垃圾值,对于自定义类会调用默认构造函数
  • 值初始化:new 变量类型(); 内置类型会零初始化(int 为 0、指针为 nullptr);类调用默认构造
  • 直接初始化:new 变量类型(值); 通过小括号传参构造对象。
  • 列表初始化:new 变量类型{值}; 统一初始化语法,可以防止窄化转换
    int *p1 = new int;
    std::cout << "未初始化的垃圾值: " << *p1 << std::endl;

    int *p2 = new int(42);
    std::cout << "初始化的值: " << *p2 << std::endl;

    int *p3 = new int();
    std::cout << "被零初始化的值:" << *p3 << std::endl;

new []用于一次性分配连续的多个对象(数组),语法为new 类型[元素个数],同样支持多种初始化方式:

int* arr1 = new int[10];            // 其中的元素未初始化,是随机值
int* arr2 = new int[10]();          // 其中的元素初始化为0
int* arr3 = new int[10]{1, 2, 3};   // 前三个元素初始化为1, 2, 3,其余元素初始化为0

4.2 delete 运算符

对单个对象进行delete:语法为delete 指针;,会先调用析构函数,再释放内存。

int* p = new int(10);
delete p;
p = nullptr;        // 避免悬垂指针

对new[]分配的数组进行delete:会逐个调用元素的析构函数,再释放整块的内存,语法为delete[] 指针;。

int* arr = new int[10];
delete[] arr;