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

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

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

接下來的幾篇將會講解非靜態資料成員的存取分析,講解靜態資料成員的情況請見上一篇:《深度解讀《深度探索C++物件模型》之資料成員的存取效率分析(一)》

普通資料成員的訪問方式

接下來的幾節討論的都是非靜態資料成員的情況,非靜態資料成員都是存放在物件中的,類的定義中相同名稱的資料成員在每個物件中都是相互獨立存在的。訪問非靜態資料成員必須透過隱式的或者顯示的類物件來訪問,否則將沒有許可權訪問。如透過顯示的方式訪問:

class Object {
public:
	int x;
	int y;
};
void foo(const Object& obj) {
    int a = obj.x + obj.y;
}

或者透過隱式的方式訪問:

class Object {
public:
	void print();
private:
	int x;
	int y;
};
void Object::print() {
    printf("x=%d, y=%d\n", x, y);
}

print函式中可以直接訪問資料成員xy,其實它是透過一個隱式的物件來訪問的,這個隱式的物件編譯器會把它插入到引數中,真實的函式宣告會被編譯器轉換為下面的方式:

void Object::print(Ojbect* const this) {
    printf("x=%d, y=%d\n", this->x, this->y);
}

普通資料成員在物件中的偏移值

《深度解讀《深度探索C++物件模型》之C++物件的記憶體佈局》一文中知道了物件的非靜態成員的佈局,由此也可以知道訪問非靜態資料成員是透過物件的首地址(基地址)加上非靜態資料成員的偏移值得到的地址。C++標準規定,物件中的成員排列順序必須按照類中宣告的資料成員的順序,宣告在前面的將排在前面,但沒有規定不同的訪問許可權層級(public, protected, private)哪個在前,哪個在後。這個由編譯器的實現者自己決定,只要保證在同一層級中先宣告的排在前面即可。如果在一個類中有宣告瞭多個的層級,如出現多個public和多個private層級,是否將多個相同的層級合併在一起也並沒有強制規定,在我的測試的編譯器中,是不區分不同的層級的,是根據類中的宣告順序來排列,不管將它宣告在哪個層,或者分佈在不同的層級中,統統按照宣告的順序來排列。

資料成員的偏移值可以透過靜態的分析方法來得到,也可以透過動態的方法來獲取,如下面的程式中,我們將每個非靜態資料成員的偏移值列印出來:

#include <cstdio>

class Base {
public:
    void print() {
        printf("&Base::a1 = %d\n", &Base::a1);
        printf("&Base::b1 = %d\n", &Base::b1);
        printf("&Base::c1 = %d\n", &Base::c1);
        printf("&Base::a2 = %d\n", &Base::a2);
        printf("&Base::b2 = %d\n", &Base::b2);
        printf("&Base::c2 = %d\n", &Base::c2);
    }
public:
    int a1;
    static int s1;
protected:
    int b1;
    static int s2;
private:
    int c1;
    static int s3;
private:
    char a2;
    static int s4;
protected:
    char b2;
    static int s5;
public:
    char c2;
    static int s6;
};

int main() {
    Base b;
    b.print();
    return 0;
}

程式輸出結果:

&Base::a1 = 0
&Base::b1 = 4
&Base::c1 = 8
&Base::a2 = 12
&Base::b2 = 13
&Base::c2 = 14

從中可以看出:

  • 靜態資料成員不影響非靜態資料成員的偏移值,因為他們不儲存在物件中,它們也沒有偏移值,獲取到的只有具體的記憶體地址值。
  • 類中的非靜態資料成員的排列是按照它們的宣告順序來的,跟宣告在哪個層級沒有關係,相同的層級中的成員也不會合並在一起。

透過 &Base::a1這種方式得到的是成員在物件中的偏移值,而透過 &b.a1這種方式得到的將是它的具體的記憶體地址值,這個記憶體地址也可以透過偏移值得到,即物件b的地址 &b+&Base::a1

存取普通資料成員在編譯器中的實現

獨立的類即是不繼承其它任何類的類,現在來分析一下獨立類的非靜態資料成員存取方法及效率,透過物件來存取資料成員和透過指標來存取資料成員有沒有效率上的差別?從上面的分析我們已經知道,非靜態資料成員在類中的宣告順序決定了它在類中的偏移值,透過偏移值可以計算出它的記憶體地址,所以物件的非靜態資料成員在編譯期間就可以獲得它的記憶體地址,這樣就相當於跟訪問一個普通的區域性變數一樣,不需要透過在執行期間接地去計算它的記憶體地址,從而導致執行時的效率損失。那如果是透過指標來訪問又如何呢?下面透過一個例子,生成對應的彙編程式碼來分析一下,假設有一個表示三維座標的類,類中包含有三個座標值x,y,z

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

void bar(Point* pp) {
    pp->x = 4;
    pp->y = 5;
    pp->z = 6;
}
int main() {
    Point p;
    p.x = 1;
    p.y = 2;
    p.z = 3;
    bar(&p);

    return 0;
}

生成對應的彙編程式碼:

bar(Point*):                   # @bar(Point*)
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax], 4
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax + 4], 5
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax + 8], 6
    pop     rbp
    ret
main:                          # @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     dword ptr [rbp - 4], 0
    mov     dword ptr [rbp - 16], 1
    mov     dword ptr [rbp - 12], 2
    mov     dword ptr [rbp - 8], 3
    lea     rdi, [rbp - 16]
    call    bar(Point*)
    xor     eax, eax
    add     rsp, 16
    pop     rbp
    ret

從彙編程式碼中可以看到,在main函式中,彙編程式碼的第18到第20行就是對應上面C++程式碼的第15到第17行, [rbp - 16] 存放的是區域性變數Point p的地址,也是成員x的地址,因為成員x是排在最前面,偏移值為0,也就是跟物件p的地址是一樣的。成員y的偏移值是4,所以基地址加上4即 [rbp - 12] ,以此類推,成員z的地址是 [rbp - 8] ,可見成員變數的地址在編譯期間就已確定了的。然後在第21行程式碼將物件p的地址存放在rdi暫存器中,將它作為呼叫bar函式的引數,傳遞給bar函式,第22行即呼叫bar函式。

然後看下透過指標的方式來訪問資料成員是怎樣的?在bar函式的彙編程式碼中,將傳遞過來的引數rdi暫存器(存放著物件p的地址)的值先存放在棧空間中的 [rbp - 8] 位置,然後再載入到rax暫存器(第4、5行),之後的第6到第10行是分別給資料成員賦值,可以看到透過指標存取資料成員也是透過偏移值來算出成員的具體地址的,地址在編譯期間就已確定,所以跟透過物件來存取是一樣的,所以兩者的效率是一樣的,不存在差別。

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

相關文章