位元詩人 [C 語言] 程式設計教學:如何實作封裝 (Encapsulation)

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

前言

物件導向程式中,若物件有進行封裝 (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 語言的物件導向程式中,封裝是最容易達成的特性,即使我們之後完全不用其他的物件導向特性,也應該用封裝保護物件應有的強健性。

關於作者

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

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