免费观看又色又爽又黄的小说免费_美女福利视频国产片_亚洲欧美精品_美国一级大黄大色毛片

C++為什么會有這么多難搞的值類別?(上)-創新互聯

前言

相信大家在寫C++的時候一定會經常討論到「左值」「右值」「將亡值」等等的概念,在筆者的其他系列文章中也反復提及這幾個概念,再加上一些「右值引用」「移動語義」等等這些概念的出現,說一點都不暈那一定是騙人的。

合山ssl適用于網站、小程序/APP、API接口等需要進行數據傳輸應用場景,ssl證書未來市場廣闊!成為創新互聯的ssl證書銷售渠道,可以享受市場價格4-6折優惠!如果有意向歡迎電話聯系或者加微信:13518219792(備注:SSL證書合作)期待與您的合作!

如果你對C++值類型的區分和具體概念還不了解的話,重磅推薦先來讀一下我的偶像——三帥妹妹的一篇文章《c++ value categories》。

很多人都在吐槽C++,為什么要設計的這樣復雜?就一個程序語言,還能搞出這么多值類別來?(話說可能自然語言都不見得有這么復雜吧……),那么這篇我們就來詳細研究一下,為什么要專門定義這樣的值類型,以及在這個過程中筆者自己的思考。

一些吐槽

不得不吐槽一下,筆者認為,C++之所以復雜,C語言是原罪。因為C++一開始設計的目的,就是為給C來進行語法擴充的。因此,C++的設計方式和其他語言會有一些不同。

一般設計一門程序語言,應該是先設計一套語言體系,我希望這個語言提供哪些語法、哪些功能。之后再去根據這套理論體系來設計編譯器,也就是說對于每一種語法如何解析,如何進行匯編。

但C++是不同的,因為在設計C++的語言體系的時候,就已經有完整的C語言了。因此,C++的語言體系建立其實是在C的語言體系、編譯器實現以及標準庫等這些之上,又重新建立的。所以說C++從設計之初,就決定了它沒辦法甩開C的缺陷。很多問題都是為了解決一個問題又不得不引入另一個問題,不斷「找補」導致的。今天要細說的C++值類別(Value Category)就是其中非常有代表性的一個。

所以要想解釋清為什么會有這些概念,我們就要從C語言開始,去猜測和體會C++設計者的初衷,遇到的問題以及「找補」的手段,這樣才能真正理解這些概念是如何誕生的。

正式開始解釋 從C語言開始講起

在C語言當中其實并沒有什么「左右值」之類的概念,單從值的角度來說C語言僅僅在意的是「可變量」和「不可變量」。但C更關心的是,數據存在哪里,首先是內存還是寄存器?為了區分「內存變量」還是「寄存器變量」,從而誕生了registerauto關鍵字(用register修飾的要放在寄存器中,auto修飾的由編譯器來決定放在哪里,沒有修飾符的要放在內存中)。

之后,即便是內存也要再繼續細致劃分,C把內存劃分為4大區域,分別是全局區、靜態區、堆區和棧區。而「棧區」主要依賴于函數(我覺得這個地方翻譯成「存儲過程」可能更合適),在C語言的視角來看,每一個程序就是一個過程(主函數),而這個過程執行的途中,會有很多子過程(其他函數),一個程序就是若干過程嵌套拼接和組合的結果。這其實也就是C語言「面向過程」的原因,因為它就是這樣來設計的。從C語言衍生出的C++、OC、Go等其實都沒有逃過這個設計框架。以OC為例,別看OC是面向對象的,但它仍然可以過程式開發,它的程序入口也是主函數,這個切入點來看它還是面相過程的,只是在執行這個過程中,衍生出了面向對象的操作。(這里就不詳細展開了。)

那么以C語言的視角來看,一個函數其實就是一個過程,所以這個過程應該就需要相對獨立的數據區域,僅僅在這個過程中生效,當過程結束,那這些數據也就不需要了。這就是函數的棧區的目的,我們管在棧區中的變量稱作「局部變量」。

雖然棧區把不同過程之間的數據隔離開了,但是我們在過程的執行之間肯定是要有一些數據傳遞的,體現在C語法上就是函數的參數和返回值。正常來說,一個函數的調用過程是:

  1. 劃分一個棧區用于當前函數的執行(這里其實只要確定一個棧底就好了)
  2. 把函數需要的所有數據入棧
  3. 執行函數體(也就是指令組了)
  4. 把函數的結果返回出去
  5. 棧區作廢,可以重復利用

在早期版本的C語言(C89)中,每個函數中需要的局部變量都是要在函數頭定義全的,也就是說函數體中是不能再單獨定義變量的,主要就是為了讓編譯器能夠劃分好內存空間給每一個局部變量。但后來在C99標準里這個要求被放開了,但本質上來說原理是沒有變的,編譯器會根據局部變量定義的順序來進行空間的分配。

要理解這一點,我們直接從匯編代碼上來看是最直觀的。首先給出一個用于驗證的C代碼:

void Demo() {int a = 0;
  long b = 1;
  short c = 2;
}

將其轉換為AMD64匯編是這樣的:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        mov     QWORD PTR [rbp-16], 1
        mov     WORD PTR [rbp-18], 2
        nop
        pop     rbp
        ret

rbp寄存器中存放的就是棧底的地址,我們可以看到,rbp-4的位置放了變量a,因為aint類型的,所以占用4個字節,也就是從[rbp][rbp-4]的位置都是變量a(這里注意里面是減法哈,按照小端序的話低字節是高位),然后按照我們定義變量的順序來排布的(中間預留4字節是為了字節對齊)。

那如果函數有參數呢?會放在哪里?比如:

void Demo(int in1, char in2) {int a = 0;
  long b = 1;
  short c = 2;
}

會轉換為:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-36], edi
        mov     eax, esi
        mov     BYTE PTR [rbp-40], al
        mov     DWORD PTR [rbp-4], 0
        mov     QWORD PTR [rbp-16], 1
        mov     WORD PTR [rbp-18], 2
        nop
        pop     rbp
        ret

可以看出來,函數參數也是作為一種局部變量來使用的,我們可以看到這里處理參數都是直接處理內存的,也就是說在函數調用的時候,就是直接把拿著實參的值,在函數的棧區創建了一個局部變量。所以函數參數在函數內部也是作為局部變量來對待的。

那如果函數有返回值呢?請看下面實例:

int Demo() {return 5;
}

會轉義為:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     eax, 5
        pop     rbp
        ret

也就是說,返回值會直接寫入寄存器,這樣外部如果需要使用函數返回值的話,就直接從寄存器中取就好了。

所以,上面的例子主要是想表明,C語言的設計對于編譯器來說是相當友好的,從某種程度上來說,就是在給匯編語法做一個語法糖。數據的傳遞都是按照硬件的處理邏輯來布局的。請大家先記住這個函數之間傳值的方式,參數就是普通的局部變量;返回的時候是把返回值放到寄存器,調用方會再從寄存器中拿。這個過程我們可以寫一個更加直觀的例子:

int Demo1(int a) {return 5;
}

void Demo2() {int a = Demo1(2);
}

匯編后是:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, 5
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     edi, 2
        call    Demo1
        mov     DWORD PTR [rbp-4], eax
        nop
        leave
        ret

這就非常說明問題了,函數傳參時,因為已經構建了被調函數的棧空間,所以可以直接變量復制,但對于返回值,這是本篇的第一個重點!!「函數返回值是放在寄存器中傳遞出去的」。

寄存器傳遞數據固然方便,但寄存器長度是有上限的,如果需要傳遞的數據超過了寄存器的長度怎么辦?

struct Test {long a, b;
};

struct Test Demo() {struct Test t = {1, 2};
  return t;
}

匯編后是:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-16], 1
        mov     QWORD PTR [rbp-8], 2
        mov     rax, QWORD PTR [rbp-16]
        mov     rdx, QWORD PTR [rbp-8]
        pop     rbp
        ret

尷尬~~編譯器竟然用了2個寄存器來返回數據……這太不給面子了,那我們就再狠一點,搞再長一點:

struct Test {long a, b, c;
};

struct Test Demo() {struct Test t = {1, 2, 3};
  return t;
}

當結構體的長度再大一點的時候,情況就發生改變了:

Demo:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret

我們能看到,這里做的事情很有趣,[rbp-40]~[rpb-16]這24個字節是局部變量t,函數執行后被寫在了[rdi]~[rdi+24]這24個字節的空間的位置,而最后寄存器中存放的是rdi的值(匯報指令有點繞,受限于AMD64匯編語法的限制,不同種類寄存器之間不能直接賦值,所以它先搞到了[rbp-40]的內存位置,然后又寫到了rcx寄存器中,所以后面的[rcx+8]其實就是[rdi+8],最后rax中其實放的也是一開始的rdi的值)。那這個rdi寄存器的值是誰給的呢?我們加上調用代碼來觀察:

struct Test {long a, b, c;
};

struct Test Demo1() {struct Test t = {1, 2, 3};
  return t;
}

void Demo2() {struct Test t = Demo1();
}

匯編成:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rax, [rbp-32]
        mov     rdi, rax
        mov     eax, 0
        call    Demo1
        nop
        leave
        ret

也就是說,在這種場景下,調用Demo1之前,rdi寫的就已經是Demo2t的地址了。編譯器其實是把「返回值」變成了「出參」,直接拿著「將要接受返回值的變量地址」進到函數里面來處理了。這是本篇的第二個重點!!「函數返回值會被轉換為出參,內部直接操作外部棧空間」。

但假如,我們要的并不是「返回值的全部」,而是「返回值的一部分」呢?比如說:

struct Test {long a, b, c;
};

struct Test Demo1() {struct Test t = {1, 2, 3};
  return t;
}

void Demo2() {long a = Demo1().a; // 只要其中的一個成員
}

那么這個時候會匯編成:

Demo1:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-40], rdi
        mov     QWORD PTR [rbp-32], 1
        mov     QWORD PTR [rbp-24], 2
        mov     QWORD PTR [rbp-16], 3
        mov     rcx, QWORD PTR [rbp-40]
        mov     rax, QWORD PTR [rbp-32]
        mov     rdx, QWORD PTR [rbp-24]
        mov     QWORD PTR [rcx], rax
        mov     QWORD PTR [rcx+8], rdx
        mov     rax, QWORD PTR [rbp-16]
        mov     QWORD PTR [rcx+16], rax
        mov     rax, QWORD PTR [rbp-40]
        pop     rbp
        ret
Demo2:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        lea     rax, [rbp-32]
        mov     rdi, rax
        mov     eax, 0
        call    Demo1
        mov     rax, QWORD PTR [rbp-32]
        mov     QWORD PTR [rbp-8], rax
        nop
        leave
        ret

我們發現,雖然在Demo2中沒有剛才那樣完整的結構體變量t,但編譯器還是會分配一片用于保存返回值的空間,把這個空間的地址寫在rdi中,然后拿著這個空間到Demo1中來操作。等Demo1函數執行完,再根據需要,把這片空間中的數據復制給局部變量a

換句話說,編譯器其實是創建了一個匿名的結構體變量(我們姑且叫它tmp),所以上面的代碼其實等價于:

void Demo2() {struct Test tmp = Demo1(); // 注意這個變量其實是匿名的
  int a = tmp.a;
}
小結

總結上面所說,對于一個函數的返回值:

  1. 如果能在一個寄存器存下,就會存到寄存器中
  2. 如果在一個寄存器存不下,就會考慮拆分到多個寄存器中
  3. 如果多個可用的寄存器都存不下,就會考慮直接用內存來存放,在調用函數之前先開放一片內存空間用于儲存返回值,然后函數內部直接使用這片空間
  4. 如果調用方直接接收函數返回值,那么就會直接把這片空間標記給這個變量
  5. 如果調用方只使用返回值的一部分,那么這片空間就會成為一個匿名的空間存在(只有地址,但沒有變量名)

這一套體系在C語言中其實并沒有太多問題,但有了C++的拓展以后,就不一樣了。

考慮上構造和析構函數會怎么樣

C++在C的基礎上,為結構體添加了構造函數和析構函數,為了能「屏蔽抽象內部的細節」,將構造和析構函數與變量的生命周期進行了綁定。在創建變量時會強制調用構造函數,而在變量釋放時會強制調用析構函數。

如果是正常在一個代碼塊內,這件事自然是無可厚非的,我們也可以簡單來驗證一下:

struct Test {Test() {}
  ~Test() {}
};

void Demo() {Test t;
}

匯編成:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Demo():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

注意C++由于支持了函數重載,因此函數簽名里會帶上參數類型,所以這里的函數名都比C語言直接匯編出來的多一個括號。

那如果一個自定義了構造和析構的類型做函數返回值的話會怎么樣?比如:

struct Test {Test() {}
  ~Test() {}
};

Test Demo1() {Test t;
  return t;
}

void Demo2() {Test t = Demo1();
}

這里我們給編譯器加上-fno-elide-constructors參數來關閉返回值優化,這樣能看到語言設計的本質,匯編后是:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-1]
        mov     rdi, rax												;注意這里rdi發生了改變!
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-1]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Demo1()
        lea     rax, [rbp-1]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        leave
        ret

這次代碼就非常有意思了,首先,編譯器自動生成了一個拷貝構造函數Test::Test(const Test &)。接下來做的事情跟純C語言結構體就有區別了,在Demo2中,由于我們仍然是用變量直接接收了函數返回值,所以它同樣還是直接把t的地址,寫入了rdi,這里行為和之前是一樣的。但是在Demo1中,rdi的值寫入了rbp-24的位置,但后面調用構造函數的時候傳入的是rbp-1,所以說明這個位置才是Demo1中的t實際的位置,等待構造函數調用完之后,又調用了一次拷貝構造,這時傳入的才是rbp-24,也就是外部傳進來保存函數返回值的地址。

也就是說,由于構造函數和析構函數跟變量生命周期相綁定了,因此這時并不能直接把「函數返回值轉出參」了,而是先生成一個局部變量,然后通過拷貝構造函數來構造「返回值」,再析構這個局部變量。所以整個過程會多一次拷貝和析構的過程。

這么做,是為了保證對象的行為自閉環,但只有當析構函數和拷貝構造函數是非默認行為的時候,這樣做才有意義,如果真的就是C類型的結構體,那就沒這個必要了,按照原來C的方式來編譯即可。因此C++在這里強行定義了「平凡(trivial)」類型的概念,主要就是為了指導編譯器,對于平凡類型,直接按照C的方式來編譯,而對于非平凡的類型,要調用構造和析構函數,因此必須按照新的方式來處理(剛才例子那樣的方式)。

那么這個時候再考慮一點,如果我們還是只使用返回值的一部分呢?比如說:

struct Test {Test() {}
  ~Test() {}
  int a;
};

Test Demo1() {Test t;
  return t;
}

void Demo2() {int a = Demo1().a;
}

結果非常有趣:

Test::Test() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::~Test() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
Test::Test(Test const&) [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     QWORD PTR [rbp-16], rsi
        mov     rax, QWORD PTR [rbp-8]
        mov     rdx, QWORD PTR [rbp-16]
        mov     edx, DWORD PTR [rdx]
        mov     DWORD PTR [rax], edx
        nop
        pop     rbp
        ret
Demo1():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-24], rdi
        lea     rax, [rbp-4]
        mov     rdi, rax
        call    Test::Test() [complete object constructor]
        lea     rdx, [rbp-4]
        mov     rax, QWORD PTR [rbp-24]
        mov     rsi, rdx
        mov     rdi, rax
        call    Test::Test(Test const&) [complete object constructor]
        lea     rax, [rbp-4]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        mov     rax, QWORD PTR [rbp-24]
        leave
        ret
Demo2():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-8]
        mov     rdi, rax
        call    Demo1()
        mov     eax, DWORD PTR [rbp-8]
        mov     DWORD PTR [rbp-4], eax						;這里是給局部變量a賦值
        lea     rax, [rbp-8]
        mov     rdi, rax
        call    Test::~Test() [complete object destructor]
        nop
        leave
        ret

這里仍然會分配一個匿名的空間用于接收返回值,然后再從這個匿名空間中取值復制給局部變量a。從上面的代碼能看出,匿名空間在rbp-8的位置,局部變量arbp-4的位置。但這里非常有意思的是,在給局部變量賦值后,立刻對匿名空間做了一次析構(所以它把rbp-8寫到了rdi中,然后cal了析構函數)。這是本篇的第三個重點!!「如果用匿名空間接收函數返回值的話,在處理完函數調用語句后,匿名空間將會被析構」。

左值(Left-hand-side Value)、純右值(Pure Right-hand-side Value)與將亡值(Expiring Value)

講了這么多,總算能回到主線上來了,先來歸納一下前文出現的3個重點:

  1. 函數返回值是放在寄存器中傳遞出去的
  2. 函數返回值會被轉換為出參,內部直接操作外部棧空間
  3. 如果用匿名空間接收函數返回值的話,在處理完函數調用語句后,匿名空間將會被析構

其實對應了函數返回數據的3種處理方式:

  1. 直接存在寄存器里
  2. 直接操作用于接收返回值的變量(如果是平凡的,直接操作;如果是非平凡的,先操作好一個局部變量,然后再拷貝過來)
  3. 先放在一個臨時的內存空間中,使用完后再析構掉

C++按照這個特征來劃分了prvalue和xvalue。(注意,英語中所有以"ex"開頭的單詞,如果縮寫的話會縮寫為"x"而不是"e",就比如說"Extreme Dynamic Range"縮寫是"XDR"而不是"EDR"; “Extensible Markup Language"縮寫為"XML"而不是"EML”。)

所謂prvalue,翻譯為“純右值”,表示的就是第1種,也就是用寄存器來保存的情況,或者就是字面常量。舉例來說,1這就是個純右值,它在匯編中就是一個單純的常數。然后就是返回值通過寄存器來進行的這種情況。對于C/C++這種語言來說,我們可以盡情操作內存,但沒法染指寄存器,所以在它看來,寄存器中的數就跟一個常數值一樣,只能感知到它的值而已,不能去操控,不能去改變。換一種說法,prvalue就是「沒有內存實體」的值,常數沒有內存實體,寄存器中的數據也沒有內存實體。所以prvalue沒有地址。

而對于第2種的情況,「返回值」的這件事情其實是不存在的,只是語義上的概念。實際就是操作了一個調用方的棧空間。因此,這種情況就等價于普通的變量,它是一個lvalue,它是實實在在可控的,有內存實體,程序可以操作。

對于第3種的情況,「返回值」被保存在一個匿名的內存空間中,它在完成某一個動作之后就失效了(非平凡析構類型的就會調用析構函數)。比如用上一節的例子來說,從Demo1函數的返回值(匿名空間)獲取了成員a交給了局部變量,然后,這個匿名空間就失效了,所以調用了~Demo析構函數。我們把這種值稱為xvalue(將亡值),xvalue也有內存實體。

以目前得到的信息來說,xvalue和lvalue的區別就在于生命周期。在C++中生命周期比在C中更加重要,在C中討論生命周期其實僅僅在于初始化和賦值的問題(比如說局部static變量的問題),但到了C++中,生命周期會直接決定了構造和析構函數的調用,因此更加重要。xvalue會在當前語句結束時立刻析構,而lvalue會在所屬代碼塊結束時再析構。所以針對于xvalue的情況,在C中并不明顯,反正我們是從匿名的內存空間讀取出數據來,這件事情就結束了;但C++中就會涉及析構函數的問題,這就是xvalue在C++中非常特殊的原因。

xvalue取址問題與C++引用

對于prvalue來說,它是純「值」或「寄存器值」,因此不能取地址,這件事無可厚非。但對于xvalue來說呢?xvalue有內存實體,但為什么也不能取地址呢?

原因就是在于,原本C語言在設計這個部分的時候,函數返回值究竟要寫到一個局部變量里,還是要寫到一個匿名的內存空間里這件事是不能僅通過一個函數調用語句來判斷,而是要通過上下文。也就是說,struct Test t = Demo1();的時候,t本身的地址就是返回值地址,此時返回值是lvalue(因為t就是lvalue);而如果是int ta = Demo1().a;的時候,返回值的地址是一個匿名的空間,此時返回值就是xvalue,而這里的ta就不再是返回值的地址。所以,如果你什么都不給,單純給一個Demo1();,編譯器就無法判斷要選取哪一種方式,所以干脆就不支持&Demo1();這種寫法,你得表達清楚了,我才能確定你要的是誰的地址。所以前一種情況下的&t就是返回值所在的地址,而后一種情況的&ta就并不是返回值所在地址了。

原本C中的這種方式倒是也合理,但是C++卻引入了「引用」的概念,希望讓「xx的引用」從「語義上」成為「xx的別名」這種感覺。但C++在實現引用的時候,又沒法做到真的給變量起別名,所以轉而使用指針的語法糖來實現引用。比如說:

int a = 5;
int &r = a;

語義上,表達的是「a是一個變量,r代指這個變量,對r做任何行為就等價于對a做同樣的行為,所以ra的替身(引用)」。但實際上卻做的是「定義了一個新的變量pr,初始化為a的地址,對p做任何行為就等價于對*pr做任何行為,這是一個取地址和解指針的語法糖」。

既然本質是指針,那么指針的解類型就是可以手動定義的,同理,變量的引用類型也是可以手動定義的。(本質上就不是別名,如果是別名的話,那類型怎么能變化呢?)比如說:

int a = 5;
char &r = reinterpret_cast(a);

上面這種寫法是成立的,因為它的本質就是:

int a = 5;
char *pr = reinterpret_cast(&a);

變化的僅僅是指針的解類型而已。自然沒什么問題。既然解類型可以強轉,自然也就符合隱式轉換特性,我們知道可變指針可以隱式轉換為不可變指針,那么「可變引用」也自然可以隱式轉換為「不可變引用」,比如說:

int a = 5;
const int &r = a;
// 等價于:
const int &r = const_cast(a);
// 等價于
const int *pr = &a;
// 等價于
const int *pr = const_cast(&a);

繞來繞去本質都是指針的行為。剛才我們說到rvalue是不能取址的,那么自然,我們就不能用一個普通的引用來接收函數返回值:

Test &r = Demo1(); // 不可以!因為它等價于
Test *pr = &Demo1(); // 這個不可以,所以上面的也不可以
常引用與右值(Right-hand-side Value)

雖然引用本質上就是指針的語法糖,但C++并不滿足于此,它為了讓「語義」更加接近人類的直覺,它做了這樣一件事:讓用const修飾的引用可以綁定函數的返回值。

從語義上來說,它不希望我們程序員去區分「寄存器返回值」還是「內存空間返回值」,既然是函數的返回值,你就可以認為它是一個「純值」就好了。或者換一個說法,如果你要屏蔽寄存器這一層的硬件實現,我們就不應該區分寄存器返回值還是內存返回值,而是假設寄存器足夠大,那么函數返回值就一定是個「純值」。那么這個「純值」就叫做rvalue。

這就是我前面提到的「語言設計」層面,在語言設計上,函數返回值就應當是個rvalue,只不過在編譯器實現的時候,根據返回值的大小,決定它放到寄存器里還是內存里,放寄存器里的就是prvalue,放內存里的就是xvalue。所以prvalue和xvalue合稱rvalue,就是這么來的。

而用const修飾的引用,它綁定普通變量的時候,語義上解釋為「一個變量的替身,并且不可變」,實際上是「含了一次const_cast隱式轉換的指針的語法糖」。

當它綁定函數返回值的時候,語義上解釋為「一個值的替身(當然也是不可變的)」,實際上是代指一片內存空間,如果函數是通過寄存器返回的,那么就把寄存器的值復制到這片空間,而如果函數是通過內存方式返回的,那么就把這片內存空間傳入函數中作為「類似于出參」的方式。

兩種方式都同為「一個替身,并且不可變」,因此又把const修飾的引用叫做「常引用」。

等等!這里好像有點奇怪哎?!照這么說的話,常引用去接受函數返回值的情況,不是跟一個普通變量去接受返回值的情況一模一樣了嗎?對,是的,沒錯!你的想法是對的!,下面兩行代碼其實會生成相同的匯編指令:

struct Test {long a, b, c;
};

Test Demo1() {Test t{1, 2, 3};
  return t;
}

void Demo2() {const Test &t1 = Demo1();
  // 匯編指令等價于
  const Test t2 = Demo1();
}

他們都是劃分了一片內存區域,然后把地址傳到函數里去使用的(也就是返回值轉出參的情況)。同理,如果返回值是通過寄存器傳遞的也是一樣:

int Demo1() {return 2;
}

void Demo2() {const int &t1 = Demo1();
  // 匯編指令等價于
  const int t2 = Demo1();
}

所以,上面兩個例子中,無論是t1還是t2,本質都是一個普通的局部變量,它們有內存實體,并且生命周期跟隨棧空間,因此都是lvalue。這是本文第四個重點!!「引用本身是lvalue」。也就是說,函數返回值是rvalue(有可能是prvalue,也有可能是xvalue),但如果你用引用來接收了,它就會變成lvalue。

目前階段的結論

這里再回過頭來看一下,剛才我們說「函數返回值是rvalue」這事好像就有一點問題了。從理論上來理解用一個變量或引用來接收一個rvalue這種說法是沒錯的,但其實編譯期并不是單純根據函數返回值這一件事來決定如何處理的,而是要帶上上下文(或者說,返回值的長度以及使用返回值的方式)。所以單獨討論f()是什么值類型并沒有意義,而是要根據上下文。我們總結如下:

  1. 常量一定是prvalue(比如說1'a'5.6f)。
  2. 變量、引用(包括常引用)都是lvalue,哪怕是用于接受函數返回值,它也是lvalue。(這里一種情況是通過寄存器復制過來的,但復制完它已經成為變量了,所以是lvalue;另一種是直接把變量地址傳到函數中去接受返回值的,這樣它本身也是lvalue)。
  3. 只有當僅使用返回值的一部分(類似于f().a的形式)的這種情況,會使用臨時空間(匿名的,會在當前語句結束后析構),這種情況下的臨時空間是xvalue。
這里的設計初衷

所以,各位發現了嗎?C++在設計時應當很單純地認為value分兩類:一類是變量,一類是值。變量它有內存實體,可以出現在賦值語句的左邊,所以稱為「左值」;值沒有內存實體,只能出現在賦值語句的右邊,所以稱為「右值」。

但在實現時,卻受到了C語言特性的約束(更準確來說是硬件的約束),造成我們不能把所有的右值都按照統一的方式來傳遞,所以才按照C語言處理返回值的方式強行劃分出了prvalue和xvalue,其作用就是用來指導析構函數的調用,以實現對象系統的自閉環。

C語言原本就比較面相硬件,所以它的處理是對于機器來說更加合理的。而C++則希望能提供一套對程序員更加友好的「語義」,所以它在「語義」的設計上是對人更加合理的,就比如這里的常引用,其實就是想成為一個「不可變的替身」。但又必須向下兼容C的解析方式,因此做了一系列的語法糖。而語法糖背后又會觸發底層硬件不同處理方式的問題,所以又不得不為了區分,而強行引入奇怪的概念(比如這里的xvalue)。

原本「找補」到這里(劃分出了xvalue和常引用的概念后)基本已經可以子閉環了。但C++偏偏就是非常倔強,又“貼心”地給程序員提供了「移動語義」,讓當前的這個閉環瞬間被打破,然后又不得不建立一個新的理論閉環。

下一篇將講解有關移動語義和右值引用的設計初衷和實現方式,這里C++的這些值類型還會有有趣的新故事。
C++為什么會有這么多難搞的值類別?(下)

你是否還在尋找穩定的海外服務器提供商?創新互聯www.cdcxhl.cn海外機房具備T級流量清洗系統配攻擊溯源,準確流量調度確保服務器高可用性,企業級服務器適合批量采購,新人活動首月15元起,快前往官網查看詳情吧

分享標題:C++為什么會有這么多難搞的值類別?(上)-創新互聯
網頁路徑:http://m.newbst.com/article10/dopdgo.html

成都網站建設公司_創新互聯,為您提供域名注冊虛擬主機網站內鏈微信公眾號網站制作手機網站建設

廣告

聲明:本網站發布的內容(圖片、視頻和文字)以用戶投稿、用戶轉載內容為主,如果涉及侵權請盡快告知,我們將會在第一時間刪除。文章觀點不代表本網站立場,如需處理請聯系客服。電話:028-86922220;郵箱:631063699@qq.com。內容未經允許不得轉載,或轉載時需注明來源: 創新互聯

微信小程序開發