【C++ Primer | 15】虛函數表剖析(一)

一、虛函數

1. 概念

多態指當不同的對象收到相同的消息時,產生不同的動作

  • 編譯時多態(靜態綁定),函數重載,運算符重載,模板。
  • 運行時多態(動態綁定),虛函數機制。

為了實現C++的多態,C++使用了一種動態綁定的技術。這個技術的核心是虛函數表(下文簡稱虛表)。本文介紹虛函數表是如何實現動態綁定的。

C++多態實現的原理:

  • ?當類中聲明虛函數時,編譯器會在類中生成一個虛函數表
  • 虛函數表是一個存儲成員函數地址的數據結構
  • 虛函數表是由編譯器自動生成與維護的
  • ?virtual成員函數會被編譯器放入虛函數表中
  • 存在虛函數表時,每個對象中都有一個指向虛函數表的指針

?

2. 類的虛表

每個包含了虛函數的類都包含一個虛表。?
我們知道,當一個類(A)繼承另一個類(B)時,類A會繼承類B的函數的調用權。所以如果一個基類包含了虛函數,那么其繼承類也可調用這些虛函數,換句話說,一個類繼承了包含虛函數的基類,那么這個類也擁有自己的虛表。

我們來看以下的代碼。類A包含虛函數vfunc1,vfunc2,由于類A包含虛函數,故類A擁有一個虛表。

class A {
public:virtual void vfunc1();virtual void vfunc2();void func1();void func2();
private:int m_data1, m_data2;
};

虛表是一個指針數組,其元素是虛函數的指針,每個元素對應一個虛函數的函數指針。需要指出的是,普通的函數即非虛函數,其調用并不需要經過虛表,所以虛表的元素并不包括普通函數的函數指針。?
虛表內的條目,即虛函數指針的賦值發生在編譯器的編譯階段,也就是說在代碼的編譯階段,虛表就可以構造出來了。

?

3. 虛表指針

虛表是屬于類的,而不是屬于某個具體的對象,一個類只需要一個虛表即可。同一個類的所有對象都使用同一個虛表。?
為了指定對象的虛表,每個對象的內部包含一個虛表的指針,來指向自己所使用的虛表。為了讓每個包含虛表的類的對象都擁有一個虛表指針,編譯器在類中添加了一個指針,*__vptr,用來指向虛表。這樣,當類的對象在創建時便擁有了這個指針,且這個指針的值會自動被設置為指向類的虛表。

上面指出,一個繼承類的基類如果包含虛函數,那個這個繼承類也有擁有自己的虛表,故這個繼承類的對象也包含一個虛表指針,用來指向它的虛

4. 動態綁定

class A 
{
public:virtual void vfunc1();virtual void vfunc2();void func1();void func2();
private:int m_data1, m_data2;
};class B : public A 
{
public:virtual void vfunc1();void func1();
private:int m_data3;
};class C: public B 
{
public:virtual void vfunc2();void func2();
private:int m_data1, m_data4;
};

類A是基類,類B繼承類A,類C又繼承類B。類A,類B,類C,其對象模型如下圖3所示。

由于這三個類都有虛函數,故編譯器為每個類都創建了一個虛表,即類A的虛表(A vtbl),類B的虛表(B vtbl),類C的虛表(C vtbl)。類A,類B,類C的對象都擁有一個虛表指針,*__vptr,用來指向自己所屬類的虛表。?

  • 類A包括兩個虛函數,故A vtbl包含兩個指針,分別指向A::vfunc1()和A::vfunc2()。?
  • 類B繼承于類A,故類B可以調用類A的函數,但由于類B重寫了B::vfunc1()函數,故B vtbl的兩個指針分別指向B::vfunc1()和A::vfunc2()。?
  • 類C繼承于類B,故類C可以調用類B的函數,但由于類C重寫了C::vfunc2()函數,故C vtbl的兩個指針分別指向B::vfunc1()(指向繼承的最近的一個類的函數)和C::vfunc2()。?

雖然圖3看起來有點復雜,但是只要抓住“對象的虛表指針用來指向自己所屬類的虛表,虛表中的指針會指向其繼承的最近的一個類的虛函數”這個特點,便可以快速將這幾個類的對象模型在自己的腦海中描繪出來。[非虛函數的調用不用經過虛表,故不需要虛表中的指針指向這些函數。

【注意】非虛函數的調用不用經過虛表,故不需要虛表中的指針指向這些函數。

下面以代碼說明,代碼如下:

#include <iostream>
using namespace std;class Base {
public:virtual void f() { cout << "Base::f" << endl; }virtual void g() { cout << "Base::g" << endl; }virtual void h() { cout << "Base::h" << endl; }
};typedef void(*Fun)(void);  //函數指針
int main()
{Base b;//  這里指針操作比較混亂,在此稍微解析下://  *****printf("虛表地址:%p\n", *(int *)&b); 解析*****://  1.&b代表對象b的起始地址//  2.(int *)&b 強轉成int *類型,為了后面取b對象的前四個字節,前四個字節是虛表指針//  3.*(int *)&b 取前四個字節,即vptr虛表地址////  *****printf("第一個虛函數地址:%p\n", *(int *)*(int *)&b);*****://  根據上面的解析我們知道*(int *)&b是vptr,即虛表指針.并且虛表是存放虛函數指針的//  所以虛表中每個元素(虛函數指針)在32位編譯器下是4個字節,因此(int *)*(int *)&b//  這樣強轉后為了后面的取四個字節.所以*(int *)*(int *)&b就是虛表的第一個元素.//  即f()的地址.//  那么接下來的取第二個虛函數地址也就依次類推.  始終記著vptr指向的是一塊內存,//  這塊內存存放著虛函數地址,這塊內存就是我們所說的虛表.//printf("虛表地址:%p\n", *(int *)&b);printf("第一個虛函數地址:%p\n", *(int *)*(int *)&b);printf("第二個虛函數地址:%p\n", *((int *)*(int *)(&b) + 1));Fun pfun = (Fun)*((int *)*(int *)(&b));  //vitural f();printf("f():%p\n", pfun);pfun();pfun = (Fun)(*((int *)*(int *)(&b) + 1));  //vitural g();printf("g():%p\n", pfun);pfun();
}

輸出結果:

通過這個示例,我們可以看到,我們可以通過強行把&b轉成int *,取得虛函數表的地址,然后,再次取址就可以得到第一個虛函數的地址了,也就是Base::f(),這在上面的程序中得到了驗證(把int* 強制轉成了函數指針)。通過這個示例,我們就可以知道如果要調用Base::g()和Base::h(),其代碼如下

(Fun)*((int*)*(int*)(&b)+0);  // Base::f()
(Fun)*((int*)*(int*)(&b)+1);  // Base::g()
(Fun)*((int*)*(int*)(&b)+2);  // Base::h()

?

二、一般繼承?

下面,再讓我們來看看繼承時的虛函數表是什么樣的。假設有如下所示的一個繼承關系:

class Base
{
public:virtual void f() { cout << "Base::f()" << endl; }virtual void g() { cout << "Base::g()" << endl; }virtual void h() { cout << "Base::h()" << endl; }
};class Derive : public Base
{
public:virtual void f1() { cout << "Base::f1()" << endl; }virtual void g1() { cout << "Base::g1()" << endl; }virtual void h1() { cout << "Base::h1()" << endl; }
};

?

?請注意,在這個繼承關系中,子類沒有重載任何父類的函數。那么,在派生類的實例中,其虛函數表如下所示:

對于實例:Derive d; 的虛函數表如下:

我們可以看到下面幾點:

  • 虛函數按照其聲明順序放于表中。
  • 父類的虛函數在子類的虛函數前面。

?

三、一般繼承(有虛函數覆蓋)?

覆蓋父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什么樣子?假設,我們有下面這樣的一個繼承關系。

class Base
{
public:virtual void f() { cout << "Base::f()" << endl; }virtual void g() { cout << "Base::g()" << endl; }virtual void h() { cout << "Base::h()" << endl; }
};class Derive : public Base
{
public:virtual void f() { cout << "Base::f1()" << endl; }virtual void g1() { cout << "Base::g1()" << endl; }virtual void h1() { cout << "Base::h1()" << endl; }
};

為了讓大家看到被繼承過后的效果,在這個類的設計中,我只覆蓋了父類的一個函數:f()。那么,對于派生類的實例,其虛函數表會是下面的一個樣子:

我們從表中可以看到下面幾點,

  • 覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
  • 沒有被覆蓋的函數依舊。

這樣,我們就可以看到對于下面這樣的程序:

Base *b = new Derive();
b->f();

由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,于是在實際調用發生時,是Derive::f()被調用了。這就實現了多態。

?

四、單一的一般繼承

?下面,我們假設有如下所示的一個繼承關系:

注意,在這個繼承關系中,父類,子類,孫子類都有自己的一個成員變量。而了類覆蓋了父類的f()方法,孫子類覆蓋了子類的g_child()及其超類的f()。

測試代碼:

#include<iostream>
using namespace std;class Parent {
public:int iparent;Parent(): iparent(10) {}virtual void f() { cout << " Parent::f()" << endl; }virtual void g() { cout << " Parent::g()" << endl; }virtual void h() { cout << " Parent::h()" << endl; }
};class Child : public Parent {
public:int ichild;Child(): ichild(100) {}virtual void f() { cout << "Child::f()" << endl; }virtual void g_child() { cout << "Child::g_child()" << endl; }virtual void h_child() { cout << "Child::h_child()" << endl; }
};class GrandChild : public Child {
public:int igrandchild;GrandChild(): igrandchild(1000) {}virtual void f() { cout << "GrandChild::f()" << endl; }virtual void g_child() { cout << "GrandChild::g_child()" << endl; }virtual void h_grandchild() { cout << "GrandChild::h_grandchild()" << endl; }
};int main()
{typedef void(*Fun)(void);GrandChild gc;int** pVtab = (int**)&gc;cout << "[0] GrandChild::_vptr->" << endl;for (int i = 0; (Fun)pVtab[0][i] != NULL; i++) {Fun pFun = (Fun)pVtab[0][i];cout << "    [" << i << "] ";pFun();}cout << "[1] Parent.iparent = " << (int)pVtab[1] << endl;cout << "[2] Child.ichild = " << (int)pVtab[2] << endl;cout << "[3] GrandChild.igrandchild = " << (int)pVtab[3] << endl;
}

輸出結果:

使用圖片表示如下:

可見以下幾個方面:

  • 虛函數表在最前面的位置。
  • 成員變量根據其繼承和聲明順序依次放在后面。
  • 在單一的繼承中,被overwrite的虛函數在虛函數表中得到了更新。

?

參考資料

  • c++中虛基類表和虛函數表的布局

?

?

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/385477.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/385477.shtml
英文地址,請注明出處:http://en.pswp.cn/news/385477.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

【Leetcode | 02】二叉樹、線性表目錄

二叉樹序號題號1 94. 二叉樹的中序遍歷 295. 不同的二叉搜索樹 II396. 不同的二叉搜索樹4 98. 驗證二叉搜索樹 5100. 相同的樹6101. 對稱二叉樹7102. 二叉樹的層次遍歷8103. 二叉樹的鋸齒形層次遍歷9104. 二叉樹的最大深度10105. 從前序與中序遍歷序列構造二叉樹11106. 從中序與…

Leetcode 118. 楊輝三角

給定一個非負整數 numRows&#xff0c;生成楊輝三角的前 numRows 行。 在楊輝三角中&#xff0c;每個數是它左上方和右上方的數的和。 示例: 輸入: 5 輸出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1] ] class Solution { public:vector<vector<int>> generate(…

管道符、重定向與環境變量

輸入輸出重定向 輸入重定向&#xff1a;將文件內容導入到命令中&#xff1b;輸出重定向&#xff1a;將命令執行后顯示到屏幕上的內容導入到文件中&#xff0c;不在屏幕中顯示。共分為&#xff1a;標準輸入重定向&#xff08;文件描述符為0&#xff09;、標準覆蓋輸出&#xff0…

【C++ Primer | 0 】字符串函數實現

1. memcpy函數原型&#xff1a; void* memcpy(void* dst, const void* src, size_t size); void* memmove(void* dst, const void* src, size_t size); 分析&#xff1a; source和destin所指的內存區域可能重疊&#xff0c;但是如果source和destin所指的內存區域重疊,那么這個…

編寫Shell腳本(批處理,一次執行多條命令)

Bash終端的優勢&#xff1a;1.上下鍵重復執行命令&#xff1b;2.tab鍵自動補齊&#xff1b;3.提供有用的環境變量&#xff1b;4.批處理。 shell腳本文件建議以.sh為后綴。 其實vim創建文本文件時&#xff0c;對名字無要求&#xff0c;但最好規定格式。 echo $SHELL&#xff08…

判斷用戶的參數(條件測試語句)

說明$?: $&#xff1f;為上一次命令的執行返回值&#xff0c;若上一次命令正常執行&#xff0c;則返回0&#xff1b;若執行出錯&#xff0c;則返回一個非0的隨機數。比如創建一個已經存在的目錄&#xff0c;則返回一個非0數。 另外&#xff0c;測試語句成立返回0&#xff0c…

流程控制語句(bash)

1.if控制語句 if then fi if then else fi if then elif then elif then else fi if 條件表達式 then 命令序列&#xff08;滿足條件才執行&#xff09; #注意&#xff0c;如果if與then&#xff08;elif與then&#xff09;寫在同一行&#xff0c;要用;隔開&#xff…

用戶身份與文件的權限(普通權限、特殊權限、隱藏權限和文件控制列表ACL)

用戶身份 root用戶是存在于所有類UNIX操作系統中的超級用戶&#xff0c;它擁有最高的系統所有權。root用戶的用戶身份號碼UID為0&#xff0c;UID相當于用戶的身份證號碼一樣&#xff0c;具有唯一性。管理員用戶&#xff08;超級用戶&#xff09;UID為0&#xff1b;系統用戶UID為…

存儲結構與磁盤劃分

文件系統層次化標準&#xff08;FHS&#xff0c;file system hierarchy standard&#xff09; 在windows操作系統中&#xff0c;要找到一個文件需要先進入該文件所在的磁盤分區&#xff08;如C:\等 C:\ZSX\zsx.txt&#xff09;&#xff0c;然后在進入該分區下的一個具…

Linux中常用文件的含義

在Linux中配置了服務文件后&#xff0c;需要重啟該服務&#xff0c;配置信息才會生效。 /etc/passwd 保存了系統中所有用戶的信息&#xff0c;一旦用戶的登陸終端設置為/sbin/nologin&#xff0c;則不再允許登錄到系統 /etc/shadow與/etc/passwd均為用戶信息文件 /…

64. 最小路徑和

給定一個包含非負整數的 m x n 網格&#xff0c;請找出一條從左上角到右下角的路徑&#xff0c;使得路徑上的數字總和為最小。 說明&#xff1a;每次只能向下或者向右移動一步。 示例: 輸入: [[1,3,1],[1,5,1],[4,2,1] ] 輸出: 7 解釋: 因為路徑 1→3→1→1→1 的總和最小。…

Linux本地yum源配置以及使用yum源安裝各種應用程序

將軟件包傳送到Linux中后&#xff0c;掛載&#xff0c;然后配置yum軟件倉庫&#xff0c;最后就可以使用yum來安裝相應的應用程序了。假設掛載目錄為/tmp/ruanjianbao&#xff0c;則下面說明配置本地yum倉庫的過程&#xff1a; &#xff08;1&#xff09;cd /etc/yum.repos.d/…

gcc與g++編譯器

首先在Linux(RHEL7.0)上安裝gcc&#xff1a;yum install gcc gcc-c -y 其中gcc-c是為了能夠編譯c源代碼&#xff0c;即g。 gcc為Linux C/C下重要的編譯環境&#xff0c;是GUN項目中符合ANSIC標準的編譯系統&#xff0c; gcc可以編譯C、C、Objective-C、Java、Fortran、Pascal…

【Leetcode | 49】230. 二叉搜索樹中第K小的元素

給定一個二叉搜索樹&#xff0c;編寫一個函數 kthSmallest 來查找其中第 k 個最小的元素。 說明&#xff1a; 你可以假設 k 總是有效的&#xff0c;1 ≤ k ≤ 二叉搜索樹元素個數。 示例 1: 輸入: root [3,1,4,null,2], k 1 3 / \ 1 4 \ 2 輸出: 1 示例 2: 輸入…

gcc編譯器的整個工作過程

gcc hello.c ./a.out 或者 gcc hello.c -o hello ./hello ./表示執行當前目錄下的可執行程序或腳本程序。 首先gcc需要調用預處理程序cpp&#xff0c;由它負責展開在源文件中定義的宏&#xff0c;并向其中插入“#include”語句所包含的內容&#xff1b;接著gcc會調用…

宏定義對調試代碼的作用

以如下代碼為例&#xff1a; //head.h #ifndef __HEAD_H__ #define __HEAD_H__#define NUM1 10 #define NUM2 20 #endif//sum.c #include <stdio.h> //直接在標準庫中查找 #include "head.h" //先在工作目錄中查找&#xff…

【第15章】多重繼承

1. 虛基類介紹 多繼承時很容易產生命名沖突&#xff0c;即使我們很小心地將所有類中的成員變量和成員函數都命名為不同的名字&#xff0c;命名沖突依然有可能發生&#xff0c;比如非常經典的菱形繼承層次。如下圖所示&#xff1a; 類A派生出類B和類C&#xff0c;類D繼承自類B和…

gcc編譯器與g++編譯器的區別

gcc與g編譯器的程序文件分別為&#xff1a;/usr/bin/g和/usr/bin/gcc。 gcc 和 GCC 是兩個不同的東西&#xff0c;GCC:GNU Compiler Collection(GUN 編譯器集合)&#xff0c;它可以編譯C、C、JAV、Fortran、Pascal、Object-C、Ada等語言。gcc是GCC中的GUN C Compiler&#xff0…

1. 排序算法

一、概述 假定在待排序的記錄序列中&#xff0c;存在多個具有相同的關鍵字的記錄&#xff0c;若經過排序&#xff0c;這些記錄的相對次序保持不變&#xff0c;即在原序列中&#xff0c;r[i]r[j]&#xff0c;且r[i]在r[j]之前&#xff0c;而在排序后的序列中&#xff0c;r[i]仍…

1036. 跟奧巴馬一起編程(15)

美國總統奧巴馬不僅呼吁所有人都學習編程&#xff0c;甚至以身作則編寫代碼&#xff0c;成為美國歷史上首位編寫計算機代碼的總統。2014年底&#xff0c;為慶祝“計算機科學教育周”正式啟動&#xff0c;奧巴馬編寫了很簡單的計算機代碼&#xff1a;在屏幕上畫一個正方形。現在…