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

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

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

《深度解讀《深度探索C++物件模型》之C++物件的記憶體佈局》這篇文章中已經詳細分析過C++的物件在經過封裝後,在各種情況下的記憶體佈局以及增加的成本。本文將進一步分析C++物件在封裝後,資料成員的存取的實現手段及訪問的效率。在這裡先丟擲一個問題,然後帶著問題來一步一步分析,如下面的程式碼:

class Point {};
Point p;
Point *pp = &p;
p.x = 0;
pp->x = 0;

上面的程式碼中,對資料成員x的存取成本是什麼?透過物件p來存取成員x和透過物件的指標pp來存取成員x的效率存在差異嗎?要搞清楚這個問題,得看具體的Point類的定義以及成員x的宣告方式。Point類可能是一個獨立的類(也就是沒有從其他類繼承而來),也可能是一個單一繼承或者多重繼承而來的類,甚至也有可能它的繼承父類中有一個是虛擬基類(virtual base class),成員x的宣告可能是靜態的或者是非靜態的。下面的幾節將根據不同的情況來一一分析。

類物件的資料成員的存取效率分析系列篇幅比較長,所以根據不同的類的定義劃分為幾種情形來分析,這篇先來分析靜態資料成員的情況。

靜態資料成員在編譯器裡的實現

在前面的文章中說過,類中的靜態資料成員是跟類相關的,而非跟具體的物件有關,它儲存在物件之外,具體的儲存位置是在程式中的資料段中。它其實跟一個全域性變數沒什麼區別,在編譯期間編譯器就已經確定好了它的儲存位置,所以能夠確定它的地址。看一下下面的程式碼:

#include <cstdio>

int global_val = 1;

class Base {
public:
    int b1;
    static int s1;
};
int Base::s1 = 1;

int main() {
    static int static_var = 1;
    int local_var = 1;
    Base b;
    printf("&global_val = %p\n", &global_val);
    printf("&static_var = %p\n", &static_var);
    printf("&local_var = %p\n", &local_var);
    printf("&b.b1 = %p\n", &b.b1);
    printf("&b.s1 = %p\n", &b.s1);

    return 0;
}

程式輸出的結果:

&global_val = 0x102d74000
&static_var = 0x102d74008
&local_var = 0x16d0933f8
&b.b1 = 0x16d0933f4
&b.s1 = 0x102d74004

可以看到全域性變數global_val和區域性靜態變數static_var以及類中的靜態資料成員s1的地址是順序且緊密排列在一起的,而且跟其他的兩個區域性變數的地址相差較大,說明這幾個都是一起儲存在程式的資料段中的。類中的非靜態資料成員b1跟區域性變數local_var一樣,是存放在棧中的。

可以進一步看看生成的彙編程式碼,看一下是怎麼存取靜態資料成員的,下面節選部分的彙編程式碼:

main:                            # @main
    # 略...
    lea     rdi, [rip + .L.str]
    lea     rsi, [rip + global_val]
    mov     al, 0
    call    printf@PLT
    lea     rdi, [rip + .L.str.1]
    lea     rsi, [rip + main::static_var]
    mov     al, 0
    call    printf@PLT
  	# 略...
    lea     rdi, [rip + .L.str.4]
    lea     rsi, [rip + Base::s1]
    mov     al, 0
    call    printf@PLT
    # 略...
    ret
global_val:
    .long   1        # 0x1

Base::s1:
    .long   1        # 0x1

main::static_var:
    .long   1        # 0x1

從彙編程式碼中看到,global_valBase::s1main::static_var是定義在資料段中的,在程式碼中直接使用它們的地址,如:

lea rsi, [rip + Base::s1]

則是將Base::s1的地址載入到rsi暫存器中,作為引數傳遞給printf函式。這也證明了它跟全域性變數,普通的靜態變數是沒有區別的。結論就是,類中的靜態資料成員的存取方式是直接透過一個具體的地址來訪問的,跟全域性變數毫無區別,所以效率上也跟訪問一個全域性變數一樣。

透過不同方式存取靜態資料成員的效率差異

訪問類的靜態資料成員可以透過類名來訪問,如Base::s1,也可以透過物件來訪問,如b.s1,甚至是透過指標來訪問,如pb->s1。那麼這幾種訪問方式有什麼差別?或者說是否有效率上的損失?其實這幾種訪問方式本質上沒有任何差別,編譯器會轉換成如Base::s1一樣的方式,後面的兩種方式只是語法上的方便而已,看一下彙編程式碼就一目瞭然。把上面的例子多餘的程式碼刪除掉,只留下Base類,然後main函式中增加幾行列印,如下:

Base b;
Base *pb = &b;
printf("&Base::s1 = %p\n", &Base::s1);
printf("&b.s1 = %p\n", &b.s1);
printf("&pb->s1 = %p\n", &pb->s1);

輸出的結果當然是同一個地址了,下面是節選的彙編程式碼:

lea     rdi, [rip + .L.str]
lea     rsi, [rip + Base::s1]
mov     al, 0
call    printf@PLT
lea     rdi, [rip + .L.str.1]
lea     rsi, [rip + Base::s1]
mov     al, 0
call    printf@PLT
lea     rdi, [rip + .L.str.2]
lea     rsi, [rip + Base::s1]
mov     al, 0
call    printf@PLT

可以看到C++中的幾行不同的訪問方式在彙編程式碼中都轉換為同樣的程式碼:

lea rsi, [rip + Base::s1]

繼承而來的靜態資料成員的存取分析

我們已經知道類中的靜態資料成員是跟物件無關的,所有的物件都共享同一個靜態資料成員。但是如果繼承而來的靜態資料成員又是怎樣的呢?假如定義一個Derived類,它是Base類的派生類,那麼靜態資料成員s1的情況又是如何?其實無論繼承多少次,靜態資料成員都只有一份,無論是Derived類還是Base類,它們都共享同一個靜態資料成員s1,可以透過下面的例子來驗證一下:

#include <cstdio>

class Base {
public:
    int b1;
    static int s1;
};
int Base::s1 = 1;

class Derived: public Base {};

int main() {
    Derived d;
    printf("&d.s1 = %p\n", &d.s1);
    printf("d.s1 = %d\n", d.s1);
    d.s1 = 2;

    Base b;
    printf("&b.s1 = %p\n", &b.s1);
    printf("b.s1 = %d\n", b.s1);

    return 0;
}

程式輸出的結果:

&d.s1 = 0x10028c000
d.s1 = 1
&b.s1 = 0x10028c000
b.s1 = 2

可以看到透過Derived類的物件dBase類的物件b訪問到的都是同一個地址,透過物件d修改s1後,透過物件b可以看到修改後的值。

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

相關文章