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

打印 上一主题 下一主题

主题 898|帖子 898|积分 2694

对象的生命周期是c++中非常紧张的概念,它直接决定了你的程序是否正确以及是否存在安全题目。
本日要说的临时变量导致的生命周期题目黑白常常见的,很多时候没有一定经验以致没法辨认出来。光是我自己写、review、回答别人的题目就犯了或者看到了许很多多这类题目,所以我想有须要做个简单的总结,自己备忘的同时也只管帮其他开发者尤其是别的语言转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 &或者右值引用上。绑定后临时对象的生命周期会一直延长到绑定的引用的生命周期结束的时候。但延长有一个例外:
  1. const int &func()
  2. {
  3.     return 100;
  4. }
复制代码
这个各人都知道是悬垂引用,但const T &不是能延长100这个临时int对象的生命周期吗,这里理论上不应该是和返回值的生命周期一样么,这么会变成悬垂引用?
答案是语法规定的例外,引用绑定延长的生命周期不能跨越作用域。这里显然100是在函数内的作用域,而返回的引用作用域在函数之外,跨越作用域了,所以这时绑定不能延长临时int对象的生命周期,临时对象在函数调用结束后销毁,所以产生了悬垂引用。
另外绑定带来的延长是不能传递的,只有直接绑定到临时对象上才能延长生命,其他环境比如通过另一个引用举行的绑定都没有效果。
复习到此为止,我们来看详细题目。
函数调用中的生命周期题目

先看例子:
  1. const int &value = std::max(v, 100);
复制代码
这是三类题目中最常见的一类,以致常见到了各大文档包括cppreference上都专门开了个脚注告诉你这么写是错的。
这个错也很难察觉,我们一步步来。
首先是看std::max的函数签名,当然因为实现代码也很简单所以一块看下简化版:
  1. template <typename T>
  2. const T & max(const T &a, const T &b)
  3. {
  4.     return a>b ? a : b;
  5. }
复制代码
参数用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. // 方案1
  2. const int maxValue = 100;
  3. const int &value = std::max(v, maxValue);
  4. // 方案2
  5. const int value = std::max(v, 100);
复制代码
方案1不须要产生临时对象,value始终能引用到表达式结束后依然存在的变量。
方案2是比较推荐的,尤其是对标量类型。由于临时变量要在完备表达式结束后才销毁,所以把它复制一份给value是完全没题目的,赋值表达式也是完备表达式的一部分。这个方案的缺点在于复制成本较高或者无法复制的对象上不实用。但c++17把复制省略标准化了,如许的表达式在大多数时候不会真的产生复制行为,所以我的建议是只要业务和语义上答应,优先利用值语义也就是方案2,真出了题目并且定位到这里了再考虑转换成方案1。
链式调用中的生命周期题目

从其他语言转c++的人相当容易踩这个坑。看个最经典的例子:
  1. const char *str = path.trimmed().toStdString().c_str();
复制代码
简单说明下代码,path是一个QString的实例,trimmed方法会返回一个去除了首尾全部空格的新的QString,toStdString()会复制底层数据然后转换成一个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种:
  1. auto tmp = path.trimmed().toStdString();
  2. const char *str = tmp.c_str();
复制代码
能解决题目,但毛病也很明显,须要多个用完就扔的变量出来,而且这个变量因为根据后续的操纵要求很可能还不能用const修饰,这东西不仅干扰思维,有时候还会成为定时炸弹。
我不推荐直接用string而不用指针,是因为有时候不得不用const char*,这种时候啥方法都不好使,只能用上面的办法去暂存临时数据,以便让它的生命周期能延长到后续操纵结束为止。
三元运算符中的生命周期题目

三元运算符中也有类似的题目。我们看个例子:
  1. const std::string str = func();
  2. 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++的做法更宽松,这反过来诱发了这节所述的题目。
我们看看详细的转换规则:

  • 两个表达式有一边产生void值另一边不是,那么三元运算符效果的类型和另一个不是效果不是void的表达式的相同(产生void的表达式只能是throw表达式,否则算语法错误)
  • 两个表达式都产生void,则效果也是void,这里不要求只能是throw表达式
  • 两个表达式效果类型相同,那么三元运算符的效果类型和表达式相同
  • 两个表达式效果类型不同或者具有不同的cv限定符,那么得看是否有其中一个类型能隐式转换成另一个,如果没有那么是语法错误,如果两方能互相转换,也是语法错误。满足这个限定条件,那么另一个类型的表达式的效果会被隐式类型转换成目标类型,比如当出现const char *和std::string的时候,因为存在const char *隐式转换成string的方法,所以终极三元运算符的效果类型是std::string;而T和const T通常效果类型是const T。
这照旧我掐头去尾简化了好几次的总结版,现实的规则更复杂,如果我把现实上的规则列在那难免被喷是语言状师,所以我就不自讨没趣了。但这个简化版规则固然粗糙,但现实开发倒是基本够用了。
回到我们出题目的表达式,因为pretty初始化后就没再修改过,那100%就是三元运算符那边有什么猫腻。恰巧的是我们正好对应在第四点上,表达式类型不同但可以举行隐式转换。
按照规则,字符串字面量""要转换成const std::string,正好存在如许的隐式转换序列(const char[8] -> const char * -> std::string, 隐式转换序列怎么得出的可以看这里),当表达式为真也就是我们的字符串是空的,一个临时的string对象就被构造出来了。接着会从这个临时的string构造一个string_view,string_view只是简单地和原来的string共有内部数据,本身没有str的所有权,而且string_view也不是“引用”,所以它不能延长临时对象的生命周期。接着完备的表达式结束了,这时在表达式内创建的临时对象如果没有什么能延长它生命的东西存在,就会被析构。显然在这一步从""转换来的临时string就析构了。
现在我们发现和pretty共有数据的string被销毁了,后面继承用pretty显然是错误的。
从别的语言转c++的开发者估计很容易踩到这种坑,短的字符串字面量转换成string在libstdc++另有特殊优化,在这个优化下你的程序就算犯了上述错误10次里照旧有七八次能正常运行,然后剩下两三次得到错误或者崩溃;要是换了另一个不同的标准库实现那就有更多的未知在等着你了。这也是string_view在标准中标明的几个undefined behavior之一。所以这个错误经验不足的话会非常潜伏。
修复倒是不难,如果能变更pretty的类型(后续可以从pretty创建string_view),那有下面几种方案可选:
  1. // 方案1
  2. std::string_view pretty = str;
  3. if (str.empty()) {
  4.     pretty = "<empty>";
  5. }
  6. // 方案2
  7. const std::string pretty = str.empty() ? "<empty>" : str;
  8. // 方案3
  9. const std::string &pretty = str.empty() ? "<empty>" : str;
复制代码
方案1里不再有类型转换和临时对象了,字符串字面量的生命周期从程序运行开始到程序退出结束,没有生命周期题目。但这个方案会显得比较啰嗦而且在字符串为空的时候得多一次赋值。
方案2也没啥特别要说的,就是前几节讲的在临时对象销毁前复制了一份。对于标量类型这么做一般没题目,对于类类型就得考虑复制成本了,不过编译器通常能做到copy elision,倒不用特别担心。
方案3其实也比较容易明白,我们不是产生了临时对象么,那么直接用常量左值引用去绑定,如许临时对象的生命周期就能被扩展延长了,而且const T &本来就能绑定到str如许的左值上,所以语法上没题目运行时也没有题目。
特例

说完三个典型题目,另有两个特例。
第一个是关于引用临时对象的非static数据成员的。详细例子如下:
详细的例子如下:
[code]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
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

饭宝

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表