Fork me on GitHub

c++临时对象导致的生命周期问题

对象的生命周期是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

posted @ 2024-07-09 14:02  apocelipes  阅读(1063)  评论(16编辑  收藏  举报