深度解讀《深度探索C++物件模型》之資料成員的存取效率分析(三)

iShare_爱分享發表於2024-04-22

接下來我將持續更新“深度解讀《深度探索C++物件模型》”系列,敬請期待,歡迎關注!也可以關注公眾號:iShare愛分享,自動獲得推文和全部的文章列表。

前面兩篇請透過這裡檢視:

深度解讀《深度探索C++物件模型》之資料成員的存取效率分析(一)

深度解讀《深度探索C++物件模型》之資料成員的存取效率分析(二)

這一節講解具體繼承的情況,具體繼承也叫非虛繼承(針對虛繼承而言),分為兩種情況討論:單一繼承和多重繼承。

單一繼承

在上面的例子中,所有的資料都封裝在一個類中,但有時可能由於業務的需要,需要拆分成多個類,然後每個類之間具有繼承關係,比如可能是這樣的定義:

class Point {
	int x;
};
class Point2d: public Point {
	int y;
};
class Point3d: public Point2d {
	int z;
};

對於這樣的單一繼承關係,在前面的文章《深度解讀《深度探索C++物件模型》之C++物件的記憶體佈局》中已經分析過了。一般而言,Point3d類的記憶體佈局跟獨立宣告的類的記憶體佈局沒什麼差別,除非在某些情況下,編譯器為了記憶體對齊而進行填充,造成空間佔用上會變大的情況,但對於存取效率而言沒什麼影響,因為在編譯期間就已經確定好了它們的偏移值。完善上面的例子,在main函式中定義Point3d的物件,然後訪問各個成員,看看對應的彙編程式碼。

int main() {
    printf("&Point2d::y = %d\n", &Point2d::y);
    printf("&Point3d::y = %d\n", &Point3d::y);
    Point3d p3d;
    p3d.x = p3d.y = p3d.z = 1;

    return 0;
}

上面兩行列印程式碼輸出的都是4,再看看第5行程式碼對應的彙編程式碼:

mov     dword ptr [rbp - 8], 1
mov     dword ptr [rbp - 12], 1
mov     dword ptr [rbp - 16], 1

生成的彙編程式碼跟獨立類的彙編程式碼沒有區別,這說明單一繼承的存取效率跟沒有繼承關係的類的存取效率是一樣的。

多重繼承

或許業務需要,繼承關係不是上面的單一繼承關係,而是需要改成多重繼承關係,多重繼承下物件的存取效率是否會受影響?我們來看一個具體的例子:

#include <cstdio>

class Point {
public:
    int x;
};
class Point2d {
public:
    int y;
};
class Point3d: public Point, public Point2d {
public:
    int z;
};

int main() {
    printf("&Point2d::y = %d\n", &Point2d::y);
    printf("&Point3d::x = %d\n", &Point3d::x);
    printf("&Point3d::y = %d\n", &Point3d::y);
    printf("&Point3d::z = %d\n", &Point3d::z);
    Point3d p3d;
    p3d.x = p3d.y = p3d.z = 1;
    Point2d* p2d = &p3d;
    p2d->y = 2;

    return 0;
}

輸出結果是:

&Point2d::y = 0
&Point3d::x = 0
&Point3d::y = 0
&Point3d::z = 8

第1、2行輸出是0很正常,因為對於Point2d類來說只有一個成員y,也沒有繼承其他類,所以y的偏移值是0,第2行輸出的是x的偏移值,它從Point類繼承而來,排在最前面,所以偏移值也是0。但為什麼第3行輸出也是0?難道不應該是4嗎?從第4行的輸出看到z的偏移值是8,說明前面確實有兩個成員在那裡了。其實這裡應該是編譯器做了調整了,因為Point2d是第二基類,訪問第二基類及之後的類時需要調整this指標,也就是將Point3d物件的起始地址調整為Point2d的起始地址,一般是將Point3d的地址加上前面子類的大小,如 &p3d+sizeof(Point) 。來看看上面程式碼生成的彙編程式碼:

main:                           # @main
    # 略...
    lea     rdi, [rip + .L.str]
    xor     eax, eax
    mov     esi, eax
    mov     al, 0
    call    printf@PLT
    lea     rdi, [rip + .L.str.1]
    xor     eax, eax
    mov     esi, eax
    mov     al, 0
    call    printf@PLT
    lea     rdi, [rip + .L.str.2]
    xor     eax, eax
    mov     esi, eax
    mov     al, 0
    call    printf@PLT
    lea     rdi, [rip + .L.str.3]
    mov     esi, 8
    mov     al, 0
    call    printf@PLT
    mov     dword ptr [rbp - 8], 1
    mov     dword ptr [rbp - 12], 1
    mov     dword ptr [rbp - 16], 1
    xor     eax, eax
    lea     rcx, [rbp - 16]
    cmp     rcx, 0
    mov     qword ptr [rbp - 32], rax       # 8-byte Spill
    je      .LBB0_2
    lea     rax, [rbp - 16]
    add     rax, 4
    mov     qword ptr [rbp - 32], rax       # 8-byte Spill
.LBB0_2:
    mov     rax, qword ptr [rbp - 32]       # 8-byte Reload
    mov     qword ptr [rbp - 24], rax
    mov     rax, qword ptr [rbp - 24]
    mov     dword ptr [rax], 2
    # 略...
    ret
# 略...

上面彙編程式碼中的第3到第7行對應的是上面C++程式碼的第一條printf列印語句(C++程式碼第17行),這裡可以看到給printf函式傳遞了兩個引數,分別透過rdi暫存器和esi暫存器,rdi暫存器儲存的是第一個引數字串,它的地址是 [rip + .L.str].L.str是字串儲存在資料段中的位置標籤,rip+這個標籤可以取得它的偏移地址,以下的 .L.str.1、.L.str.2.L.str.3都是字串的位置標籤),esi是第二個引數,這裡的值被設為0了。

第8到12行彙編程式碼對應的是C++程式碼中的第二條printf列印語句,同樣地,給rdi暫存器設定字串的地址,給esi暫存器設定值為0。第13到第17行對應的是第三條printf列印語句,第18到第21行就是對應C++程式碼中的第四條printf列印語句,可以看到編譯器在編譯期間已經確定好了它們的偏移值為0, 0, 0, 8

第22到24行對應的C++的第22行程式碼,是對物件的成員進行賦值,可以看到透過物件來存取資料成員跟獨立的類存取資料成員是一樣的,已經知道了每個成員的記憶體地址了,所以存取的效率跟獨立的類的存取效率沒有差別。

彙編程式碼的第25行到37行對應C++的第23、24行程式碼,是將Point3d的地址轉換成父類Point2d的指標型別,透過父類Point2d的指標來訪問資料成員。前面提到過的將子類轉換成第2及之後的基類時會進行this指標的調整,這裡就是具體的實現。相當於虛擬碼:Point2d* p2d = &p3d+sizeof(Point),其實這裡應該還需要判斷下p3d是否為0,所以正確應該是:Point2d* p2d = &p3d ? &p3d+sizeof(Point) : 0。上面的第26到29行即是判斷是否為0,如果為0則跳轉到第33行,如果不為0則將p3d的地址 [rbp - 16] 加上44Point類的大小,然後存放在 [rbp - 32] ,再載入到rax暫存器中,然後對其賦值2(彙編程式碼第37行)。

透過分析彙編程式碼,多重繼承的情況,如果是透過物件來存取資料成員,是跟獨立類的存取效率是一致的,如果是透過第二及之後的基類的指標來存取,則需要調整this指標,可以看到對應的彙編程式碼也多了好好多行,所以效率上會有一些損失。

如果您感興趣這方面的內容,請在微信上搜尋公眾號iShare愛分享並關注,以便在內容更新時直接向您推送。
image

相關文章