指针那些事儿

指针那些事儿

1. 指针的本质

指针的主要目的在于表示内存中的数据。在此,我们先简要回顾内存存储数据的机制。

内存由众多单元格组成,每个单元格都被赋予一个唯一的地址。每个单元格的存储容量为一个字节,这恰好与 char 类型所占的空间大小一致。从硬件层面而言,系统提供了依据地址获取存储数据的方法。一份数据在内存中存储时,可能占用一个单元格,也可能由若干连续的单元格共同存储。

那么,若要表示内存中的一份数据,可行的方式有哪些呢?

  1. 记录该数据所有单元格对应的地址。
  2. 基于通常情况下同一份数据的单元格是连续存储这一前提,仅需记录数据存储区域的首地址以及数据的总长度即可。

显然,第二种方式相较于第一种方式具有明显优势。而指针,本质上就是基于第二种方式的一种表达方式。

进一步来讲,数据总长度信息与数据类型信息在本质上是等价的。因为一种数据类型实际上就明确了存储该数据所需占用的单元格数量。然而,使用数据总长度来描述数据的存储需求,在表达上可能会引发歧义。因此,在编程中,人们更倾向于使用数据类型来表示数据长度这一概念。

再进一步分析,数据类型对于编译器而言,决定了其对首地址数据的解读方式。编译器的主要功能是将 C/C++ 等高级语言转换为汇编语言,而在汇编语言层面,并不存在自定义类型长度的概念。当程序中使用自定义类型时,编译器会对该类型进行解析,明确表示该数据所需的单元格数量。在编译器生成汇编语言代码的过程中,会直接指定按照相应数量的单元格去读取数据。所以,也有人认为,数据类型的重要意义在于告知编译器应采用何种格式去读取特定地址处的数据。

综上所述,指针就是数据类型,结合内存地址,来表示一份内存的数据。

1.1 指针的种类有哪些

在 C 和 C++编程中,指针是一种强大而灵活的工具,具有多种类型,每种类型都有其特定的用途和特点。以下详细介绍各类指针:

  1. 基本对象指针:这类指针指向基本数据类型。例如,int* p; 这条语句定义了一个指针 p,它专门用于指向整型数据。通过这个指针,程序可以直接访问和操作内存中存储的整型值。

  2. ``void指针void*是一种特殊的指针类型,它具有通用性,可以指向任何类型的数据。通常在需要存储任意类型地址的场景中使用,比如在编写通用函数时,其参数可能接受不同类型的数据地址,这时就可以使用void*指针。但需要注意的是,由于void 指针没有明确的数据类型,在使用它访问所指向的数据时,通常需要进行类型转换,将其转换为具体的数据类型指针,以便编译器能够正确解析和处理数据。

  3. 常量指针与指向常量的指针:这是两个容易混淆但概念截然不同的指针类型,同时还有指向常量的常量指针。

    • 指向常量的指针:以 const int* p; 为例,这里定义的指针 p 可以指向一个整型常量。这种指针的特点是,不能通过它来修改所指向的值,这为数据提供了一定的保护机制,防止意外的修改。但指针本身可以指向其他的整型常量或变量。
    • 常量指针:如 int* const p;,此指针 p 本身是一个常量。一旦初始化后,它所指向的地址就不能再改变,但通过这个指针可以修改其所指向的值。这在一些需要固定指向某个特定内存位置的场景中很有用。
    • 指向常量的常量指针const int* const p; 定义的指针既不能改变它所指向的地址,也不能通过它修改所指向的值,为数据和指针本身都提供了最高级别的保护。
  4. 函数指针:函数指针用于指向函数,它存储的是函数在内存中的地址。通过函数指针,可以像调用普通函数一样调用其所指向的函数。例如,void (*func_ptr)(int); 定义了一个名为 func_ptr 的指针,它指向一个接受一个整型参数且无返回值的函数。这种机制使得程序在运行时能够根据不同的条件动态选择要执行的函数,增加了程序的灵活性和可扩展性。

  5. 数组指针与指向数组的指针:在数组与指针的关系中,存在数组指针和指向数组的指针这两种概念,需要清晰区分。

    • 数组名与指针的关系:在 C 和 C++中,数组名在很多情况下可以视为指向其首元素的指针。例如,定义 int arr[10]; 后,int* ptr = arr; 这种赋值是合法的,此时 ptr 就指向了数组 arr 的第一个元素。
    • 指向数组的指针:可以声明专门指向整个数组的指针,例如 int (*ptr_to_array)[10];。这里 ptr_to_array 是一个指针,它指向一个包含 10 个整型元素的数组。与指向数组首元素的指针不同,这种指针在移动时,每次移动的步长是整个数组的大小,而不是单个元素的大小。
  6. 多级指针:多级指针是指针的指针,例如 int **ptr; 定义了一个二级指针 ptr。它指向的是一个指针,而这个指针又指向一个整型数据。通过多级指针,可以构建更复杂的数据结构,并且在处理动态分配的多维数组等场景中非常有用。多级指针的概念可以扩展到更多级别,如三级指针 int ***ptr; 等,每增加一级,指针所指向的对象就是下一级的指针。

  7. 成员指针:成员指针专门用于指向类的非静态成员变量。例如,int MyClass::*ptr; 定义了一个指针 ptr,它可以指向 MyClass 类中的某个整型成员。使用成员指针可以在运行时动态地访问类的不同成员变量,这在一些需要灵活操作类成员的场景中非常实用,比如在实现反射机制或某些通用的类操作算法时。

1.2 指针的大小

在特定的系统架构中,各类指针在内存中所占据的空间大小通常是一致的。这背后的原因在于,指针的核心功能是存储内存地址,而地址空间的规模是由硬件与操作系统共同确定的,并非取决于指针所指向的数据类型。

在常见的 32 位系统里,不管指针指向的是int类型数据(如int*)、char类型数据(如char*),还是double类型数据(如double*)等,指针自身的大小一般为 4 字节,换算成二进制位即 32 位。这是因为 32 位系统的地址总线宽度为 32 位,它所能表示的地址范围决定了指针需占用 4 字节来存储完整的内存地址。

而在 64 位系统环境下,情况类似,几乎所有类型的指针,无论其指向何种数据类型,通常大小为 8 字节,也就是 64 位。64 位系统的地址总线更宽,能够处理更大的地址空间,因此需要 8 字节来存储一个内存地址。

需要留意的是,尽管上述情况是普遍现象,但在使用特殊编译器选项,或者处于某些特定的虚拟化环境时,即便在 32 位系统中,也存在将指针配置为 64 位的可能性。不过,这种情形相对罕见,在常规的 32 位系统应用开发中,指针大小基本遵循 4 字节的标准。

1.3 总结

综上所述,指针的本质可归结为类型(与数据长度等价)和地址两个关键要素。

在现代计算机系统中,无论指针指向何种数据类型,其自身占用的内存大小通常是固定的,且与计算机的位数相关。以常见情况为例,在 64 位计算机系统中,指针一般占用 8 字节的内存空间。

指针类型具有重要作用,它指导编译器如何解读特定地址中的内存内容以及确定该内容的大小。例如,int* 类型的指针会使编译器按照 int 类型的长度和格式去解释所指向地址处的内存数据。

void* 指针较为特殊,它能够存储一个地址,但由于其未明确所指对象的数据类型,因此无法直接通过它对所指对象进行操作,因为此时无法确定其覆盖的地址空间范围及数据格式。

在使用指针进行内存操作时,硬件亲和性是一个不容忽视的重要因素。内存访问并非简单地逐个地址获取数据,实际上,它通常遵循一定的模式,按 “行” 进行操作。例如,当通过指针访问 int 类型(4 字节数据)的数据时,并非分四次分别访问对应的四个字节地址,而是一次性访问包含这四个字节的一行内存空间。字节对齐机制正是基于这种内存访问模式而产生的。字节对齐能够确保数据在内存中的存储方式符合硬件高效访问的要求,从而显著提升内存访问效率,使基于指针的内存操作更为顺畅和高效。

进一步深入分析,内存访问过程通常是先查询高速缓存,若未命中再查询内存。这里提到的 “行” 的大小,实际上由高速缓存决定。综合各种因素考量,目前常见的缓存行大小一般为 64 字节,这相当于 8 个地址长度,或者说能够容纳 16 个 int 型数据。

值得注意的是,当读取非内存对齐的数据时,可能会出现数据读取的原子性问题。例如,若一个数据需要读取两行才能完整获取,在读完第一行后,第二行的数据有可能在读取前被修改。不过,随着计算机硬件技术的不断发展,部分硬件已能够保证此类数据读取的原子性。

基于上述内存访问机制与指针操作原理,当面临存储同一类型数据多次且要求连续存储的需求时,应如何实现呢?

2. 指针和数组

同样,对于连续存储同一类型数据多次的需求,有以下两种常见思路:

  1. 记录每个数据的指针。然而,这种方式在实际应用中会带来较高的管理成本,因为需要维护多个指针,增加了内存开销和操作复杂度。
  2. 依据同一类型数据通常连续存储这一普遍特性,仅需掌握首数据的地址以及连续数据的数量即可。显然,第二种方式更为简洁高效。而数组,本质上正是基于这种方式的一种数据结构表达。

以 C 语言代码为例:

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

在这个数组定义中,arr 本质上代表了首数据的地址。数组的长度为 4,表示连续存储了 4 个 int 类型的数据。具体到数组元素的地址计算,arr 作为数组名,它指向数组的首元素,即首数据地址。对于数组中的其他元素,其地址遵循一定的计算规则:

  • arr[0] 是数组的首元素,其值为 1,而 arr 所代表的首数据地址假设为 0x0001(这里仅为示例)。
  • arr[1] 的地址为 首数据地址 + 类型长度 * 序数,由于 int 类型通常占 4 字节,所以 arr[1] 的地址为 0x0001 + 4 * 1 = 0x0005
  • 同理,arr[2] 的地址为 0x0001 + 4 * 2 = 0x0009
  • arr[3] 的地址为 0x0001 + 4 * 3 = 0x000D

由此可见,数组可以看作是由首数据指针和数组长度构成。进一步而言,数组是对指针概念的一种封装和抽象。它通过简洁的语法和特定的内存布局,为开发者提供了一种方便的方式来管理和操作连续存储的同类型数据集合。例如,数组隐藏了指针运算的细节,使得对数据的访问更加直观和安全。同时,数组还提供了边界检查等机制(虽然在 C 语言中这种检查并不严格),相比直接使用指针,减少了因指针操作不当导致的错误。

当我们讨论数组时,高维数组是一种常见且重要的扩展形式。那么,什么是高维数组,它又有哪些实际用途呢?

3. 高维数组

首先明确高维数组的定义。数组用于存储多个同一类型的数据,如果这个“同一类型”本身就是一个数组,那么就构成了高维数组。以二维数组为例,如以下 C 语言代码:

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

在这个例子中,arr 是一个二维数组,它可以看作是由两个元素组成的数组,而每个元素又是一个包含两个 int 类型数据的数组。

为了更深入理解高维数组的结构,我们需要引入“降维”的概念。对于 int arr[2][2],可以借助类型定义来辅助理解,例如:

// 其实typedef int[2] type可能更好理解,但parse解析规则不是这样的
// 但理解是一个意思即可
typedef int TYPE[2];
TYPE arr = { {1, 2}, {3, 4} };

这里将 int[2] 定义为 TYPE 类型,arr 就是一个由两个 TYPE 类型元素组成的数组,这样从结构上更清晰地展示了二维数组的构成。

再通过一个具体的内存空间示例进一步说明:

int tab[2][2] = { {1, 2}, {3, 4} };

从内存空间角度来看,计算机内存是按顺序线性排列的,假设起始地址为 0x7ffc5c78b530,每个 int 类型数据占 4 字节(在 32 位系统下),则内存布局如下:

Address type length Value Interpretation
0x7ffc5c78b530 int[2] 8 0x7ffc5c78b530 tab 的地址,即 tab[0] 的地址
0x7ffc5c78b538 int[2] 8 0x7ffc5c78b538 tab[1] 的地址
0x7ffc5c78b530 int 4 1 tab[0][0] 的值
0x7ffc5c78b534 int 4 2 tab[0][1] 的值
0x7ffc5c78b538 int 4 3 tab[1][0] 的值
0x7ffc5c78b53c int 4 4 tab[1][1] 的值

在这个布局中,tab 作为二维数组名,代表数组首地址,即 tab[0] 的地址 0x7ffc5c78b530tab[0] 又指向第一行的首元素 tab[0][0],其值为 1。tab + 1 则指向第二行的首地址 tab[1],即 0x7ffc5c78b538tab[1] 指向第二行首元素 tab[1][0],其值为 3。

从二维数组的逻辑角度理解,它可以看作是一个表格,有行和列的概念:

  col: 0 col: 1
row: 0 1 2
row: 1 3 4

本质上,高维数组在内存中也是按顺序一个单元挨着一个单元存储的,和一维数组并无区别。例如 int tab[2][2]int row[4] 在物理存储上是类似的,都是连续存储 4 个 int 类型的数据。然而,int tab[2][2] 这种高维数组的表示形式提供了更为便捷的访问方式。比如,要访问第二行的所有数据,可以通过 tab[1] 获取第二行的首地址,进而遍历该行数据;要访问第二行第一列的数据,即第三个数据,可以表示为 tab[1][0]。这种按行和列的逻辑访问方式,使得处理具有行列结构的数据(如矩阵等)更加直观和高效。

4. 多级指针

4.1 多级指针在参数传递中的应用

首先明确在函数参数传递场景中多级指针的作用。当我们需要在函数中传递一个指针进来或者出去时,多级指针就能发挥重要作用。例如,假设我们有一个函数需要返回一个动态分配的数组,同时返回数组的长度,并且用一个状态值表示操作是否成功。

#include <stdio.h>
#include <stdlib.h>

// 假设ID是一个自定义类型,这里简单用int代替
typedef int ID;

// 函数声明
bool getArr(ID id, int* cnt, int** arr);

int main() {
    ID id = 1;
    int cnt;
    int *arr = NULL;
    bool isSuccess = getArr(id, &cnt, &arr);
    if (isSuccess) {
        for (int i = 0; i < cnt; i++) {
            printf("%d ", arr[i]);
        }
        free(arr);
    }
    return 0;
}

bool getArr(ID id, int* cnt, int** arr) {
    // 这里简单模拟获取数组的逻辑
    *cnt = 3;
    *arr = (int*)malloc(*cnt * sizeof(int));
    if (*arr == NULL) {
        return false;
    }
    for (int i = 0; i < *cnt; i++) {
        (*arr)[i] = i + 1;
    }
    return true;
}

在上述代码中,getArr 函数接受一个 ID 类型的参数 id,以及两个指针参数 cntarrcnt 是一个 int* 类型的指针,用于传出数组的长度;arr 是一个 int** 类型的指针,用于传出动态分配的数组的地址。通过这种方式,函数可以返回多个值。

从内存空间角度来看,在 main 函数中,arr 最初是一个空指针,&arr 传递给 getArr 函数。在 getArr 函数内部,*arr 被分配内存并填充数据。这里的二级指针 arr 就像是一个“桥梁”,连接了函数内外,使得函数可以修改外部指针变量的值,从而达到传递指针出去的目的。

简单来说,如果需要传指针到函数进去修改,就会多一级指针。如果本身是二级指针,就会变成三级指针。

4.2 多级指针等价于高维数组

除了用于函数传参,需要增加一级指针,还有一种常见的多级指针的场景就是高维数组。数组在传参的时候,会退化为多级指针。 todo:

98. example

#include <cstdlib>

using Node = struct Node {
    int data;
    struct Node* next;
};

bool insertNodeAtHead(Node*** headRef, int data) {
    Node* newNode = (Node*)malloc(sizeof(Node));
    if (!newNode) return false;

    newNode->data = data;
    newNode->next = **headRef;
    **headRef = newNode;  // 修改原始头指针
    return true;
}

int main() {
    Node* head = nullptr;
    Node** headPtr = &head;          // 指向头指针的指针
    insertNodeAtHead(&headPtr, 42);  // 传递三级指针
}

99. quiz

1. 数组退化为指针是什么意思?

数组退化为指针是指在某些情况下,数组名会被编译器自动转换为指向数组第一个元素的指针。这种情况通常发生在数组作为函数参数传递时。

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
}

void printArray(int arr[5], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
}


int main() {
    int array[5] = {1, 2, 3, 4, 5};
    printArray(array, 5);  // array 退化为指针
    return 0;
}

在这个例子中,array 作为参数传递给 printArray 函数时,退化为指向 array 第一个元素的指针。正如前面说的,数组的本质是是首元素指针+数组长度

2. 数组的内存安全如何保证?越界风险如何避免?

数组的内存安全是指在使用数组时,确保不访问数组边界之外的内存,以避免越界访问。越界访问可能导致程序崩溃或意外行为。

  1. 使用数组长度:在访问数组元素时,始终使用数组的长度进行边界检查。
  2. 使用标准库函数:使用标准库函数(如 memcpymemset 等)时,确保传递的长度参数正确。
  3. 使用动态数组:使用动态数组(如 std::vector)可以自动管理数组的大小和边界检查。
  4. 启用编译器警告:启用编译器的警告和错误检查选项,可以帮助发现潜在的越界访问问题。



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好