為什麼要用 C 語言寫物件導向程式?

雖然 C 語言沒有直接支援物件導向程式的語法,但我們可以在一些真實世界的專案看到具有物件導向思維的 C 程式碼,一些知名的例子像是 Linux 核心和 GTK+ 等。然而,我們在 C 語言的教材很少直接探討這個議題,大部分教材的態度是在教完基本的 C 語法後就轉往 C++,像是 C 程式設計藝術 (全華圖書) 的前面是 C 語言,後面就塞入 C++ 相關的內容來增加版面。那麼,我們為什麼要用 C 語言寫物件導向程式呢?

物件導向是一種程式設計的範式 (paradigm),理論上是抽象的程式設計思維,但卻會受到程式語言特性的影響,從每個程式語言的語法就可以看到不同程度的物件特性。對物件導向來說,最基本的是資料 (data) 和行為 (behavior) 連動所帶來的狀態 (state) 改變;再進一步就是封裝 (encapsulation)、組合 (composition)、繼承 (inheritance)、多型 (polymorphism) 等特性;一些相對次要的特性包括建構子 (constructor)、運算子重載 (operator overloading) 等。如果仔細觀察不同語言,就會發現不同語言對上述特性的支援度不同。

對於 C 語言來說,如果要創造新的複合型別,就是用結構 (struct) 將該型別的屬性 (fields) 包起來。以下我們以二維空間的點 (point) 為例,來建立一個類別和相關的方法,這是一個常見的例子:

#include <stdlib.h>

// Declare Point class.
typedef struct point {
    double x;
    double y;
} Point;

// The constructor of Point.
Point* point_new(double x, double y)
{
    Point* self = (Point*) malloc(sizeof(Point));
    
    self->x = x;
    self->y = y;
    
    return self;
}

// The getter of x.
double point_x(Point* self)
{
    return self->x;
}

// The setter of x.
void point_set_x(Point* self, double x)
{
    self->x = x;
}

// The getter of y.
double point_y(Point* self)
{
    return self->y;
}

// The setter of y.
void point_set_y(Point* self, double y)
{
    self->y = y;
}

// The destructor of Point.
void point_free(Point* self)
{
    if (self) {
        free(self);
        self = NULL;
    }
}

在我們這個例子中,Point 類別內的屬性和方法已經有基本的連動,但除此之外,就什麼都沒有;我們沒用 opaque pointer 將物件封裝,也沒有實作其他的物件導向特性。Point 算不算一個物件呢?

我們延續 Point 類別的例子,來看外部程式如何使用 Point 物件:

// Excerpt.

int main(void)
{
    // Create a Point object.
    Point* pt = point_new(0, 0);

    // Access x and y.
    printf("(%.2f, %.2f)\n", point_x(pt), point_y(pt));
    
    // Mutate x and y.
    point_set_x(pt, 3);
    point_set_y(pt, 4);
    
    // Access x and y again.
    printf("(%.2f, %.2f)\n", point_x(pt), point_y(pt));
    
    // Free the object.
    point_free(pt);

    // Return the program status.
    return 0;
}

在我們這個例子中,除了手動將 Point 物件帶進函式的語法有點 verbose 以外,這個例子就是基本的物件屬性存取。這樣的程式碼表面上看起來不太物件導向,但物件和函式已有基本的連動了。

由於 C 語言沒有內建的物件導向語法,我們要撰寫物件導向程式時都要自己撰寫額外的樣板程式碼去模擬一些物件導向的特性。基本上,這些做法並沒有真正的標準,都是我們對某項物件導向特性解構後重新用 C 語言去實作。像是有開發者以 C 語言巨集寫了一整套的輕量級物件系統 (見 lw_oopc),我們可以參考該物件系統的寫法,甚至也可以直接將該物件系統拿來用,但這些物件導向的語法就不適合放在 C 語言的標準裡。由於 C 的物件導向程式沒有標準的做法,我們看到某一套實作時,要去思考該寫法背後的思維,為什麼要這樣寫?解決了什麼語法特性?而不僅是直接硬背下來。

程式設計討論區上其實也不乏相關的討論 (像這裡),由一些相關的討論,可以看出不同開發者對這個議題的態度不同。有一派開發者會說「對,我們可以這樣做」,然後就會開始討論一些用 C 模擬物件導向的技巧 (甚至是黑魔術);另一派則會直接說「為什麼不直接用 C++」。Stroustrup 博士在做 cfront 時應該也想過類似的問題,最後的答案就是做出一個保留 C 特性的新語言,也就是 C++。

那麼,我們什麼時候會用 C 語言寫物件導向程式呢?通常是在 (1) 維護現有程式碼和 (2) 需要使用 C 而非 C++ 或其他語言時。程式設計初心者會以為追逐新興語言很重要,但是,我們並不會因為 Rust 這類新興編譯語言出現後就把整個軟體專案翻掉再來一次,通常會有更強烈的理由才這麼做;在網路上有時會看到用 Rust 重寫某個類 Unix 系統指令或工具的社群專案,往往就是得到一個功能不齊的次級品,而對資訊界沒有實質的貢獻。有時候我們的目標環境不允許我們用 C++ 或其他比較肥大的語言,只能使用 Bash、C、Lua 等相對節省運算資源的工具,這時候用一些物件導向的手法整理程式碼的確會有所幫助。

C 語言無法獲得完整的物件導向特性,但撰寫一些基於物件的特性的程式碼倒是沒有問題。像是我們可以透過 opaque pointer 和 static function 很容易就獲得具有封裝概念的物件,這種輕量級物件對於整理程式碼相當有幫助。相較起來,要在 C 語言中撰寫具有多型特性的程式就比較費工,需要撰寫較多的𣖙板程式碼來達到這樣的語法特性;至於繼承則是無法取得的特性,只能用組合的方法來模擬繼承的效果。C 語言畢竟是程序性的 (procedural) 程式語言,當我們需要撰寫大量樣板程式碼來滿足某些語法特性時,或許我們誤用了工具。

除了實用的觀點,用 C 撰寫物件導向程式也是很好的頭腦體操。由於 C 沒有內建的物件導向語法,我們可以重新思考到底我們平日所熟悉的物件導向特性想要達成什麼目的,我們需要用什麼 C 語言特性來滿足這些需求;藉由這個解構再組合的過程,我們對物件導向又有進一步的了解。當然,物件導向只是撰寫程式的過程中所用到的一些特性,不是最後的產品,我們也不需要一直沉醉在這樣的頭腦體操中;我們已經有 C++ (或 Java) 了,如果能直接用現有的工具就能解決問題,何必重造輪子呢?

上篇如何以 C 語言撰寫泛型程式?
下篇如何撰寫虛擬碼 (Pseudocode)