OOPC精要——撩開“對象”的神祕面紗

前言:

何爲面向過程:

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

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

何爲面向對象:

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

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

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;                                         
    //指向函數的指針類型
    void  (*setname)(struct object *this,char *name);           
};
void setname(struct object *this,char *name)
{
	this->name=name;
}

C++語言中的類:

//通過class關鍵字類定義類
class object{
	public:                  
		void setname(char *name);
	private:
	    char *name;      
};
void object::setname(char *name){
    this->name = name;
}

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:

img

class對象的內存模型:
假如創建了 10 個對象,編譯器會將成員變量和成員函數分開存儲:分別爲每個對象的成員變量分配內存,但是所有對象都共享同一段函數代碼,放在code區。如下圖所示:
在這裏插入圖片描述
成員變量在堆區或棧區分配內存,成員函數放在代碼區。對象的大小隻受成員變量的影響,和成員函數沒有關係。對象的內存分佈按照聲明的順序依次排列,和結構體非常類似,也會有內存對齊的問題。

可以看到結構體和對象的內存模型都是非常乾淨的,C語言裏訪問成員函數實際上是通過指向函數的指針變量來訪問(相當於回調),那麼C++編譯器究竟是根據什麼找到了成員函數呢?
實際上C++的編譯代碼的過程中,把成員函數最終編譯成與對象無關的全局函數,如果函數體中沒有成員變量,那問題就很簡單,不用對函數做任何處理,直接調用即可。
如果成員函數中使用到了成員變量該怎麼辦呢?成員變量的作用域不是全局,不經任何處理就無法在函數內部訪問。
C++規定,編譯成員函數時要額外添加一個this指針參數,把當前對象的指針傳遞進去,通過this指針來訪問成員變量。
this 實際上是成員函數的一個形參,在調用成員函數時將對象的地址作爲實參傳遞給 this。不過 this 這個形參是隱式的,它並不出現在代碼中,而是在編譯階段由編譯器默默地將它添加到參數列表中。
這樣通過傳遞對象指針完成了成員函數和成員變量的關聯。這與我們從表明上看到的剛好相反,通過對象調用成員函數時,不是通過對象找函數,而是通過函數找對象。

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

4 掩碼結構體

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

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

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

typedef struct __byte_queue_t {
    uint8_t   *pchBuffer;
    uint16_t  hwBufferSize;
    uint16_t  hwHead;
    uint16_t  hwTail;
    uint16_t  hwCount;
}__byte_queue_t;

typedef struct {
    uint8_t chMask [sizeof(struct __byte_queue_t)];
} byte_queue_t;

您甚至可以這樣做…如果您對內容很認真的話

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數組,我們知道數組是按照元素對齊的,因此數組chMask對齊到字節,又由於chMask是結構體byte_queue_t的中的對齊要求最大的那個元素(也是唯一元素),因此整個結構體的對齊就是按字節對齊;

  • 通過分析容易發現,原本的結構體中對齊要求最高的元素是指針pchBuffer,由於它要求對齊到word,因此整個結構體都是按照Word對齊的。

  • 當你用掩碼結構體聲名結構體變量的時候,這個變量多半不是對齊到word的而是對齊到了任意的字節地址上。更具文章《漫談C變量——對齊(1)》和《漫談C變量——對齊(2)》中的介紹,當我們我們用指針訪問結構體時,如果指針默認的對齊方式與對象實際的對齊方式不符時,就會引發“非對齊訪問”——輕則性能下降,重則觸發hardfault。

爲了解決這個問題,可以利用 __alignof__() 來獲取__byte_queue_t的對齊值,再使用__attribute__((align))來指定chMask的對齊方式。改進如下:


//! the original structure in class source code
typedef struct __byte_queue_t {                
    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(__byte_queue_t)]  
        __attribute__((aligned(__alignof__(__byte_queue_t))));                  
} byte_queue_t;

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

5 C語言實現類的封裝

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

假設我們要封裝一個基於字節的隊列類,不妨叫做byte_queue_t,因此我們建立了一個類文件byte_queue.c和對應的接口頭文件byte_queue.h

byte_queue.h


//! the original structure in class source code
//! 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;
...
extern bool queue_init(byte_queue_t *ptQueue, 
                       uint8_t *pchBuffer, 
                       uint16_t hwSize);
extern bool enqueue(byte_queue_t *ptQueue, uint8_t chByte);
extern bool dequeue(byte_queue_t *ptQueue, uint8_t *pchByte);
extern bool is_queue_empty(byte_queue_t *ptQueue);
...

byte_queue.c


#include "./queue.h"

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

需要注意的是,這裏之所以不像前面那樣首先定義類型__byte_queue_t,然後在掩碼結構體byte_queue_t的定義中直接使用__byte_queue_t來計算數組的大小並取得對齊方式,是因爲:

  • __byte_queue_t 裏包含了類的成員信息,我們不希望用戶能夠直接訪問這些成員;

  • 用戶使用模塊時只會包含 byte_queue.h,因此必然不能直接把__byte_queue_t放置到該頭文件中;

  • 基於上述考慮,byte_queue.h 的掩碼結構體定義只能自己再抄寫一份;

  • 目前這種方式是“防君子不妨小人的”,但如果我們真正不想暴露任何成員信息給用戶時,可以考慮使用前面介紹過的完全抹去成員變量名稱的方式——在這種情況下就更不能將__byte_queue_t 放置到 byte_queue.h 中了。

可以看到,實際上類型byte_queue_t是一個掩碼結構體,裏面只有一個起到掩碼作用的數組chMask其大小、對齊方式和真正後臺的的類型__byte_queue_t相同——這就是掩碼結構體實現私有成員保護的祕密。 解決了私有成員保護的問題,剩下還有一個問題,對於byte_queue.c的函數來說byte_queue_t只是一個數組,那麼正常的功能要如何實現呢?下面的代碼片將斷爲你解釋一切:


...
#define __class(__NAME)                  __##__NAME
#define class(__NAME)                   __class(__NAME)   
#ifndef this
#   define this                            (*ptThis)
#endif

bool is_queue_empty(byte_queue_t *ptQueue)
{
    class(byte_queue_t) *ptThis = (class(byte_queue_t) *)ptQueue;
    if (NULL == ptQueue) {
        return true;
    }
    return ((this.hwHead == this.hwTail) && (0 == this.hwCount));
}
...

可以從這裏看出來,只有類的源文件才能看到內部使用的結構體,而掩碼結構體是模塊內外都可以看到的,簡單來說,如果實際內部的定義爲外部的模塊所能直接看見,那自然就沒有辦法起到保護作用。
從編譯器的角度來說,這種從byte_queue_t__byte_queue_t類型指針的轉義是邏輯上的,並不會因此產生額外的代碼,簡而言之,使用掩碼結構體幾乎是沒有代價的。

再次強調:實現面向對象,掩碼結構體並不是必須的,只是錦上添花,所以不理解的話,也不要糾結

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

聯繫作者:
歡迎關注本人公衆號,加羣交流:

在這裏插入圖片描述

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