为什么需要移动语义来删除临时副本?
因此,我对移动语义的理解是,它们允许您覆盖函数以使用临时值(rvalues),并避免潜在的昂贵副本(通过将状态从未命名的临时移动到您的已命名的左值)。
我的问题是为什么我们需要特殊的语义? 为什么C ++ 98编译器不能使用这些副本,因为编译器决定给定的表达式是左值还是右值? 举个例子:
void func(const std::string& s) {
// Do something with s
}
int main() {
func(std::string("abc") + std::string("def"));
}
即使没有C ++ 11的移动语义,编译器仍然应该能够确定传递给func()
的表达式是右值,因此不需要临时对象的副本。 那为什么要区分呢? 看起来这种移动语义的应用本质上是复制elision或其他类似编译器优化的变体。
作为另一个例子,为什么还要像下面那样编码?
void func(const std::string& s) {
// Do something with lvalue string
}
void func(std::string&& s) {
// Do something with rvalue string
}
int main() {
std::string s("abc");
// Presumably calls func(const std::string&) overload
func(s);
// Presumably calls func(std::string&&) overload
func(std::string("abc") + std::string("def"));
}
看起来像const std::string&
overload可以处理这两种情况:通常是左值,而将右值作为常量引用(因为临时表达式根据定义是常量)。 由于编译器知道表达式是左值还是右值,它可以决定是否在右值情况下隐藏副本。
基本上,为什么移动语义被认为是特殊的,而不仅仅是一个可以通过pre-C ++ 11编译器执行的编译器优化?
移动功能完全不会影响临时副本。
存在相同数量的临时对象,只是不是通常调用复制构造函数,而是调用移动构造函数,它允许调用原始文件而不是独立复制。 有时这可能会大大提高效率。
C ++形式化对象模型完全不受移动语义的修改。 对象仍然有一个明确的生命周期,从某个特定的地址开始,到它们在那里被销毁时结束。 他们在一生中从不“移动”。 当它们“从中移出”时,真正发生的事情就是从即将死亡的对象中挖出胆量,并将其有效地置于一个新对象中。 它可能看起来像是在移动,但是从形式上来说,它们并不是真的,因为那样会完全破坏C ++。
被移出并不是死亡。 移动需要将对象保留在它们仍处于活动状态的“有效状态”中,而且将始终在后面调用析构函数。
删除拷贝是完全不同的事情,在某些临时对象链中,一些中间体被跳过。 编译器不需要在C ++ 11和C ++ 14中删除拷贝,即使它违反了通常指导优化的“假设”规则,也可以这样做。 即使副本可能有副作用,编译器在高优化设置下仍可能会跳过一些临时对象。
相比之下,“保证副本删除”是一个新的C ++ 17功能,这意味着该标准需要在某些情况下进行复制删除。
移动语义和复制ellision给出了两种不同的方法来提高这些“临时链”场景中的效率。 在移动语义中,所有临时对象仍然存在,但不是调用复制构造函数,而是调用一个(希望)较便宜的构造函数,移动构造函数。 在复制椭圆中,我们可以一起跳过一些对象。
基本上,为什么移动语义被认为是特殊的,而不仅仅是一个可以通过pre-C ++ 11编译器执行的编译器优化?
移动语义不是“编译器优化”。 它们是类型系统的新部分。 即使在gcc
和clang
使用-O0
进行编译时,移动语义也会发生 - 它会导致调用不同的函数,因为对象即将死亡的事实现在在注释类型中被“注释”。 它允许“应用程序级优化”,但这与优化器的功能不同。
也许你可以把它看作一个安全网。 当然,在理想的世界里,优化器总是会消除每一个不必要的副本。 但是,有时候,构建一个临时的过程非常复杂,涉及动态分配,而编译器并没有全部看透。 在很多这样的情况下,您将通过移动语义进行保存,这可能会让您避免进行动态分配。 这反过来可能会导致生成的代码更易于优化器进行分析。
有保证的拷贝椭圆的东西有点像,他们找到了一种方法来形式化关于临时对象的这种“常识”,以便更多的代码不仅按照您期望的方式进行优化,而且还需要按照您的方式工作期望它被编译时,并且当你认为不应该有一个副本时,不要调用一个拷贝构造函数。 因此,您可以例如通过工厂函数的值返回不可复制的,不可移动的类型。 编译器会发现,在进程到达优化器之前,没有任何复制发生在该进程的很早之前。 这实际上是这一系列改进的下一次迭代。
复制elision和移动语义不完全相同。 通过复制elision,整个对象不被复制,它保持原位。 随着举动,“东西”仍然被复制。 该副本并未真正消除。 但那个“东西”是一个完整的副本必须拖延的苍白阴影。
一个简单的例子:
class Bar {
std::vector<int> foo;
public:
Bar(const std::vector<int> &bar) : foo(bar)
{
}
};
std::vector<int> foo();
int main()
{
Bar bar=foo();
}
祝你好运,试图让你的编译器消除副本,在这里。
现在,添加这个构造函数:
Bar(std::vector<int> &&bar) : foo(std::move(bar))
{
}
而现在, main()
的对象是通过移动操作构造的。 完整的副本并没有被消除,但移动操作只是一些线路噪声。
另一方面:
Bar foo();
int main()
{
Bar bar=foo();
}
这将在这里得到一个完整的copy-elision。 没有任何东西被复制。
总之:移动语义实际上并不会消除或消除副本。 它只是使得结果副本“更少”。
你对C ++中某些事物的工作有一个基本的误解:
即使没有C ++ 11的移动语义,编译器仍然应该能够确定传递给func()的表达式是右值,因此不需要临时对象的副本。
该代码不会引发任何复制,即使在C ++ 98中也是如此。 一个const&
是一个引用而不是一个值。 而且因为它是const
,它完全有能力引用一个临时的。 因此,一个采用const string&
的const string&
永远不会获取该参数的副本。
该代码将创建一个临时文件并将该临时文件的引用传递给func
。 根本没有复制发生。
作为另一个例子,为什么还要像下面那样编码?
没人会。 如果该函数将从它移动,函数应该只通过rvalue-reference获取一个参数。 如果一个函数只是在不修改它的情况下观察该值,那么它们就像在C ++ 98中一样,通过const&
来接受它。
最重要的是:
因此,我对移动语义的理解是,它们允许您覆盖函数以使用临时值(rvalues),并避免潜在的昂贵副本(通过将状态从未命名的临时移动到您的已命名的左值)。
你的理解是错误的。
搬家不只是关于临时价值, 如果是的话,我们不会有std::move
让我们移动左值。 移动是将数据的所有权从一个对象转移到另一个对象。 虽然这种情况经常发生在临时情况下,但它也可能发生在左值:
std::unique_ptr<T> p = ...
std::unique_ptr<T> other_p = std::move(p);
assert(p == nullptr); //Will always be true.
此代码创建一个unique_ptr,然后将该指针的内容移动到另一个unique_ptr
对象中。 它不涉及临时工; 它将内部指针的所有权转移给另一个对象。
这不是编译器可以推断出你想要做的事情。 你必须明确你想要在左值执行这样的动作(这就是为什么std::move
在那里)。
上一篇: Why are move semantics necessary to elide temporary copies?