上一章我们介绍了指标的基本概念,是时候将其进一步延伸了。还记得我们提过,在C语言中记忆体对程式设计师而言是裸露的,系统会根据对应资料型态分配所占用的位元数(例如:int至少佔16bits),并将变数值储存于对应的位置;配合阵列的概念,在储存一连串同样资料型态的变数时,我们可以借由每一阵列值对应的index来随机存取想要的数值,资料型态所占用的位元数则无需我们计算,只需像阵列一样给出index即可,我们看一个小例子:

#include <stdio.h>

int main() {
// index : [0][1][2]
int arr [] = {1, 3, 5};

// same as int * ptr = arr;
int * ptr = &arr[0];
ptr = (ptr+2); // index 3-rd element
printf("from pointer : %d\\n", *(ptr));
printf("from arr : %d\\n", arr[2]);

printf("access arr[1] from pointer : %d\\n", *(ptr-1));
}

上一章介绍阵列时提过,arr本身几乎等同于一个指向第一个变数值 1 记忆体位址的指标,所以可以直接赋值给ptr,不过我们也可以先存取到变数值arr[0],再复习一下取址符号 &arr[0] 同样的来取得第一个变数值的记忆体位址。

关键在于*(ptr+2),想必聪明的读者已能透过上一章介绍的内容轻易的解读出它的意义,由于ptr是指向int的指标,系统自然知道(ptr+2)是往后位移2个整数 (在colab上是int佔32bits,而我们不需显示的说明是位移64bits的记忆体位址)再取值,而这样的效果等同于arr[2],就可读性与便利性而言,读者可能也逐渐理解为何上一章结尾笔者仍建议 "尽可能地使用阵列取代部分指标的工作"。

既然指标进行的是记忆体位址的位移,可以往后(ptr+2),自然的也可以往前了(ptr-1);反观,阵列定义时即可用正整数的index存取所有范围的数值,没有用arr[-1]尝试非法存取其他变数范围的需求,自然地在C语言中arr[-1]就比较少见,不过ptr-1仍是很常见的位移操作手段。

就K&R教科书花了许多篇幅讲解指标的这个特性,我们可以尝试再比较一下阵列与指标在其他任务上的操作;例如逐一遍历阵列中的所有储存值:

#include<stdio.h>
#define len(arr) sizeof(arr)/sizeof(arr[0])

int main(){
int arr [] = {1, 3, 5, 7, 9, NULL};
int * ptr = arr;

printf("1. array version traversal\\n")
// len(arr) - 1 to skip NULL dummy value, that\'s the endpoint for pointer
for(int idx=0 ; idx < len(arr)-1 ; ++idx)
printf("%d, ", arr[idx]);

printf("\\n2. pointer version traversal\\n")
for(; *ptr != NULL ; ++ptr)
printf("%d, ", *ptr);
}

可以看到,採用阵列完成仍是较为理想的;或许採用指标在位移和存取数值上看起来比较优雅,但对于何时抵达一连串储存值的尽头,指标显然比较不容易处理这个议题(我们借由在结尾放置 NULL值来提供hint);如果阵列长度如同教科书的范例永远是固定的(例如一年总是12个月、英文字母总是26个、神秘数字总是42),则这个问题就没有这么严重了。

#include<stdio.h>

int main(){
const int N = 5;
// type conversion to composed literal (int []), just kind of array..
int * ptr = (int []){1, 3, 5, 7, 9};

for(int idx=0 ; idx < N ; ++idx, ++ptr){
--(*ptr); // you can remove ( ), but why not keep it ~
printf("%d, ", *ptr);
}
}

既然指标指向的阵列长度是固定的,我们就可以不依赖end point值(NULL)做结尾判断,但需另外设置一个counter(idx变数)纪录已遍历的数值个数;这类分化同一事务的作法是否恰当(计数器自己纪录自己的、指标自己位移自己的),则看交由各位读者自行判断了(至少笔者不喜欢www)。

值得一提的是,在例子中笔者带出了一个简化阵列赋值给指标的作法;以往我们会先定义一个阵列变数,在将其位址赋值给指标ptr,而这一切可以借由复合常量(composed literal)的支援进行简化,它的声明如同阵列一样,我们可以对初始化列表{1, 3, 5, 7, 9}使用(int [])来进行显示的转型;背地里,系统会在记忆体产生一个匿名的阵列(因为是由初使化列表转型、尚未赋值给任何变数),接着宣告一个指向此匿名阵列的指标ptr;而我们也可以自由地更改里面的内容(范例中我们将每个值减1),即使它被称为常量。

介绍了指标的位移和阵列的优势,我们可以借由对两者概念的理解,适当的将位移与阵列结合,轻易实现一些trick:

#include<stdio.h>

int main(){
int arr [ ] = {1, 3, 5, 7, 9};
printf("sizeof arr : %d vs. sizeof int : %d\\n", sizeof(arr), sizeof(arr[0]));
// not safty : (*(&arr + 1))[-1]), *(*(&a + 1) - 1)

int* arr_end = (int *)(&a + 1);
printf("The last element of arr : %d", arr_end[-1]);
}

我们想要存取阵列的最后一个值最好的作法当然是arr[sizeof(arr)/sizeof(arr[0])],虽然这个做法在没有採用巨集时似乎不太优雅,但sizeof在arr和arr[0]的差异却给了我们一个转机! 我们知道sizeof(arr)的尺寸会返回整个阵列的长度,因此当我们对arr的取址并往后位移1格时,事实上(&arr+1)已经让我们移动到阵列末尾的位址,此时将此位址转型为指向资料型态 (int*),而非指向阵列的指针时,根据sizeof(arr[0])的尺寸则会返回一个整数资料所占用的长度,我们对位于尾端的位址往前 *(arr_end-1)移动一个整数,刚好就是阵列最后元素的起始位址,这巧妙的trick很多时候也被写成一行外星密码(*(&arr + 1))[-1]或*(*(&a + 1) - 1),在理解的同时也请注意安全。

巧妙的trick或许有用,却是经不起时间考验的;这句话不一定是指着技术本身有问题,而是将人为因素考量进去的经验。不过在这里,(*(&arr + 1))[-1]或*(*(&a + 1) - 1)确实是技术上不安全的! 你可以储存一个阵列尾端的指标 *(&a + 1),但对这个位址取值 (解参考, deference)的行为时常是未定义的,也就是运气好时它可能成功(例如阵列后面刚好不是程式非法存取的记忆体区段),运气不好时则会莫名的失败。

然而,阵列也有不适用的时候;将阵列作为参数传递至函数时,他会退化为一般的指标!!

#include <stdio.h>
#define len(arr) sizeof(arr)/sizeof(arr[0])

void array_decay(int arr []){
printf("prnt len of arr : %ld", len(arr));
}

int main()
{
int arr [] = {1, 3, 5, 7, 9};
printf("len of arr : %ld\\n", len(arr));

array_decay(arr);
return 0;
}

从运行结果可以看出,原本阵列arr占用了20 bytes(5个int)的空间,将阵列传递至函数时则退化成普通的指针,指针的大小只佔 8 bytes(这个数值还会随着具体的机器而不同),既然指针的大小已经与阵列不符,巨集后续採用sizeof进行的运算就全盘皆错了。要在函数中得知阵列的实际长度,只能在传入阵列前先进行计算,并将长度作为整数参数一併传入函数了。

关于为何阵列传入函数会退化成指标,请参阅传送们


回顾指标的概念,指标声明了一个储存其他变数记忆体位置的变数,既然指标自身也是变数,它就应该也有记忆体位置;而巧妙的是我们能用另一个指标来储存这个指标的记忆体位置,形成了指标的指标(多重指标)。除了上ㄧ章提到储存字串阵列时会使用多重指标外,另一个典型的例子就是多维阵列了:

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

double** make_matrix(int h, int w, double init_value){
double **mtx = calloc(h, sizeof(double*));
for(int idx=0; idx<h ; ++idx){
mtx[idx] = calloc(w, sizeof(double));
for(int jdx=0 ; jdx<w ; ++jdx)
mtx[idx][jdx] = init_value;
}
return mtx;
}

int main()
{
int h=3, w=2;
double** mtx = make_matrix(h, w, 3.14);
for(int idx=0; idx<h ; ++idx){
for(int jdx=0 ; jdx<w ; ++jdx)
printf("%f ", mtx[idx][jdx]);
printf("\\n");
}
// forgot to free(mtx)...
}

我们撰写了一个函数make_matrix来创建矩阵,如上ㄧ章所提,回传ㄧ个在函数内创建的阵列就需运用calloc自行分配与管理记忆体,这样主程式main所接收的多维阵列才能继续使用。

malloc是最常听闻的内建函数,用于辅助我们分配ㄧ个匿名的记忆体区块,它的函数声明为 void *malloc(size_t size),要求你直接计算完总佔用的尺寸(nitems*sizeof(items_type))再传递参数给它,而它会返回ㄧ个void*指标指向分配的匿名记忆体区块;基于CH3提到的隐式转换之ㄧ,我们只需声明适当的指标变数来接收void*指标就会自动转型。

然而malloc的缺点在于它所分配的匿名记忆体区块并不会初始化,所以取得后还需要按需求初始化里面的数值;虽然这并不构成麻烦 (范例子中我们也是直接根据给定的init_value初始化),不过calloc却能更贴心的帮我们先将匿名记忆体区块的值初始化为0或NULL,因此大部分仍推荐採用calloc函数;此外它的函数介面也更清晰 void *calloc(size_t nitems, size_t size),它将要分配几个值(nitems)与分配的资料型态所佔位元(size, 或是sizeof(items_type))相区分。

理解分配的函数后,多重指标也只是依序地要求分配空间,或指向已创建的阵列;这边我们创建的对象是矩阵,因为只有两个维度,首先将h(height)个double指标赋值给双重指标double** mtx,尔后再用1个for迴圈遍历这h个指标变数,对于每个指标变数,我们再同样分配拥有w(width)个值的记忆体区段给它们即可;为便于同时初始化数值,笔者额外用了第二个for迴圈将w个值按需求初始化;讲解到这里可以说是有点画蛇添足了,聪明的读者只要熟悉前面指标的概念,上面的程式码恐怕只是小试身手的层级了 ~


很高兴看到C语言亲和的ㄧ面,遗憾的是有时候我们要对ㄧ个不确定维度的多维阵列进行操作(多维指标),这时我们也就不能像上述范例,预先撰写for迴圈了;这时我们回到开头的ㄧ个概念:"记忆体对我们是裸露的",因此实务上比较採用直接宣告ㄧ个阵列,阵列直接包含所有维度的值,而将各维度的资讯(例如每ㄧ个维度有多少元素)当作参数一併传给函数;活用开头介绍的位移来灵活地存取、操作不同维度的资讯。

然而,在建构更加严谨、大型的程式,我们宁可建议读者直接採用ㄧ些知名的开源库,例如(GNU Scientific Lib, GSL)就提供了完整测试、多应用场景、良好的介面设计等性质的多维阵列物件(2维即为矩阵)、常用的数值计算函数,让我们不需重复造轮子!

或许你曾和笔者ㄧ样,觉得只要写python的script-kid才会call别人的函数库,都已经写到C语言了还要用别人的lib吗? 这样不够hack喔! 对于累积经验等自行开发的专案,自行撰写程式码,并进行对应的测试是ㄧ个很好的方向;不过大型程式常考验的更是整体的软件架构、软件品质的管控、减少技术的负债等,这些不是我们需要浪费生命去踩雷的,相信其他世界上的co-worker,一起建构更好的软件,我想才是更有意义的 ~

预告 CH5:ㄧ些开头没介绍的入门概念?!