【C/C++】自家特性都做不好?- GCC的?:运算问题
在之前的视频中讲过GNUC的一个扩展语法,“?:”表达式:https://www.bilibili.com/video/BV1Ze411K7xk
简单总结一下,就是GNUC允许“?:”运算省略第二个运算分量,其写法含义是在第一运算分量为真时,返回它自身的值,例如:
当然,这里返回的是x自身的“原值”,而不是作为bool变量的true或false(C里面是1和0),否则就没意义了
这种语法的初衷是在复杂表达式中避免重复求值,因为表达式“x”可能存在副作用,例如:
当然了,正常写这种代码时候可以将表达式的值存在临时变量中,不过那就需要增加语句,很多时候还是不可避免会出现复杂表达式的(尤其是上面举的这个宏的例子)
在视频中我也提到,C++中,如果我们采用返回自定义Error对象,且Error对象的布尔值表示是否有错误的话,则可以利用上面的代码简化开发,例如:
假设这个项目中,所有函数通过返回Err类的智能指针来指示错误,指针非空表示出错,那么显然,上面这句代码的含义是:依次调用xyz,如果中途某个返回错误,则将其错误返回,否则一直执行到结束,这比起挨个调用并if判断错误要简单多了,代码直观优雅
然而在实践中出现了问题,在最近的一个项目中发现,这种代码会导致程序偶现崩溃
看例子:
这段例程模拟了出问题的代码,乍一看,并有崩溃,但实际上已经是有问题的了,因为我打印了shared_ptr的内部信息,main函数中接收的err这个指针实际是非法的(use_count是个随机值),这也是崩溃不会必现的原因之一,有时候程序看似能正常运行,但实际上内存已经写乱了
假如在f函数中不用“?:”这个语法,而是老老实实一个个取返回值判断,则不会出现上面的问题
为进一步探查这个问题,就再简化一下测试case:
通过这个例子我们看到,流程的确出了问题,A这个对象只构造了一次,却有两次析构调用,同样的,如果f中不用“?:”这种语法,而是用普通的C++语法来实现,就不会有这个问题了,进一步做实验还可以得知,如果“?:”的第一运算分量不是临时量,则也不会出现问题,所以简单总结下,这个问题的详细描述是:
return语句中用“?:”表达式,第一分量是临时值,且布尔值为真
第二分量为空,此时预期返回第一分量的值
第一分量在表达式结束后析构了
return出去的临时值在调用者看来是已经构造的,所以调用者又调用了一次析构
事实上,return中就算返回的不是“?:”的结果,而仅仅是包含了“?:”,也会出问题,例如将上面f的那句改成:
那么整个程序会对A构造两次,析构三次,还是有问题,这就有点诡异了,因为这里逗号表达式的值只是最后一个子表达式(“A()”)的值,前面的应该求值扔掉,但是这里处理依然有问题,同样的,如果不用“?:”这个特殊语法,问题就不会出现
现象讲清楚了,先说结论,我认为是gcc在这个GNUC扩展语法的处理存在问题
一般来说,GNUC不可能显式规定这里就应该多析构一次(这不合逻辑),那么有没有可能是未定义行为或未指定行为呢?这好像还真有可能,因为GNUC对于“?:”这个特殊语法的描述,是在其C扩展语法的文档中:https://gcc.gnu.org/onlinedocs/gcc-13.1.0/gcc/Conditionals.html
至于这个语法能不能用在C++中,按文档惯例似乎又是可以的(GNUC的文档中,C扩展如果不能应用于C++一般会特殊说明,例如C风格的闭包定义),但毕竟人家没明说
但是,即便是这样,我依然认为gcc这里的处理存在问题,因为就这个扩展语法而言,可以很自然地扩展到C++,毕竟C++和C一样,也是个基于值类型的语言,只不过其值传递过程涉及了对象的拷贝和移动构造罢了,原则没有变。虽然我之前讲过C++有很多难以理解的UB,但细究起来,那些东西都是在代码优化这一个大原则下,而上面说的这个问题,如果作为UB,又看不出其在优化领域能有多大的作用
另一个理由是,GNUC作为一套标准,并非是gcc这个编译器集合独占的,其他一些编译器也实现或部分支持了GNUC扩展语法,我们不妨看看clang是怎么处理上面的两个例子:
可以看到,对于简化后的例子,clang的处理就是正确的,“A()?:A()”这个表达式中,在第一分量会被移动构造为一个新对象返回,然后析构,返回的对象在main中就可以安全析构了,而在“shared_ptr<Err>”的例子中,clang的这种处理自然也保证了最终err指针的合法(use_count为1,符合预期),因而不会出现gcc编译结果的崩溃情况。clang在这个语法上符合我们在C++中对其的作用推论和预期
尽管GNUC扩展语法是GNU标准的,而gcc是其“正统”实现,但并不代表它没有问题。在使用一些冷门语法尤其是扩展语法的时候,还是要小心些