如何實作封裝 (Encapsulation)

    前言

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

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