从 C++ 表达式中保存临时变量生命的最佳方法
Best way to preserve the life... of temporaries from a C++ expression
考虑一个带有重载右结合 C++ 运算符的二元运算 X
:
a+=b+=c
--> Y{a, X{b,c}}
可以从某种语法树(X 和 Y 对象的组合)中的表达式中“冻结”有关操作数的所有信息,并在以后访问它。 (这不是问题)
struct X{Operand& l; Operand& r; /*...*/};
struct Y{Operand& l; X r; /*...*/};
Operand a, b, c;
auto x = Y{a, X{b,c}};
//access members of x...
如果我将 Y::r
存储为值(如上),则将涉及复制或至少移动。
如果我将 Y::r
存储为右值引用(例如 X&& r;
),那么它将引用一个临时变量,该临时变量将在表达式结束时被销毁,给我留下一个悬空引用。
为了在多个地方多次使用已经构建的表达式,捕获它或防止这种自动破坏的最佳方法是什么?
- 我所说的捕捉是指以某种方式延长它的生命,而不是手动将它分配给局部变量(有效但不能很好地扩展!想想这个案例:
a+=b+=…+=z
)
- 我知道移动比复制便宜...但是什么都不做更好(对象在那里,已经构建)
- 您可以将表达式作为右值引用参数传递给函数或 lambda,并在其中访问其成员 function/lambda...但您不能(在外部)重用它!你必须每次都重新创建它(有人称这种方法为“厄运的 Lambda”,也许还有其他缺点)
这是一个测试程序(直播于https://godbolt.org/z/7f78T4zn9):
#include <assert.h>
#include <cstdio>
#include <utility>
#ifndef __FUNCSIG__
# define __FUNCSIG__ __PRETTY_FUNCTION__
#endif
template<typename L,typename R> struct X{
L& l; R& r;
X(L& l, R& r): l{l}, r{r} {printf("X{this=%p &l=%p &r=%p} %s\n", this, &this->l, &this->r, __FUNCSIG__);};
~X(){printf("X{this=%p} %s\n", this, __FUNCSIG__);};
X(const X& other) noexcept = delete;
X(X&& other) noexcept = delete;
X& operator=(const X&) noexcept = delete;
X& operator=(X&&) noexcept = delete;
};
template<typename L,typename R> struct Y{
L& l; R&& r;
Y(L& l, R&& r): l{l}, r{std::forward<R>(r)} {
printf("Y{this=%p &l=%p r=%p} %s\n", this, &this->l, &this->r, __FUNCSIG__);
assert(&this->r == &r);
};
~Y(){printf("Y{this=%p} %s\n", this, __FUNCSIG__);};
void func(){printf("Y{this=%p} &r=%p ... ALREADY DELETED! %s\n", this, &r, __FUNCSIG__);};
};
struct Operand{
Operand(){printf("Operand{this=%p} %s\n", this, __FUNCSIG__);}
~Operand(){printf("Operand{this=%p} %s\n", this, __FUNCSIG__);}
};
//================================================================
int main(){
Operand a, b, c;
printf("---- 1 expression with temporaries\n");
auto y = Y{a, X{b,c}};//this will come from an overloaded right-associative C++ operator, like: a+=b+=c
printf("---- 2 immediately after expression... but already too late!\n");//at this point the temporary X obj is already deleted
y.func();//access members...
printf("---- 3\n");
return 0;
}
这是一个输出示例,您可以在其中看到 X 临时对象的地址进入 Y::r ... 并在有机会捕获它之前立即销毁:
---- 1 expression with temporaries
X{this=0x7ffea39e5860 &l=0x7ffea39e584e &r=0x7ffea39e584f} X::X(Operand&, Operand&)
Y{this=0x7ffea39e5850 &l=0x7ffea39e584d r=0x7ffea39e5860} Y::Y(Operand&, X&&)
X{this=0x7ffea39e5860} X::~X()
---- 2 immediately after expression... but already too late!
没有办法按照你希望的方式延长临时工的寿命。
有几种方法可以延长临时寿命。他们中的大多数都没有帮助。例如,在构造函数期间用于成员初始化的临时对象一直持续到构造函数结束。这在这种表达式树的一个“层”中可能很有用,但对两个“层”没有帮助。
延长临时生命的一种有趣方式是成为参考对象。
{
const std::string& x = std::string("Hello") + " World";
foo();
std::cout << x << std::endl; // Yep! Still "Hello World!"
}
这将持续到 x
超出范围。但它不会做任何事情来延长其他临时工的生命。 "Hello"
仍然会在该行的末尾被销毁,即使 "Hello world"
继续存在。为了您的特定目标,您还需要 "Hello"
。
在这一点上,你能看出我以前被这个问题困扰过吗?
我发现有两种方法是一致的。
- 通过复制和移动来管理您的树,以便最终的模板化表达式真正包含对象(这是您不想要的答案。抱歉)
- 通过巧妙的引用管理您的树以避免复制。然后通过删除构造函数来分配一个局部变量来保存它是不可能的。然后仅在表达式中使用操作数(这是您不想要的另一个答案,因为它会导致您提到的 lambda 技巧)。
- 并且有人知道您可以将值分配给新的本地引用(因为非根临时节点消失),它被完全破坏了。然而,也许这是可以接受的。那些人知道他们是谁,他们应该因为试图变得特别而受到所有的麻烦(并不是说我是其中之一......)
这两种方法我自己都做过。我制作了一个带有临时变量的 JSON 引擎,当用半个像样的 g++ 或 Visual Studio 编译时,它实际上编译到创建我的数据结构所需的堆栈中的预编译存储的最小数量。这是光荣的(而且 几乎 没有错误...)。我构建了无聊的“只复制数据”结构。
我发现了什么?根据我的经验,这种恶作剧得到回报的角落很小你需要:
- 一种超高性能的情况,其中这些构造函数和析构函数的成本并不微不足道。
- 表达式树结构首先是有原因的,比如 DAG 转换,这不能用简单的 lambda 来完成
- 调用这个库的代码需要看起来非常干净,以至于你不能为叶节点分配你自己的局部变量(因此回避了唯一一个你不能在最后复制东西的真正不可动摇的情况瞬间)。
- 您不能依赖优化器来优化您的代码。
通常这三种情况之一给出。特别是,我注意到 STL 和 Boost 都倾向于采用复制所有方法。 STL 函数默认复制,并提供 std::ref
以供您在 return 中陷入粗略的情况以提高性能。 Boost 有很多这样的表达式树。据我所知,它们都依赖于复制所有。我知道 Boost.Phoenix 可以(Phoenix 基本上是您原始示例的完整版本),Boost.Spirit 也可以。
这两个示例都遵循我认为您必须遵循的模式:根节点“拥有”其后代,或者在编译时使用巧妙的模板,这些模板将操作数作为成员变量(而不是对所述操作数的引用, a. la. Phoenix),或在 运行 时间(使用指针和堆分配)。
另外,考虑到您的代码变得严格依赖于完美符合规范的 C++ 编译器。我不认为那些真的存在,尽管比我更好的编译器开发人员尽了最大的努力。你生活在一个小角落里,“但它符合规范”可以被驳斥为“但我不能编译它”任何现代编译器!"
我喜欢创意。并且,如果您知道如何做自己想做的事情,请大声评论我的回答,以便我学习您的聪明才智。但是从我自己努力挖掘 C++ 规范以找到完全符合您要求的方法,我很确定它不存在。
考虑一个带有重载右结合 C++ 运算符的二元运算 X
:
a+=b+=c
--> Y{a, X{b,c}}
可以从某种语法树(X 和 Y 对象的组合)中的表达式中“冻结”有关操作数的所有信息,并在以后访问它。 (这不是问题)
struct X{Operand& l; Operand& r; /*...*/};
struct Y{Operand& l; X r; /*...*/};
Operand a, b, c;
auto x = Y{a, X{b,c}};
//access members of x...
如果我将 Y::r
存储为值(如上),则将涉及复制或至少移动。
如果我将 Y::r
存储为右值引用(例如 X&& r;
),那么它将引用一个临时变量,该临时变量将在表达式结束时被销毁,给我留下一个悬空引用。
为了在多个地方多次使用已经构建的表达式,捕获它或防止这种自动破坏的最佳方法是什么?
- 我所说的捕捉是指以某种方式延长它的生命,而不是手动将它分配给局部变量(有效但不能很好地扩展!想想这个案例:
a+=b+=…+=z
) - 我知道移动比复制便宜...但是什么都不做更好(对象在那里,已经构建)
- 您可以将表达式作为右值引用参数传递给函数或 lambda,并在其中访问其成员 function/lambda...但您不能(在外部)重用它!你必须每次都重新创建它(有人称这种方法为“厄运的 Lambda”,也许还有其他缺点)
这是一个测试程序(直播于https://godbolt.org/z/7f78T4zn9):
#include <assert.h>
#include <cstdio>
#include <utility>
#ifndef __FUNCSIG__
# define __FUNCSIG__ __PRETTY_FUNCTION__
#endif
template<typename L,typename R> struct X{
L& l; R& r;
X(L& l, R& r): l{l}, r{r} {printf("X{this=%p &l=%p &r=%p} %s\n", this, &this->l, &this->r, __FUNCSIG__);};
~X(){printf("X{this=%p} %s\n", this, __FUNCSIG__);};
X(const X& other) noexcept = delete;
X(X&& other) noexcept = delete;
X& operator=(const X&) noexcept = delete;
X& operator=(X&&) noexcept = delete;
};
template<typename L,typename R> struct Y{
L& l; R&& r;
Y(L& l, R&& r): l{l}, r{std::forward<R>(r)} {
printf("Y{this=%p &l=%p r=%p} %s\n", this, &this->l, &this->r, __FUNCSIG__);
assert(&this->r == &r);
};
~Y(){printf("Y{this=%p} %s\n", this, __FUNCSIG__);};
void func(){printf("Y{this=%p} &r=%p ... ALREADY DELETED! %s\n", this, &r, __FUNCSIG__);};
};
struct Operand{
Operand(){printf("Operand{this=%p} %s\n", this, __FUNCSIG__);}
~Operand(){printf("Operand{this=%p} %s\n", this, __FUNCSIG__);}
};
//================================================================
int main(){
Operand a, b, c;
printf("---- 1 expression with temporaries\n");
auto y = Y{a, X{b,c}};//this will come from an overloaded right-associative C++ operator, like: a+=b+=c
printf("---- 2 immediately after expression... but already too late!\n");//at this point the temporary X obj is already deleted
y.func();//access members...
printf("---- 3\n");
return 0;
}
这是一个输出示例,您可以在其中看到 X 临时对象的地址进入 Y::r ... 并在有机会捕获它之前立即销毁:
---- 1 expression with temporaries
X{this=0x7ffea39e5860 &l=0x7ffea39e584e &r=0x7ffea39e584f} X::X(Operand&, Operand&)
Y{this=0x7ffea39e5850 &l=0x7ffea39e584d r=0x7ffea39e5860} Y::Y(Operand&, X&&)
X{this=0x7ffea39e5860} X::~X()
---- 2 immediately after expression... but already too late!
没有办法按照你希望的方式延长临时工的寿命。
有几种方法可以延长临时寿命。他们中的大多数都没有帮助。例如,在构造函数期间用于成员初始化的临时对象一直持续到构造函数结束。这在这种表达式树的一个“层”中可能很有用,但对两个“层”没有帮助。
延长临时生命的一种有趣方式是成为参考对象。
{
const std::string& x = std::string("Hello") + " World";
foo();
std::cout << x << std::endl; // Yep! Still "Hello World!"
}
这将持续到 x
超出范围。但它不会做任何事情来延长其他临时工的生命。 "Hello"
仍然会在该行的末尾被销毁,即使 "Hello world"
继续存在。为了您的特定目标,您还需要 "Hello"
。
在这一点上,你能看出我以前被这个问题困扰过吗?
我发现有两种方法是一致的。
- 通过复制和移动来管理您的树,以便最终的模板化表达式真正包含对象(这是您不想要的答案。抱歉)
- 通过巧妙的引用管理您的树以避免复制。然后通过删除构造函数来分配一个局部变量来保存它是不可能的。然后仅在表达式中使用操作数(这是您不想要的另一个答案,因为它会导致您提到的 lambda 技巧)。
- 并且有人知道您可以将值分配给新的本地引用(因为非根临时节点消失),它被完全破坏了。然而,也许这是可以接受的。那些人知道他们是谁,他们应该因为试图变得特别而受到所有的麻烦(并不是说我是其中之一......)
这两种方法我自己都做过。我制作了一个带有临时变量的 JSON 引擎,当用半个像样的 g++ 或 Visual Studio 编译时,它实际上编译到创建我的数据结构所需的堆栈中的预编译存储的最小数量。这是光荣的(而且 几乎 没有错误...)。我构建了无聊的“只复制数据”结构。
我发现了什么?根据我的经验,这种恶作剧得到回报的角落很小你需要:
- 一种超高性能的情况,其中这些构造函数和析构函数的成本并不微不足道。
- 表达式树结构首先是有原因的,比如 DAG 转换,这不能用简单的 lambda 来完成
- 调用这个库的代码需要看起来非常干净,以至于你不能为叶节点分配你自己的局部变量(因此回避了唯一一个你不能在最后复制东西的真正不可动摇的情况瞬间)。
- 您不能依赖优化器来优化您的代码。
通常这三种情况之一给出。特别是,我注意到 STL 和 Boost 都倾向于采用复制所有方法。 STL 函数默认复制,并提供 std::ref
以供您在 return 中陷入粗略的情况以提高性能。 Boost 有很多这样的表达式树。据我所知,它们都依赖于复制所有。我知道 Boost.Phoenix 可以(Phoenix 基本上是您原始示例的完整版本),Boost.Spirit 也可以。
这两个示例都遵循我认为您必须遵循的模式:根节点“拥有”其后代,或者在编译时使用巧妙的模板,这些模板将操作数作为成员变量(而不是对所述操作数的引用, a. la. Phoenix),或在 运行 时间(使用指针和堆分配)。
另外,考虑到您的代码变得严格依赖于完美符合规范的 C++ 编译器。我不认为那些真的存在,尽管比我更好的编译器开发人员尽了最大的努力。你生活在一个小角落里,“但它符合规范”可以被驳斥为“但我不能编译它”任何现代编译器!"
我喜欢创意。并且,如果您知道如何做自己想做的事情,请大声评论我的回答,以便我学习您的聪明才智。但是从我自己努力挖掘 C++ 规范以找到完全符合您要求的方法,我很确定它不存在。