目錄
- C語言面向物件
- 為什么要面向物件
- 面向物件三大特性的實作
- 封裝
- 類
- 檔案
- 包
- 封裝性小結
- 繼承
- 多型
- 函式指標
- 父結構體指標
- 弱函式
- 總結
C語言面向物件
為什么要面向物件
C語言作為一門面向程序的高級語言,具有非常高的運行效率,但相對來說它的封裝和擴展性能就沒有那么強,為了能夠寫出具有足夠封裝性和擴展性的C語言程式,我們就需要用面向物件的思想來撰寫C語言程式,
有人可能會覺得面向物件的效率低,但事實上C語言運行已經十分高效,面向物件的編程方式并不會帶來非常顯著的效率下降,其次,當工程所涉及的模塊越來越多,功能越來越復雜的時候,面向物件就成為了必然選擇,
面向物件可以降低代碼耦合度,你是否遇到過為了加某一新功能,牽一發而動全身,引入了諸多BUG不說,最后還沒能完全支持新功能,這種情況大概率是因為函式及變數各處隨意呼叫導致代碼耦合度過高,或者函式介面設計不當,不得不持續引入新的函式介面以實作新功能,因此能否基于面向物件的思想撰寫高擴展性和可維護性的C程式越來越成為判斷一名C語言程式員水平高低的標準之一,
下面將介紹幾種簡單的C語言面向物件的方法,這些方法僅根據我個人經驗總結得出,如有謬誤歡迎指正,
面向物件三大特性的實作
面向物件的編程思想具有三大特征:封裝、多型、繼承,
封裝
按我的理解,封裝就是把具有相同性質的變數、函式及介面統一管理,只能通過某個渠道才能訪問里面的內容,好比是一個存放了各種東西的倉庫,只能用特定鑰匙才能打開它并使用倉庫里存放的東西,這個倉庫就是對里面存放東西的封裝,外面看不到里面到底有什么,以JAVA為例,JAVA的封裝性體現在類(class)、檔案(.java)和包(package)上,
類
類是能夠體現封裝性最重要的特征之一,JAVA中一個類的非靜態成員可以通過該類的實體物件訪問,而在C語言中,就需要結構體來承擔JAVA中類的職責,所謂類的實體物件,在C中就是以該結構體為變數型別的變數,很多時候我們會用typedef來將一個結構體定義為一個型別,型別命名時常以_t作為結尾,但與JAVA的類不同的是,訪問一個C的結構體可以通過一個變數或者是該結構體型別的指標,
typedef struct {
int a;
char b;
} example_t;
// 實體物件
example_t obj = {
.a = 1,
.b = 'b'
};
// 指標形式
example_t *p_obj = (example_t *)malloc(sizeof(example_t));
p_obj->a = 2;
p_obj->b = 'z';
上面只是變數的實作方法,那么有沒有辦法在結構體內包含一個函式呢?C語言中雖然不能直接將一個函式放到結構體里面,但是可以通過函式指標實作,而這個函式指標類似于是JAVA中的抽象函式,指定了函式回傳值型別以及引數串列個數及其型別,但其本質仍是一個指標,我們需要為該函式指標賦值,即將其指向同型別的函式地址
typedef struct {
int a;
char b;
int (*func)(int, int);
} example_t;
int get_sum(int x, int y)
{
return x + y;
}
void test(void)
{
example_t obj = {
.a = 1,
.b = 'c',
.func = get_sum
};
// 回傳值為9
int ret = obj.func(4, 5);
}
上例中,*func表示回傳值為int,引數串列有兩個引數,都是int,符合這個特征的函式指標,我們可以看到get_sum剛好符合這個條件,我們就可以把get_sum的函式地址賦值給func,當通過obj呼叫func時,實際上會跳轉到get_sum的地址,并開始執行get_sum中的代碼,通過上例我們不難看出,只要符合*func所指定型別的,不管函式內部執行什么都可以,那么我們就也可以用不同的函式去賦值,實作不同的功能,
typedef struct {
int a;
char b;
int (*func)(int, int);
} example_t;
int get_sum(int x, int y)
{
return x + y;
}
int get_minus(int x, int y)
{
return x - y;
}
void test(void)
{
example_t obj = {
.a = 1,
.b = 'c',
.func = get_sum
};
// 回傳值為9
int ret1 = obj.func(4, 5);
// 改變指向的函式
obj.func = get_minus;
// 回傳值變為-1
int ret2 = obj.func(4, 5);
}
至此我們可以看到C語言的結構體也可以實作類似于JAVA中類的功能,甚至實作了部分多型的功能,
檔案
檔案也是封裝性的體現之一,為什么這么說呢?對于一個.c檔案,如果其他檔案想要呼叫它的全域變數或者函式等,可以通過extern關鍵字宣告后使用,如果我們希望一些變數或者函式只能夠在本檔案中使用的時候(即不希望被外界呼叫),那么可以通過static關鍵字修飾,可以理解為static關鍵字修飾的變數或函式被封裝到這個.c檔案里面了,只允許該.c檔案自己使用,這樣可以避免跨檔案隨意呼叫造成的耦合,
// 在檔案1中定義了變數a,sum和被static修飾的minus函式
int a = 1;
int sum(int a, int b)
{
return a + b;
}
static int minus(int a, int b)
{
reutn a - b;
}
// 在檔案2中呼叫檔案1中的a和sum函式
void test(void)
{
// 在函式內extern表示該變數或函式的作用域僅限于該函式,可防止擴大其作用域,破壞封裝性
extern int a;
extern int sum(int a, int b);
// 被static修飾的變數或函式不能extern
// extern int minus(int a, int b);
int res = sum(a + 5);
}
包
JAVA中通過import引入jar包,C語言中則是通過include頭檔案引入頭檔案中定義的函式、型別等,因此只有那些允許被其他檔案使用的函式、變數、型別、宏等才應該被放入.h頭檔案中,即意味著放入頭檔案中的內容將被公開,如果把只有內部(即.h對應的.c檔案)才需要用到的一些函式或變數等放入頭檔案,一方面會引起呼叫者的困惑,一堆亂糟糟的函式不知道那些需要被呼叫,另一方面,呼叫者可能因錯誤呼叫本不應被公開的函式等,導致內部狀態等改變,進而引起程式執行出錯,所以當我們用面向物件的思路編程時,一定要非常小心哪些內容應放到頭檔案,可以被公開,而哪些不需要,那么不需要的最好可以通過static修飾,
// algorithm.c源檔案
static int sum(int a, int b)
{
return a + b;
}
static minus(int a, int b)
{
return a - b;
}
static int multiple(int a, int b)
{
return a * b;
}
int calculate(int a, int b, int c)
{
return multiple(sum(a, b), minus(b, c));
}
// algorithm.h頭檔案
int calculate(int a, int b, int c);
// test.c源檔案
#include "algorithm.h"
void test(void)
{
int res = calculate(2, 6, 3);
}
注意上述例子是在test.c源檔案中include,那么和.h頭檔案中include有什么區別呢?假設我們在test.h檔案中include了algorithm.h,再由test.c include test.h,我們也能實作相同的功能,但是,如果我們還有一個app.h頭檔案include了test.h,那么algorithm.h中定義的內容也會泄露到app.h中形成連鎖反應,所以如果只是源檔案用到了某個頭檔案,盡量不要用下面例子中在它自身的頭檔案中去include,而是像上面代碼一樣,在源檔案中include,否則非常容易導致回圈依賴,導致編譯不通過,比如algorithm.h又include了app.h,就會形成回圈依賴,相當于《我include了我自己》,
// algorithm.h頭檔案
#incluide "app.h"
int calculate(int a, int b, int c);
// test.h頭檔案
#include "algorithm.h"
int test(void);
// test.c源檔案
#include "test.h"
int test(void)
{
return calculate(2, 6, 3) + 5;
}
// app.h頭檔案
/* 這里相當于同時include了"algorithm.h"
* "algorithm.h"和"app.h"形成了回圈依賴,非常容易出錯 */
#include "test.h"
封裝性小結
JAVA中的類對應C中的結構體,private作用域相當于是加了static的函式,default的作用域類似于沒有加static但是也沒有在頭檔案中宣告的函式,其他檔案仍可通過extern參考,而頭檔案中的內容則可以認為是public的內容,
繼承
繼承即是在父類或者基類的基礎上,由子類繼承其變數、函式型別,并根據需求進行擴充,一般父類中定義的是所有子類都具有的屬性或者通用的方法,通過繼承,我們可以規范子類成員的型別的方法,但目前我并沒有遇到C語言中可以實作繼承的比較好的方式,只能將父類,即父結構體,以成員的形式放在子結構體中
// 父結構體
typedef struct {
const char *name;
int age;
} people_t;
// 子結構體1
typedef struct {
people_t base;
const char *school;
} student_t;
// 子結構體2
typedef struct {
people_t base;
int salary;
int (*earn_money)(void);
} adult_t;
// 孫結構體
typedef struct {
adult_t base;
bool has_beard;
} man_t;
我們知道,在C語言中,結構體其實是存盤了資訊的一片空間,結構體內部的變數或者指標等,都是只是用來標識該資訊存放相對于結構體起始位置的偏移以及所占空間大小,以上面孫結構體為例,其實際結構體存盤的內容為:
typedef struct {
const char *name;
int age; // people_t 型別訪問邊界
int salary;
int (*earn_money)(void); // adult_t 型別訪問邊界
bool has_beard; // man_t 型別訪問邊界
} man_t;
如果我們有一個man_t型別的變數x,要訪問其age成員,那么我們需要呼叫x.base.base.age,還有一種方法是將其強轉為people_t型別,并通過people_t指標訪問age成員
void test(void)
{
man_t x;
// 一般訪問方式
x.base.base.age = 30;
// 父型別指標方式
people_t *peo = (people_t *)&x;
peo->age = 30;
}
以此類推,當我們傳入一個函式的時候,也可以用父結構體指標的方式來擴展可接受的引數型別,相當于JAVA中可以通過父類物件接受子類物件,如
void init_people(people_t *peo)
{
if(peo == NULL)
return;
peo->name = "default";
peo->age = "20";
}
void test(void)
{
man_t man;
adult_t adt;
student_t stu;
init_people((people_t *)&man);
init_people((people_t *)&adt);
init_people((people_t *)&stu);
}
(ps. 其實C語言中我們甚至還可以將父類物件強轉為子類物件,不過這種方式有一定訪問非法地址的風險,所以需要有限制條件,會在后續內容中提到)
多型
所謂多型,用更加通俗一點的理解就是通過相同的介面,達到實作不同功能的目的,JAVA中多型一般體現為重寫(Override),它是基于繼承機制的一個操作,即在不同子類中根據需求重寫父類的一個介面,以實作不同功能,在C中,有三種方式可以實作多型,
函式指標
函式指標已經在介紹C語言封裝的時候提到了,函式指標規定了所指向函式的型別,即統一了函式介面,并可以通過該函式指標跳轉對應函式,
typedef struct {
int pre_val;
int (*func)(int a, int b);
} alg_t;
int sum(int a, int b)
{
return a + b;
}
int munus(int a, int b)
{
return a - b;
}
void test(void)
{
// 實體物件alg1的func函式指標指向了sum函式
alg_t alg1 = {
.pre_val = 0,
.func = sum
};
// 實體物件alg2的func函式指標指向了minus函式
alg_t alg2 = {
.pre_val = 0,
.func = minus
};
// 呼叫不同物件的同一介面,實作不同功能
alg1.pre_val = alg1.func(4, 5); // 結果為9
alg2.pre_val = alg2.func(4, 5); // 結果為-1
}
父結構體指標
這種方式其實更加接近函式的多載,但不能改變引數個數,當一個介面中允許傳入的引數是一個父結構體指標時,事實上我們也可以傳入一個子結構體指標,
typedef enum {
DEFAULT,
STUDENT,
TEACHER
} role_t;
// 父結構體
typedef struct {
const char *name;
int age;
role_t role; // 用來標識子結構體型別
} people_t;
// 子結構體1
typedef struct {
people_t base;
const char *school;
int score;
} student_t;
// 子結構體2
typedef struct {
people_t base;
const char *course;
} teacher_t;
void init_people(people_t *peo)
{
if (peo == NULL)
abort();
// 判斷子結構體型別
switch (peo->role) {
case DEFAULT:
peo->name = "Unkown";
peo->age = -1;
case STUDENT:
// 注意這里將父結構體指標強轉為子結構體指標務必保證型別正確,否則可能會訪問非法地址及資料溢位風險
student_t *stu = (student_t *)peo;
stu->name = "John";
stu->age = 10;
stu->school = "Tsinghua"
stu->score = 99;
break;
case TEACHER:
// 注意這里將父結構體指標強轉為子結構體指標務必保證型別正確,否則可能會訪問非法地址及資料溢位風險
teacher_t *tea = (teacher_t *)peo;
tea->name = "Mike";
tea->age = 35;
tea->course= "C language";
break;
default:
abort();
}
}
void test(void)
{
student_t stu;
teacher_t tea;
// 這里需要正確標識子結構體型別,才能將子結構體強轉回父結構體
stu.base.role = STUDENT;
tea.base.role = TEACHER;
init_people((people_t *)&stu);
init_people((people_t *)&tea);
}
以此類推,其實上文中的父結構體指標也可以被void *代替,這樣我們的父結構體就不一定必須要在頭檔案中定義,但此時父結構體中的內容在子結構體中必須單獨自己定義,單獨定義時因為沒有任何約束條件,所以必須同父結構體的變數型別、順序等相同,即在存盤中的存盤格式相同,
// init.h頭檔案
typedef enum {
STUDENT,
TEACHER
} role_t;
// 子結構體1
typedef struct {
// 父結構體中的欄位
const char *name;
int age;
role_t role; // 用來標識子結構體型別
// 子結構體中額外的欄位
const char *school;
int score;
} student_t;
// 子結構體2
typedef struct {
// 父結構體中的欄位
const char *name;
int age;
role_t role; // 用來標識子結構體型別
// 子結構體中額外的欄位
const char *course;
} teacher_t;
void init_people(void *peo);
// init.c源檔案
#include "init.h"
// 父結構體
typedef struct {
const char *name;
int age;
role_t role; // 用來標識子結構體型別
} people_t;
void init_people(void *peo)
{
if (peo == NULL)
abort();
people_t people = (people_t *) peo;
// 判斷子結構體型別
switch (people ->role) {
case STUDENT:
// 注意這里將父結構體指標強轉為子結構體指標務必保證型別正確,否則可能會訪問非法地址及資料溢位風險
student_t *stu = (student_t *)peo;
stu->name = "John";
stu->age = 10;
stu->school = "Tsinghua"
stu->score = 99;
break;
case TEACHER:
// 注意這里將父結構體指標強轉為子結構體指標務必保證型別正確,否則可能會訪問非法地址及資料溢位風險
teacher_t *tea = (teacher_t *)peo;
tea->name = "Mike";
tea->age = 42;
tea->course= "C language";
break;
default:
abort();
}
}
// test.c源檔案
#include "init.h"
void test(void)
{
student_t stu;
teacher_t tea;
// 這里需要正確標識子結構體型別,才能將子結構體強轉回父結構體
stu.role = STUDENT;
tea.role = TEACHER;
init_people(&stu);
init_people(&tea);
}
用void *代替父結構體指標的好處是,父結構體不必被暴露出來,即上層呼叫者(這里是test.c)不會傳入一個父結構體的物件,但是缺陷是,由于傳入的指標型別是void *,資料型別不明顯,上層呼叫者有可能會傳入其他奇怪的指標,另外,不管是父結構體指標還是void *這樣做雖然增強了介面函式的通用性,但是還是會有指向非法地址或者溢位風險,比如上層呼叫者將role配錯,就有可能引起不可預料的風險,因此需要謹慎使用,
弱函式
GUN C支持弱函式,可以通過在函式名前加__attribute__((weak))來表示一個函式是弱函式,弱函式一般用來實作一個介面的默認功能,當有一個回傳值、函式名、引數串列完全相同的函式在其他地方被定義是,該弱函式會被覆寫,也就相當于JAVA中的重寫(override),但是弱函式的方式只能允許同時定義一弱一強兩個函式,不能夠重復多次使用,在下面例子中,只有當宏定義OVERRIDE_EN是非零時,強函式才會被定義,并覆寫弱函式,否則將執行弱函式,
#define OVERRIDE_EN 1
__attribute__((weak)) int calc(int a, int b)
{
return a + b;
}
#if OVERRIDE_EN
int calc(int a, int b)
{
return a - b;
}
#endif
void test(void)
{
// 當OVERRIDE_EN定義為非零時,結果為-1, 當OVERRIDE_EN未定義或為0時,結果為9
int res = calc(4, 5);
}
總結
本文簡單介紹了一些C語言實作面向物件的方法,可見C語言也能有物件(學C語言也能有物件),至于這些面向物件的方法到底有什么用?下一篇文章將會以驅動設計為例,簡單介紹一些應用的例子,(撰寫中)
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/387885.html
標籤:java
