6

本文介绍了有助于提高 C 代码复用程度的一些基本抽象手段。原本是写给组里初学 C 的的小伙伴们看的……

C 程序是什么

A:程序是什么?

B:程序 = 算法 + 数据结构

A:算法是什么?

B:是一个函数,$Y = f(X, t)$。这个函数描述的是一组初始状态 $X$ 如何随着时间 $t$ 的变化逐步演化为另一组状态 $Y$。

A:数据结构是什么?

B:状态的描述。

A:C 程序是什么?

B:用 C 语言描述的算法 + 数据结构

累不累

B:对于事物的某一状态,是否只存在一种描述?

A:显然不是,否则作家们早已饿死。

B:空间中的某一位置,是不是一种状态?

A:是。用 C 语言可将其描述为一个点:

typedef struct {
        double x;
        double y;
        double z;
} Point;

B:为什么,你这么肯定空间是 3 维的?

A:……那将其描述为一个 n 维空间中的点:

typedef struct {
        int n;
        double *data;
} Point;

B:n 会小于 0 么?

A:……

typedef struct {
        unsigned int n;
        double *data;
} Point;

B:在 64 位的机器上,n 的取值范围还能不能放大到 $[0, 2^{64}-1]$?

A:……

typedef struct {
        size_t n;
        double *data;
} Point;

B:有时,与空间位置相关的一些运算,不需要太高的精度,但是对运算效率非常重视,这时需要用 float 类型。

A:那将 double 换成 float

typedef struct {
        size_t n;
        float *data;
} Point;

B:世界很复杂。同一个程序中,有些地方重视运算效率,有些地方重视运算精度,怎么应对?

A:……

typedef struct {
        size_t n;
        float *data;
} PointFLT;

typedef struct {
        size_t n;
        float *data;
} PointDBL;

B:程序是什么?

A:程序 = 算法 + 数据结构

B:既然你现在对同一种状态,采用了两种形式的描述,那么这种状态所处的算法,是否也对应着两种形式的描述呢?你试试写一个简单的算法,用两个空间位置构建一个矢量。

A:……

typedef struct {
        size_t n;
        float *data;
} PointFLT;

typedef struct {
        size_t n;
        float *data;
} VectorFLT;

typedef struct {
        size_t n;
        float *data;
} PointDBL;

typedef struct {
        size_t n;
        float *data;
} VectorDBL;

VectorFLT point_sub_flt(PointFLT a, PointFLT b)
{
        if (a.n != b.n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        VectorFLT v;
        v.n = a.n;
        v.data = malloc(v.n * sizeof(float));
        for (size_t i = 0; i < a.n; i++) {
                v.data[i] =  a.data[i] - b.data[i];
        }
        return v;
}

VectorDBL point_sub_dbl(PointDBL a, PointDBL b)
{
        if (a.n != b.n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        VectorDBL v;
        v.n = a.n;
        v.data = malloc(v.n * sizeof(double));
        for (size_t i = 0; i < a.n; i++) {
                v.data[i] =  a.data[i] - b.data[i];
        }
        return v;
}

B:累不累?

A:……

B:你并不累。累的人是我,因为是我杜撰的这篇文章,所有的字都是我一个一个敲出来的。

A:……

B:point_sub_fltpoint_sub_dbl 也很累。因为它们需要将你传入的参数值完整的复制到自己体内。午餐的时候,你买了俩包子,但是并没有吃它们,而是对它们进行了克隆,然后吃掉了克隆出来的包子。第二天早上你……太恶心了……

A:……

VectorFLT * point_sub_flt(PointFLT *a, PointFLT *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        VectorFLT *v = malloc(sizeof(VectorFLT));
        v->n = a->n;
        v->data = malloc(v->n * sizeof(float));
        for (size_t i = 0; i < a->n; i++) {
                v->data[i] =  a->data[i] - b->data[i];
        }
        return v;
}

VectorDBL * point_sub_dbl(PointDBL *a, PointDBL *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        VectorDBL *v = malloc(sizeof(VectorDBL));
        v->n = a->n;
        v->data = malloc(v->n * sizeof(double));
        for (size_t i = 0; i < a->n; i++) {
                v->data[i] =  a->data[i] - b->data[i];
        }
        return v;
}

B:函数的参数值,除非它们的类型是 C 语言的内建类型(int, float, double 等),否则尽量以指针的形式传递它们。

A:一闪一闪亮晶晶,漫天都是小星星……

B:现在,需要思考一下,为什么同一种状态,同一种算法,你却为它们重复写了两套代码?如果有人想在同一个程序中,想用整数来表示空间位置,因为计算机中整数的运算是最快的……是不是还要再写一套代码?

数据类型

A:看来,这一切都是数据类型惹的祸。如果能有一种数据类型,可以按照我们的意愿自动进行变化,这样该多好!

B:1 = 1,是否成立?

A:废话!

B:1 公里 = 1 公斤,是否成立?

A:废话!

B:数据类型,是否与物理学中的单位很像?

A:……

B:如果将物理学中所有的单位都删掉,它是不是就变成了数学?例如,$F = ma$ 描述的只不过是直线,$E = mc^2$ 描述的只不过是抛物线,每个星球上物体所受的重力只不过是四维空间中三维流形上相应位置处的曲率。

A:那么 C 语言中所有数据类型都删掉,会变成什么?

B:void *

A:为什么不是 void

B:因为 void 表示一个值为空集的变量。既然值为空集,这样的变量有形无质。你不可能指着某段内存空间说,这段空间不存储任何数据。因为内存空间中始终是有数据的,除非断电了。

之所以说「某段内存空间」,是因为内存空间是一维空间。

A:不是很明白。int foo 去掉了类型,就剩下变量名 foo 了,跟 void * 有什么关系?

B:在你看来,foo 是个变量的名字,然而从 C 编译器的角度来看,foo 是一个符号,它代表某段内存空间的起始地址。foo 的值是这段内存空间中存储的数据。程序如何确定这段内存空间的长度以及如何解读这段数据,完全取决于 foo 的类型。若将 foo 的类型抹掉,那么 foo 就失去了意义,它只能表示内存空间中的一个地址。既然失去类型的变量,它只能表示一个内存地址,这不就是 void * 么?

A:貌似很深奥,但你究竟想表达什么?

B:数据类型是表象,void * 是本质。只有在本质层面上去编写程序,才能够达到代码量最少。也就是说,我们应该将 3 公里 - 2 公里 = 1 公里 这样的程序转化为 (3 - 2) 公里 = 1 公里

A:你的意思是,应当将变量的数据类型移除,然后在 void * 层面上写出代码,最后再将类型安回去?

B:恭喜你终于又一次明白了你在小学时就已经懂了的道理。

容器

B:现在我们试着去掉 Point 结构体中的类型:

typedef struct {
        size_t n;
        void *data;
} Point;

A:恕我多言,为何不将 n 的类型也去掉?

B:在 C 语言中,还有比 size_t 更适合表示自然数的数据类型么?

A:……

B:现在,这个 Pointdata 成员可以容纳任意类型的数据了,可将其称为容器。可以将它想象为一个 n 个格子的收纳盒。从形式上看,矢量与点,并没有什么不同。既然如此,我们也没必要区分点与矢量,直接用一个更本质一些的名字代替它们,即 Array

typedef struct {
        size_t n;
        void *data;
} Array;

算法

B:现在,请用 Array 来描述「基于两个点构造一个矢量」的算法。

A:唯。

Array * point_sub(Array *a, Array *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n * sizeof(

写不下去了……

B:写不下去的地方先空着。

A:唯。

Array * point_sub(Array *a, Array *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n * sizeof(____));
        for (size_t i = 0; i < a->n; i++) {
                v->data[i] = ____;
        }
        return v;
}

B:既然 point_sub 无法确定该为 v->data 分配多少字节的内存段,也无法确定 a->data[i]b->data[i] 运算过程,那么可以将这两个过程交给 point_sub 的使用者来确定,所以可将 point_sub 定义为:

Array * point_sub(Array *a, Array *b, size_t mem_size, void (*sub)(void *, void *, void *))
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n * mem_size);
        for (size_t i = 0; i < a->n; i++) {
                void *ai = a->data + i * mem_size;
                void *bi = b->data + i * mem_size;
                void *vi = v->data + i * mem_size;
                sub(ai, bi, vi);
        }
        return v;
}

A:……这段代码,我有两处看不懂。

B:哪两处?

A:这里和那里。

B:……也许只有 sub 参数与 a->data + i * mem_size 这样的表达式看不太懂吧?

A:认真看了看,的确如此。

B:sub 是一个函数指针,它可以指向任何类型为 void (*)(void *, void *, void *) 的函数。这个函数接受 3 个指针类型的参数,没有返回值。

A:void (*)(void *, void *, void *) 是一种类型?

B:然。虽然形式上有点奇怪,但是 void (*sub)(void *, void *, void *)int *foo 这样的类型声明本质上没有区别。为了有助于理解,可以无视 C 语法,对 sub 的声明语句进行「拓扑」变形:

void (
                                       *sub
)(void *, void *, void *)

这样去看,是不是与

int                                    *foo

很像?

A:脑洞扩大了 1024 KB……

B:至于 a->data + i * mem_size,这其实就是 C 语言编程中很常见的内存基址偏移。由于 i 是从 0 开始的循环变量,所以每次循环时,就从 a->data 开始偏移 i * mem_size 字节,而 mem_size 便是确定的数据类型的字节数。

A:不明白为什么要这么做。

B:看下面这段代码:

double a[] = {1.0, 2.0, 3.0};
double b[] = {-1.0, -2.0, -3.0};
double v[3];
for (size_t i = 0; i < 3; i++) {
        v[i] = a[i] - b[i];
}

再看一下它的等效代码:

double a[] = {1.0, 2.0, 3.0};
double b[] = {-1.0, -2.0, -3.0};
double v[3];
for (size_t i = 0; i < 3; i++) {
        double *ai = a + i * sizeof(double);
        double *bi = b + i * sizeof(double);
        double *vi = v + i * sizeof(double);
        *vi = *ai - *bi;
}

A:还是不太明白……

B:退学吧……

A:……

特例

B:上一节定义的 point_sub 函数,虽然看起来很复杂,但它的确是对一种算法的 C 语言描述。

A:基于两个点构建一个矢量,这也称得上算法?

B:算法是什么?

A:是一个函数,$Y = f(X, t)$。这个函数描述的是一组初始状态 $X$ 如何随着时间 $t$ 的变化逐步演化为另一组状态 $Y$。

B:上一节定义的 point_sub 函数,虽然看起来很复杂,但它的确是对一种算法的 C 语言描述。

A:……

B:point_sub 函数所描述的算法是:基于两个给定的 $n$ 维点 $a$ 与 $b$,构建矢量 $v$。之前定义的 point_sub_fltpoint_sub_dbl 描述的是另外一种算法,即:基于给定的 $n$ 维点 $a$ 与 $b$,构建矢量 $v$, 并且 $a$,$b$ 及 $v$ 的各维分量的类型均为 floatdouble

A:这样看来,point_sub_fltpoint_sub_dbl 应该是 point_sub 的两种特例。

B:然。可基于 point_sub 定义 point_sub_fltpoint_sub_dbl

static void sub_flt(void *ai, void *bi, void *vi)
{
        *(float *)vi = *(float *)ai - *(float *)bi;
}
Array * point_sub_flt(Array *a, Array *b)
{
        return point_sub(a, b, sizeof(float), sub_flt);
}

static void sub_dbl(void *ai, void *bi, void *vi)
{
        *(double *)vi = *(double *)ai - *(double *)bi;
}
Array * point_sub_dbl(Array *a, Array *b)
{
        return point_sub(a, b, sizeof(double), sub_dbl);
}

A:一闪一闪亮晶晶,漫天都是小星星……

B:滚!

环境

B:再看一遍 point_sub 函数:

Array * point_sub(Array *a, Array *b, size_t mem_size, void (*sub)(void *, void *, void *))
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n * mem_size);
        for (size_t i = 0; i < a->n; i++) {
                void *ai = a->data + i * mem_size;
                void *bi = b->data + i * mem_size;
                void *vi = v->data + i * mem_size;
                sub(ai, bi, vi);
        }
        return v;
}

它的参数是不是太多了一些,显得有点乱?

A:你刚发现?

B:打个包怎样?

A:不明觉厉。

B:这个应该没什么难懂的:

typedef struct {
        size_t mem_size;
        void (*sub)(void *, void *, void *);
} ArrayEnv;

Array * point_sub(ArrayEnv *env, Array *a, Array *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成矢量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n *env-> mem_size);
        for (size_t i = 0; i < a->n; i++) {
                void *ai = a->data + i * env->mem_size;
                void *bi = b->data + i * env->mem_size;
                void *vi = v->data + i * env->mem_size;
                env->sub(ai, bi, vi);
        }
        return v;
}

A:……

B:可将 ArrayEnv 理解为协议或约定。要调用 point_sub 函数,必须提供 ArrarEnv 的实例。

管道

B:现在来看一个调用了 point_sub 函数的程序:

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

typedef struct {
        size_t n;
        void *data;
} Array;

typedef struct {
        size_t mem_size;
        void (*sub)(void *, void *, void *);
} ArrayEnv;

Array * point_sub(ArrayEnv *env, Array *a, Array *b)
{
        if (a->n != b->n) fprintf(stderr, "两个不同维度的点无法构成向量!");
        Array *v = malloc(sizeof(Array));
        v->n = a->n;
        v->data = malloc(v->n * env->mem_size);
        for (size_t i = 0; i < a->n; i++) {
                void *ai = a->data + i * env->mem_size;
                void *bi = b->data + i * env->mem_size;
                void *vi = v->data + i * env->mem_size;
                env->sub(ai, bi, vi);
        }
        return v;
}

static void sub_flt(void *ai, void *bi, void *vi)
{
        *(float *)vi = *(float *)ai - *(float *)bi;
}

int main(void)
{
        size_t n = 3;
        float a_data[] = {1.0, 2.0, 3.0};
        float b_data[] = {-1.0, -2.0, -3.0};
        
        Array *a = malloc(sizeof(Array));
        a->n = n;
        a->data = a_data;
        
        Array *b = malloc(sizeof(Array));
        b->n = n;
        b->data = b_data;
        
        ArrayEnv env = {sizeof(float), sub_flt};
        Array *v = point_sub(&env, a, b);
        for (size_t i = 0; i < v->n; i++) {
                float *vi = v->data + i * env.mem_size;
                if (i < v->n - 1) printf("%f ", *vi);
                else printf("%f\n", *vi);
        }

        free(v->data);
        free(v);
        free(b);
        free(a);
        
        return 0;
}

A:太长,不看。

B:这里面有一条你看不到的管道。

A:太长,不看。

B:有类型的数据 $\rightarrow$ 容器 $\rightarrow$(环境 + 算法)$\rightarrow$ 容器 $\rightarrow$ 有类型的数据。

A:为什么非要这么麻烦?

#include <stdio.h>

int main(void)
{
        size_t n = 3;
        float a[] = {1.0, 2.0, 3.0};
        float b[] = {-1.0, -2.0, -3.0};
        float c[3];
        for (size_t i = 0; i < n; i++) {
                c[i] = a[i] - b[i];
                if (i < n - 1) printf("%f ", c[i]);
                else printf("%f\n", c[i]);
        }
        return 0;
}

这样,代码岂不是更少?

B:我竟无言以对。

过犹不及

B:依靠 void * 虽然能够获得一定程度的数据抽象能力,但是请谨慎用之。C 代码中,过度或过于细致的抽象会消耗一部分程序性能。譬如上述代码中的

env->sub(ai, bi, vi);

其运算效率肯定低于基于减法运算符的两个数的相减运算。

对于密集型的运算过程,抽象程度越低,程序的性能越好


可进一步阅读本文的续篇「在 C 的世界之外

Dirty hack for displaying formula.


$$ E = mc^2 $$


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。