【在主画面加入捷径】
       
【选择语系】
繁中 简中

[C 语言] 程序设计教学:如何透过外部模板在 C 语言中撰写拟泛型程序

【赞助商连结】

    注:本文手法非主流,请谨慎使用。

    其实泛型程序是一种模板 (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_free(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_free(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_free(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) 这类不支援泛型程序的语言,就可以参考本文的手法来省下一些重覆的程序代码。

    【赞助商连结】
    TAGS: C 语言