蘇州專業(yè)高端網(wǎng)站建設公司專業(yè)制作網(wǎng)站的公司哪家好
對象的生命周期是c++中非常重要的概念,它直接決定了你的程序是否正確以及是否存在安全問題。
今天要說的臨時變量導致的生命周期問題是非常常見的,很多時候沒有一定經(jīng)驗甚至沒法識別出來。光是我自己寫、review、回答別人的問題就犯了或者看到了許許多多這類問題,所以我想有必要做個簡單的總結,自己備忘的同時也盡量幫其他開發(fā)者尤其是別的語言轉c++的人少踩些坑。
問題主要分為三類,每類我都會給出典型例子,最后會給出解決辦法。不過在深入討論每一類問題之前,先讓我們復習點必要的基礎知識。
基礎回顧
基礎回顧少不了,否則看c++的文章容易變成看天書。
但也別緊張,都叫“基礎”了那肯定是些簡單的偏常識的東西,不難的。
第一個基礎是語句和表達式。語句好理解,for(...){}
是一個語句,int a = num + 1;
也是一個語句,除了一些特殊的語法結構,語句通常以分號結尾。表達式是什么呢,語句中除了關鍵字和符號之外的東西都可以算表達式,比如int a = num + 1
中,num
、1
、num + 1
都是表達式。當然單獨的表達式也可以構成語句,比如num;
是語句。
這里就有個概率要回顧了:“完整的表達式”。什么叫完整,粗暴的理解就是同一個語句里的所有子表達式組合起來的那個表達式才叫“完整的表達式”。舉個例子int a = num + 1;
中int a = num + 1
才是一個完整的表達式;str().trimmed().replace(pattern, gettext());
中str().trimmed().replace(pattern, gettext())
才是完整的表達式。
這個概念后面會很有用。
第二個要復習的是const T &
對臨時變量生命周期的影響。
一個臨時對象(通常是prvalue)可以綁定到const T &
或者右值引用上。綁定后臨時對象的生命周期會一直延長到綁定的引用的生命周期結束的時候。但延長有一個例外:
const int &func()
{return 100;
}
這個大家都知道是懸垂引用,但const T &
不是能延長100這個臨時int對象的生命周期嗎,這里理論上不應該是和返回值的生命周期一樣么,這么會變成懸垂引用?
答案是語法規(guī)定的例外,引用綁定延長的生命周期不能跨越作用域。這里顯然100是在函數(shù)內(nèi)的作用域,而返回的引用作用域在函數(shù)之外,跨越作用域了,所以這時綁定不能延長臨時int對象的生命周期,臨時對象在函數(shù)調用結束后銷毀,所以產(chǎn)生了懸垂引用。
另外綁定帶來的延長是不能傳遞的,只有直接綁定到臨時對象上才能延長生命,其他情況比如通過另一個引用進行的綁定都沒有效果。
復習到此為止,我們來看具體問題。
函數(shù)調用中的生命周期問題
先看例子:
const int &value = std::max(v, 100);
這是三類問題中最常見的一類,甚至常見到了各大文檔包括cppreference上都專門開了個腳注告訴你這么寫是錯的。
這個錯也很難察覺,我們一步步來。
首先是看std::max
的函數(shù)簽名,當然因為實現(xiàn)代碼也很簡單所以一塊看下簡化版:
template <typename T>
const T & max(const T &a, const T &b)
{return a>b ? a : b;
}
參數(shù)用const T &
有道理,這樣左值右值都能收;返回值用引用也還算有道理,畢竟這里復制一份參數(shù)語義和性能上都比較欠缺,因為我們要的是a和b中最大的那個,而不是最大值的副本。真正的問題是這么做之后,max的返回值不能延長a或者b的生命周期,但a和b卻可以延長作為參數(shù)的臨時對象的生命周期,換句話說max只能延長臨時對象的生命周期到max函數(shù)運行結束。
現(xiàn)在還不知道問題在哪對吧,我們接著看std::max(v, 100)
這個表達式。
其中v是沒問題的,但100是字面量,在這綁定到const int&
時必須實例化出一個int的臨時對象。正是這個臨時對象上發(fā)生了問題。
有人會說這個臨時對象在max返回后失效了,但事實并非如此。
真相是,在一個完整的表達式里產(chǎn)生的臨時對象,它的生命周期從被創(chuàng)建完成開始,一直到完整的表達式結束時才結束。
也就是說100這個臨時對象在max返回后其實還存在,但max的返回值不能延長它的生命周期,value是通過引用進行間接綁定的所以也不能延長這個臨時對象的生命。最后完整的表達式結束,臨時對象100被消耗,現(xiàn)在value是懸垂引用了。
這就是典型的臨時對象導致的生命周期問題。
由于這個問題太常見,所以不僅是文檔和教程有列舉,比較新的編譯器也會有警告,比如GCC13。
除此之外就只能靠sanitizer來檢測了。sanitizer是一種編譯器在正常的生成代碼中插入一些特殊的監(jiān)測點來實現(xiàn)對程序行為監(jiān)控的技術,比較常見的應用是檢測有沒有不正常的內(nèi)存讀寫或者是多線程有沒有數(shù)據(jù)競爭等問題。這里我們對懸垂引用的使用正好是一種不正常的內(nèi)存讀取,在檢測范圍內(nèi)。
編譯使用這個指令就能啟用檢測:g++ -fsanitize=address xxx.cpp
。遇到內(nèi)存相關的問題它會立刻報錯并退出執(zhí)行。
問題的本質在于max很容易產(chǎn)生臨時對象,但自己又完全沒法對這個臨時對象的生命周期產(chǎn)生影響,返回值不是引用可以一定程度上規(guī)避問題,然而作為通用的庫函數(shù),這里除了用引用又沒啥其他好辦法。所以這得算半個設計上的失誤。
不僅僅是max和min,所有參數(shù)是常量左值引用或者非轉發(fā)引用的右值引用,并且返回值的類型是引用且返回的是自己的某一個參數(shù)的函數(shù)都存在相同的問題。
想徹底解決問題有點難,但回避這個問題倒是不難:
// 方案1
const int maxValue = 100;
const int &value = std::max(v, maxValue);// 方案2
const int value = std::max(v, 100);
方案1不需要產(chǎn)生臨時對象,value始終能引用到表達式結束后依然存在的變量。
方案2是比較推薦的,尤其是對標量類型。由于臨時變量要在完整表達式結束后才銷毀,所以把它復制一份給value是完全沒問題的,賦值表達式也是完整表達式的一部分。這個方案的缺點在于復制成本較高或者無法復制的對象上不適用。但c++17把復制省略標準化了,這樣的表達式在大多數(shù)時候不會真的產(chǎn)生復制行為,所以我的建議是只要業(yè)務和語義上允許,優(yōu)先使用值語義也就是方案2,真出了問題并且定位到這里了再考慮轉換成方案1。
鏈式調用中的生命周期問題
從其他語言轉c++的人相當容易踩這個坑。看個最經(jīng)典的例子:
const char *str = path.trimmed().toStdString().c_str();
簡單說明下代碼,path
是一個QString
的實例,trimmed
方法會返回一個去除了首尾全部空格的新的QString
,toStdString()
會復制底層數(shù)據(jù)然后轉換成一個std::string
,c_str應該不用我多說了這個是把string內(nèi)部數(shù)據(jù)轉換成一個const char*
的方法。
這句表達式同樣有問題,問題在于表達式結束后str會成為懸垂指針。
一步步來分解問題。首先c_str保證返回的指針有效,前提是調用c_str的那個string對象有效。如果string對象的生命周期結束了,那么c_str返回的指針也就無效了。
path.trimmed().toStdString()
本身是沒問題的,每一步都是返回的新的值類型的對象實例,但是問題在于這些對象實例都是臨時對象,但我們沒有做任何措施來延長臨時對象的生命周期,整句表達式結束后它們就全析構生命周期終結了。
現(xiàn)在問題應該明了了,臨時對象上調了c_str,但這個臨時對象表達式結束后不存在了。所以str最后變成了懸垂指針。
為啥會坑到其他語言轉來的人呢?因為對于有gc的語言,上述表達式實際上又產(chǎn)生了新的到臨時對象的可達路徑,所以對象是不會回收的,而對于rust之類的語言還可以精細控制讓對象的每一部分具有不同的生命周期,上述表達式稍微改改是有機會正常使用的。這些語言轉到c++把老習慣帶過來就要被坑了。
推薦的解決辦法只有1種:
auto tmp = path.trimmed().toStdString();
const char *str = tmp.c_str();
能解決問題,但毛病也很明顯,需要多個用完就扔的變量出來,而且這個變量因為根據(jù)后續(xù)的操作要求很可能還不能用const修飾,這東西不僅干擾思維,有時候還會成為定時炸彈。
我不推薦直接用string而不用指針,是因為有時候不得不用const char*
,這種時候啥方法都不好使,只能用上面的辦法去暫存臨時數(shù)據(jù),以便讓它的生命周期能延長到后續(xù)操作結束為止。
三元運算符中的生命周期問題
三元運算符中也有類似的問題。我們看個例子:
const std::string str = func();
std::string_view pretty = str.empty() ? "<empty>" : str;
很簡單的一行代碼,我們判斷字符串是不是空的,如果是就轉換成特殊的占位符字符串。用string_view當然是因為我們不想復制出一份str,所以只用string_view來引用原來的字符串,而且string_view也能引用字符串字面量,用在這里看起來正合適。
事實是這段代碼無比的危險。而且-Wall
和-Wextra
都沒法讓編譯器在編譯時檢測到問題,我們得用sanitizer:g++ -std=c++20 -Wall -Wextra -fsanitize=address test.cpp
。接著運行程序,我們會看到這樣的報錯:ERROR: AddressSanitizer: stack-use-after-scope on address ...
。
這個報錯提示我們使用了某個已經(jīng)析構了的變量。而且新版本的編譯器還會很貼心得告訴你就是使用了pretty
這個變量導致的。
不過雖然我們知道了具體是哪一行的那個變量導致的問題,但原因卻不知道,而且當我們的字符串不為空的時候也不會觸發(fā)問題。
這個時候其實就是語法規(guī)則在作祟了。
c++里規(guī)定三元運算符產(chǎn)生的結果最終只能有一種統(tǒng)一的類型。這個好理解,畢竟要賦值給某個固定類型的變量的表達式產(chǎn)生大于一種可能的結果類型既不合邏輯也很難正確編譯。
但這導致了一個問題,如果三元運算符兩邊的表達式確實有不同的結果類型怎么辦?現(xiàn)代語言通常的做法是直接報錯,然而c++的做法是按照語法規(guī)則做類型轉換,實在轉換不來才會報錯。看起來c++的做法更寬松,這反過來誘發(fā)了這節(jié)所述的問題。
我們看看具體的轉換規(guī)則:
-
兩個表達式有一邊產(chǎn)生void值另一邊不是,那么三元運算符結果的類型和另一個不是結果不是void的表達式的相同(產(chǎn)生void的表達式只能是throw表達式,否則算語法錯誤)
-
兩個表達式都產(chǎn)生void,則結果也是void,這里不要求只能是throw表達式
-
兩個表達式結果類型相同,那么三元運算符的結果類型和表達式相同
-
兩個表達式結果類型不同或者具有不同的cv限定符,那么得看是否有其中一個類型能隱式轉換成另一個,如果沒有那么是語法錯誤,如果兩方能互相轉換,也是語法錯誤。滿足這個限定條件,那么另一個類型的表達式的結果會被隱式類型轉換成目標類型,比如當出現(xiàn)
const char *
和std::string
的時候,因為存在const char *
隱式轉換成string的方法,所以最終三元運算符的結果類型是std::string
;而T
和const T
通常結果類型是const T
。
這還是我掐頭去尾簡化了好幾次的總結版,實際的規(guī)則更復雜,如果我把實際上的規(guī)則列在那難免被噴是語言律師,所以我就不自討沒趣了。但這個簡化版規(guī)則雖然粗糙,但實際開發(fā)倒是基本夠用了。
回到我們出問題的表達式,因為pretty初始化后就沒再修改過,那100%就是三元運算符那里有什么貓膩。恰巧的是我們正好對應在第四點上,表達式類型不同但可以進行隱式轉換。
按照規(guī)則,字符串字面量"<empty>"
要轉換成const std::string
,正好存在這樣的隱式轉換序列(const char[8] -> const char * -> std::string, 隱式轉換序列怎么得出的可以看這里),當表達式為真也就是我們的字符串是空的,一個臨時的string對象就被構造出來了。接著會從這個臨時的string構造一個string_view
,string_view只是簡單地和原來的string共有內(nèi)部數(shù)據(jù),本身沒有str的所有權,而且string_view也不是“引用”,所以它不能延長臨時對象的生命周期。接著完整的表達式結束了,這時在表達式內(nèi)創(chuàng)建的臨時對象如果沒有什么能延長它生命的東西存在,就會被析構。顯然在這一步從"<empty>"
轉換來的臨時string就析構了。
現(xiàn)在我們發(fā)現(xiàn)和pretty
共有數(shù)據(jù)的string被銷毀了,后面繼續(xù)用pretty
顯然是錯誤的。
從別的語言轉c++的開發(fā)者估計很容易踩到這種坑,短的字符串字面量轉換成string在libstdc++還有特殊優(yōu)化,在這個優(yōu)化下你的程序就算犯了上述錯誤10次里還是有七八次能正常運行,然后剩下兩三次得到錯誤或者崩潰;要是換了另一個不同的標準庫實現(xiàn)那就有更多的未知在等著你了。這也是string_view在標準中標明的幾個undefined behavior之一。所以這個錯誤經(jīng)驗不足的話會非常隱蔽。
修復倒是不難,如果能變更pretty的類型(后續(xù)可以從pretty創(chuàng)建string_view),那有下面幾種方案可選:
// 方案1
std::string_view pretty = str;
if (str.empty()) {pretty = "<empty>";
}// 方案2
const std::string pretty = str.empty() ? "<empty>" : str;// 方案3
const std::string &pretty = str.empty() ? "<empty>" : str;
方案1里不再有類型轉換和臨時對象了,字符串字面量的生命周期從程序運行開始到程序退出結束,沒有生命周期問題。但這個方案會顯得比較啰嗦而且在字符串為空的時候得多一次賦值。
方案2也沒啥特別要說的,就是前幾節(jié)講的在臨時對象銷毀前復制了一份。對于標量類型這么做一般沒問題,對于類類型就得考慮復制成本了,不過編譯器通常能做到copy elision,倒不用特別擔心。
方案3其實也比較容易理解,我們不是產(chǎn)生了臨時對象么,那么直接用常量左值引用去綁定,這樣臨時對象的生命周期就能被擴展延長了,而且const T &
本來就能綁定到str這樣的左值上,所以語法上沒問題運行時也沒有問題。
特例
說完三個典型問題,還有兩個特例。
第一個是關于引用臨時對象的非static數(shù)據(jù)成員的。具體例子如下:
具體的例子如下:
struct Data {int a;std::string b;bool c;
};Data get_data(int a, const std::string &b, bool c)
{return {a, b, c};
}int main()
{std::cout << get_data(1, "test", false).b << '\n';const auto &str = get_data(1, "test", false).b;std::cout << str << '\n';
}
這個例子是沒有問題的。原因在于,如果我們用引用綁定了臨時對象的非static數(shù)據(jù)成員,也就是subobject,那么不僅僅是數(shù)據(jù)成員,整個臨時對象的生命周期都會得到延長。所以這里str雖然只綁定到了成員b,但整個臨時對象會獲得和str一樣的生命周期,所以不會在完整的表達式結束后銷毀,因此后續(xù)繼續(xù)使用str是安全的。
這個subobject還包括數(shù)組元素,所以const int &num = <temp-array>[index];
也會導致整個數(shù)組的生命周期被延長。
符合要求的形式還有很多,這里就不一一列舉了。
不過這個特例帶來了風險,因為完整表達式結束后我們訪問不到其他成員了,但它們都還實際存在,這會留下資源泄露的隱患?,F(xiàn)代的編程語言也基本都是這么做的,為了照顧大部分人的習慣倒也無可厚非,自己注意一下就行。
第二個特例是for-range循環(huán)。先看例子:
class Data {std::vector<int> data_;
public:Data(std::initializer_list<int> l): data_(l){}const std::vector<int> &get_data() const{return data_;}
};int main()
{for (const auto &v: Data{1, 2, 3, 4, 5}.get_data()) {std::cout << v << '\n';}
}
在c++23之前,這是錯的,實際上我們用msvc運行會看到什么也沒輸出,用GCC和sanitize則直接報錯了。GCC同時還會直接給出警告告訴你這里有懸垂引用。
問題倒是不難理解,for循環(huán)里冒號右側的表達式實際上是一個完整的表達式,并且在進入for循環(huán)之前就計算完了,所以臨時對象被銷毀,我們通過引用返回值間接傳遞出來的東西自然也就失效了。
然而這是語言設計上的bug。同樣作為初始化語句,for (int i=xxx, i < xx, ++i)
中的i的生命周期就是從初始化開始,到for循環(huán)結束才結束的,所以形式上類似的for-range沒有理由作為例外,否則很容易產(chǎn)生陷阱并限制使用上的便利性。
如果只是和普通for循環(huán)有差異那倒還好,問題是標準規(guī)定了for-range需要轉換成某些規(guī)定形式,這會導致下面的結果:
// 正常的沒有問題
for (const auto &v : std::vector{1,2,3,4,5}) {std::cout << v << '\n';
}
同樣都是初始化語句里的臨時變量,怎么一個有生命周期問題一個沒有?因為和標準規(guī)定的轉換形式有關,感興趣的可以去深究一下。但這是實打實的行為矛盾,就像一個人早上說自己是地球人但吃完午飯就改口說自己是大猩猩一樣荒謬。
這個bug也有一段時間了,直到前年才有提案來想辦法解決,不過好消息是已經(jīng)被接受進c++23了,現(xiàn)在for-range的初始化語句中產(chǎn)生的臨時對象的生命周期會延長到for-range循環(huán)結束,不管是什么形式的。
可惜到目前為止,我還沒看到有編譯器支持(GCC 14.1,clang 18.1.8),作為臨時解決辦法,你只能這么寫:
int main()
{const auto &tmp = Data{1, 2, 3, 4, 5};for (const auto &v: tmp.get_data()) {std::cout << v << '\n';}
}
如何發(fā)現(xiàn)生命周期問題
既然這些坑這么危險又這么隱蔽,那有辦法及時發(fā)現(xiàn)防患于未然嗎?
這還是比較難的,也是當今的熱門研究方向。
rust選擇了用類型系統(tǒng)+編譯檢測來扼殺生命周期問題,但效果不太理想,除了issue里那些bug之外,緩慢的編譯速度和無法簡單實現(xiàn)某些數(shù)據(jù)結構也是不小的問題。但整體來說還是比c++前進了很多步,上面列舉的三類問題一些是語法規(guī)則禁止的,另一些則能在編譯時檢測出來。
c++語法已經(jīng)成型也很難引進太大的變化,想及時發(fā)現(xiàn)問題,就得依賴這三樣了:
-
constexpr
-
sanitizer
-
靜態(tài)分析
constexpr里禁止任何形式的內(nèi)存泄露,也禁止越界訪問和使用已經(jīng)析構的數(shù)據(jù),但這些檢測只有在編譯期計算時才進行,而且不是什么東西都能放進constexpr的,所以雖然能發(fā)現(xiàn)生命周期問題,但限制太大。
sanitizer沒有constexpr那么多限制,而且檢測的種類更多也更仔細,但缺點是需要程序真正運行到有問題的代碼上才能上報,如果不想每次都運行整個程序你就得有一個質量上乘的單元測試集;sanitizer還會拖慢性能,以address檢測器為例,平均而言會導致性能下降1到2倍,盡管已經(jīng)比valgrind這樣的工具快多了,但有時候還是會因為太慢而帶來不便。
靜態(tài)分析不需要運行實際代碼,它會分析代碼的調用路徑和操作,然后根據(jù)一定的模式來找出看起來有問題的代碼。好處是不用實際運行,安裝配置簡單,編譯器一般還自帶了一個可以用;壞處是容易誤報,分析能力有時不如人類尤其是邏輯比較復雜時。
工具各有千秋,結合起來一起使用是比較常見的工程實踐。
個人的知識和經(jīng)驗也絕不能落下,因為從編碼這個源頭上就扼殺生命周期問題是目前最經(jīng)濟有效的辦法。
總結
常見的表達式中臨時變量導致的生命周期問題就是這些了。
modern c++其實一直在推行值語義,一定程度上可以緩解這些問題,但c++真的太復雜了,永遠沒有銀彈能解決所有問題。還是得自己慢慢積累知識和經(jīng)驗才行。
文章轉載自:apocelipes
原文鏈接:https://www.cnblogs.com/apocelipes/p/18291697
體驗地址:引邁 - JNPF快速開發(fā)平臺_低代碼開發(fā)平臺_零代碼開發(fā)平臺_流程設計器_表單引擎_工作流引擎_軟件架構