作者 | 編程指北 責編 | 張文
頭圖 | CSDN 下載自視覺中國
別誤會,今天要寫的不是我對象,畢竟我還沒有......
這篇文章主要是聊聊我對於程式語言中「對象」的一些簡單認識,
面向過程 VS 面向對象
為什麼 C 叫面向過程(Procedure Oriented)的語言,而 Java、C++ 之類叫面向對象(Object Oriented)呢?
之前聽到一個有趣的說法:
在 C 語言中我們是這樣寫代碼的:
function_a(yyy);function_b(xxx);
從左往右看過去,最先看到的是函數,也就是 Procedure,故叫做 Procedure Oriented。
而在 Java 這類語言我們通常是這樣的:
Worker worker = new Woker("小北");worker.touchFish("5分鐘");worker.coding("1小時");
第一眼看到的就是一個個的對象,所以叫做面向對象 Object Oriented。
回到正題。在 C 語言中,數據和操作數據的函數是互相分開的,你並不知道數據和函數之間有什麼關聯,這在語言層面上是不支持的。
在 C 語言中,編程就是將一堆以功能為核心導向的函數進行組合,依次調用這些函數就可以了。
這就叫面向過程,其實和我們思考問題的方式是吻合的。比如要實現一個貪吃蛇遊戲,那面向過程的設計思路就是首先分析問題的步驟:
開始遊戲隨機生成食物繪製畫面接收輸入並改變方向判斷是否碰到牆壁和食物等...而用面向對象的思路則是:
首先,將整個遊戲拆解為一個個的實體:蛇、食物、障礙物、規則系統、動畫系統。
其次,分別去實現這些實體應該具有的功能(即成員函數),同時你還要考慮不同實體之間如何交互和傳遞消息。說白了就是調用關係和傳參。
比如規則系統接收蛇、食物、障礙物作為參數,可以判定是否吃到食物或者碰到牆壁。
動畫系統則可以接收蛇、食物、障礙物等作為參數,然後在屏幕上動態的顯示出來。
這樣做的好處是:可以利用面向對象有封裝、繼承、多態性的特性,設計出低耦合的系統,使系統更加靈活、更加易於維護。
好了,上面這段大概可以看做八股文。你分別用 C 和 Java/C++ 寫過程序自然知道二者區別。不然我說再多的高內聚、低耦合也沒啥用。
對象如何實現?
對象的就是由一堆的屬性(成員變量)和一系列的方法(成員函數)組成。在講這個之前,先補充說明一個函數指針。
我們都知道函數在 C/C++、Java 這類語言中都不是一等公民,一等公民的意思就是能夠像其它整數、字符串變量一樣,可以被賦值或者作為函數參數、返回值等。但是在 JS、Python 這類動態語言中,函數卻是一等公民,可以作為參數、返回值等等。
究其原因,這類語言底層實現中,一切東西皆是對象。函數、整數、字符串、浮點數都是對象,函數才因此具備同其它基本類型一樣的一等公民的身份。
在 C/C++ 中,函數雖然是二等公民, 但我們可以通過函數指針來變相的實現將函數用於變量賦值、函數參數、返回值場景。
函數指針是啥?
我們知道普通變量申明後,編譯器就會自動分配一塊適合的內存。函數也是同樣的,編譯的時候會將一個函數編譯好,然後放在一塊內存中。
(上面這段說法實際很不準確,因為編譯器不會分配內存,編譯好的代碼也是以二進位的形式放在磁碟上,只有程序開始運行時才會加載到內存)
如果我們把函數的首地址也存儲在某個指針變量裡,就可以通過這個指針變量來調用所指向的函數了,這個存儲函數首地址的特殊指針就叫做函數指針。
比如有一個函數 int func(int a);
我們如何申明一個可以指向 func 的函數指針呢?
int (*func_p)(int);
看起來有點奇怪,其實函數指針變量的聲明格式和同函數 func 的聲明一樣,只不過把 func 換成(*func_p)罷了。
為什麼要括號呢?因為不要括號的話 int *func_p(int); 就是申明一個返回指針的函數了,括號就是為了避免這種歧義。
我們來多看幾個函數指針的申明吧:
int (*f1)(int); // 傳入int,返回int void (*f2)(char*); //傳入char指針,沒有返回值 double* (*f3)(int, int); //傳遞兩個整數,返回 double指針
來看一個函數指針的具體用處吧:
# include<stdio.h>typedefvoid(*work)() Work; // typedef 定義一種函數指針類型voidxiaobei_work(){printf("小北工作就是寫代碼");}voidshuaibei_work(){printf("帥北工作就是摸魚")}voiddo_work(Work worker){ worker();}intmain(void){ Work x_work = xiaobei_work; Work s_work = shuaibei_work; do_work(x_work); do_work(s_work);return0;}
輸出:
小北工作就是寫代碼帥北工作就是摸魚
其實這裡有點為了用函數指針而用了,不過大家應該體會到了,函數指針最大的優點就是將函數變量化了。
我們可以將函數作為參數傳遞給其它函數,於是就有了多態的雛形。我們可以傳遞不同的函數來實現不同的行為。
voidqsort(void* base, size_t num, size_t width, int(*compare)(constvoid*,constvoid*))
這是 C 標準庫中 qsort 函數的申明,它最後一個參數就要求傳入一個函數指針,這個函數指針負責比較兩個 element。
因為兩個元素的比較方式只有調用者才知道,所以這裡需要以函數指針的形式告訴 qsort 如何去判定兩個元素的大小。
好了,函數指針就簡單介紹到這裡,接下來回到主題,對象。
對象
那麼在 C 語言中如何簡單模擬一個對象呢?
當然只能靠結構體啦,而成員函數就可以通過函數指針來實現,其它的比如訪問控制、繼承等我們暫時不考慮。
struct Animal {char name[20];void (*eat)(struct Animal* this, char *food); // 成員方法 eatint (*work)(struct Animal* this); // 成員方法 工作};
但是 eat 和 work 都還沒有任何具體實現,所以我們可以在一個初始化函數中構造 Animal 對象。
voideat(struct Animal* this, char *food) { printf("%s 在吃 %s\n", this->name, food);};voidwork(struct Animal* this) { printf("%s 在工作\n", this->name);}struct Animal* Init(constchar *name) {struct Animal *animal = (struct Animal *)malloc(sizeof(struct Animal)); strcpy(animal->name, name); animal->eat = eat; animal->work = work;return animal;}
在 Init 函數內部我們就完成了「成員函數」的賦值和一些初始化工作,並且給 eat 和 work 兩個函數指針都綁定了具體的實現。
接下來我們可以使用一下這個對象:
intmain() {struct Animal *animal = Init("小狗"); animal->eat(animal, "牛肉"); animal->work(animal);return0;}
輸出:
小狗在吃牛肉小狗在工作
為什麼明明 animal 調用的 eat 方法,卻還要把 animal 當參數傳遞給 eat 方法呢,難道 eat 不知道是哪一個 Animal 調用的它嗎?
答案是確實不知道。對象其實就是在內存中一段有意義的區域,每一個不同的對象都有各自的內存位置。
而他們的成員函數卻存放在代碼段,並且只會存在一份副本。
所以 animal->eat(...)調用方式和直接調用 eat(...),效果完全等同,那個animal 存在的意義就是讓你從面向過程轉變為面向對象思考,將方法調用轉變為對象間消息傳遞。
所以當調用成員函數的時候,我們還需要傳入一個參數 this,用來指代當前是哪個對象在調用。
由於 C 語言不支持面向對象,所以我們需要手動將 animal 作為參數傳遞給 eat、work 函數。
如果是在 C++ 這種面向對象的語言中,我們直接不用手動傳遞這個參數,就像下面這樣:
animal->eat(「牛肉」);animal->work();
實際上這是編譯器幫我們去做這個事,上面這兩行代碼,經過編譯器之後會變成下面這個樣子:
eat(animal, "牛肉");work(animal);
然後,編譯器還會在編譯階段默默地將 this 作為成員函數的一個形參添加到參數列表。
並且哪個對象調用的方法,那個對象就會被當做參數賦值給 this。
學習 Java 的的同學也一定對這個this非常熟悉吧,Java 中和 C++ 中的 this 基本都是一樣的作用。
或者說,幾乎所有的面向對象語言,都會存在一個類似的機制,來將調用對象隱式的傳遞給成員函數,比如 Python 中的對象定義:
classStu:def__init__(self, name, age):self.name = nameself.age = agedefdisplayStu(self): print "Name : ", self.name, ", Age: ", self.age
可以看到每個成員函數第一個參數都必須叫 self,這個 self 實際上就是和 this是一樣的作用。
只有這樣,當你在成員函數內訪問成員變量的時候,編譯器才知道你訪問的是哪一個對象。
誒,別忙,按照這樣說,那豈不是,如果我在成員函數內不訪問任何成員變量,就不需要傳遞這個 this 指針?
或者說可以傳遞一個空指針?
理論上確實成立,並且在 C++ 中也是可行的,比如下面這段代碼:
classStu{public:voidHello(){cout << "hello world" << endl; }private:char *name;int age;float score;};
由於,在 Hello 函數中沒有用到任何成員變量,所以我們甚至可以這樣玩:
Stu *stu = new Stu;stu->Hello(); // 正常對象,正常調用stu = NULL;stu->Hello() // 雖然 stu 為 NULL,但是依然不會發送運行時錯誤
這裡實際上可以這樣看:
stu->Hello(); 等價於Hello(NULL);
由於在 Hello 函數內部,沒有使用任何的成員變量,所以就不需要用 this 指針去定位成員變量的內存位置,在這種情況下,調用對象為不為 NULL 其實是不重要的。
但是如果 Hello 函數訪問了成員變量,比如:
voidHello(){cout << "Hello " << this->name << endl;}
這裡需要用到 this 去訪問 name 成員變量,那麼就會導致運行時程序發生 coredump,因為我們訪問了一個 NULL 地址,或者說是基於 NULL 偏移一定位置的地址,這段空間絕對是沒有訪問權限的。
恰好之前也有位同學在群裡問了這個問題:
這個問題的解釋就和上面的一樣,但是這個結論不能推廣到其它語言,比如 Java、Python。這些語言的虛擬機一般會做一些額外的檢查,比如判斷調用對象是否是空指針等,是的話就會觸發空指針異常。
而 C++ 就真的是很純粹的編譯成彙編,只要從彙編層面能跑通,那就沒問題,所以才能利用這個「奇技淫巧」。
那寫這篇文章的目的,就是想讓大家對「對象」有一個具體的認識,最好是明白對象在內存中或者 JVM 中是如何布局的。
我以前就會覺得對象挺神奇的,一堆的功能,後來才後知後覺,這不就是一個結構體再加上編譯器的語法糖嗎