C 的容器、環境與算法

本文介紹了有助於提升 C 代碼複用程度的一些基本抽象手段。本來是寫給組裏初學 C 的的小夥伴們看的……算法

C 程序是什麼

A:程序是什麼?編程

B:程序 = 算法 + 數據結構segmentfault

A:算法是什麼?數據結構

B:是一個函數,$Y = f(X, t)$。這個函數描述的是一組初始狀態 $X$ 如何隨着時間 $t$ 的變化逐步演化爲另外一組狀態 $Y$。函數

A:數據結構是什麼?性能

B:狀態的描述。指針

A:C 程序是什麼?code

B:用 C 語言描述的算法 + 數據結構orm

累不累

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 $$

相關文章
相關標籤/搜索