行列指针迷思

在中国的C语言编程领域中,有一对概念叫做“行指针”与“列指针”,他们两个形如下:

C
int arr[3][4];
int (*pRow)[4] = arr; // Row Pointer
int *pCol = arr[0]; // Column Pointer

其中行列指针都是用来访问一个二维数组而用,但是其访问的方式有一些不同,比如我要访问二行三列的元素时,行列指针访问其元素的方法分别如下:

C
int value = 0;

// Access a Row Pointer
value = pRow[2][3];
value = *(*(pRow + 2) + 3);

// Access a Column Pointer
value = *(pCol + 4 * 2 + 3);

但有趣的事情是,行指针看起来似乎与数组指针长得更像一些,而列指针更加偏向于传统的一般指针。于是我打算从英文资料内去寻找相关资料。但当我尝试使用类似于row pointer或者column pointer的关键词在Google上搜索相关资料时,真正在讨论这个概念的英文页面是从CSDN机器翻译的中文文章。加之这个概念本身与一般指针过于类似,我开始考虑这个概念本身是否是正确的。

先讨论内存结构

一维指针与二维指针的结构是极其类似的,他们两个都是连续的排列在一段连续的内存空间内,类似一条条带一样。但是不同点在于一维指针没有主次序之分,而二维指针存在主次序。用一个具体例子来说明:

假设这里有一个最多可以容纳8个元素的一维指针:

长度为sizeof(int) * 8,那么那将会在内存中开辟出一块连续的空间,并给我们指向这个内存空间起始位置的地址。此时我们可以直接通过对指针本身添加偏差值来获取我们指定一维序的地址。

有一个简化的计算方法是对一维指针添加单个Index符号(也就是[])来计算偏差值并访问其元素。

我们再来假设另外一个可以容纳8个元素的二维指针:

其中二维序长度为2,一维序长度为4,那么这一个指针的长度就是sizeof(int) * 2 * 4。同样的,我们在此也会开辟出一块连续的内存空间,同时也将会获得指向这一块内存空间起始位置的地址。

此时我们同样也是对指针添加偏差值来获得指定序的地址,但有一点不同的是,此处我们并非直接通过指定一维序来计算偏差值,而是通过公式 二维序 * 二维序长度 + 一维序 作为偏差值来计算目标序的地址。有一个简化的计算方法是对一维指针添加两个Index符号(也就是[][])来计算偏差值并访问其元素。

本质上,无论是一个数组有多少个维度,其内存空间都是一致的,没有任何区别。

而我们通过空间分布可以观察到,我们在访问二维数组和一维数组时,我们实际上全程都是在操作一维数组(这一个要求对于更高维度的数组也同样适用),而我们又知道访问一维数组的唯一方法是对一维数组的起始位置添加偏差量来访问目标位置,因此我们可以得出结论——在数组中,访问数组本质上是在操作一个单重指针。

那么行指针、列指针和数组指针是什么?

数组指针的写法与行指针完全一致:

C
#define m 3
#define n 4

int arr[m][n];
int (*p)[n] = arr; // Row Pointer

注意,在这个定义中,我们有一处(*p)表示优先级。由于我是从面向对象开始学习编程的,如果用面向对象的思想来考虑这一个语句,我们可以将语句变换为:

C
int[n] (*p) // fake but easy to understand.

此时的含义是“指向某一个长度为n的一维数组指针”。如果我们需要通过这个指针去访问某一特定位置的值,最简单的访问方法是p[a][b],看起来与直接访问arr[a][b]并无二异。

同时我们注意到,由于此时这个指针的类型是int[n],因此在这个时候p的单位偏移量就不再是通常指针的sizeof(int),而是n * sizeof(int),因此我们也就可以通过直接使用p + n来获得主序n的起始元素地址。

总结一下,行指针就是数组指针,并且其这个指针的类型长度为每一行的元素数量与元素长度之积;而列指针实际上与一般的单重指针无差异。

为什么我们需要使用数组指针?

下述代码我在Windows 11 x86_64环境下使用clang通过编译并且能够正确输出结果。

这个就是最有意思的一个问题了,无论是行指针还是列指针,两个指针实际上都是一维指针,那么为什么我们要分开使用这两个家伙呢?答案还是在类型之中。

我们知道数组的访问本质上是对指针添加以字节为单位的偏移量来实现的,那么我们也应当从这个角度出发,真实的机器偏移量是用sizeof(type)来计算的,我们的指针由于本身带有所指向类型的信息(除了void*,由于它能容纳任何一种指针,因此它的长度应当是运行平台下指针所占用内存空间的长度),因此我们的编译器帮我们完成了具体偏移量的计算,我们只需要对指针加上1,就可以获得指向下一个指针位置的地址。

回到现在的问题,如果是数组指针(行指针),那么数组指针本身是要带有这个数组长度信息的,因此我们在对这个指针操作的时候其偏移量就不再是一般的单个元素长度,而是所有元素长度之和。

我用一个微妙的例子来展示这个含义:

C
// type int[4]
int arr[4];

// a equality structure
struct arr_4
{
    int pos_0;
    int pos_1;
    int pos_2;
    int pos_3;
};

// note: Using a length of 4 due
// to memory layout alien

此时,我们发现sizeof(int) * 4sizeof(arr_4)所返回的数值是一样的。

上面的arrarr_4在理论上都可以被认为是数组来处理的,因此我们可以暂且将arr_4视作int[4]的等价类型来处理,那么这样子就可以使用这样子的方法访问arr_4数组:

C
struct arr_4 ar;
int* p = ar; // May gets warning

for (int i = 0; i < 4; i++)
{
    *p = i;
    printf("%d", *p);
    p++:
}

如果这个时候,我想要使用二维数组,那么我们应该怎么做呢?那么我们就以arr_4为基础:

C
struct arr_4 arr[3];
int (*p)[4] = arr; // May gets warning

for (int i = 0; i < 3; i++)
{
    for (int j = 0; j < 4; j++)
        p[i][j] = j;
}

此时这个操作依然能够正确运行,但是这个例子中有一个明显有意思的地方:我可以直接使用Index符号来获取或设置某个特定位置的元素。之前我们讨论过Index符号实际上是对偏差量计算公式的封装,但是当时我没有提到一个问题,它是如何得知偏差量中的元素长度的?

还记得数组指针本身是带有类型信息的吗?实际上编译器通过我们给定的指针类型,自动推算出元素的长度,我们在使用Index的时候便可以隐式利用到长度信息,从而简化了我们访问特定位置元素所需要使用的代码,这也是我们使用数组指针的原因——安全性。

而从这一组在行为上等价的示例代码中,我们也能窥见一点:实现数组指针的方法实际上就是类型的转换。

总结

本质上这个问题并非困难的话题——它甚至有点不那么入眼,看起来非常像入门必备的知识。但是我觉得这个知识从本质上就是有问题的,它并不是按照计算机天然的运行原理来思考的,而是人为割裂成了需要依靠记忆与背诵的知识点。

我还记得刚开始上这个点的时候被PPT上大页大页的公式所迷惑,让我抛弃了我所积累的计算机科学基础,拘泥于“行列”这两字的概念中。然而当我回头重新思考的时候,可笑之处自然显现。


发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注