誰說C語言不能面向對象(之二:封裝)

      從這節開始,我們就要正式開始用C語言實現面向對象了。不過,受限於C語言的語法,實現OO還是需要很多編程技巧的。在此之前,我想先介紹一種可以算得上是捷徑的方法吧。

    其實用C語言來實現OO,我們並不是第一個。說起來,這也算挺成熟的技術了,成熟到都已經過時了。有一個很著名的程序語言,就是利用C語言,來實現OO的。這就是大名鼎鼎的Objective-C,蘋果公司曾經的御用開發語言,直到Swift誕生這麼些年,OC的風騷才逐漸開始衰退。OC其實本質上就是C,所以,我們在體驗用C來進行OO的時候,OC可以稱得上是一個捷徑。

    編譯Objective-C的編譯器首先需要將OC轉化成C源碼,然後再使用C的編譯器進行編譯。有一種方法可以取出這種中間件,下面來介紹這種方法:

    首先,我們需要有一個可以通過編譯的.m文件,例如說:

#import <Foundation/NSObject.h>
#import <stdio.h>

@interface Test : NSObject {
    int _mem;
}
@property int mem;
- (void)func;
+ (void)class_func:(int)arg;
@end

@implementation Test
@synthesize mem = _mem;
- (void)func {
    printf("%d\n", self.mem);
}
+ (void)class_func:(int)arg {
    printf("%d\n", arg);
}
@end

int main(int argc, const char * argv[]) {
    Test *test = [[Test alloc] init];
    test.mem = 5;
    [test func];
    [Test class_func:3];
    return 0;
}

    我們叫它test.m,然後,利用gcc可以將其轉化爲C代碼。在控制檯輸入以下命令,可以將其轉化爲C代碼:

gcc -rewrite-objc test.m

    之後,我們會得到test.cpp。注意,這裏雖然是cpp結尾的,但是中間是純C的代碼,不含C++代碼。由於這個文件中含有頭文件內容以及動態鏈接庫的很多代碼,大概有2000多行,所以這裏我僅僅把主要的一小部分貼出來,細節如果大家感興趣可以自己看。 

// @implementation Test
// @synthesize mem = _mem;
static int _I_Test_mem(Test * self, SEL _cmd) { return (*(int *)((char *)self + OBJC_IVAR_$_Test$_mem)); }
static void _I_Test_setMem_(Test * self, SEL _cmd, int mem) { (*(int *)((char *)self + OBJC_IVAR_$_Test$_mem)) = mem; }


static void _I_Test_func(Test * self, SEL _cmd) {
    printf("%d\n", ((int (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("mem")));
}

static void _C_Test_class_func_(Class self, SEL _cmd, int arg) {
    printf("%d\n", arg);
}
// @end

int main(int argc, const char * argv[]) {
    Test *test = ((Test *(*)(id, SEL))(void *)objc_msgSend)((id)((Test *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Test"), sel_registerName("alloc")), sel_registerName("init"));
    ((void (*)(id, SEL, int))(void *)objc_msgSend)((id)test, sel_registerName("setMem:"), 5);
    ((void (*)(id, SEL))(void *)objc_msgSend)((id)test, sel_registerName("func"));
    ((void (*)(id, SEL, int))(void *)objc_msgSend)((id)objc_getClass("Test"), sel_registerName("class_func:"), 3);
    return 0;
}

    大致上來說,我們的類成員屬性、方法都被轉化爲了函數。這也進一步說明,用C來實現OO是完全可行的,因爲OO只是一種編程思維,而並非語言的特性。

    詳細的OC源碼我就不介紹了,接下來我們擺脫OC,自己來用C實現一套OO機制。首先需要分析的是,要想實現OO,我們應當實現哪些功能?OO和PO(面向過程)相比,其最突出的特性莫過於封裝、繼承和多態。封裝,就是將“屬性”和“動作”結合起來,共同實現在一個“類”中。而繼承和多態則相對來說複雜一些,並且,多態基於繼承,而繼承又基於封裝。所以,我們需要先實現封裝。

    既然要實現封裝,我們就需要一種結構,能夠即存儲屬性,又能存儲動作。存儲屬性的話,可以使用C語言的結構體,存儲動作的話可以使用函數。但是問題就在於,表示“動作”的這個函數,應當是對象專有的。舉例而言,在A類中有一個方法fa,那麼只有A的對象(或是A子類的對象)纔可以調用fa方法,而其他無關的對象無法調用。因此,我們就需要採用一種方法,來限定這個fa只能是A類型的變量才能調用,最簡單的方法,就是在函數的第一個參數上傳一個固定A類型的參數,這樣,一方面,不是這種類型的參數無法傳入該函數,也就間接做到的對象專有,另一方面,我們還可以在函數內部通過第一個參數來訪問對象的屬性。

    爲了方便說明,以後舉例子之前,我會先採用和C語言語法相似,但支持OO的C++代碼先表示一下,然後再用C代碼來重寫。假如我用一個類來表示二維座標中的點,那麼用C++代碼應當是這樣的:

class Point {
public:
    // 屬性
    double x, y;
    // 動作
    void move(Point offset) { // 平移
        x += offset.x;
        y += offset.y;
    }
    double distanceFromOrigin() { // 到原點的距離
        return sqrt(x * x + y * y);
    }
};

    這裏需要解釋一下爲什麼我用public,因爲暫時,我們還沒辦法在C中控制變量的作用域,因此,我們暫且都按照公有屬性。我們定義了一個Point類,它的屬性有x和y,方法有兩個,一個是平移,一個是計算到原點距離。分析一下,平移這個操作其實是要改變對象的屬性值,而計算距離對屬性是隻讀的。

    接下來,就按照我們之前的方法,將這段代碼轉化爲C語言,用結構體表示對象屬性,用第一個參數是對象本身的函數表示對象動作,示例如下:

// class Point
struct point_property {
    double x, y;
};
void point_move(struct point_property *self, struct point_property *offset) {
    self->x += offset->x;
    self->y += offset->y;
}
double point_distanceFromOrigin(struct point_property *self) {
    return sqrt(self->x * self->x + self->y * self->y);
}
// end class Point

    就像這樣,函數的第一個參數傳入的是這個對象的屬性,像是move這個函數中,傳入了另一個對象,其實相當於傳入了另一個對象的屬性,所以,我們仍然傳入struct point_property即可。

    另外一個問題就是,爲什麼傳入指針而不是結構體本身。這是因爲,在OO中,傳入對象本身會得到一個對象的拷貝,所以應當實現拷貝構造函數的,這個在後面研究,所以offset傳入了指針。而至於爲什麼self要傳指針,原因很簡單,如果你需要修改對象的屬性,那你肯定不能用值傳遞,而要用引用傳遞,傳指針也就理所應當了。

    那麼我們試着調用一下吧,還是先展示C++代碼:

int main(int argc, const char * argv[]) {
    Point p;
    p.x = 3;
    p.y = 4;
    printf("%lf\n", p.distanceFromOrigin());
    Point off;
    off.x = 1;
    off.y = 2;
    p.move(off);
    printf("(%lf, %lf)\n", p.x, p.y);
    return 0;
}

    輸出結果請自行驗證。用C來實現的話,定義對象的時候,我們就定義對象屬性的結構體變量,給對象成員賦值時,我們就給結構體變量賦值,調用方法時我們就調用對應的函數,並且把結構體變量傳入函數,示例如下:

int main(int argc, const char * argv[]) {
    struct point_property p;
    p.x = 3;
    p.y = 4;
    printf("%lf\n", point_distanceFromOrigin(&p));
    struct point_property off;
    off.x = 1;
    off.y = 2;
    point_move(&p, &off);
    printf("(%lf, %lf)\n", p.x, p.y);
    return 0;
}

    這樣確實是實現了一個初始的OO,不過離真正的OO還差的很遠。我們知道,對象中的成員,大多數應該是私有的,也就是說,對外無感知。在類之外的地方,是不能夠對對象的私有變量進行直接的調用或更改的。成員變量應當讓類內部來管理,而外部能調用的,就只有接口而已。接口中有一個很重要的,就是構造函數。也就是說,當我們初始化一個對象的時候,我們應當調用構造函數來初始化,而並不是像現在這樣通過手動給屬性賦值。所以,我們的代碼需要進行再次的封裝,真正能夠做到,在類之外操作對象,只需要操作接口。

    那麼我們就需要實現構造函數,然後將成員變量進行隱藏,C++代碼如下:

class Point {
private:
    double x, y;
public:
    Point(double x, double y): x(x), y(y) {
        printf("a point (%lf, %lf) has been created\n", x, y);
    }
    void move(Point offset) { // 平移
        x += offset.x;
        y += offset.y;
    }
    double distanceFromOrigin() { // 到原點的距離
        return sqrt(x * x + y * y);
    }
    void show() { // 打印
        printf("(%lf, %lf)\n", x, y);
    }
};

int main(int argc, const char * argv[]) {
    Point p(3, 4);
    printf("%lf\n", p.distanceFromOrigin());
    Point off(1, 2);
    p.move(off);
    p.show();
    return 0;
}

    這裏由於將x和y設爲私有變量了,因此,爲了打印方便,又定義了一個show方法用於打印點的座標。現在的問題就在於,如何用C語言來實現構造函數?這我們得了解構造函數的作用。構造函數就是用於構造一個對象,然後初始化對象中的成員,之後再完成一些其他的操作。從C++的構造函數的語法就能看出,參數傳參,之後的初始化列表進行成員初始化,然後函數體做其他的操作。既然是要構造一個對象,那麼使用C語言的時候,返回值就一定是一個對象屬性的結構體。示例如下:

// class Point
struct point_property {
    double x, y;
};
struct point_property point_construct(double x, double y) {
    struct point_property res; // 創建對象
    res.x = x;
    res.y = y; // 初始化成員
    printf("a point (%lf, %lf) has been created\n", x, y); // 其他操作
    return res;
}
void point_move(struct point_property *self, struct point_property *offset) {
    self->x += offset->x;
    self->y += offset->y;
}
double point_distanceFromOrigin(struct point_property *self) {
    return sqrt(self->x * self->x + self->y * self->y);
}
void point_show(struct point_property *self) {
    printf("(%lf, %lf)\n", self->x, self->y);
}
// end class Point

int main(int argc, const char * argv[]) {
    struct point_property p = point_construct(3, 4);
    printf("%lf\n", point_distanceFromOrigin(&p));
    struct point_property off = point_construct(1, 2);
    point_move(&p, &off);
    point_show(&p);
    return 0;
}

    這樣看起來倒是沒什麼問題了,功能確實都實現了,但是,這樣的代碼看起來的確不讓人很舒服,感覺上,沒有C++那種整體感,那種強烈的封裝感,並且,point類型的方法用point_這樣的前綴來命名,其實是軟約束,有沒有辦法讓這些函數真的可以存在在對象裏,建立一個對象與函數的硬約束呢?當然有,我們需要對我們的代碼進行整改,用一個函數列表來存儲所有與point相關的方法。於此同時處理一些C的語法,利用點編程技巧讓我們的代碼看起來更舒服一些。在此之前,我們先將代碼的聲明和實現進行拆分,提供頭問題,C++的示例如下:

// Point.hpp
#ifndef Point_hpp
#define Point_hpp

class Point {
private:
    double x, y;
public:
    Point(double x, double y);
    void move(Point offset);
    double distanceFromOrigin();
    void show();
};

#endif /* Point_hpp */
// Point.cpp
#include "Point.hpp"

Point::Point(double x, double y): x(x), y(y) {
    printf("a point (%lf, %lf) has been created\n", x, y);
}
void Point::move(Point offset) { // 平移
    x += offset.x;
    y += offset.y;
}
double Point::distanceFromOrigin() { // 到原點的距離
    return sqrt(x * x + y * y);
}
void Point::show() { // 打印
    printf("(%lf, %lf)\n", x, y);
}

    改良後的C語言代碼如下:

// Point.h
#ifndef Point_h
#define Point_h

// class Point
struct point;
// 屬性定義
struct point_property {
    double x, y;
};
// 方法聲明
struct point_property point_construct(double, double); // 構造函數

void point_move(struct point *, struct point *);
double point_distanceFromOrigin(struct point *);
void point_show(struct point *);

//類本體
typedef struct point {
// 屬性
    struct point_property property;
// 方法列表
    void (*move)(struct point *self, struct point *offset);
    double (*distanceFromOrigin)(struct point *self);
    void (*show)(struct point *self);
} Point;
Point point(double x, double y); // 初始化函數
// end class Point

#endif /* Point_h */
// Point.c
#include "Point.h"
#include <stdio.h>
#include <math.h>

struct point_property point_construct(double x, double y) {
    struct point_property res; // 創建對象
    res.x = x;
    res.y = y; // 初始化成員
    printf("a point (%lf, %lf) has been created\n", x, y); // 其他操作
    return res;
}
void point_move(struct point *self, struct point *offset) {
    self->property.x += offset->property.x;
    self->property.y += offset->property.y;
}
double point_distanceFromOrigin(struct point *self) {
    return sqrt(self->property.x * self->property.x + self->property.y * self->property.y);
}
void point_show(struct point *self) {
    printf("(%lf, %lf)\n", self->property.x, self->property.y);
}

Point point(double x, double y) {
    Point res;
    res.property = point_construct(x, y);
    res.move = point_move;
    res.distanceFromOrigin = point_distanceFromOrigin;
    res.show = point_show;
    return res;
}

    調用測試:

#include "Point.h"

int main(int argc, const char * argv[]) {
    Point p = point(3, 4);
    printf("%lf\n", p.distanceFromOrigin(&p));
    Point off = point(1, 2);
    off.move(&p, &off);
    p.show(&p);
    return 0;
}

    之所以這樣寫,是爲了讓函數本身能夠進到對象裏,這樣,我們不用再根據類名去找全局函數,而是在對象中就擁有函數指針,可以直接調用。但是這樣還是存在一個問題,那就是,雖然對象裏封裝了函數,但是,僅僅是全局函數的指針的而已,不同對象間還是可以相互調用,例如上面代碼中,p.show(&p)和off.show(&p)其實是一樣的,真正決定對象的並不是前面函數指針的擁有者,而是實際函數傳參。所以這樣的設計,就顯得有點臃腫,因爲每一個對象都需要攜帶所有的函數指針,不划算,我們其實只需要一份函數列表就可以了。因此,需要換一種設計思路,把函數列表存到一個全局變量中,然後在每個對象中只需要擁有這個全局變量就可以了。示例代碼如下:

// Point.h
#ifndef Point_h
#define Point_h

// class Point
struct point;
// 屬性定義
struct point_property {
    double x, y;
};
// 方法聲明
struct point_property point_construct(double, double); // 構造函數

void point_move(struct point *, struct point *);
double point_distanceFromOrigin(struct point *);
void point_show(struct point *);

struct point_method_t {
    // 方法列表
    void (*move)(struct point *self, struct point *offset);
    double (*distanceFromOrigin)(struct point *self);
    void (*show)(struct point *self);
}; 
//類本體
typedef struct point {
// 屬性
    struct point_property property;
    struct point_method_t *method;
} Point;
Point point(double x, double y); // 初始化函數
// end class Point

#endif /* Point_h */
//Point.c
#include "Point.h"
#include <stdio.h>
#include <math.h>

struct point_property point_construct(double x, double y) {
    struct point_property res; // 創建對象
    res.x = x;
    res.y = y; // 初始化成員
    printf("a point (%lf, %lf) has been created\n", x, y); // 其他操作
    return res;
}
void point_move(struct point *self, struct point *offset) {
    self->property.x += offset->property.x;
    self->property.y += offset->property.y;
}
double point_distanceFromOrigin(struct point *self) {
    return sqrt(self->property.x * self->property.x + self->property.y * self->property.y);
}
void point_show(struct point *self) {
    printf("(%lf, %lf)\n", self->property.x, self->property.y);
}

struct point_method_t point_method = { // 全局的方法列表
    .move = point_move,
    .distanceFromOrigin = point_distanceFromOrigin,
    .show = point_show,
};

Point point(double x, double y) {
    Point res;
    res.property = point_construct(x, y);
    res.method = &point_method;
    return res;
}

    調用測試代碼:

#include "Point.h"

int main(int argc, const char * argv[]) {
    Point p = point(3, 4);
    printf("%lf\n", p.method->distanceFromOrigin(&p));
    Point off = point(1, 2);
    off.method->move(&p, &off);
    p.method->show(&p);
    return 0;
}

    可能這樣看上去,倒還不如剛纔那種方式更簡潔,至少從語法上來說是這樣。但是這樣做其一,複用了空間,所有的對象不再全都擁有完整的函數列表,而是僅僅只有一個指針指向那個全局的函數列表。其二就是,把函數單獨管理,這樣有利於我們後面的繼承和多態的實現。

    關於封裝的內容就先講這麼多,請大家繼續關注後續連載。

【本文爲逗比老師全權擁有,允許轉載,但務必在開頭標註作者信息和轉載源連接,禁止惡意的複製與修改。】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章