位元詩人 [C 語言] 程式設計教學:使用前置處理器 (Preprocessor) 撰寫擬泛型程式

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

前言

在前文中,我們展示了使用指向 void 的指標來實作的泛型程式。在本文中,我們會展示以 C 前置處理器 (C 巨集) 來實作的泛型程式。

其實這個方法不是很建議使用,因為 (1) 沒有型別安全,(2) 難以除錯。這己經算是一種反模式 (anti-pattern),我們仍然展示這個方法,讀者可自行決定要不要使用在自己的專案中。

我們將完整的程式碼放在這裡,有興趣的讀者可自行追蹤,本文僅節錄部分內容。

使用泛型「函式」的外部程式

我們先從外部程式來看如何使用此泛型佇列:

#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include "queue.h"

// Declare queue function once per type.
queue_declare(int);

int main(void)
{
    bool failed = false;

    // Queue: NULL
    queue_class(int) *q = queue_new(int,
        (queue_params(int)) { .item_free = NULL });
    if (!q) {
        perror("Failed to allocate queue q");
        return false;
    }

    // Queue: 9 -> NULL
    if (!queue_enqueue(int, q, 9)) {
        failed = true;
        goto QUEUE_FREE;
    }

    // Queue: 9 -> 5 -> NULL
    if (!queue_enqueue(int, q, 5)) {
        failed = true;
        goto QUEUE_FREE;
    }

    // Queue: 9 -> 5 -> 7 -> NULL
    if (!queue_enqueue(int, q, 7)) {
        failed = true;
        goto QUEUE_FREE;
    }

    // Queue: 5 -> 7 -> NULL
    if (queue_dequeue(int, q) != 9) {
        failed = true;
        goto QUEUE_FREE;
    }

    // Queue: 7 -> NULL
    if (queue_dequeue(int, q) != 5) {
        failed = true;
        goto QUEUE_FREE;
    }

    // Queue: NULL
    if (queue_dequeue(int, q) != 7) {
        failed = true;
        goto QUEUE_FREE;
    }

    if (!queue_is_empty(int, q)) {
        failed = true;
        goto QUEUE_FREE;
    }

QUEUE_FREE:
    queue_free(int, q);

    if (failed) {
        return 1;
    }

    return 0;
}

由此可見,我們不需要額外的 box class 就可以在基礎型別中使用此泛型程式。附帶一提,由於我們的「函式」是從巨集擴張而來的,直接使用指標會引發錯誤,要額外加入以下型別定義:

typedef struct klass * klass_p;

之後再將 klass_p 做為型別宣告,塞入我們的巨集即可。

泛型「函式」的內部實作

我們來看 queue_declare 的實際內容:

#define queue_declare(type) \
    queue_node_declare(type) \
    queue_class_declare(type) \
    queue_node_new_declare(type) \
    queue_params_declare(type) \
    queue_class_new_declare(type) \
    queue_class_free_declare(type) \
    queue_class_is_empty_declare(type) \
    queue_class_peek_declare(type) \
    queue_class_enqueue_declare(type) \
    queue_class_dequeue_declare(type)

由此可知,其實 queue_declare 只是一個宣告其他巨集的巨集。

以下是一些「函式」的宣告:

#define queue_new(type, item_free) \
    queue_##type##_new(item_free)

#define queue_free(type, self) \
    queue_##type##_free(self)

#define queue_is_empty(type, self) \
    queue_##type##_is_empty(self)

#define queue_peek(type, self) \
    queue_##type##_peek(self)

#define queue_enqueue(type, self, data) \
    queue_##type##_enqueue(self, data)

#define queue_dequeue(type, self) \
    queue_##type##_dequeue(self)

在泛型程式中,我們不能將型別預先寫死,所以要由巨集使用者提供型別資訊,在巨集中會擴張成相對應的函式。

以下是巨集版本的類別宣告:

#define queue_class(type) queue_##type

#define queue_class_declare(type) \
    typedef struct queue_##type##_s queue_class(type); \
    struct queue_##type##_s { \
        freeFn item_free; \
        queue_node(type) *head; \
        queue_node(type) *tail; \
    };

仔細觀察,可發現本質上仍是佇列類別,只是把型別的地方在編譯時代換掉。

以下是巨集版本的建構函式:

#define queue_class_new_declare(type) \
    queue_class(type) * queue_##type##_new(queue_params(type) params) \
    { \
        queue_class(type) * q = malloc(sizeof(queue_class(type))); \
        if (!q) { \
            return q; \
        } \
        q->item_free = params.item_free; \
        q->head = NULL; \
        q->tail = NULL; \
        return q; \
    }

由於是巨集的緣故,程式碼會比一般的建構函式難閱讀一些。

以下是巨集版本的解構函式:

#define queue_class_free_declare(type) \
    void queue_##type##_free(void *self) \
    { \
        if (!self) { \
            return; \
        } \
        queue_node(type) *curr = ((queue_class(type) *) self)->head; \
        freeFn fn = ((queue_class(type) *) self)->item_free; \
        queue_node(type) *temp; \
        while (curr) { \
            temp = curr; \
            curr = curr->next; \
            if (fn) { \
                fn(temp->data); \
            } \
            free(temp); \
        } \
        free(self); \
    }

在節點內的數據是基礎型別時,不需要手動釋放記憶體,但該數據是指標型別時則要。所以本程式檢查 fn 是否存在,當 fn 存在時呼叫該函式把記憶體放掉。

以下是從將資料推入佇列前端的巨集:

#define queue_class_enqueue_declare(type) \
    bool queue_##type##_enqueue(queue_class(type) *self, type data) \
    { \
        assert(self); \
        queue_node(type) *node = queue_node_new(type, data); \
        if (!node) { \
            return false; \
        } \
        if (!(self->tail)) { \
            self->head = node; \
            self->tail = node; \
            return true; \
        } \
        self->tail->next = node; \
        node->prev = self->tail; \
        self->tail = node; \
        return true; \
    }

其實和一般的佇列函式相差不大。

以下是將資料從佇列前端移出的巨集:

#define queue_class_dequeue_declare(type) \
    type queue_##type##_dequeue(queue_class(type) *self) \
    { \
        assert(self); \
        if (self->head == self->tail) { \
            type popped = self->head->data; \
            free(self->head); \
            self->head = NULL; \
            self->tail = NULL; \
            return popped; \
        } \
        queue_node(type) *curr = self->head; \
        type popped = curr->data; \
        self->head = curr->next; \
        free(curr); \
        return popped; \
    }

在此處,我們沒有拷貝節點內的數據,如果碰到指標型別時,巨集使用者要自行負責釋放該數據。

結語

稍微想一下前置處理器的工作方式,就會知道這樣的「函式庫」其實不太實用。

在巨集擴張後,原本的程式已經被代換掉了,程式的行數可能和原本的程式碼有相當的差距,我們無法直接從編譯器的錯誤訊息得知到底是在原程式的那一行出錯。雖然近年來編譯器對巨集除錯的技術已經進步了,不代表我們應該濫用這樣的特性。

此外,如果實際寫過一些巨集程式就知道 C 巨集容易料想不到的地方出錯,在出錯後除錯相對困難。這是因為巨集的本質是字串代換,缺乏靜態型別語言應有的型別安全。

由上述特性可知,雖然我們可以用 C 前置處理器寫泛型程式,但應謹慎為之。

關於作者

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

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