含临时对象引用的表达式模板能否复用?如何实现轻量可复用版本?
你提的问题正好戳中了表达式模板设计里最容易踩的坑——临时对象的生命周期管理,咱们一步步拆解你的疑问:
1. 能否复用使用临时对象const引用的表达式?
答案是绝对不能,你的理解完全正确:当你写auto expr = a + b + c;时,a+b会生成一个临时的VecSum<Vec, Vec>对象,然后这个临时对象再和c相加,生成外层的VecSum<VecSum<Vec, Vec>, Vec>(也就是expr的类型)。
外层VecSum存储的是对这个内层临时对象的const引用,但根据C++规则:绑定到const引用的临时对象,其生命周期只会延长到包含该绑定的完整表达式结束——也就是auto expr = ...;这条语句执行完毕后,那个内层的VecSum临时对象就会被销毁。
所以后续再用expr(比如Vec v2 = expr;)时,expr里的引用已经指向了一个被销毁的对象,属于悬垂引用,行为是未定义的(可能崩溃、输出垃圾值,或者看似正常但埋下隐患)。
2. 如何实现轻量、可复用的表达式模板?
要解决这个问题,核心是让表达式对象完全拥有自己的所有操作数,或者确保操作数的生命周期至少和表达式对象一致。下面是两种实用的方案:
方案一:值语义+移动优化(推荐,平衡性能与简单性)
把原代码中VecSum存储的const引用改成直接存储值,同时用C++的完美转发和移动语义来避免不必要的拷贝。这样每个表达式节点都会持有自己的操作数,生命周期和表达式对象一致,完全可以安全复用。
修改后的完整代码如下:
#include <cassert> #include <vector> #include <utility> // 用于std::forward和std::move template <typename E> class VecExpression { public: double operator[](size_t i) const { return static_cast<E const&>(*this)[i]; } size_t size() const { return static_cast<E const&>(*this).size(); } }; class Vec : public VecExpression<Vec> { std::vector<double> elems; public: double operator[](size_t i) const { return elems[i]; } double &operator[](size_t i) { return elems[i]; } size_t size() const { return elems.size(); } Vec(size_t n) : elems(n) {} Vec(std::initializer_list<double> init) : elems(init) {} template <typename E> Vec(VecExpression<E> const& expr) : elems(expr.size()) { for (size_t i = 0; i != expr.size(); ++i) { elems[i] = expr[i]; } } }; template <typename E1, typename E2> class VecSum : public VecExpression<VecSum<E1, E2> > { E1 _u; E2 _v; public: // 完美转发构造函数,支持左值拷贝、右值移动 template <typename U, typename V> VecSum(U&& u, V&& v) : _u(std::forward<U>(u)), _v(std::forward<V>(v)) { assert(_u.size() == _v.size()); } double operator[](size_t i) const { return _u[i] + _v[i]; } size_t size() const { return _v.size(); } }; // 重载operator+的四个版本,覆盖左右值组合 template <typename E1, typename E2> VecSum<E1, E2> operator+(VecExpression<E1> const& u, VecExpression<E2> const& v) { return VecSum<E1, E2>(*static_cast<const E1*>(&u), *static_cast<const E2*>(&v)); } template <typename E1, typename E2> VecSum<E1, E2> operator+(VecExpression<E1>&& u, VecExpression<E2> const& v) { return VecSum<E1, E2>(std::move(*static_cast<E1*>(&u)), *static_cast<const E2*>(&v)); } template <typename E1, typename E2> VecSum<E1, E2> operator+(VecExpression<E1> const& u, VecExpression<E2>&& v) { return VecSum<E1, E2>(*static_cast<const E1*>(&u), std::move(*static_cast<E2*>(&v))); } template <typename E1, typename E2> VecSum<E1, E2> operator+(VecExpression<E1>&& u, VecExpression<E2>&& v) { return VecSum<E1, E2>(std::move(*static_cast<E1*>(&u)), std::move(*static_cast<E2*>(&v))); } int main() { Vec v0 = {23.4,12.5,144.56,90.56}; Vec v1 = {67.12,34.8,90.34,89.30}; Vec v2 = {34.90,111.9,45.12,90.5}; auto expr = v0 + v1 + v2; Vec v_result1 = expr; // 完全合法 Vec v_result2 = expr; // 同样合法,无悬垂引用 }
这个方案的优势:
- 逻辑简单,容易维护;
- 对于临时表达式对象(比如
a+b的结果),会用移动语义转移所有权,几乎没有开销; - 对于左值
Vec对象,会进行一次拷贝,适合大多数场景。
方案二:共享所有权(适合大对象场景)
如果你的Vec对象非常大,拷贝开销不可接受,可以用std::shared_ptr来共享操作数的所有权,确保操作数的生命周期和表达式对象一致。
关键修改部分如下:
#include <memory> // 用于std::shared_ptr // VecExpression和Vec类保持不变,省略重复代码 template <typename E1, typename E2> class VecSum : public VecExpression<VecSum<E1, E2> > { std::shared_ptr<const E1> _u; std::shared_ptr<const E2> _v; public: // 左值构造:拷贝到shared_ptr VecSum(const E1& u, const E2& v) : _u(std::make_shared<const E1>(u)), _v(std::make_shared<const E2>(v)) { assert(u.size() == v.size()); } // 右值构造:移动到shared_ptr VecSum(E1&& u, E2&& v) : _u(std::make_shared<const E1>(std::move(u))), _v(std::make_shared<const E2>(std::move(v))) { assert(u.size() == v.size()); } // 混合左值/右值的构造 VecSum(const E1& u, E2&& v) : _u(std::make_shared<const E1>(u)), _v(std::make_shared<const E2>(std::move(v))) { assert(u.size() == v.size()); } VecSum(E1&& u, const E2& v) : _u(std::make_shared<const E1>(std::move(u))), _v(std::make_shared<const E2>(v)) { assert(u.size() == v.size()); } double operator[](size_t i) const { return (*_u)[i] + (*_v)[i]; } size_t size() const { return _v->size(); } }; // operator+的四个重载版本和方案一一致,省略重复代码
这个方案的优势:
- 对于大的左值
Vec对象,只需要拷贝一次到shared_ptr,后续所有表达式节点共享这个拷贝; - 临时对象依然用移动语义,开销极小;
- 表达式对象可以安全复用,直到所有引用它的表达式都被销毁。
唯一的小缺点是std::shared_ptr带来的轻微引用计数开销,但对于大对象来说,这个开销完全可以忽略。
内容的提问来源于stack exchange,提问作者joergbrech

