【在主畫面加入捷徑】
       
【選擇語系】
繁中 简中

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

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
【贊助商連結】

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

    其實泛型程式是一種模板 (template) 的概念,我們在撰寫模板時,會先將一部分的內容挖空、塞入模板代碼,之後要生成程式碼時,再將實際的程式碼填入、取代掉模板代碼的部分。對於 C++、Java、C# 等支援泛型的程式語言來說,可以透過編譯器來檢查泛型程式碼是否有問題,對於程式人來說會比較方便。對於 C、Go (golang) 等不支援泛型的程式語言來說,其實也可以用外部模板語言來模擬泛型。本文以一個 C 語言的實例來說明如何以外部模板語言模擬泛型程式。

    在本文中,我們用模板製作堆疊 (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) 這類不支援泛型程式的語言,就可以參考本文的手法來省下一些重覆的程式碼。

    【贊助商連結】
    標籤: C 語言
    【贊助商連結】
    【分類瀏覽】