C++初始化成員的方式有許多,尤其是隨著C++11值類別的重新定義,各種方式之間的差異更是細微。
本文將以String成員初始化為例,探討以下5種方式之間的優劣:輸入不同,它們的開銷也完全不同,我們將以4種不同的輸入分別討論。本篇結束,下方的表格也將填滿。![6c0b8b9c-6bab-11ed-8abf-dac502259ad0.png](https://file1.elecfans.com//web2/M00/98/02/wKgZomTnSFaAEZTMAACaslF7ihs993.png)
1call by-const-reference
這種方式最是廣為流傳,C++11之前亦可使用,是早期的推薦方式。
比如:
structS{
std::stringmem;
S(conststd::string&s):mem{s}{}
};
即便現在,使用這種方式也是大有人在。根據4種不同的輸入分析其開銷,代碼如下:
std::stringstr{"dummy"};
Ss1("dummy");//1.Implicitctor
Ss2(str);//2.lvalue
Ss3(std::move(str));//3.xvalue
Ss4(std::string{"dummy"});//4.prvalue
第一,Implicit ctor。當傳入一個字符串字面量時,會先通過隱式構造創建一個臨時的string對象,將它綁定到形參之上,再通過拷貝構造復制到成員變量。共2次分配。第二,lvalue。對于左值,將直接綁定到形參上,再通過拷貝構造復制到成員變量。共1次分配。
第三,xvalue。對于消亡值,操作同上。共1次分配。
第四,prvalue。對于純右值,操作同上。共1次分配。
由此可知,使用const-reference string時,至少存在1次分配。對于左值來說,這本無可厚非;但對于右值來說,這將徒增一次沒必要的拷貝;對于字符串字面量,還會由隱式構造創建一個臨時對象,增加一次開銷。
因此,這種方式只有針對左值時,效果才不錯,其他情況都會增加一次開銷。
2call by-value
這是從C++11開始也廣為流傳的一種方式,使用的人也不少。4種調用不變,實現變為了:
structS{
std::stringmem;
S(std::strings):mem{std::move(s)}{}
};
這種方式采用值傳遞參數,在許多人的印象中,這種開銷很大,實際情況到底如何呢?第一,Implicit ctor。同樣,先通過隱式構造創建一個臨時對象,然后將其值偷取到成員變量。共1次分配+1次移動。第二,lvalue。拷貝對象,然后將其值偷取到成員變量。共1次分配+1次移動。第三,xvalue。值經過兩次偷取到成員變量。共0次分配+2次移動。第四,prvalue。值直接原地構造,然后偷取到成員變量。共0次分配+1次移動。可以看到,call by-const-reference的缺點,這種方式全部避免。只在接受左值時,多了一次移動。因此,很多情況下,這種方式往往是一種更好的選擇,它可以避免無效的拷貝。3Two-overloads
這種方式通過多提供一個移動構造來消除call by-const-reference的缺點,由于存在兩個重載函數,所以稱為two-overloads。此時實現變為了:
structS{
std::stringmem;
S(conststd::string&s):mem{s}{}
S(std::string&&s):mem{std::move(s)}{}
};
相信你已經猜到了現在的開銷。第一,Implicit ctor。同樣,先創建一個臨時對象,然后調用移動構造。共1次分配+1次移動。第二,lvalue。調用拷貝構造。共1次分配。第三,xvalue。調用移動構造。共0次分配+2次移動。第四,prvalue。調用移動構造。共0次分配+1次移動。通過多增加一個重載函數,得到了不少好處,因此這也是一種可行的方式,但多寫一個重載函數總是頗顯瑣碎。4C++17 string_view
C++17 std::string_view也是一種可行的方案,所謂是又輕又快。采用這種方式,實現變為:
structS{
std::stringmem;
S(std::string_views):mem{s}{}
};
此時的開銷情況如何?第一,Implicit ctor。除了mem創建,沒有多余開銷。共1次分配。第二,lvalue。通過隱式轉換創建string_view,然后拷貝到成員變量。共1次分配。第三,xvalue。同上。共1次分配。第四,prvalue。同上。共1次分配。對于右值,這種方式也會產生沒必要的開銷。最重要的是,std::string_view隱藏有許多潛在的危險,就像操作裸指針一樣,需要程序員來確保它的有效性。稍不留神,就有可能產生懸垂引用,指向一個已經刪除的string對象。因此,若是對其沒有一定的研究,極有可能使用錯誤的用法。5Fowarding references
Forwarding references可以自動匹配左值或是右值版本,也是一種不錯的方式。
實現變為:
structS{
std::stringmem;
template<classT>
S(T&&s):mem{std::forward(s)}{}
};
此時的開銷又如何?第一,Implicit ctor。除了mem構造,無額外開銷。共1次分配。第二,lvalue。直接綁定到實例化的模板函數參數上,然后拷貝一份。共1次分配。第三,xvalue。調用移動構造。共0次分配+2次移動。第四,prvalue。調用移動構造。共0次分配+1次移動。這種方式借助了模板,參數的實際類型根據TAD推導,所以它的開銷也都很小。很多時候,這種方式就是最佳選擇,它可以避免非必要的移動或是拷貝,也適用于非String成員的初始化。但有些時候,你可能想明確指定參數類型,此時這種方式就多有不便了。下節有相應例子。6曲未盡
分析至此,已然可以初步得出一張開銷對比圖。
![6c2b0968-6bab-11ed-8abf-dac502259ad0.png](https://file1.elecfans.com//web2/M00/98/02/wKgZomTnSFaAIVU4AAGfHSuxfDM330.png)
看情況。
沒有哪種方式是完全占優的,可以依據使用次數最多的操作計算消耗,從而正確決策。
舉個例子:
structS{
usingvalue_type=std::vector<std::string>;
usingassoc_type=std::map<std::string,value_type>;
voidpush_data(std::string_viewkey,value_typedata){
datasets.emplace(std::make_pair(key,std::move(data)));
}
assoc_typedatasets;
};
功能很簡單,就是往一個map中添加數據。此時,如何讓浪費最小?假設我們后面使用次數最多的操作為:
Ss;
s.push_data("key1",{"Dear","Friend"});
s.push_data("key2",{"Apple"});
s.push_data("key3",{"Jack","Tom","Jerry"});
s.push_data("key4",{"20","22","11","20"});
那么上述實現就是一種較好的方式。對于鍵,如果使用call by-const-reference,將會創建一個沒必要的臨時對象,而使用string_view可以避免此開銷。對于值,實際上也使用隱式構造創建了一個臨時vector對象,此時call by-value也是一種開銷較小的方式。你可能覺得Forwarding reference也是一種不錯的方式。
voidpush_data(auto&&key,auto&&data){
datasets.emplace(std::make_pair(
std::forward<decltype(key)>(key),
std::forward<decltype(data)>(data)
));
}
對于鍵來說的確不錯,但對于值來說就存在問題了。因為模板參數推導為initializer_list,而參數傳遞需要的是vector,使用這種方式還得手動創建一個臨時的vector。所以,具體問題具體分析,才能選擇最恰當的方式,有時甚至可以組合使用。大家也許注意到,開銷對比圖標題為"初始化Long String成員開銷圖",那么還有短String嗎?6.1SSO短字符串優化
各家編譯器在實現std::string時,基本都會采取一種SSO(Small String Optimization)策略。
此時,對于短字符串,將不會在堆上額外分配內存,而是直接存儲在棧上。比如,有些實現會在size的最低標志位上用1代表長字符串,0代表短字符串,根據這個標志位來決定操作形式。
可以通過重載operator new和operator delete來捕獲堆分配情況,一個例子如下:
std::size_tallocated=0;
void*operatornew(size_tsz){
void*p=std::malloc(sz);
allocated+=sz;
returnp;
}
voidoperatordelete(void*p)noexcept{
returnstd::free(p);
}
intmain(){
allocated=0;
std::strings("hi");
std::printf("stackspace=%zu,heapspace=%zu,capacity=%zu
",
sizeof(s),allocated,s.capacity());
}
例子來源:https://stackoverflow.com/a/28003328
在clang 14.0.0上得出的結果為:
stackspace=32,heapspace=0,capacity=15
可以看到,對于短字符串,將不會在堆上分配額外的內存,內容實際存在在棧上。
早期版本的編譯器可能沒有這種優化,但如今的版本基本都有。
也就是說,這時的移動操作實際就相當于復制操作。于是開銷就可以如下圖表示。![6c56f794-6bab-11ed-8abf-dac502259ad0.png](https://file1.elecfans.com//web2/M00/98/02/wKgZomTnSFaAGNeuAAFp_4r-Te8281.png)
于是可以得出結論:盡管小對象的拷貝操作很快,call by-value還是要慢于其他方式,string_view則是一種較好的方式。
但是,string_view使用起來要格外當心,若你不想為此操心,使用call by-const-reference則是一種不錯的方式。
6.2無拷貝,無移動
當僅需要接受參數,之后即不拷貝,也無移動的情境下,情況又不一樣。
此時的開銷如下圖。
![6c804400-6bab-11ed-8abf-dac502259ad0.png](https://file1.elecfans.com//web2/M00/98/02/wKgZomTnSFaAdVwGAAF_OBB8N9o456.png)
6.3優化限制:Aliasing situations
Aliasing situations指的是多個變量實際指向的是同一塊內存,這些變量之間互為別名。這種情況會導致編譯器束手束腳,不敢優化。
以引用的方式傳遞參數便會產生許多額外的Aliasing situations。
舉個例子:
intfoo_by_ref(constS&s){
intm=s.value;
bar();
intn=s.value;
returnm+n;
}
intfoo_by_value(Ss){
intm=s.value;
bar();
intn=s.value;
returnm+n;
}
例子來源:https://reductor.dev/cpp/2022/06/27/pass-by-value-vs-pass-by-reference.html
引用傳遞版本,編譯器無法判定bar()中是否修改了s.value,比如s引用的是一個全局變量,bar()中就可以修改它。因此,m和n的值可能并不相同,編譯器必須加載兩次s.value。
而值傳遞版本,由于參數進行了拷貝,不存在外部修改,m和n的值肯定相同,于是編譯器可以優化為只加載一次s.value。
這是call by-const-reference的另一處缺點,它可能會限制編譯器的優化,而這又恰恰成了call by-value的一個優點。
7總結
本篇文章介紹了5種初始化String成員的方式,詳細分析對比了它們的開銷。
沒有哪種方式是最優解,如何選擇需要依具體情況而論。
Two-overloads這種方式一般不會考慮,因為總有其他方式比它的開銷更小,還只需編寫一個函數。
string_view在很多情況下的開銷可觀,但是需要格外注意潛在的懸垂引用問題。
其他三種方式亦是有利有弊,可根據文中提及的各種情況進行分析。
總而言之,各方式之間存在著細微而本質的差別,且還有許多特殊情況需要單獨分析,開銷在不同情境下也不盡相同。
一句話,看情況。
審核編輯 :李倩
-
C++
+關注
關注
22文章
2114瀏覽量
73922 -
變量
+關注
關注
0文章
613瀏覽量
28489 -
string
+關注
關注
0文章
40瀏覽量
4751
原文標題:5 種方式初始化 String 成員,怎樣選擇?
文章出處:【微信號:CPP開發者,微信公眾號:CPP開發者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
EE-359:ADSP-CM40x啟動時間優化和器件初始化
![EE-359:ADSP-CM40x啟動時間優化和器件<b class='flag-5'>初始化</b>](https://file.elecfans.com/web1/M00/D9/4E/pIYBAF_1ac2Ac0EEAABDkS1IP1s689.png)
STM32F407 MCU使用SD NAND?不斷電初始化失效解決方案
![STM32F407 MCU使用SD NAND?不斷電<b class='flag-5'>初始化</b>失效解決方案](https://file1.elecfans.com/web3/M00/01/D6/wKgZPGdY_fiAfhvVAAES2FwC9UU093.png)
segger編譯器初始化問題
瀚海微SD NAND應用之SD協議存儲功能描述2 初始化命令
![瀚海微SD NAND應用之SD協議存儲功能描述2 <b class='flag-5'>初始化</b>命令](https://file1.elecfans.com/web2/M00/FD/A2/wKgZomadyY2APOCNAAIGhvObh-4141.png)
esp32調試MQTT的程序,如何對.host初始化?
在初始化IO口為外部中斷線的時候,最先初始化的會被后初始化的覆蓋掉為什么?
使用STM32CubeIDE初始化STM32407的SPI1(PB3)初始化失敗的原因?怎么解決?
什么是異步?異步的八種實現方式
![什么是異步?異步的八<b class='flag-5'>種</b>實現<b class='flag-5'>方式</b>](https://file1.elecfans.com/web2/M00/C1/D0/wKgaomXanoWAO6eYAAANz0b1eIo975.jpg)
MCU單片機GPIO初始化該按什么順序配置?為什么初始化時有電平跳變?
![MCU單片機GPIO<b class='flag-5'>初始化</b>該按什么順序配置?為什么<b class='flag-5'>初始化</b>時有電平跳變?](https://file1.elecfans.com/web2/M00/C1/7A/wKgaomXWul2AKoIuAAAxlaP9tbg978.png)
評論