c++臨時物件導致的生命週期問題

apocelipes發表於2024-07-09

物件的生命週期是c++中非常重要的概念,它直接決定了你的程式是否正確以及是否存在安全問題。

今天要說的臨時變數導致的生命週期問題是非常常見的,很多時候沒有一定經驗甚至沒法識別出來。光是我自己寫、review、回答別人的問題就犯了或者看到了許許多多這類問題,所以我想有必要做個簡單的總結,自己備忘的同時也儘量幫其他開發者尤其是別的語言轉c++的人少踩些坑。

問題主要分為三類,每類我都會給出典型例子,最後會給出解決辦法。不過在深入討論每一類問題之前,先讓我們複習點必要的基礎知識。

基礎回顧

基礎回顧少不了,否則看c++的文章容易變成看天書。

但也別緊張,都叫“基礎”了那肯定是些簡單的偏常識的東西,不難的。

第一個基礎是語句和表示式。語句好理解,for(...){}是一個語句,int a = num + 1;也是一個語句,除了一些特殊的語法結構,語句通常以分號結尾。表示式是什麼呢,語句中除了關鍵字和符號之外的東西都可以算表示式,比如int a = num + 1中,num1num + 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物件的生命週期嗎,這裡理論上不應該是和返回值的生命週期一樣麼,這麼會變成懸垂引用?

答案是語法規定的例外,引用繫結延長的生命週期不能跨越作用域。這裡顯然100是在函式內的作用域,而返回的引用作用域在函式之外,跨越作用域了,所以這時繫結不能延長臨時int物件的生命週期,臨時物件在函式呼叫結束後銷燬,所以產生了懸垂引用。

另外繫結帶來的延長是不能傳遞的,只有直接繫結到臨時物件上才能延長生命,其他情況比如透過另一個引用進行的繫結都沒有效果。

複習到此為止,我們來看具體問題。

函式呼叫中的生命週期問題

先看例子:

const int &value = std::max(v, 100);

這是三類問題中最常見的一類,甚至常見到了各大文件包括cppreference上都專門開了個腳註告訴你這麼寫是錯的。

這個錯也很難察覺,我們一步步來。

首先是看std::max的函式簽名,當然因為實現程式碼也很簡單所以一塊看下簡化版:

template <typename T>
const T & max(const T &a, const T &b)
{
    return a>b ? a : b;
}

引數用const T &有道理,這樣左值右值都能收;返回值用引用也還算有道理,畢竟這裡複製一份引數語義和效能上都比較欠缺,因為我們要的是a和b中最大的那個,而不是最大值的副本。真正的問題是這麼做之後,max的返回值不能延長a或者b的生命週期,但a和b卻可以延長作為引數的臨時物件的生命週期,換句話說max只能延長臨時物件的生命週期到max函式執行結束。

現在還不知道問題在哪對吧,我們接著看std::max(v, 100)這個表示式。

其中v是沒問題的,但100是字面量,在這繫結到const int&時必須例項化出一個int的臨時物件。正是這個臨時物件上發生了問題。

有人會說這個臨時物件在max返回後失效了,但事實並非如此。

真相是,在一個完整的表示式裡產生的臨時物件,它的生命週期從被建立完成開始,一直到完整的表示式結束時才結束

也就是說100這個臨時物件在max返回後其實還存在,但max的返回值不能延長它的生命週期,value是透過引用進行間接繫結的所以也不能延長這個臨時物件的生命。最後完整的表示式結束,臨時物件100被消耗,現在value是懸垂引用了。

這就是典型的臨時物件導致的生命週期問題。

由於這個問題太常見,所以不僅是文件和教程有列舉,比較新的編譯器也會有警告,比如GCC13。

除此之外就只能靠sanitizer來檢測了。sanitizer是一種編譯器在正常的生成程式碼中插入一些特殊的監測點來實現對程式行為監控的技術,比較常見的應用是檢測有沒有不正常的記憶體讀寫或者是多執行緒有沒有資料競爭等問題。這裡我們對懸垂引用的使用正好是一種不正常的記憶體讀取,在檢測範圍內。

編譯使用這個指令就能啟用檢測:g++ -fsanitize=address xxx.cpp。遇到記憶體相關的問題它會立刻報錯並退出執行。

問題的本質在於max很容易產生臨時物件,但自己又完全沒法對這個臨時物件的生命週期產生影響,返回值不是引用可以一定程度上規避問題,然而作為通用的庫函式,這裡除了用引用又沒啥其他好辦法。所以這得算半個設計上的失誤。

不僅僅是max和min,所有引數是常量左值引用或者非轉發引用的右值引用,並且返回值的型別是引用且返回的是自己的某一個引數的函式都存在相同的問題。

想徹底解決問題有點難,但迴避這個問題倒是不難:

// 方案1
const int maxValue = 100;
const int &value = std::max(v, maxValue);

// 方案2
const int value = std::max(v, 100);

方案1不需要產生臨時物件,value始終能引用到表示式結束後依然存在的變數。

方案2是比較推薦的,尤其是對標量型別。由於臨時變數要在完整表示式結束後才銷燬,所以把它複製一份給value是完全沒問題的,賦值表示式也是完整表示式的一部分。這個方案的缺點在於複製成本較高或者無法複製的物件上不適用。但c++17把複製省略標準化了,這樣的表示式在大多數時候不會真的產生複製行為,所以我的建議是隻要業務和語義上允許,優先使用值語義也就是方案2,真出了問題並且定位到這裡了再考慮轉換成方案1。

鏈式呼叫中的生命週期問題

從其他語言轉c++的人相當容易踩這個坑。看個最經典的例子:

const char *str = path.trimmed().toStdString().c_str();

簡單說明下程式碼,path是一個QString的例項,trimmed方法會返回一個去除了首尾全部空格的新的QStringtoStdString()會複製底層資料然後轉換成一個std::string,c_str應該不用我多說了這個是把string內部資料轉換成一個const char*的方法。

這句表示式同樣有問題,問題在於表示式結束後str會成為懸垂指標。

一步步來分解問題。首先c_str保證返回的指標有效,前提是呼叫c_str的那個string物件有效。如果string物件的生命週期結束了,那麼c_str返回的指標也就無效了。

path.trimmed().toStdString()本身是沒問題的,每一步都是返回的新的值型別的物件例項,但是問題在於這些物件例項都是臨時物件,但我們沒有做任何措施來延長臨時物件的生命週期,整句表示式結束後它們就全析構生命週期終結了。

現在問題應該明瞭了,臨時物件上調了c_str,但這個臨時物件表示式結束後不存在了。所以str最後變成了懸垂指標。

為啥會坑到其他語言轉來的人呢?因為對於有gc的語言,上述表示式實際上又產生了新的到臨時物件的可達路徑,所以物件是不會回收的,而對於rust之類的語言還可以精細控制讓物件的每一部分具有不同的生命週期,上述表示式稍微改改是有機會正常使用的。這些語言轉到c++把老習慣帶過來就要被坑了。

推薦的解決辦法只有1種:

auto tmp = path.trimmed().toStdString();
const char *str = tmp.c_str();

能解決問題,但毛病也很明顯,需要多個用完就扔的變數出來,而且這個變數因為根據後續的操作要求很可能還不能用const修飾,這東西不僅干擾思維,有時候還會成為定時炸彈。

我不推薦直接用string而不用指標,是因為有時候不得不用const char*,這種時候啥方法都不好使,只能用上面的辦法去暫存臨時資料,以便讓它的生命週期能延長到後續操作結束為止。

三元運算子中的生命週期問題

三元運算子中也有類似的問題。我們看個例子:

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 ...

這個報錯提示我們使用了某個已經析構了的變數。而且新版本的編譯器還會很貼心得告訴你就是使用了pretty這個變數導致的。

不過雖然我們知道了具體是哪一行的那個變數導致的問題,但原因卻不知道,而且當我們的字串不為空的時候也不會觸發問題。

這個時候其實就是語法規則在作祟了。

c++裡規定三元運算子產生的結果最終只能有一種統一的型別。這個好理解,畢竟要賦值給某個固定型別的變數的表示式產生大於一種可能的結果型別既不合邏輯也很難正確編譯。

但這導致了一個問題,如果三元運算子兩邊的表示式確實有不同的結果型別怎麼辦?現代語言通常的做法是直接報錯,然而c++的做法是按照語法規則做型別轉換,實在轉換不來才會報錯。看起來c++的做法更寬鬆,這反過來誘發了這節所述的問題。

我們看看具體的轉換規則:

  1. 兩個表示式有一邊產生void值另一邊不是,那麼三元運算子結果的型別和另一個不是結果不是void的表示式的相同(產生void的表示式只能是throw表示式,否則算語法錯誤)
  2. 兩個表示式都產生void,則結果也是void,這裡不要求只能是throw表示式
  3. 兩個表示式結果型別相同,那麼三元運算子的結果型別和表示式相同
  4. 兩個表示式結果型別不同或者具有不同的cv限定符,那麼得看是否有其中一個型別能隱式轉換成另一個,如果沒有那麼是語法錯誤,如果兩方能互相轉換,也是語法錯誤。滿足這個限定條件,那麼另一個型別的表示式的結果會被隱式型別轉換成目標型別,比如當出現const char *std::string的時候,因為存在const char *隱式轉換成string的方法,所以最終三元運算子的結果型別是std::string;而Tconst T通常結果型別是const T

這還是我掐頭去尾簡化了好幾次的總結版,實際的規則更復雜,如果我把實際上的規則列在那難免被噴是語言律師,所以我就不自討沒趣了。但這個簡化版規則雖然粗糙,但實際開發倒是基本夠用了。

回到我們出問題的表示式,因為pretty初始化後就沒再修改過,那100%就是三元運算子那裡有什麼貓膩。恰巧的是我們正好對應在第四點上,表示式型別不同但可以進行隱式轉換。

按照規則,字串字面量"<empty>"要轉換成const std::string,正好存在這樣的隱式轉換序列(const char[8] -> const char * -> std::string, 隱式轉換序列怎麼得出的可以看這裡),當表示式為真也就是我們的字串是空的,一個臨時的string物件就被構造出來了。接著會從這個臨時的string構造一個string_view,string_view只是簡單地和原來的string共有內部資料,本身沒有str的所有權,而且string_view也不是“引用”,所以它不能延長臨時物件的生命週期。接著完整的表示式結束了,這時在表示式內建立的臨時物件如果沒有什麼能延長它生命的東西存在,就會被析構。顯然在這一步從"<empty>"轉換來的臨時string就析構了。

現在我們發現和pretty共有資料的string被銷燬了,後面繼續用pretty顯然是錯誤的。

從別的語言轉c++的開發者估計很容易踩到這種坑,短的字串字面量轉換成string在libstdc++還有特殊最佳化,在這個最佳化下你的程式就算犯了上述錯誤10次裡還是有七八次能正常執行,然後剩下兩三次得到錯誤或者崩潰;要是換了另一個不同的標準庫實現那就有更多的未知在等著你了。這也是string_view在標準中標明的幾個undefined behavior之一。所以這個錯誤經驗不足的話會非常隱蔽。

修復倒是不難,如果能變更pretty的型別(後續可以從pretty建立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也沒啥特別要說的,就是前幾節講的在臨時物件銷燬前複製了一份。對於標量型別這麼做一般沒問題,對於類型別就得考慮複製成本了,不過編譯器通常能做到copy elision,倒不用特別擔心。

方案3其實也比較容易理解,我們不是產生了臨時物件麼,那麼直接用常量左值引用去繫結,這樣臨時物件的生命週期就能被擴充套件延長了,而且const T &本來就能繫結到str這樣的左值上,所以語法上沒問題執行時也沒有問題。

特例

說完三個典型問題,還有兩個特例。

第一個是關於引用臨時物件的非static資料成員的。具體例子如下:

具體的例子如下:

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資料成員,也就是subobject,那麼不僅僅是資料成員,整個臨時物件的生命週期都會得到延長。所以這裡str雖然只繫結到了成員b,但整個臨時物件會獲得和str一樣的生命週期,所以不會在完整的表示式結束後銷燬,因此後續繼續使用str是安全的。

這個subobject還包括陣列元素,所以const int &num = <temp-array>[index];也會導致整個陣列的生命週期被延長。

符合要求的形式還有很多,這裡就不一一列舉了。

不過這個特例帶來了風險,因為完整表示式結束後我們訪問不到其他成員了,但它們都還實際存在,這會留下資源洩露的隱患。現代的程式語言也基本都是這麼做的,為了照顧大部分人的習慣倒也無可厚非,自己注意一下就行。

第二個特例是for-range迴圈。先看例子:

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迴圈裡冒號右側的表示式實際上是一個完整的表示式,並且在進入for迴圈之前就計算完了,所以臨時物件被銷燬,我們透過引用返回值間接傳遞出來的東西自然也就失效了。

然而這是語言設計上的bug。同樣作為初始化語句,for (int i=xxx, i < xx, ++i)中的i的生命週期就是從初始化開始,到for迴圈結束才結束的,所以形式上類似的for-range沒有理由作為例外,否則很容易產生陷阱並限制使用上的便利性。

如果只是和普通for迴圈有差異那倒還好,問題是標準規定了for-range需要轉換成某些規定形式,這會導致下面的結果:

// 正常的沒有問題
for (const auto &v : std::vector{1,2,3,4,5}) {
    std::cout << v << '\n';
}

同樣都是初始化語句裡的臨時變數,怎麼一個有生命週期問題一個沒有?因為和標準規定的轉換形式有關,感興趣的可以去深究一下。但這是實打實的行為矛盾,就像一個人早上說自己是地球人但吃完午飯就改口說自己是大猩猩一樣荒謬。

這個bug也有一段時間了,直到前年才有提案來想辦法解決,不過好訊息是已經被接受進c++23了,現在for-range的初始化語句中產生的臨時物件的生命週期會延長到for-range迴圈結束,不管是什麼形式的。

可惜到目前為止,我還沒看到有編譯器支援(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';
    }
}

如何發現生命週期問題

既然這些坑這麼危險又這麼隱蔽,那有辦法及時發現防患於未然嗎?

這還是比較難的,也是當今的熱門研究方向。

rust選擇了用型別系統+編譯檢測來扼殺生命週期問題,但效果不太理想,除了issue裡那些bug之外,緩慢的編譯速度和無法簡單實現某些資料結構也是不小的問題。但整體來說還是比c++前進了很多步,上面列舉的三類問題一些是語法規則禁止的,另一些則能在編譯時檢測出來。

c++語法已經成型也很難引進太大的變化,想及時發現問題,就得依賴這三樣了:

  • constexpr
  • sanitizer
  • 靜態分析

constexpr裡禁止任何形式的記憶體洩露,也禁止越界訪問和使用已經析構的資料,但這些檢測只有在編譯期計算時才進行,而且不是什麼東西都能放進constexpr的,所以雖然能發現生命週期問題,但限制太大。

sanitizer沒有constexpr那麼多限制,而且檢測的種類更多也更仔細,但缺點是需要程式真正執行到有問題的程式碼上才能上報,如果不想每次都執行整個程式你就得有一個質量上乘的單元測試集;sanitizer還會拖慢效能,以address檢測器為例,平均而言會導致效能下降1到2倍,儘管已經比valgrind這樣的工具快多了,但有時候還是會因為太慢而帶來不便。

靜態分析不需要執行實際程式碼,它會分析程式碼的呼叫路徑和操作,然後根據一定的模式來找出看起來有問題的程式碼。好處是不用實際執行,安裝配置簡單,編譯器一般還自帶了一個可以用;壞處是容易誤報,分析能力有時不如人類尤其是邏輯比較複雜時。

工具各有千秋,結合起來一起使用是比較常見的工程實踐。

個人的知識和經驗也絕不能落下,因為從編碼這個源頭上就扼殺生命週期問題是目前最經濟有效的辦法。

總結

常見的表示式中臨時變數導致的生命週期問題就是這些了。

modern c++其實一直在推行值語義,一定程度上可以緩解這些問題,但c++真的太複雜了,永遠沒有銀彈能解決所有問題。還是得自己慢慢積累知識和經驗才行。

參考資料

https://en.cppreference.com/w/cpp/language/operator_other

相關文章