前言
註:本文手法非主流,請謹慎使用。
其實泛型程式是一種模板 (template) 的概念,我們在撰寫模板時,會先將一部分的內容挖空、塞入模板代碼,之後要生成程式碼時,再將實際的程式碼填入、取代掉模板代碼的部分。
對於 C++、Java、C# 等支援泛型的程式語言來說,可以透過編譯器來檢查泛型程式碼是否有問題,對於程式人來說會比較方便。對於 C、Go (golang) 等不支援泛型的程式語言來說,其實也可以用外部模板語言來模擬泛型。本文以一個 C 語言的實例來說明如何以外部模板語言模擬泛型程式。
使用模板語言實踐泛型的思維
由於 C 語言沒有原生的泛型,所以我們要跳脫泛型在表面的意義,思考泛型在程式碼的本質。
其實泛型就是一種內建的模板語言。我們在寫泛型程式時,其實是在用該語言內建的模板語言寫模板。當我們使用泛型程式時,需要指定搭配的型態,這時候編譯器在背後會偷偷地將泛型模板套上指定的型態,最後轉為指定型態的程式碼。由於這個過種在背後完成的,轉換後的程式碼不會顯示出來。
相對來說,當我們使用外部模板語言時,會產生持久性的程式碼。我們再將轉換後的程式碼及其餘的程式碼一起編譯成程式。也就是說,我們把泛型程式外顯化了。
實際案例:具有泛型特性的 Stack 型態
在本文中,我們用模板製作堆疊 (stack),這個堆疊可同時支援基礎型別和指標型別。我們將完整的模板及測試程式放在這裡,有興趣的讀者可以自行前往觀看。
本文會節錄一部分程式碼,用來說明這個實作的思維。我們選用的模板語言是 Mustache,但使用的模板語言不是最重要的,重點在於實踐泛型的思維和過程。
我們先來觀看這個堆疊的頭文件 (header) 部分的模板:
#ifndef STACK_H
#define STACK_H
#ifdef __cplusplus
extern "C" {
#endif
#ifdef __cplusplus
#include <cstdlib>
#else
#include <stdbool.h>
#include <stdlib.h>
#endif
{{alias}}
#ifndef clone_fn
typedef void * (*clone_fn)(void *);
#endif
#ifndef free_fn
typedef void (*free_fn)(void *);
#endif
typedef struct stack stack_t;
typedef struct {
clone_fn clone;
free_fn free;
} stack_params_t;
stack_t * stack_new(stack_params_t params);
void stack_delete(void *self);
{{type}} stack_peek(stack_t *self);
bool stack_push(stack_t *self, {{type}} data);
{{type}} stack_pop(stack_t *self);
#ifdef __cplusplus
}
#endif
#endif
扣除掉 {{type}}
等一小部分模板代碼外,可以看得出來其實這個模板就是典型的堆疊的頭文件。我們在生成模板時,會將 {{type}}
等部分代換掉,就可以生成合法的 C 語言頭文件。
假定我們要生成適用於 int
型別的堆疊,我們將相關的元資料存在以下的 JSON 中:
{
"alias": "",
"type": "int",
"suffix": "int"
}
使用以下指令即可生成頭文件:
$ mustach data_int.json stack_header.txt > stack_int.h
接著,我們可以取得 stack_int.h,用於我們的專案中。
以下是適用於 int
型別的測試程式:
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include "stack_int.h"
int main(void)
{
stack_t *s = stack_new((stack_params_t){
.clone = NULL,
.free = NULL
});
int data[] = {3, 4, 5, 6};
int temp;
for (size_t i = 0; i < 4; i++) {
if (!stack_push(s, data[i])) {
perror("Failed to push data\n");
goto FREE;
}
temp = stack_peek(s);
if (temp != data[i]) {
fprintf(stderr, "Wrong data: %d\n", temp);
goto FREE;
}
}
int data1[] = {6, 5, 4, 3};
for (size_t i = 0; i < 4; i++) {
temp = stack_pop(s);
if (temp != data1[i]) {
fprintf(stderr, "Wrong data: %d\n", temp);
goto FREE;
}
}
FREE:
stack_delete(s);
return 0;
}
可以看得出來,這個程式就是基本的堆疊操作。經實測,這個程式可以正確執行,以 Valgrind 檢查也沒有記憶體洩露的問題。
由於我們在撰寫泛型程式,要同時考慮基礎型別和指標型別的差異,因此,在型別宣告上會略有不同:
typedef struct node node_t;
struct node {
{{type}} data;
node_t *next;
};
typedef void * (*clone_fn)(void *);
typedef void (*free_fn)(void *);
typedef struct stack stack_t;
struct stack {
node_t *top;
clone_fn clone;
free_fn free;
};
在此處,我們額外宣告 clone_fn
和 free_fn
兩個函式指標型別。在堆疊的資料為指標型別時,我們就需要使用這兩個函式來處理內部資料。
這個堆疊的建構函式如下:
typedef struct {
clone_fn clone;
free_fn free;
} stack_params_t;
stack_t * stack_new(stack_params_t params)
{
stack_t *s = malloc(sizeof(stack_t));
if (!s) {
return s;
}
s->top = NULL;
s->clone = params.clone;
s->free = params.free;
return s;
}
當我們在撰寫模板時,我們無法得知這兩個函式實際的內容為何,故要由外部程式來提供。藉由參數傳遞,我們不需要預先知道這兩個函式的實際內部實作,將程式相依性委外處理。這裡以結構為參數,而不使用固定位置參數,函式庫使用者就不用死背參數位置。實例如下:
stack_t *s = stack_new((stack_params_t){
.clone = NULL,
.free = NULL
});
在這個例子中,我們的堆疊所對應的型別是 int
,不需要這兩個函式,故傳入 NULL
。
如果我們不需要這兩個函式,為什麼要大費周章地傳入 NULL
呢?因為我們需要同時考慮基礎型別和指標型別的情境。可由這個堆疊程式的解構函式即可知:
void stack_delete(void *self)
{
free_fn fn = ((stack_t *) self)->free;
node_t *p = ((stack_t *) self)->top;
node_t *temp;
while (p) {
temp = p;
p = p->next;
if (fn) {
fn((void *) temp->data);
}
free(temp);
}
free(self);
}
當釋放內部資料的函式 fn
不為空時,代表內部資料為指標型別,這時候就呼叫 fn
來釋放內部資料,接著才釋放掉該節點。當內部資料為 int
或其他基礎型別時,就不需要手動釋放記憶體,這時候 fn
為空,不會觸發釋放內部資料的記憶體的程式。
沿續這個概念,我們來看使用 clone
函式的情境。在這裡我們展示將節點移出堆疊的函式:
{{type}} stack_pop(stack_t *self)
{
assert(self && self->top);
{{type}} out;
if (self->clone) {
out = self->clone((void *) self->top->data);
}
else {
out = self->top->data;
}
node_t *p = self->top;
self->top = p->next;
if (self->free) {
self->free((void *) p->data);
}
free(p);
return out;
}
當 clone
函式不為空時,代表內部資料是指標型別,這時候就呼叫 clone
函式以拷貝內部資料。反之,當內部資料為 int
等基礎型別時,就直接用指派運算來拷貝內部資料即可。
附註
在本文中,我們假定堆疊的資料型別為 int
,但我們在範例專案中,另外寫了一個指標型別的例子。有興趣的讀者可以自行追蹤程式碼,此處不再重覆。
由於我們的模板需同時考慮基礎型別和指標型別的情境,程式碼會變得比較複雜。如果願意花一些工,可以分別針對基礎型態和指標型態各寫一個模板,程式碼會單純得多。
結語
使用外部模板來寫 C 程式碼,其實和用 C 巨集 (前置處理器) 來寫差不多,都會使專案的複雜度上升。因為程式碼都會經過多一次轉換,使得追蹤程式碼的難度上升。
使用外部模板比 C 巨集來說,多了型別安全。因為模板產生的 C 程式碼已有指定的型別,而 C 巨集本身缺乏型別的概念。但使用外部模板,就需要引入外部模板語言,在編譯程式碼時就多了道步驟,建置開發環境的過程也會變複雜。
傳統上,類 Unix 系統的程式人會用 m4 這類巨集語言來寫模板,但只要知道模板的概念,其實不一定要用 m4,像是本文範例中所用的 Mustache 也可以。
由於現在高階語言眾多,許多語言已經內建泛型的特性,就不需用這個手法繞一圈生程式碼;但專案中要使用 C 或 Go 語言 (golang) 這類不支援泛型程式的語言,就可以參考本文的手法來省下一些重覆實作的程式碼。