如何實作類別 (Class) 和物件 (Object)

    前言

    真正的物件 (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 語言的物件。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Yahoo
    【追蹤本站】
    Facebook Facebook Twitter