位元詩人 [C 語言] 程式設計教學:透過外部模板撰寫擬泛型程式

C 語言泛型
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

註:本文手法非主流,請謹慎使用。

其實泛型程式是一種模板 (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_fnfree_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) 這類不支援泛型程式的語言,就可以參考本文的手法來省下一些重覆實作的程式碼。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。