C/C++面向對象編程之多態

目錄:

C/C++面向對象編程之封裝
C/C++面向對象編程之繼承
C/C++面向對象編程之多態

1、類型轉換

先回憶一下C語言中經常使用數據類型的轉換。
數據類型轉換的前提是,編譯器知道如何對數據進行取捨。例如:

int a = 10.9;
printf("%d\n", a);

輸出結果爲 10,編譯器會將小數部分直接丟掉(不是四捨五入)。再如:

float b = 10;
printf("%f\n", b);

輸出結果爲 10.000000,編譯器會自動添加小數部分。
類其實也是一種數據類型,也可以發生數據類型轉換。
將派生類對象賦值給基類對象時,會捨棄派生類新增的成員,也就是“大材小用”,如下圖所示:
在這裏插入圖片描述
這種轉換關係是不可逆的,只能用派生類對象給基類對象賦值,而不能用基類對象給派生類對象賦值。理由很簡單,基類不包含派生類的成員變量,無法對派生類的成員變量賦值。同理,同一基類的不同派生類對象之間也不能賦值。
看個例子:

#include <iostream>
using namespace std;

//基類
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

//派生類
class B: public A{
public:
    B(int a, int b);
public:
    void display();
public:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}


int main(){
    A a(10);
    B b(66, 99);
    //賦值前
    a.display();
    b.display();
    cout<<"--------------"<<endl;
    //賦值後
    a = b;
    a.display();
    b.display();

    return 0;
}
運行結果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

本例中 A 是基類, B 是派生類,a、b 分別是它們的對象,由於派生類 B 包含了從基類 A 繼承來的成員,因此可以將派生類對象 b 賦值給基類對象 a。通過運行結果也可以發現,賦值後 a 所包含的成員變量的值已經發生了變化。

賦值的本質是將現有的數據寫入已分配好的內存中,對象的內存只包含了成員變量,所以對象之間的賦值是成員變量的賦值,成員函數不存在賦值問題。
看到這裏可能會產生一個疑問,派生類對象b賦值給基類對象a,基類對象a就可以訪問派生類對象b的同名變量很好理解,那麼爲什麼訪問派生類的同名函數a.display()調用的是基類的函數,而不是派生類的同名函數?
我在C/C++面相對象編程之封裝中,把對象如何調用成員函數有過一段解釋,下邊再回憶一下C++是如何調用成員函數的:
我們知道成員函數是存放在代碼區,供所有對象共享。實際上C++的編譯代碼的過程中,把成員函數最終編譯成與對象無關的全局函數,如果函數體中沒有成員變量,那問題就很簡單,不用對函數做任何處理,直接調用即可。
如果成員函數中使用到了成員變量該怎麼辦呢?成員變量的作用域不是全局,不經任何處理就無法在函數內部訪問。
C++規定,編譯成員函數時要額外添加一個參數,把當前對象的指針傳遞進去,通過指針來訪問成員變量。
例如上邊例子中:

void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

被編譯後就變成了類似於下邊的函數:

void new_function_name(A* const p){
    //通過指針p來訪問a
cout<<"Class A: m_a="<<p->m_a<<endl;
}

使用 a.display();調用函數時,也會被編譯成類似下面的形式:

new_function_name(&a);

這樣通過傳遞對象指針就完成了成員函數和成員變量的關聯。這與我們從表面上看到的剛好相反,通過對象調用成員函數時,不是通過對象找函數,而是通過函數找對象。
這樣就解釋了爲什麼派生類對象 b 賦值給基類對象 a後,a.display()調用的是基類的函數,而不是派生類的同名函數,即對象調用成員函數,只與對象所在的類型有關。
再把上邊的例子改成指針看一下效果:

#include <iostream>
using namespace std;

//基類
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

//派生類
class B: public A{
public:
    B(int a, int b);
public:
    void display();
public:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}

int main(){
    A *a = new A(10);
    B *b = new B(66, 99);
    //賦值前
    a->display();
    b->display();
    cout<<"--------------"<<endl;
    //賦值後
    a = b;
    a->display();
    b->display();

    return 0;
}
運行結果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

本例將派生類指針賦值給基類指針。與對象變量之間的賦值不同的是,對象指針之間的賦值並沒有拷貝對象的成員,也沒有修改對象本身的數據,僅僅是改變了指針的指向的地址,指針指向的數據類型並不會改變。運行結果和上邊的例子還是一樣。
編譯器雖然通過指針的指向來訪問成員變量,但是卻不通過指針的指向來訪問成員函數:編譯器通過指針的類型來訪問成員函數。
概括起來說就是:編譯器通過指針來訪問成員變量,指針指向哪個對象就使用哪個對象的數據;編譯器通過指針的類型來訪問成員函數,指針屬於哪個類的類型就使用哪個類的函數。

2、爲什麼引入多態和虛函數

我們直觀上認爲,如果指針指向了派生類對象,那麼就應該使用派生類的成員變量和成員函數,這符合人們的思維習慣。但是通過上邊類型轉換的例子,我們會發現通過基類指針只能訪問派生類的成員變量,但是不能訪問派生類的成員函數。爲了消除這種尷尬,讓基類指針能夠訪問派生類的成員函數,C++ 增加了虛函數(Virtual Function)。使用虛函數非常簡單,只需要在函數聲明前面增加 virtual 關鍵字。
把上邊的例子再改一下:

#include <iostream>
using namespace std;

//基類
class A{
public:
    A(int a);
public:
   virtual void display();//聲明爲虛函數
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

//派生類
class B: public A{
public:
    B(int a, int b);
public:
    virtual void display();//聲明爲虛函數
public:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}

int main(){
    A *a = new A(10);
    B *b = new B(66, 99);
    //賦值前
    a->display();
    b->display();
    cout<<"--------------"<<endl;
    //賦值後
    a = b;
    a->display();
    b->display();

    return 0;
}
運行結果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class B: m_a=66, m_b=99
Class B: m_a=66, m_b=99

和前面的例子相比,本例僅僅是在 display() 函數聲明前加了一個virtual關鍵字,將成員函數聲明爲了虛函數(Virtual Function),把派生類的指針變量b,賦值給基類指針變量a後,基類指針a就可以訪問派生類的成員函數了。
有了虛函數,基類指針指向基類對象時就使用基類的成員(包括成員函數和成員變量),指向派生類對象時就使用派生類的成員。換句話說,基類指針可以按照基類的方式來做事,也可以按照派生類的方式來做事,它有多種形態,或者說有多種表現方式,我們將這種現象稱爲多態(Polymorphism)
C++提供多態的目的是:可以通過基類指針對所有派生類(包括直接派生和間接派生)的成員變量和成員函數進行“全方位”的訪問,尤其是成員函數。如果沒有多態,我們只能訪問成員變量。

3、編譯器如何實現多態和虛函數

在類型轉換一節強調,通過指針調用普通的成員函數時會根據指針的類型(通過哪個類定義的指針)來判斷調用哪個類的成員函數,爲什麼加了虛函數後,就可以通過指針指向的對象找到對應的虛函數?
編譯器之所以能通過指針指向的對象找到虛函數,是因爲在創建對象時額外地增加了虛函數表。
如果一個類包含了虛函數,那麼在創建該類的對象時就會額外地增加一個數組,數組中的每一個元素都是虛函數的入口地址。不過數組和對象是分開存儲的,爲了將對象和數組關聯起來,編譯器還要在對象中安插一個指針,指向數組的起始位置。這裏的數組就是虛函數表(Virtual function table),簡寫爲vtable。
看個例子:

#include <iostream>
#include <string>
using namespace std;

//People類
class People{
public:
    People(string name, int age);
public:
    virtual void display();
    virtual void eating();
protected:
    string m_name;
    int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){ }
void People::display(){
    cout<<"Class People:"<<m_name<<"今年"<<m_age<<"歲了。"<<endl;
}
void People::eating(){
    cout<<"Class People:我正在吃飯,請不要跟我說話..."<<endl;
}

//Student類
class Student: public People{
public:
    Student(string name, int age, float score);
public:
    virtual void display();
    virtual void examing();
protected:
    float m_score;
};
Student::Student(string name, int age, float score):
    People(name, age), m_score(score){ }
void Student::display(){
    cout<<"Class Student:"<<m_name<<"今年"<<m_age<<"歲了,考了"<<m_score<<"分。"<<endl;
}
void Student::examing(){
    cout<<"Class Student:"<<m_name<<"正在考試,請不要打擾T啊!"<<endl;
}

//Senior類
class Senior: public Student{
public:
    Senior(string name, int age, float score, bool hasJob);
public:
    virtual void display();
    virtual void partying();
private:
    bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob):
    Student(name, age, score), m_hasJob(hasJob){ }
void Senior::display(){
    if(m_hasJob){
        cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成績從大學畢業了,並且順利找到了工作,Ta今年"<<m_age<<"歲。"<<endl;
    }else{
        cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成績從大學畢業了,不過找工作不順利,Ta今年"<<m_age<<"歲。"<<endl;
    }
}
void Senior::partying(){
    cout<<"Class Senior:快畢業了,大家都在吃散夥飯..."<<endl;
}

int main(){
    People *p = new People("趙紅", 29);
    p -> display();

    p = new Student("王剛", 16, 84.5);
    p -> display();

    p = new Senior("李智", 22, 92.0, true);
    p -> display();

    return 0;
}

運行結果:
Class People:趙紅今年29歲了。
Class Student:王剛今年16歲了,考了84.5分。
Class Senior:李智以92的成績從大學畢業了,並且順利找到了工作,Ta今年22歲。
各個類的對象內存模型如下所示:
在這裏插入圖片描述
圖中左半部分是對象佔用的內存,右半部分是虛函數表 vtable。在對象的開頭位置有一個指針 vfptr,指向虛函數表,並且這個指針始終位於對象的開頭位置。
仔細觀察虛函數表,可以發現基類的虛函數在 vtable 中的索引(下標)是固定的,不會隨着繼承層次的增加而改變,派生類新增的虛函數放在 vtable 的最後。如果派生類有同名的虛函數遮蔽(覆蓋)了基類的虛函數,那麼將使用派生類的虛函數替換基類的虛函數,這樣具有遮蔽關係的虛函數在 vtable 中只會出現一次。
當通過指針調用虛函數時,先根據指針找到 vfptr,再根據 vfptr 找到虛函數的入口地址。以虛函數 display() 爲例,它在 vtable 中的索引爲 0,通過 p 調用時:

p -> display();

編譯器內部會發生類似下面的轉換:

( *( *(p+0) + 0 ) )(p);

下面我們一步一步來分析這個表達式:

  • 0是 vfptr 在對象中的偏移,p+0是 vfptr 的地址;
  • (p+0)是 vfptr 的值,而 vfptr 是指向 vtable 的指針,所以(p+0)也就是 vtable 的地址;
  • display() 在 vtable 中的索引(下標)是 0,所以( *(p+0) + 0 )也就是 display() 的地址;
  • 知道了 display() 的地址,( *( *(p+0) + 0 ) )§也就是對 display() 的調用了,這裏的 p 就是傳遞的實參,它會賦值給 this 指針。

可以看到,轉換後的表達式是固定的,只要調用 display() 函數,不管它是哪個類的,都會使用這個表達式。換句話說,編譯器不管 p 指向哪裏,一律轉換爲相同的表達式。轉換後的表達式沒有用到與 p 的類型有關的信息,只要知道 p 的指向就可以調用函數。這跟名字編碼(Name Mangling)算法有着本質上的區別(非虛函數編譯器還是通過名字編碼算法對函數重新命名)。
再來看一下 eating() 函數,它在 vtable 中的索引爲 1,通過 p 調用時:

p -> eating();

編譯器內部會發生類似下面的轉換:

( *( *(p+0) + 1 ) )(p);

對於不同的虛函數,僅僅改變索引(下標)即可。

  • 必須存在繼承關係;
  • 繼承關係中必須有同名的虛函數,並且它們是覆蓋關係(函數原型相同)。
  • 存在基類的指針,通過該指針調用虛函數。

4、多態的思想

“多態(polymorphism)”指的是同一名字的事物可以完成不同的功能。多態可以分爲編譯時的多態和運行時的多態。前者主要是指函數的重載(包括運算符的重載)、對重載函數的調用,在編譯時就能根據實參確定應該調用哪個函數,因此叫編譯時的多態;而後者則和繼承、虛函數等概念有關。
在現實生活中由未來的事情兼容以前的事情很常見,而虛函數實現的多態的牛逼之處在於可以由現在的事情兼容未來的事情。
多態可以實現把不同的子類對象都當作父類來看,可以屏蔽不同子類對象之間的差異,寫出通用的代碼,做出通用的編程,以適應需求的不斷變化。我們可以只針對基類寫出一段程序,但它可以適應於這個類的家族,因爲編譯器會自動找出合適的對象來執行操作。
使用多態可以解決項目中緊偶合的問題,提高程序的可擴展性。

5、C語言用實現多態的思想

所謂多態,其實就是“多種形態”。C++中虛函數的主要作用就是實現多態。簡單說父類的指針調用重寫的虛函數,當父類指針指向父類對象
時調用的是父類的虛函數,指向子類對象時調用的是子類的虛函數。
在C語言中,可以利用“結構在內存中的佈局與結構的聲明具有一致的順序”這一事實實現繼承,再結合C語言的函數指針類型轉換可以實現類似的多態功能。C語言的基類定義一個函數指針,把派生類對象指針強制轉換成基類對象指針,以達到基類的對象指針可以調用到派生類對象的回調函數,實現多態的目的。
看個例子:

#include <iostream>
using namespace std;

//用一個函數指針  
typedef void(*FUN)();            
  
//父類  
struct AA  
{  
    FUN fun;  
};  
  
//子類  
struct BB  
{  
    AA a;  
};  
  
void FunA()  
{  
    printf("AA::fun\n");  
}  
  
void FunB()  
{  
    printf("BB::fun\n");  
}  

int main(){

    AA a;  
    BB b;  
    a.fun = FunA;   //父類對象設置父類回調函數  
    b.a.fun = FunB;   //子類對象設置子類回調函數  
  
    AA* p = &a;   //定義一個父類指針指向父類對象  
    p->fun();    //調用父類的fun函數  
    p = (AA*)&b;   //父類指針指向子類對象  
    p->fun();    //調用子類的fun函數  

    return 0;
}

輸出:
AA::fun
BB::fun

指針的使用非常靈活,C語言結構體做類型轉換也可以把基類對象指針強制轉換成派生類對象指針來使用,使基類指針具有訪問派生類任意成員的功能(C++的多態只能訪問派生類的虛函數)。

結合RT-Thread設備框架部分的例子:

#include <iostream>

//基類
struct  rt_device{
	//抽象出所有設備共同的屬性和行爲
	char *name;
	void(*init)(struct  rt_device *device);
	void(*open)(struct  rt_device *device,int oflag);
	void(*close)(struct  rt_device *device);
	void(*read)(struct  rt_device *device,int pos,void *buffer, int size);
	void(*write)(struct  rt_device *device,int pos, const void *buffer, int size);
	void(*control)(struct  rt_device *device, int cmd, void *args);
};
//rt_pin繼承rt_device
struct  rt_pin{
	struct  rt_device parent;
	char *pin_status;
};
//rt_serial繼承rt_device
struct  rt_serial{
	struct  rt_device parent;
	char *baud_rate;
};

//pin回調函數,以初始化函數爲例
void pin_init(struct  rt_device *device)
{
	struct  rt_pin *pin = NULL;
	pin = (struct  rt_pin *)device;//類型轉換
	pin->pin_status="OUT";
	printf("%s_pin_status=%s\n",pin->parent.name,pin->pin_status);
}
//serial回調函數,以初始化函數爲例
void serial_init(struct  rt_device *device)
{
	struct  rt_serial *serial = NULL;
	serial = (struct  rt_serial *)device;//類型轉換
	serial->baud_rate="115200";
	printf("%s_baud_rate=%s\n",serial->parent.name,serial->baud_rate);
}
//設置統一接口
void device_init(struct  rt_device *device)
{
	device->init(device);
	/* ..添加其他內容..*/
}
void device_read(struct  rt_device *device,int pos,void *buffer, int size)
{
	device->read(device,pos,buffer,size);
	/* ..添加其他內容..*/
}
void device_write(struct  rt_device *device,int pos, const void *buffer, int size)
{
	device->write(device,pos,buffer,size);
	/* ..添加其他內容..*/
}

int main(){
	struct rt_device *device = NULL;
	struct rt_pin    *pin    =(struct rt_pin *)malloc(sizeof(struct rt_pin));//創建pin設備
	struct rt_serial *serial =(struct rt_serial *)malloc(sizeof(struct rt_serial));//創建serial設備

	pin->parent.init = pin_init;//設置pin設備的回調函數
	/*可以繼續設置其他回調函數*/
	pin->parent.name = "GPIOA";//設置pin接口
	device_init(&(pin->parent));//調用初始化接口

	serial->parent.init = serial_init;//設置serial設備的回調函數
	/*可以繼續設置其他回調函數*/
	serial->parent.name = "UART1";//設置串口波特率
	device_init(&(serial->parent));//調用初始化接口
    return 0;
}

一個附加問題,說明類和結構體繼承的差異,對比C和C++的兩段代碼:

#include <iostream>
using namespace std;

//基類A
class  A{
public:
	int m_a;
};

//基類B
class  B{
public:
	int m_b;
};

//派生類 C
class  C: public A,public B{
public:
	int m_c;
};

int main(){

	A *pa=new A;
	B *pb=new B;
	C *pc=new C;

	pa->m_a =1;
	pb->m_b =2;

	pc->m_a =3;
	pc->m_b =3;
	pc->m_c =3;
	cout<<"pb->m_b="<<pb->m_b<<endl;
	pa=pc;//類型轉換
	pb=pc;//類型轉換
	cout<<"pb->m_b="<<pb->m_b<<endl;

	cout<<"pa="<<pa<<endl;
    cout<<"pb="<<pb<<endl;
    cout<<"pc="<<pc<<endl;

    return 0;
}

在VC6.0的編譯環境下,輸出結果爲:
pb->m_b=2
pb->m_b=3
pa=007318F0
pb=007318F4
pc=007318F0
注意到:指針pb的值和pa、pc並不一樣
看下派生類C的對象內存模型:

內存地址 C類對象 對象指針
0x007318F0 m_a <-pa、pc
0x007318F4 m_b <-pb
0x007318F8 m_c

再看下一段代碼:

#include <iostream>

//基類
struct  A{
	int x;
};
//基類
struct  B{
	char y;
};
//多繼承
struct  C{
	struct  A parent1;
	struct  B parent2;
	int z;
};

int main(){

	struct A *a=(struct A *)malloc(sizeof(struct A));
	struct B *b=(struct B *)malloc(sizeof(struct B));
	struct C *c=(struct C *)malloc(sizeof(struct C));
	a->x = 1;
	b->y = 2;

	c->parent1.x=3;
	c->parent2.y=3;
	c->z = 3;
	printf("b->y=%x\n",b->y);
	a=(struct A *)c;//類型轉換
	b=(struct B *)c;//類型轉換
	printf("b->y=%x\n",b->y);

	printf("a=%x\n",a);
	printf("b=%x\n",b);
	printf("c=%x\n",c);

    return 0;
}

在VC6.0的編譯環境下,輸出結果爲:
b->y=2
b->y=3
a=751790
b=751790
c=751790
注意到:指針abc的值一樣
看下結構體C的變量內存模型:

內存地址 結構體C 指針變量
0x00751790 m_a <- a、b、c
0x00751794 m_b
0x00751798 m_c

這也是結構體指針變量和對象指針變量不一樣的一個地方,對象的指針必須要求指向對象的起始位置(這可能和虛函數有關),而結構體變量指針沒有這個要求。

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