天天看點

C/C++面向對象程式設計之封裝

前言:

何為面向過程:

面向過程,本質是“順序,循環,分支”

面向過程開發,就像是總有人問你要後續的計劃一樣,下一步做什麼,再下一步做什麼,意外、事物中斷、突發事件怎麼做。理論上來說,任何一個過程都可以通過“順序,循環,分支”來描述出來,但是實際上,很多項目的複雜度,都不是“順序循環分支”幾句話能說清楚的。稍微大一點的項目,多線程,幾十件事情并發, 如果用這種最簡單的描述方式,要麼幾乎無法使用,缺失細節太多,要麼事無巨細,用最簡單的描述,都會讓後期複雜度提升到一個爆炸的狀态。

何為面向對象:

面向對象,本質是“繼承,封裝,多态”

面向對象的核心是把資料和處理資料的方法封裝在一起。面向對象可以簡單的了解為将一切事物子產品化 ,面向對象的代碼結構,有效做到了層層分級、層層封裝,每一層隻了解需要對接的部分,其他被封裝的細節不去考慮,有效控制了小範圍内資訊量的爆炸。然而當項目的複雜度超過一定程度的時候,子產品間對接的代價遠遠高于實體業務幹活的代價, 因為面向對象概念的層級劃分,要實作的業務需要封裝,封裝好跟父類對接。多繼承是萬惡之源,讓整個系統結構變成了網狀、環狀,最後變成一坨亂麻。

Erlang 的建立者 JoeArmstrong 有句名言:

面向對象語言的問題在于,它們依賴于特定的環境。你想要個香蕉,但拿到的卻是拿着香蕉的猩猩,乃至最後你擁有了整片叢林。

能解決問題的就是最好的:

程式設計要專注于“應用邏輯的實作”本身,應該盡量避免被“某種技術”分心 。《UNIX程式設計藝術》,第一原則就是KISS原則,整本書都貫徹了KISS(keep it simple, stupid!) 原則。寫項目、寫代碼,目的都是為了解決問題。而不是花費或者說浪費過多的時間在考慮與要解決的問題完全無關的事情上。不管是面向過程,還是面向對象,都是為了解決某一類問題的技術。各有各的用武之地:

在驅動開發、嵌入式底層開發這些地方,面向過程開發模式,幹淨,利索,直覺,資源掌控度高。在這些環境,面向過程開發幾乎是無可替代的。

在工作量大,難度較低、細節過多、用簡單的規範規則無法面面俱到的環境下,用面向對象開發模式,用低品質人力砸出來産業化項目。

1、面向對象程式設計

面向對象隻是一種設計思路,是一種概念,并沒有說什麼C++是面向對象的語言,java是面向對象的語言。C語言一樣可以是面向對象的語言,Linux核心就是面向對象的原生GNU C89編寫的,但是為了支援面向對象的開發模式,Linux核心編寫了大量概念維護modules,維護struct的函數指針,核心驅動裝載等等機制。而C++和java為了增加面向對象的寫法,直接給編譯器加了一堆文法糖。

2、什麼是類和對象

在C語言中,結構體是一種構造類型,可以包含若幹成員變量,每個成員變量的類型可以不同;可以通過結構體來定義結構體變量,每個變量擁有相同的性質。

在C++語言中,類也是一種構造類型,但是進行了一些擴充,可以将類看做是結構體的更新版,類的成員不但可以是變量,還可以是函數;不同的是,通過結構體定義出來的變量還是叫變量,而通過類定義出來的變量有了新的名稱,叫做對象(Object)在 C++ 中,通過類名就可以建立對象,這個過程叫做類的執行個體化,是以也稱對象是類的一個執行個體(Instance)類的成員變量稱為屬性(Property),将類的成員函數稱為方法(Method)。在C語言中的使用struct這個關鍵字定義結構體,在C++ 中使用的class這個關鍵字定義類。

結構體封裝的變量都是 public 屬性,類相比與結構體的封裝,多了 private 屬性和 protected 屬性, private 和protected 關鍵字的作用在于更好地隐藏了類的内部實作 ,隻有類源代碼才能通路私有成員,隻有派生類的類源代碼才能通路基類的受保護成員,每個人都可以通路公共成員。這樣可以有效的防止可能被不知道誰通路的全局變量。

C語言中的結構體:

//通過struct 關鍵字定義結構體
struct object
{
    char name[8];             
    char type;                
    char flag;               
    //指向函數的指針類型
    void  (*display)(void);           
};
           

複制

C++語言中的類:

//通過class關鍵字類定義類
class object{
public:
    char name[8];            
    char type;              
    char flag;               
    //類包含的函數體
    void display(){
        printf("123456789");
    }
};
           

複制

3、記憶體分布的對比

不管是C語言中的結構體或者C++中的類,都隻是相當于一個模闆,起到說明的作用,不占用記憶體空間;結構體定義的變量和類建立的對象才是實實在在的資料,要有地方來存放,才會占用記憶體空間。

結構體變量的記憶體模型:

結構體的記憶體配置設定是按照聲明的順序依次排列,涉及到記憶體對齊問題。

為什麼會存在記憶體對齊問題,引用傻孩子公衆号裸機思維的文章《漫談C變量——對齊》加以解釋:

在ARM Compiler裡面,結構體内的成員并不是簡單的對齊到字(Word)或者半字(Half Word),更别提位元組了(Byte),結構體的對齊使用以下規則:
  • 整個結構體,根據結構體内最大的那個元素來對齊。比如,整個結構體内部最大的元素是WORD,那麼整個結構體就預設對齊到4位元組。
  • 結構體内部,成員變量的排列順序嚴格按照定義的順序進行。
  • 結構體内部,成員變量自動對齊到自己的大小——這就會導緻空隙的産生。
  • 結構體内部,成員變量可以通過 attribute ((packed))單獨指定對齊方式為byte。

strut對象的記憶體模型:

//通過struct 關鍵字定義結構體
struct {
    uint8_t    a;
    uint16_t   b;
    uint8_t    c;
    uint32_t   d;
};
           

複制

memory layout:

C/C++面向對象程式設計之封裝

class對象的記憶體模型:

假如建立了 10 個對象,編譯器會将成員變量和成員函數分開存儲:分别為每個對象的成員變量配置設定記憶體,但是所有對象都共享同一段函數代碼,放在code區。如下圖所示:

C/C++面向對象程式設計之封裝

成員變量在堆區或棧區配置設定記憶體,成員函數放在代碼區。對象的大小隻受成員變量的影響,和成員函數沒有關系。對象的記憶體分布按照聲明的順序依次排列,和結構體非常類似,也會有記憶體對齊的問題。

可以看到結構體和對象的記憶體模型都是非常幹淨的,C語言裡通路成員函數實際上是通過指向函數的指針變量來通路(相當于回調),那麼C++編譯器究竟是根據什麼找到了成員函數呢?

實際上C++的編譯代碼的過程中,把成員函數最終編譯成與對象無關的全局函數,如果函數體中沒有成員變量,那問題就很簡單,不用對函數做任何處理,直接調用即可。

如果成員函數中使用到了成員變量該怎麼辦呢?成員變量的作用域不是全局,不經任何處理就無法在函數内部通路。

C++規定,編譯成員函數時要額外添加一個this指針參數,把目前對象的指針傳遞進去,通過this指針來通路成員變量。

this 實際上是成員函數的一個形參,在調用成員函數時将對象的位址作為實參傳遞給 this。不過 this 這個形參是隐式的,它并不出現在代碼中,而是在編譯階段由編譯器默默地将它添加到參數清單中。

這樣通過傳遞對象指針完成了成員函數和成員變量的關聯。這與我們從表明上看到的剛好相反,通過對象調用成員函數時,不是通過對象找函數,而是通過函數找對象。

這在C++中一切都是隐式完成的,對程式員來說完全透明,就好像這個額外的參數不存在一樣。

無論是C還是C++,其函數第一個參數都是一個指向其目标對象的指針,也就是this指針,隻不過C++由編譯器自動生成——是以方法的函數原型中不用專門寫出來而C語言模拟的方法函數則必須直接明确的寫出來

4 掩碼結構體

在C語言的編譯環境下,不支援結構體内放函數體,除了函數外,就和C++語言裡定義類和對象的思路完全一樣了。還有一個差別是結構體封裝的對象沒有好用的private 和protected屬性,不過C語言也可以通過掩碼結構體這個騷操作來實作private 和protected的特性。

注:此等操作并不是面向對象必須的,這個屬于錦上添花的行為,不用也不影響面向對象。

先通過一個例子直覺體會一下什麼是掩碼結構體,以下例子來源為:傻孩子的PLOOC的readme,作者倉庫位址:https://github.com/GorgonMeducer/PLOOC

//! the original structure in class source code
struct byte_queue_t {
    uint8_t   *pchBuffer;
    uint16_t  hwBufferSize;
    uint16_t  hwHead;
    uint16_t  hwTail;
    uint16_t  hwCount;
};

//! the masked structure: the class byte_queue_t in header file
typedef struct byte_queue_t {
    uint8_t chMask [sizeof(struct {
        uint8_t   *pchBuffer;
        uint16_t  hwBufferSize;
        uint16_t  hwHead;
        uint16_t  hwTail;
        uint16_t  hwCount;
    })];
} byte_queue_t;
           

複制

為了使其工作,我們必須確定類源代碼不包括其自己的接口頭檔案。您甚至可以這樣做…如果您對内容很認真

//! the masked structure: the class byte_queue_t in header file
typedef struct byte_queue_t {
    uint8_t chMask [sizeof(struct {
        uint32_t        : 32;
        uint16_t        : 16;
        uint16_t        : 16;
        uint16_t        : 16;
        uint16_t        : 16;
    })];
} byte_queue_t;
           

複制

通過這個例子,我們可以發現給使用者提供的頭檔案,其實是一個固态存儲器,即使用位元組數組建立的掩碼,使用者通過掩碼結構體建立的變量無法通路内部的成員,這就是實作屬性私有化的方法。至于如何實作隻有類源代碼才能通路私有成員,隻有派生類的類源代碼才能通路基類的受保護成員的特性,這裡先埋個伏筆,關注本公衆号,後續文章再深入探讨。

還回到掩碼結構體本身的特性上,可以發現一個問題,掩碼結構體丢失了結構體的對齊資訊,因為掩碼的本質是建立了一個chMask數組,我們知道數組是按照元素對齊的,而原本結構體是按照Word對齊的。是以當你用掩碼結構體聲名結構體變量的時候,這個變量多半不是對齊到word的,當你在子產品内通路這個對象的時候…編譯器預設你整個結構體是對齊到word,這就會導緻錯位的産生,可能會直接導緻hardfault了!

為了解決這個問題,可以利用_ attribute_ ((align))以及 _ alignof_的操作,對它進行如下改進:

//! the original structure in class source code
struct byte_queue_t {
    struct  {                                                               \
            uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;                                                         \
               }__attribute__((aligned(__alignof__(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;}))));   
};

//! the masked structure: the class byte_queue_t in header file
typedef struct byte_queue_t {
            uint8_t chMask                  \
                [sizeof(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;})]                              \
                __attribute__((aligned(__alignof__(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;}))));                                       \
} byte_queue_t;
           

複制

這部分了解起來可能稍微有點複雜,但是不了解也沒關系,現在先知道有這個東西,後續文章還會有更騷的操作來更直覺的實作封裝、繼承和多态!

5 C語言實作類的封裝

如果你趟過了掩碼結構體那條河,那麼恭喜你,你已經成功上岸了。我們繼續回到面向對象的問題上,面向對象的核心是把資料和處理資料的方法封裝在一起。封裝并不是隻有放在同一個結構體裡這一種形式,放在同一個接口頭檔案裡(也就是.h)裡,也是一種形式——即,一個接口頭檔案提供了資料的結構體,以及處理這些資料的函數原型聲明,這已經完成了面向對象所需的基本要求。下邊将通過C語言的具體執行個體加以說明。

假設我們要封裝一個基于位元組的隊列類,不妨叫做Queue,是以我們建立了一個類檔案queue.c和對應的接口頭檔案queue.h。假設我們約定queue.c将不包含queue.h(這麼做的好處很多,在以後的内容裡再講解,當然對掩碼結構體技術來說,子產品的實作是否包含子產品的接口頭檔案并不是關鍵)。

queue.h

...
//! the masked structure: the class byte_queue_t in header file
typedef struct queue_t {
            uint8_t chMask                  \
                [sizeof(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;})]                              \
                __attribute__((aligned(__alignof__(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;}))));                                       \
} queue_t;

...
extern bool queue_init(queue_t *ptQueue, uint8_t *pchBuffer, uint16_t hwSize);
extern bool enqueue(queue_t *ptQueue, uint8_t chByte);
extern bool dequeue(queue_t *ptQueue, uint8_t *pchByte);
extern bool is_queue_empty(queue_t *ptQueue);
...
           

複制

queue.c

...
//! the original structure in class source code
typedef struct __queue_t {
    struct  {                                                               \
            uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;                                                         \
               }__attribute__((aligned(__alignof__(struct {uint8_t   *pchBuffer;
            uint16_t  hwBufferSize;
            uint16_t  hwHead;
            uint16_t  hwTail;
            uint16_t  hwCount;}))));   
}__queue_t;
...
           

複制

可以看到,實際上類型queue_t是一個掩碼結構體,裡面隻有一個起到掩碼作用的數組chMask,其大小和真正背景的的類型__queue_t相同——這就是掩碼結構體實作私有成員保護的秘密。解決了私有成員保護的問題,剩下還有一個問題,對于queue.c的函數來說queue_t隻是一個數組,那麼正常的功能要如何實作呢?下面的代碼片将斷為你解釋一切:

...
#define __class(__NAME)                  __##__NAME
#define class(__NAME)                   __class(__NAME)   
bool is_queue_empty(queue_t *ptQueue)
{
    CLASS(queue_t) *ptQ = (CLASS(queue_t) *)ptQueue;
    if (NULL == ptQueue) {
        return true;
    }
    return ((ptQ->hwHead == ptQ->hwTail) && (0 == ptQ->hwCount));
}
...
           

複制

可以從這裡看出來,隻有類的源檔案才能看到内部使用的結構體,而掩碼結構體是子產品内外都可以看到的,簡單來說,如果實際内部的定義為外部的子產品所能直接看見,那自然就沒有辦法起到保護作用。

從編譯器的角度來說,這種從queue_t到__queue_t類型指針的轉義是邏輯上的,并不會是以産生額外的代碼,簡而言之,使用掩碼結構體幾乎是沒有代價的。

再次強調:實作面向對象,掩碼結構體并不是必須的,隻是錦上添花,是以不了解的話,也不要糾結!

想要更深入了解C語言面向對象的思想,建議參考的書籍:《UML+OOPC嵌入式C語言開發精講》