前言
物件導向程式中,若物件有進行封裝 (encapsulation),除了透過公開介面外,我們無法更動該物件內部的狀態;在程式設計中,就是要透過該物件相關的函式呼叫來存取物件的屬性。
封裝主要是強化物件的強健性 (robustness),避免預料之外的狀況發生。封裝並不是物件導向必備的特性,Python 的物件基本上無法達到真正的封裝,但人們仍然廣泛地使用 Python 撰寫的程式進行各種任務。
以 C 語言實踐封裝的思維
C 語言不強調封裝的概念,所以我們要重新思考封裝在程式碼中的意義。封裝的目的是資訊隱藏,也就是說,只提供最少量的必要資訊,其他的部分則不開放給外部程式。
C 語言中,的確有一些隱藏資訊的方式:
- Opaque pointer
static
函式static
變數- 函式可視度 (visibility)
當使用 opaque pointer 時,我們可以隱藏結構體的內部屬性,給外部程式一個無法操作屬性的指標。
當使用 static
函式時,外部程式是無法呼叫該函式的,實際上成為私有函式。
當使用 static
變數時,該變數只有該函式或該原始檔可存取,實際上成為私有變數。
藉由控制函式可視度,我們可以決定在編譯動態函式庫 (dynamic library) 時輸出的函式。這項設置不僅可以減少命名衝突,也可以改善動態函式庫的效能。控制函式可視度的方式會因 C 編譯器而異。
由此可知,我們可以用一些 C 語言的特性滿足封裝的需求。雖無封裝之名,但有封裝之實。
實際範例:Point 類別
在本文中,我們同樣使用二維空間的點 (point) 來展示物件,這次加上一些封裝的手法。先看一下封裝過的 point_t *
物件如何使用:
#include <assert.h>
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h>
#include "point.h"
int main(void)
{
bool failed = false;
// Create a `point_t *` object.
point_t *pt = point_new(0, 0);
if (!pt) {
perror("Failed to allocate point_t pt");
failed = true;
goto point_delete;
}
// Check x and y.
if (!(point_x(pt) == 0)) {
failed = true;
goto point_delete;
}
if (!(point_y(pt) == 0)) {
failed = true;
goto point_delete;
}
// Mutate x and y.
if (!point_set_x(pt, 3)) {
failed = true;
goto point_delete;
}
if (!point_set_y(pt, 4)) {
failed = true;
goto point_delete;
}
// Check x and y again.
if (!(point_x(pt) == 3)) {
failed = true;
goto point_delete;
}
if (!(point_y(pt) == 4)) {
failed = true;
goto point_delete;
}
point_delete:
// Free the object.
point_delete(pt);
if (failed) {
exit(EXIT_FAILURE);
}
return 0;
}
細心的讀者可發現,這個例子幾乎和前文的例子一模一樣。封裝並不影響物件的公開方法,而是保護未公開的屬性和方法,避免外部程式不當的存取。
接著來看 point_t
類別的公開方法:
#ifndef POINT_H
#define POINT_H
// Declare point_t class with hidden fields.
typedef struct point_t point_t;
// The constructor of `point_t *`.
point_t * point_new(double x, double y);
// The getters of `point_t *`.
double point_x(point_t *self);
double point_y(point_t *self);
// The setters of `point_t *`.
void point_set_x(point_t *self, double x);
void point_set_y(point_t *self, double y);
// The destructor of `point_t *`.
void point_delete(void *self);
#endif // POINT_H
眼尖的讀者應該已經發現這個版本的 point_t *
類別沒有宣告其屬性,我們會將屬性藏在 C 原始碼中。但關鍵的 C 語法特性是 forward declaration,如下例:
typedef struct point_t point_t;
我們可以在尚未宣告 struct point_t
時就先用 typedef
重定義其別名,藉此達到封裝的目的。其實也可以不用 typedef
,僅宣告 struct point
,如下例:
struct point_t;
筆者本身習慣用 typedef
重定義別名,之後的語法會較簡潔。
接著我們來看 point_t
類別內部的實作:
#include <assert.h>
#include <stdbool.h>
#include <stdlib.h>
#include "point.h"
// x and y are hidden from external programs.
struct point_t {
double x;
double y;
};
// Declaration for private methods.
static bool _is_x_valid(double);
static bool _is_y_valid(double);
// The constructor of point_t.
point_t* point_new(double x, double y)
{
if (!(_is_x_valid(x) && _is_y_valid(y))) {
return NULL;
}
point_t* pt = (point_t *) malloc(sizeof(point_t));
if (!pt) {
return pt;
}
point_set_x(pt, x);
point_set_y(pt, y);
return pt;
}
// The getter of x.
double point_x(point_t *self)
{
assert(self);
return self->x;
}
// The setter of x.
bool point_set_x(point_t *self, double x)
{
if (!_is_x_valid(x)) {
return false;
}
assert(self);
self->x = x;
return true;
}
// The getter of y.
double point_y(point_t *self)
{
assert(self);
return self->y;
}
// The setter of y.
bool point_set_y(point_t *self, double y)
{
if (!_is_y_valid(y)) {
return false;
}
assert(self);
self->y = y;
return true;
}
// Private validator for x.
static bool _is_x_valid(double x)
{
return x >= 0.0;
}
// Private validator for y.
static bool _is_y_valid(double y)
{
return y >= 0.0;
}
// The destructor of point_t.
void point_delete(void *self)
{
if (!self) {
return;
}
free(self);
}
我們在 C 程式碼中補上 struct point_t
的宣告,對外部程式來說,這部分就是隱藏的,藉此達到封裝的效果。
我們另外加上兩個私有方法 (private methods),這兩項方法是要確認屬性是否合法 (valid):
// Private validator for x.
static bool _is_x_valid(double x)
{
return x >= 0.0;
}
// Private validator for y.
static bool _is_y_valid(double y)
{
return y >= 0.0;
}
利用 static
宣告函式,該函式的可視域 (scope) 就限縮在同一個檔案中,藉此達到封裝的特性。
(選擇性) 控制函式可視度
在編譯動態函式庫時,函式可視度會決定輸出的函式。
在 GCC 或 Clang 中,預設會將所有的函式都輸出。然而,輸出的函式越多,越有可能造成命名衝突。將函式隱藏的方式是在編譯 C 或 C++ 程式碼時加上 -fvisibility=hidden
,然後在想輸出的函式加入額外的標註,詳見下文。
在 Visual C++ 中,預設會隱藏所有的函式,只有設置為輸出的函式才會輸出。輸出的方式可在標頭檔中加上標註或使用 DEF 檔。
假定我們撰寫的函式庫是 mylib ,以下標頭檔設置可兼容於 GCC、Clang、Visual C++ 等主流 C 編譯器:
#if _MSC_VER
#if MYLIB_IMPORT_SYMBOLS
#define MYLIB_PUBLIC __declspec(dllimport)
#elif KSV_EXPORT_SYMBOLS
#define MYLIB_PUBLIC __declspec(dllexport)
#else
#define MYLIB_PUBLIC
#endif
#elif __GNUC__ >= 4 || __clang__
#define MYLIB_PUBLIC __attribute__((__visibility__("default")))
#else
#define MYLIB_PUBLIC
#endif
#if __GNUC__ >= 4 || __clang__
#define MYLIB_PRIVATE __attribute__((__visibility__("hidden")))
#else
#define MYLIB_PRIVATE
#endif
如果要搭配前一節所寫的函式庫,就將標註加上去即可:
MYLIB_PUBLIC point_t * point_new(double x, double y);
在 Visual C++ 中,使用以下標註來控制動態函式庫的函式可視度:
__declspec(dllimport)
__declspec(dllexport)
只有使用 __declspec(dllexport)
標註的函式才會輸出。所以,可以用來控制函式可視度。但在靜態函式庫使用這些標註會造成編譯錯誤,在編譯靜態函式庫時要去掉這些標註。
在 GCC 或 Clang 中,使用以下標註來控制函式庫的函式可視度:
__attribute__((__visibility__("default")))
__attribute__((__visibility__("hidden")))
在未使用 -fvisibility=hidden
時,所有的函式都會輸出。使用該參數後,只有標為 default
的函式才會輸出。藉此用來控制函式可視度。
這項設置會和 C 編譯器相關,所以要考慮使用其他 C 編譯器的情境。這時候不設置是最安全的選項。
結語
在 C 語言的物件導向程式中,封裝是最容易達成的特性,即使我們之後完全不用其他的物件導向特性,也應該用封裝保護物件應有的強健性。