前言
真正的物件 (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
和物件 b
。point_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
內部的欄位。我們按照數學上的習慣,用 x
和 y
來命名這兩個欄位。
接著在第 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 語言沒有真正的建構子,使用一般函式充當建構函式即可。我們常用 new 、 create 、 ctor 等字眼來表達該函式是建構函式,像是本範例的 point_new()
。
我們在第 3 行為物件 pt
配置記憶體。由於 malloc()
是有可能失敗的動作,所以要考慮失敗處理。在建構函式中,配置記憶體失敗時會回傳空指標,而配置成功時會回傳物件。外部程式可藉由判斷物件是否為空來確認物件是否成功地建立。
在第 6 行及第 7 行中,我們刻意使用 x
和 y
的 setter 函式來修改欄位,而不直接對 x
和 y
賦值,因為我們要確保物件的一致性。當我們更動 setter 函式的行為時,在建構函式中也可以獲得一致的行為。
再來看解構函式的部分:
void point_delete(void *self)
{
if (!self)
return;
free(self);
}
同樣地,C 語言沒有真正的解構子,使用一般函式充當解構函式即可。我們常用 delete 、 free 、 dtor 等字眼來表達該函式是解構函式,像是本範例的 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 語言的物件。