一步步分析:C語言如何物件導向程式設計

sewain發表於2020-12-20
這是道哥的第009篇原創

一、前言

在嵌入式開發中,C/C++語言是使用最普及的,在C++11版本之前,它們的語法是比較相似的,只不過C++提供了物件導向的程式設計方式。

雖然C++語言是從C語言發展而來的,但是今天的C++已經不是當年的C語言的擴充套件了,從2011版本開始,更像是一門全新的語言。

那麼沒有想過,當初為什麼要擴充套件出C++?C語言有什麼樣的缺點導致C++的產生?

C++在這幾個問題上的解決的確很好,但是隨著語言標準的逐步擴充,C++語言的學習難度也逐漸加大。沒有開發過幾個專案,都不好意思說自己學會了C++,那些左值、右值、模板、模板引數、可變模板引數等等一堆的概念,真的不是使用2,3年就可以熟練掌握的。

但是,C語言也有很多的優點:

其實最後一個優點是最重要的:使用的人越多,生命力就越強。就像現在的社會一樣,不是優者生存,而是適者生存。 ![](https://img2020.cnblogs.com/blog/1440498/202012/1440498-20201220171658610-314159968.png)

這篇文章,我們就來聊聊如何在C語言中利用物件導向的思想來程式設計。也許你在專案中用不到,但是也強烈建議你看一下,因為我之前在跳槽的時候就兩次被問到這個問題。

二、什麼是物件導向程式設計

有這麼一個公式:程式=資料結構+演算法

C語言中一般使用程式導向程式設計,就是分析出解決問題所需要的步驟,然後用函式把這些步驟一步一步呼叫,在函式中對資料結構進行處理(執行演算法),也就是說資料結構和演算法是分開的

C++語言把資料和演算法封裝在一起,形成一個整體,無論是對它的屬性進行操作、還是對它的行為進行呼叫,都是通過一個物件來執行,這就是物件導向程式設計思想。

如果用C語言來模擬這樣的程式設計方式,需要解決3個問題:

  1. 資料的封裝
  2. 繼承
  3. 多型

第一個問題:封裝

封裝描述的是資料的組織形式,就是把屬於一個物件的所有屬性(資料)組織在一起,C語言中的結構體型別天生就支援這一點。

第二個問題:繼承

繼承描述的是物件之間的關係,子類通過繼承父類,自動擁有父類中的屬性和行為(也就是方法)。這個問題只要理解了C語言的記憶體模型,也不是問題,只要在子類結構體中的第一個成員變數的位置放置一個父類結構體變數,那麼子類物件就繼承了父類中的屬性。

另外補充一點:學習任何一種語言,一定要理解記憶體模型!

第三個問題:多型

按字面理解,多型就是“多種狀態”,描述的是一種動態的行為。在C++中,只有通過基類引用或者指標,去呼叫虛擬函式的時候才發生多型,也就是說多型是發生在執行期間的,C++內部通過一個虛表來實現多型。那麼在C語言中,我們也可以按照這個思路來實現。

如果一門語言只支援類,而不支援多型,只能說它是基於物件的,而不是物件導向的。

既然思路上沒有問題,那麼我們就來簡單的實現一個。

三、先實現一個父類,解決封裝的問題

Animal.h

#ifndef _ANIMAL_H_
#define _ANIMAL_H_

// 定義父類結構
typedef struct {
    int age;
    int weight;
} Animal;

// 建構函式宣告
void Animal_Ctor(Animal *this, int age, int weight);

// 獲取父類屬性宣告
int Animal_GetAge(Animal *this);
int Animal_GetWeight(Animal *this);

#endif

Animal.c

#include "Animal.h"

// 父類建構函式實現
void Animal_Ctor(Animal *this, int age, int weight)
{
    this->age = age;
    this->weight = weight;
}

int Animal_GetAge(Animal *this)
{
    return this->age;
}

int Animal_GetWeight(Animal *this)
{
    return this->weight;
}

測試一下:

#include <stdio.h>
#include "Animal.h"
#include "Dog.h"

int main()
{
    // 在棧上建立一個物件
    Animal a;  
    // 構造物件
    Animal_Ctor(&a, 1, 3); 
    printf("age = %d, weight = %d \n", 
            Animal_GetAge(&a),
            Animal_GetWeight(&a));
    return 0;
}

可以簡單的理解為:在程式碼段有一塊空間,儲存著可以處理Animal物件的函式;在棧中有一塊空間,儲存著a物件。

與C++對比:
在C++的方法中,隱含著第一個引數this指標。當呼叫一個物件的方法時,編譯器會自動把物件的地址傳遞給這個指標。

所以,在Animal.h中函式我們就模擬一下,顯示的定義這個this指標,在呼叫時主動把物件的地址傳遞給它,這樣的話,函式就可以對任意一個Animal物件進行處理了。

四、 實現一個子類,解決繼承的問題

Dog.h

#ifndef _DOG_H_
#define _DOG_H_

#include "Animal.h"

// 定義子類結構
typedef struct {
    Animal parent; // 第一個位置放置父類結構
    int legs;      // 新增子類自己的屬性
}Dog;

// 子類建構函式宣告
void Dog_Ctor(Dog *this, int age, int weight, int legs);

// 子類屬性宣告
int Dog_GetAge(Dog *this);
int Dog_GetWeight(Dog *this);
int Dog_GetLegs(Dog *this);

#endif

Dog.c

#include "Dog.h"

// 子類建構函式實現
void Dog_Ctor(Dog *this, int age, int weight, int legs)
{
    // 首先呼叫父類建構函式,來初始化從父類繼承的資料
    Animal_Ctor(&this->parent, age, weight);
    // 然後初始化子類自己的資料
    this->legs = legs;
}

int Dog_GetAge(Dog *this)
{
    // age屬性是繼承而來,轉發給父類中的獲取屬性函式
    return Animal_GetAge(&this->parent);
}

int Dog_GetWeight(Dog *this)
{
    return Animal_GetWeight(&this->parent);
}

int Dog_GetLegs(Dog *this)
{
    // 子類自己的屬性,直接返回
    return this->legs;
}

測試一下:

int main()
{
    Dog d;
    Dog_Ctor(&d, 1, 3, 4);
    printf("age = %d, weight = %d, legs = %d \n", 
            Dog_GetAge(&d),
            Dog_GetWeight(&d),
            Dog_GetLegs(&d));
    return 0;
}

在程式碼段有一塊空間,儲存著可以處理Dog物件的函式;在棧中有一塊空間,儲存著d物件。由於Dog結構體中的第一個引數是Animal物件,所以從記憶體模型上看,子類就包含了父類中定義的屬性。

Dog的記憶體模型中開頭部分就自動包括了Animal中的成員,也即是說Dog繼承了Animal的屬性。

五、利用虛擬函式,解決多型問題

在C++中,如果一個父類中定義了虛擬函式,那麼編譯器就會在這個記憶體中開闢一塊空間放置虛表,這張表裡的每一個item都是一個函式指標,然後在父類的記憶體模型中放一個虛表指標,指向上面這個虛表。

上面這段描述不是十分準確,主要看各家編譯器的處理方式,不過大部分C++處理器都是這麼幹的,我們可以想這麼理解。

子類在繼承父類之後,在記憶體中又會開闢一塊空間來放置子類自己的虛表,然後讓繼承而來的虛表指標指向子類自己的虛表。

既然C++是這麼做的,那我們就用C來手動模擬這個行為:建立虛表和虛表指標。

1. Animal.h為父類Animal中,新增虛表和虛表指標

#ifndef _ANIMAL_H_
#define _ANIMAL_H_

struct AnimalVTable;  // 父類虛表的前置宣告

// 父類結構
typedef struct {
    struct AnimalVTable *vptr; // 虛表指標
    int age;
    int weight;
} Animal;

// 父類中的虛表
struct AnimalVTable{
    void (*say)(Animal *this); // 虛擬函式指標
};

// 父類中實現的虛擬函式
void Animal_Say(Animal *this);

#endif

2. Animal.c

#include <assert.h>
#include "Animal.h"

// 父類中虛擬函式的具體實現
static void _Animal_Say(Animal *this)
{
    // 因為父類Animal是一個抽象的東西,不應該被例項化。
    // 父類中的這個虛擬函式不應該被呼叫,也就是說子類必須實現這個虛擬函式。
    // 類似於C++中的純虛擬函式。
    assert(0); 
}

// 父類建構函式
void Animal_Ctor(Animal *this, int age, int weight)
{
    // 首先定義一個虛表
    static struct AnimalVTable animal_vtbl = {_Animal_Say};
    // 讓虛表指標指向上面這個虛表
    this->vptr = &animal_vtbl;
    this->age = age;
    this->weight = weight;
}

// 測試多型:傳入的引數型別是父類指標
void Animal_Say(Animal *this)
{
    // 如果this實際指向一個子類Dog物件,那麼this->vptr這個虛表指標指向子類自己的虛表,
    // 因此,this->vptr->say將會呼叫子類虛表中的函式。
    this->vptr->say(this);
}

在棧空間定義了一個虛擬函式表animal_vtbl,這個表中的每一項都是一個函式指標,例如:函式指標say就指向了程式碼段中的函式_Animal_Say()。
物件a的第一個成員vptr是一個指標,指向了這個虛擬函式表animal_vtbl。

3. Dog.h不變

4. Dog.c中定義子類自己的虛表

#include "Dog.h"

// 子類中虛擬函式的具體實現
static void _Dog_Say(Dog *this)
{
    printf("dag say \n");
}

// 子類建構函式
void Dog_Ctor(Dog *this, int age, int weight, int legs)
{
    // 首先呼叫父類建構函式。
    Animal_Ctor(&this->parent, age, weight);
    // 定義子類自己的虛擬函式表
    static struct AnimalVTable dog_vtbl = {_Dog_Say};
    // 把從父類中繼承得到的虛表指標指向子類自己的虛表
    this->parent.vptr = &dog_vtbl;
    // 初始化子類自己的屬性
    this->legs = legs;
}

5. 測試一下

int main()
{
    // 在棧中建立一個子類Dog物件
    Dog d;  
    Dog_Ctor(&d, 1, 3, 4);

    // 把子類物件賦值給父類指標
    Animal *pa = &d;
    
    // 傳遞父類指標,將會呼叫子類中實現的虛擬函式。
    Animal_Say(pa);
}

記憶體模型如下:

物件d中,從父類繼承而來的虛表指標vptr,所指向的虛表是dog_vtbl。

在執行Animal_Say(pa)的時候,雖然引數型別是指向父類Animal的指標,但是實際傳入的pa是一個指向子類Dog的物件,這個物件中的虛表指標vptr指向的是子類中自己定義的虛表dog_vtbl,這個虛表中的函式指標say指向的是子類中重新定義的虛擬函式_Dog_Say,因此this->vptr->say(this)最終呼叫的函式就是_Dog_Say。

基本上,在C中物件導向的開發思想就是以上這樣。
這個程式碼很簡單,自己手敲一下就可以了。如果想偷懶,請在後臺留言,我發給您。

六、C物件導向思想在專案中的使用

1. Linux核心

看一下關於socket的幾個結構體:

struct sock {
    ...
}

struct inet_sock {
    struct sock sk;
    ...
};

struct udp_sock {
    struct sock sk;
    ...
};

sock可以看作是父類,inet_sock和udp_sock的第一個成員都是是sock型別,從記憶體模型上看相當於是繼承了sock中的所有屬性。

2. glib庫

以最簡單的字串處理函式來舉例:

GString *g_string_truncate(GString *string, gint len)
GString *g_string_append(GString *string, gchar *val)
GString *g_string_prepend(GString *string, gchar *val)
...

API函式的第一個引數都是一個GString物件指標,指向需要處理的那個字串物件。

GString *s1, *s2;
s1 = g_string_new("Hello");
s2 = g_string_new("Hello");

g_string_append(s1," World!");
g_string_append(s2," World!");

3. 其他專案

還有一些專案,雖然從函式的引數上來看,似乎不是物件導向的,但是在資料結構的設計上看來,也是物件導向的思想,比如:

Modbus協議的開源庫libmodbus
用於家庭自動化的無線通訊協議ZWave
很久之前的高通手機開發平臺BREW


【原創宣告】

作者:道哥(公眾號: IOT物聯網小鎮)

知乎:道哥

B站:道哥分享

掘金:道哥分享

CSDN:道哥分享

如果覺得文章不錯,請轉發、分享給您的朋友。


我會把十多年嵌入式開發中的專案實戰經驗進行總結、分享,相信不會讓你失望的!

長按下圖二維碼關注,花20秒鐘瞭解一下也沒什麼損失,萬一是個適合你的寶藏公眾號呢!


轉載:歡迎轉載,但未經作者同意,必須保留此段宣告,必須在文章中給出原文連線。

推薦閱讀

[1] 原來gdb的底層除錯原理這麼簡單
[2] 生產者和消費者模式中的雙緩衝技術
[3] 深入LUA指令碼語言,讓你徹底明白除錯原理

相關文章