位元詩人 [C 語言] 程式設計教學:如何實作類別 (Class) 和物件 (Object)

C 語言物件
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

真正的物件 (object),要有狀態 (state) 和行為 (behavior) 間的連動。狀態以資料 (data) 的形式儲存在物件的屬性 (field) 上,行為則是透過函式 (function) 來實作。和物件連動的函式,又稱為方法 (method)。

C 語言並沒有真正的物件,只能撰寫在精神上貼近物件的函式。在本文中,我們會以平面座標中的點 (point) 為例,展示兩種物件的寫法。

典型的寫法

在本節中,我們展示第一種以 C 語言撰寫物件的方式,這算是主流的手法,而且實作上比較簡單。

我們先來看外部程式如何使用 point_t * 物件。假定 point_t * 類別已經實作出來,在外部程式中引入 point.h 標頭檔。參考以下範例程式碼:

#include <stdio.h>                         /*  1 */
#include "point.h"                         /*  2 */

int main(void)                             /*  3 */
{                                          /*  4 */
    point_t *a = point_new(0, 0);          /*  5 */
    if (!a) {                              /*  6 */
        perror("Failed to allocate a\n");  /*  7 */
        goto ERROR;                        /*  8 */
    }                                      /*  9 */

    point_t *b = point_new(3, 4);          /* 10 */
    if (!b) {                              /* 11 */
        perror("Failed to allocate b\n");  /* 12 */
        goto ERROR;                        /* 13 */
    }                                      /* 14 */

    if (!(point_distance(a, b) == 5.0)) {  /* 15 */
        perror("Wrong distance\n");        /* 16 */
        goto ERROR;                        /* 17 */
    }                                      /* 18 */

    point_delete((void *) b);              /* 19 */
    point_delete((void *) a);              /* 20 */

    return 0;                              /* 21 */

ERROR:                                     /* 22 */
    if (b)                                 /* 23 */
        point_delete((void *) b);          /* 24 */

    if (a)                                 /* 25 */
        point_delete((void *) a);          /* 26 */

    return 1;                              /* 27 */
}                                          /* 28 */

我們在第 5 行及第 10 行分別建立型態為 point_t * 物件 a 和物件 bpoint_new() 函式在內部使用到 malloc() 函式動態配置記憶體,而 malloc() 是有可能失敗的動作,所以我們要檢查物件是否成功建立。

我們在第 6 行至第 9 行檢查物件 a 是否成功建立。若 a 未成功建立,放棄一般的程式流程,直接跳到第 22 行後的錯誤處理流程。

同樣地,我們在第 11 行至第 14 行間檢查物件 b 是否成功建立,再來決定後續的程式流程。

接著,我們在第 15 行將物件 a 和物件 b 傳入 point_distance() 函式,求兩點間的距離。我們在第 15 行至第 18 行間以 if 敘述確認兩點間的距離是正確的。

最後,我們分別在第 19 行及第 20 行將物件 a 和物件 b 所占用的記憶體釋放掉。並在第 21 行回傳程式正常結束的離開狀態值 0

如果程式在某段過程出錯了,我們會把程式跳到第 22 行,即 ERROR 標籤所在的位置,走錯誤處理的流程。我們同樣會先釋放物件的記憶體,但我們不確定物件是否已建立,故使用 if 敘述檢查物件是否存在。最後,我們改在第 27 行回傳非零數值 1,代表程式發生錯誤。

我們來看 point.h 標頭檔的宣告:

#pragma once                                    /*  1 */

typedef struct point_t point_t;                 /*  2 */

struct point_t {                                /*  3 */
    double x;                                   /*  4 */
    double y;                                   /*  5 */
};                                              /*  6 */

point_t * point_new(double x, double y);        /*  7 */
void point_delete(void *self);                  /*  8 */
double point_x(point_t *self);                  /*  9 */
double point_y(point_t *self);                  /* 10 */
void point_set_x(point_t *self, double x);      /* 11 */
void point_set_y(point_t *self, double y);      /* 12 */
double point_distance(point_t *a, point_t *b);  /* 13 */

在第 1 行時,我們使用 #pragma once 防止重覆引入標頭檔。雖然 #pragma once 不是標準 C 語法,很多 C 編譯器都有實作這項功能,可用來取代傳統的 #include guard。

我們在第 2 行利用 typedef 宣告結構體 point_t 的別名。由於結構體名稱和別名可以用相同的名字,建議用這種方式來宣告,看起來比較簡潔。

接著,在第 3 行至第 6 行宣告結構體 point_t 內部的欄位。我們按照數學上的習慣,用 xy 來命名這兩個欄位。

接著在第 7 行至第 13 行的部分是數個函式宣告。這些函式宣告都相當簡短,讀者可試著自己閱讀。注意我們用 point_ 前綴來模擬命名空間,以避免函式命名衝突。

我們分段來看 point_t * 物件的實作。先看建構函式的部分:

point_t * point_new(double x, double y)                 /*  1 */
{                                                       /*  2 */
    point_t *pt = (point_t *) malloc(sizeof(point_t));  /*  3 */
    if (!pt)                                            /*  4 */
        return pt;                                      /*  5 */

    point_set_x(pt, x);                                 /*  6 */
    point_set_y(pt, y);                                 /*  7 */

    return pt;                                          /*  8 */
}                                                       /*  9 */

C 語言沒有真正的建構子,使用一般函式充當建構函式即可。我們常用 newcreatector 等字眼來表達該函式是建構函式,像是本範例的 point_new()

我們在第 3 行為物件 pt 配置記憶體。由於 malloc() 是有可能失敗的動作,所以要考慮失敗處理。在建構函式中,配置記憶體失敗時會回傳空指標,而配置成功時會回傳物件。外部程式可藉由判斷物件是否為空來確認物件是否成功地建立。

在第 6 行及第 7 行中,我們刻意使用 xy 的 setter 函式來修改欄位,而不直接對 xy 賦值,因為我們要確保物件的一致性。當我們更動 setter 函式的行為時,在建構函式中也可以獲得一致的行為。

再來看解構函式的部分:

void point_delete(void *self)
{
    if (!self)
        return;

    free(self);
}

同樣地,C 語言沒有真正的解構子,使用一般函式充當解構函式即可。我們常用 deletefreedtor 等字眼來表達該函式是解構函式,像是本範例的 point_delete()

point_t 類別的 getter 和 setter 都相當簡單:

double point_x(point_t *self)
{
    assert(self);

    return self->x;
}

double point_y(point_t *self)
{
    assert(self);

    return self->y;
}

void point_set_x(point_t *self, double x)
{
    assert(self);

    self->x = x;
}

void point_set_y(point_t *self, double y)
{
    assert(self);

    self->y = y;
}

相信讀者可以很輕易地理解這段程式碼。

最後來看計算距離的函式:

double point_distance(point_t *a, point_t *b)
{
    assert(a);
    assert(b);

    double dx = point_x(a) - point_x(b);
    double dy = point_y(a) - point_y(b);

    return sqrt(pow(dx, 2) + pow(dy, 2));
}

按照數學上的定義來計算即可,應該相當容易。

由本節的範例可看出,C 語言的擬物件和函式之間沒有真正的連動,只是利用刻意安排,寫出具有物件感的 C 程式碼。

替代的寫法

在本節中,我們展示另一種實作物件的方式。這個方式稍嫌麻煩,但寫起來更有物件的精神。

我們先看外部程式如何使用 point_t * 物件。同樣地,我們假定 point_t * 類別已經實作出來,引入其標頭檔 point.h 。參考範例程式如下:

#include "point.h"                           /*  1 */

int main(void)                               /*  2 */
{                                            /*  3 */
    point_class_t *cls = point_class_new();  /*  4 */
    if (!cls) {                              /*  5 */
        perror("Failed to allocate cls\n");  /*  6 */
        goto ERROR;                          /*  7 */
    }                                        /*  8 */

    point_t *a = cls->new(0, 0);             /*  9 */
    if (!a) {                                /* 10 */
        perror("Failed to allocate a\n");    /* 11 */
        goto ERROR;                          /* 12 */
    }                                        /* 13 */

    point_t *b = cls->new(3, 4);             /* 14 */
    if (!b) {                                /* 15 */
        perror("Failed to allocate b\n");    /* 16 */
        goto ERROR;                          /* 17 */
    }                                        /* 18 */

    if (!(cls->distance(a, b) == 5.0)) {     /* 19 */
        perror("Wrong distance\n");          /* 20 */
        goto ERROR;                          /* 21 */
    }                                        /* 22 */

    cls->delete((void *) b);                 /* 23 */
    cls->delete((void *) a);                 /* 24 */
    point_class_delete((void *) cls);        /* 25 */

    return 0;                                /* 26 */

ERROR:                                       /* 27 */
    if (b)                                   /* 28 */
        cls->delete((void *) b);             /* 29 */

    if (a)                                   /* 30 */
        cls->delete((void *) a);             /* 31 */

    if (cls)                                 /* 32 */
        point_class_delete((void *) cls);    /* 33 */

    return 1;                                /* 34 */
}                                            /* 35 */

一開始,我們不急著建立 point_t * 物件,而是額外建立 point_class_t * 物件,該物件用來代表 point_t * 的類別,這段動作的程式位於第 4 行。由於建立 cls 物件的建構函式內部有用到 malloc(),需檢查該物件是否成功地建立。

接著,我們用建好的 cls 物件為類別,在第 9 行及第 14 行分別建立 point_t * 物件 a 和物件 b。同樣地,需分別檢查兩物件是否成功地建立。

我們在第 19 行將物件 a 和物件 b 傳入 cls 的方法 distance() 以求得兩點間的距離。這裡用簡單的 if 敘述檢查兩點間的距離是否有錯。

最後,在第 23 行至第 25 行將物件所占的記憶體逐一釋放掉。在第 26 行回傳 0 代表整個程式正確地運行。

如果程式在運行中發生問題,會跳到第 27 行,即 ERROR 標籤所在的位置,走錯誤處理的流程。在這裡,確認物件存在後,同樣會逐一釋放物件的記憶體。但在第 34 行回傳非零值 1,表示程式運行中發生錯誤。

我們接著來看 point.h 的宣告:

#pragma once

typedef struct point_class_t point_class_t;
typedef struct point_t point_t;

struct point_class_t {
    point_t * (*new)(double x, double y);
    void (*delete)(void *self);
    double (*x)(point_t *self);
    double (*y)(point_t *self);
    void (*set_x)(point_t *self, double x);
    void (*set_y)(point_t *self, double y);
    double (*distance)(point_t *a, point_t *b);
};

point_class_t * point_class_new();
void point_class_delete(void *cls);

struct point_t {
    double x;
    double y;
};

point_t * _point_new(double x, double y);
void _point_delete(void *self);
double _point_x(point_t *self);
double _point_y(point_t *self);
void _point_set_x(point_t *self, double x);
void _point_set_y(point_t *self, double y);
double _point_distance(point_t *a, point_t *b);

在標頭檔中,我們宣告兩個結構體,分別是代表類別的 point_class_t 和代表物件的 point_t

point_class_t 類別中,我們利用函式指標宣告數個該類別的方法 (method)。

但在 point_t 中,我們仍然要宣告相對應的函式,point_class_t 物件才能指向各個方法的實作。

我們來看 point_class_t 類別的實作:

point_class_t * point_class_new()
{
    point_class_t *cls = \
        (point_class_t *) malloc(sizeof(point_class_t));
    if (!cls)
        return cls;

    cls->new = _point_new;
    cls->delete = _point_delete;
    cls->x = _point_x;
    cls->y = _point_y;
    cls->set_x = _point_set_x;
    cls->set_y = _point_set_y;
    cls->distance = _point_distance;

    return cls;
}

在這個建構函式中,cls 本身是物件,但在外部程式中當成類別來使用。

由這段程式碼可看出,cls 物件本身不負責實作,其方法會另外指向各個實作函式。

雖然 cls 物件在外部程式中當成類別來用,cls 物件同樣需要自己的解構函式:

void point_class_delete(void *cls)
{
    if (!cls)
        return;

    free(cls);
}

至於各個實作函式的細節和前例相同,故不重覆展示。

結語

在本文中,我們展示兩種撰寫類別和物件的方法。由於物件導向程式不是標準 C 的一部分,兩種寫法都可行。讀者可以參考本文所列的方法,自己實作 C 語言的物件。

關於作者

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

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