auto 也有不好用的时候。

自从开始用上 C++11,就喜欢上了 auto 关键字。类型名字太长?用 auto!类型不知道?用 auto!嗯?只是有点犯懒?用 auto!作为 "placeholder type specifier", auto 似乎是高手的利器,懒人的福音。

spirit 引起的 segfault

但是笔者前两天碰到一个 segfault,而且不是总能重现。最后发觉它是滥用 auto 的结果。比如说,下面的的代码片段用来匹配 IEC 的前缀

struct iec_prefix_t {
  std::string_view prefix;
  unsigned order;
}
static constexpr iec_prefix_t iec_prefixes[] = {
  {"k", 10},
  {"m", 20},
  {"g", 30},
  {"t", 40},
};
// ...
qi::symbols<char, unsigned> prefix;
for (auto [prefix, order] : iec_prefixes) {
  prefix.add(prefix, order);
}
auto postfix = spirit::ascii::no_case[prefix] >> -(qi::string("iB") | qi::string("B"));
uint64_t n = 0;
unsigned order = 0;
if (qi::parse(s.begin(), s.end(), qi::uint_ >> -postfix, n, power)) {
  return n << order;
} else {
  throw std::invalid_argument("hmmm");
}

一切看起来岁月静好。但是却发现有时候 qi::parse() 有时候会出现 segfault。表达式这么可爱,能出什么错呢?stackoverflow 上有个很对口的 问题,摘录回答如下:

It’s a bug in your code, nothing wrong with the compiler or the optimization levels.

The cinch is with expression templates (like the ones used by Boost Proto, and hence by Boost Spirit). They are only valid to the end of their enclosing full expression[1]

The canonical workaound is:

 BOOST_SPIRIT_AUTO(ana, *~qi::char_('*') > +qi::char_('*'));

Spirit X3 promises to remove this wart. Slightly related, I think Protox11 also removes this issue by being aware of references at all times.


[1] Grep the standard for lifetime extension of temporaries. The expression templates keep references to the literals used (the rest has value semantics anyways), but the temporaries aren’t bound to (const) references. So they go out of scope. Undefined Behaviour results

看来是 postfix 指向的对象含有一些引用,被引用的对象的生命周期没能坚持很久,它们到 qi::parse() 的时候已经香消玉损了。这里涉及两组熟悉又陌生的概念:

expression template

先看看表达式模板(expression template)是什么。它是 C++ 魔法师们的创造,不属于 C++ 标准的范畴,见 wikipedia 上的条目。总结下来,表达式模板有这么几个特点:

  • 往往使用嵌套模板的方式组织成一个树。

  • 表达式通过 const 引用保存子表达式。为了避免复制产生的开销,更不消说有的类型不支持复制,仅仅保存引用。

  • 惰性求值。只有表达式参与真正的求值的时候,才会开始计算。

因此,

auto postfix = spirit::ascii::no_case[prefix] >> -(qi::string("iB") | qi::string("B"));

并不是普通的值语义的标量对象,它是一个嵌套的表达式模板实例。如下所示:

Diagram

每个操作符分别都产生了新的表达式,而这些表达式都通过 const 引用持有保存其子表达式的引用,从里到外的每个表达式都是临时对象。即使我们通过 postfix 保存了最外面的表达式,即图中的绿色方块。但是里面的所有其他表达式都在 auto postfix 这个语句中等号右侧的表达式求值完毕之后就析构了。更不用说 qi::string("iB") 它们了。难怪,在 qi::parse() 使用 postfix 的时候会碰到 segfault。

临时对象和引用

那我们看看 C++ 标准(草案)的原文怎么说

The lifetime of a reference begins when its initialization is complete. The lifetime of a reference ends as if it were a scalar object requiring storage.

— ISO/IEC JTC1 SC22 WG21 N 4860

关键是后面一句。简单说,就是引用还在,因为它只是块儿内存,只要那块内存还没有重写,引用就活着。不过…… 引用毕竟是引用,它和值是两码事。因此,会不会代码犯了和下面程序类似的错误?

#include <iostream>
#include <string>

using namespace std;

string& hello() {
  string s("hello");
  return s;
}

int main() {
  auto s = hello();
  cout << s << endl;
}

GCC 碰到这种明显的错误会看不下去,

test.cc: In function ‘std::string& hello()’:
test.cc:9:10: warning: reference to local variable ‘s’ returned [-Wreturn-local-addr]
    9 |   return s;
      |          ^
test.cc:8:10: note: declared here
    8 |   string s("hello");
      |          ^

当然,有的情况下,引用可以 帮助临时对象续命。但是如果不属于上面的情况,要是被引用的对象析构了,那么就算引用还是有效的,我们一样会碰到我们的老朋友—— undefined behavior。这也是这个问题在不同环境下可能没法重现的原因。因为对象即使析构,它的内存在被重写之前,数据还是保存着它生前的样子。而内存重用是我们通常没法直接控制的。

所以问题的原委已经明白了。上图中绿色方块的 lhsrhs 作为引用,在对 postfix 赋值之后仍然是有效的,但是它们指向的对象就销毁了。为了能够把整个表达式树完整地保存下来,我们必须进行一次 deep copy。Spirit 的维护者 实现的 BOOST_SPIRIT_AUTO 解决的就是这个问题。也许根据 最新的例子,我们最好用 boost::spirit::qi::copy()