CppCon 2015 学习:Practical Move Semantics
- 与
std::unique_ptr
和已删除的拷贝构造函数相关的编译错误- 问题背景:
std::unique_ptr
是一种独占的智能指针,意味着它不允许拷贝构造或拷贝赋值,因为它负责管理的资源只能有一个拥有者。如果你在代码中试图拷贝一个std::unique_ptr
,就会遇到编译错误。通常,这是因为默认的拷贝构造函数已被删除。 - 解决方案:正确地使用
std::move
来移动unique_ptr
,而不是试图拷贝它。
- 问题背景:
- 你是否曾经不确定是否触发了容器的拷贝或移动操作?
- 问题背景:在 C++ 中,容器的拷贝和移动操作可能会因不同的语法或函数调用而有所不同。对于大型容器或资源密集型对象,这种区分尤其重要。你可能会遇到不清楚代码是否进行了拷贝还是移动的情况。
- 解决方案:使用
std::move
显式触发移动语义,确保你在需要的时候进行资源的转移,而不是拷贝。检查编译器的警告和错误信息,有时它们可以帮助你理解是否发生了拷贝。
- 你是否曾经怀疑调用
std::move
是否必要?- 问题背景:
std::move
是一种强制性转换,它将对象标记为可移动(而不是可拷贝)。然而,std::move
并不实际“移动”对象,它只是为编译器提供一个提示,允许使用移动语义。你可能会疑惑是否每次调用std::move
都是必需的,或者是否有可能不小心使用了它。 - 解决方案:只有在你需要将对象的所有权转移时,才使用
std::move
。如果你不需要转移所有权,只是进行拷贝操作,std::move
就不应该被调用。要小心,std::move
之后的对象通常不应再被使用,除非你显式地重新赋值或重置它。
- 问题背景:
总结
这些问题都涉及 C++ 中的移动语义和拷贝构造函数的使用。C++ 的智能指针和容器通过提供明确的拷贝和移动控制来帮助管理资源。了解何时使用拷贝,何时使用移动,是编写高效、无内存泄漏代码的关键。
std::unique_ptr
:它的拷贝构造函数被删除,使用时要确保通过std::move
进行移动,而不是拷贝。- 拷贝和移动操作:检查是否显式使用
std::move
,避免不必要的拷贝,特别是对于大型对象。 std::move
的使用:只有在需要将对象的所有权转移时使用std::move
,并确保在移动后,源对象不再使用。
代码分析:std::unique_ptr
和所有相关示例
1. NewFoo()
函数
std::unique_ptr<Foo> NewFoo() {return std::make_unique<Foo>(1);
}
- 目的:创建并返回一个
std::unique_ptr<Foo>
,它管理一个Foo
对象。 std::make_unique<Foo>(1)
创建一个Foo
对象,并返回一个unique_ptr
。这确保了该对象的生命周期由std::unique_ptr
管理,不会被意外删除。
2.AcceptFoo()
函数
void AcceptFoo(std::unique_ptr<Foo> f) { f->PrintDebugString();
}
- 目的:接受一个
std::unique_ptr<Foo>
,并通过该指针调用PrintDebugString()
方法。 std::unique_ptr<Foo>
作为值传递给AcceptFoo
,意味着传入的unique_ptr
被转移到f
,因此调用函数后,原始的unique_ptr
不再有效。
3. Simple()
函数
void Simple() {AcceptFoo(NewFoo()); // 函数调用
}
- 目的:直接传递
NewFoo()
的返回值(即std::unique_ptr<Foo>
)到AcceptFoo
。 - 由于
std::unique_ptr
不能拷贝,只有通过移动(std::move
)才能传递给AcceptFoo
,此代码可以编译并正常工作,因为std::move
是隐式完成的。
4. DoesNotBuild()
函数
void DoesNotBuild() {std::unique_ptr<Foo> g = NewFoo();AcceptFoo(g); // DOES NOT COMPILE!
}
- 问题:这段代码不编译。
- 原因:
std::unique_ptr
不允许拷贝,只能移动,因此尝试将g
传递给AcceptFoo
会导致编译错误。- 如果你想将
g
传递给AcceptFoo
,必须使用std::move(g)
,否则编译器将无法通过类型匹配。
- 如果你想将
5. SmarterThanTheCompilerButNot()
函数
void SmarterThanTheCompilerButNot () {Foo* j = new Foo(2);std::unique_ptr<Foo> k(j);std::unique_ptr<Foo> l(j); // DOUBLE DELETE ERROR
}
- 问题:这里创建了两个
std::unique_ptr
,它们都管理同一个裸指针j
。这会导致 双重删除(double-delete) 错误。 - 原因:
std::unique_ptr
会自动删除它管理的资源。如果两个unique_ptr
同时管理相同的资源,它们都会在销毁时尝试删除相同的内存,导致运行时崩溃。- 这违反了
std::unique_ptr
的唯一性原则。每个unique_ptr
应该独占一个资源,不能共享同一个资源。
- 这违反了
6. EraseTheName()
函数
void EraseTheName() {std::unique_ptr<Foo> g = NewFoo();AcceptFoo(std::move(g)); // DOES COMPILE!
}
- 目的:这段代码展示了如何通过
std::move(g)
将std::unique_ptr
g
转移到AcceptFoo()
。 - 解释:
std::move(g)
显式地将g
的所有权转移到AcceptFoo
。因此,g
在传递给AcceptFoo
后将变得不可用。- 这段代码 编译成功,并符合
std::unique_ptr
的移动语义。
7. std::vector<Foo>
示例
std::vector<Foo> NewFoo() {return std::vector<Foo>({1});
}
- 目的:返回一个
std::vector<Foo>
对象。 - 这段代码展示了如何通过
std::make_unique
和std::move
来返回一个std::vector
,这是 C++ 中管理动态数组的一种常见方式。
8. AcceptFoo()
用于 std::vector<Foo>
void AcceptFoo(std::vector<Foo> f) { … }
- 目的:接受一个
std::vector<Foo>
对象。 - 解释:与
std::unique_ptr
类似,std::vector
也有移动语义。你可以将一个std::vector<Foo>
通过移动传递,而不会发生拷贝。
9. EraseTheName()
中的 std::move(g)
void EraseTheName() {std::vector<Foo> g = NewFoo();AcceptFoo(std::move(g)); // DOES NOT COPY!
}
- 目的:通过
std::move(g)
将std::vector<Foo>
转移到AcceptFoo
,而不是进行拷贝。 - 解释:
- 这段代码 不进行拷贝,而是将
g
移动到AcceptFoo
,从而避免不必要的拷贝操作。 - 使用
std::move
使得g
的资源被转移,而不是复制,确保了性能上的优势。
- 这段代码 不进行拷贝,而是将
总结:
std::unique_ptr
:只能传递(或移动)它的所有权,不能拷贝。每次你想将unique_ptr
传递给函数时,都需要确保使用std::move
来明确地将所有权转移。std::move
:用于显式地将一个对象的所有权从一个unique_ptr
移动到另一个地方。如果不使用std::move
,就会导致编译错误。- 避免资源双重删除:通过
std::unique_ptr
的唯一性,确保没有多个unique_ptr
管理同一资源,否则会导致运行时的 double-delete 错误。 - 智能指针的使用规则:智能指针的设计原则是避免资源管理上的错误,并避免不必要的资源拷贝,确保对象生命周期的正确性。
通过这些示例,可以更好地理解如何使用std::unique_ptr
和std::move
来正确管理资源和避免常见的错误。
代码分析
1. 两个名字:一个复制
vector<int> foo;
FillAVectorOfIntsByOutputParameterSoNobodyThinksAboutCopies(&foo);
vector<int> bar = foo; // Yep, this is a copy.
map<int, string> my_map;
string forty_two = "42";
my_map[5] = forty_two; // Also a copy: my_map[5] counts as a name.
- 分析:
foo
被复制给bar
:这行代码vector<int> bar = foo;
是一个拷贝构造,它会创建一个新的vector<int>
,并将foo
中的所有元素拷贝到bar
中。my_map[5] = forty_two;
:在my_map[5]
这行代码中,forty_two
会被复制到my_map
的元素中。map
是通过拷贝赋值将forty_two
字符串存储在my_map[5]
中。- 总结:
- 拷贝发生时,每当我们有两个名字指向同一个数据(例如
foo
和bar
)时,数据就会被复制。这通常发生在通过拷贝构造函数或者赋值操作符时。
- 拷贝发生时,每当我们有两个名字指向同一个数据(例如
2. 一个名字:一个移动
vector<int> GetSomeInts() {vector<int> ret = {1, 2, 3, 4};return ret;
}
// Just a move: either "ret" or "foo" has the data, but never both at once.
vector<int> foo = GetSomeInts();
// Also a move: std::move makes the old name no longer count.
vector<int> bar = std::move(foo);
- 分析:
GetSomeInts
函数:该函数创建了一个vector<int>
,并返回它。返回时,ret
的内容不会被拷贝,而是通过移动语义将其传递给调用者(例如foo
)。移动操作比拷贝更高效,因为它不会复制元素,而是直接转移资源的所有权。std::move(foo)
:通过std::move
,foo
的数据被移动到bar
中,而不是复制。这是通过将foo
的资源“转交”给bar
,并使foo
变为一个有效但空的对象来完成的。foo
不再拥有原来的数据,而bar
拥有该数据。- 总结:
- 移动发生时,通过
std::move
或返回局部变量时的返回值优化(如返回局部变量ret
),数据会被“移动”而不是“复制”。这意味着所有权转移,不会再有多份数据副本。
- 移动发生时,通过
3. 没有名字:临时对象
void OperatesOnVector(const vector<int>& v);
// No copies: the values in the vector returned by GetSomeInts()
// will be moved (O(1)) into the temporary constructed between these
// calls and passed by reference into OperatesOnVector().
OperatesOnVector(GetSomeInts());
- 分析:
- 临时对象:
GetSomeInts()
返回一个临时vector<int>
,然后该临时对象被传递给OperatesOnVector
。由于OperatesOnVector
接受的是const vector<int>&
(即传引用),它会避免对临时对象进行复制。 - 临时对象的生命周期会随着函数调用结束而结束,但在调用过程中,它的资源可能通过移动语义被传递(而不是拷贝)。
- 总结:
- 没有名字的临时对象,如
GetSomeInts()
返回的临时vector<int>
,当它通过引用传递给函数时,不会进行拷贝操作。如果函数参数接受的是const T&
,且传递的是临时对象,则可以利用移动语义(如果可能的话)进行更高效的操作。
- 没有名字的临时对象,如
- 临时对象:
关键概念总结
- 两个名字:拷贝:
- 当有两个对象(如
foo
和bar
)指向同一数据时,数据会被拷贝。此时会调用拷贝构造函数或赋值操作符。 - 例如,
vector<int> bar = foo;
会将foo
中的数据复制到bar
。
- 当有两个对象(如
- 一个名字:移动:
- 移动是将数据的所有权从一个对象转移到另一个对象,而不是复制数据。通过
std::move
或函数返回值优化可以实现。 - 例如,
vector<int> bar = std::move(foo);
将foo
的数据移动到bar
中,foo
不再拥有数据。
- 移动是将数据的所有权从一个对象转移到另一个对象,而不是复制数据。通过
- 没有名字:临时对象:
- 临时对象(如
GetSomeInts()
返回的vector<int>
)在传递时,如果是通过引用传递,并且没有进行复制操作,数据可能通过移动语义传递给函数。 - 例如,
OperatesOnVector(GetSomeInts());
中,GetSomeInts()
返回的临时对象会直接传递给OperatesOnVector
,并且不会进行不必要的复制。
- 临时对象(如
小结:
- 拷贝 发生在有两个名字时,数据被复制到新对象。
- 移动 发生在只有一个名字时,数据的所有权从一个对象转移到另一个对象。
- 临时对象 通过引用传递给函数时,通常会避免拷贝,通过移动语义(如果可能)更高效地进行操作。
代码分析
1. foo()
返回 std::vector<string>
:O(1) 还是 O(n)?
std::vector<string> foo() {std::vector<string> ret;ret.resize(100);for (int i = 0; i < 100; ++i) {ret.push_back(std::to_string(i));}return ret;
}
void f() {auto vec = foo(); // O(1) or O(n)?
}
- 分析:
- 在函数
foo()
中:- 创建了一个
std::vector<string> ret
。 - 使用
resize(100)
将ret
的大小调整为 100。 - 然后用
push_back
向ret
中添加 100 个字符串(通过std::to_string(i)
转换数字为字符串)。这实际上是调用了 100 次push_back
,每次插入一个新元素。
- 创建了一个
return ret
:- 返回一个
std::vector<string>
,由于 C++11 引入了返回值优化 (RVO),编译器通常会进行优化,避免不必要的拷贝操作。通常情况下,这样的返回值会使用移动语义而不是拷贝。 - 这意味着
ret
并不是被拷贝到vec
中,而是直接“移动”到vec
,这通常是 O(1) 时间复杂度。 - 但是在某些情况下,如果编译器不能进行优化,可能会发生 O(n) 的拷贝(但这非常少见,特别是在现代编译器中)。
- 返回一个
- 总结:
- O(1) 或 O(n),具体取决于编译器是否能够进行移动优化(如果能够的话,则是 O(1))。
- 在没有特殊优化的情况下,返回的
std::vector
会涉及到拷贝操作,因此是 O(n) 的时间复杂度。
- 在函数
2. 使用静态变量 ret
的情况
std::vector<string> foo() {static std::vector<string> ret;ret.resize(100);for (int i = 0; i < 100; ++i) {ret.push_back(std::to_string(i));}return ret;
}
void f() {auto vec = foo(); // O(1) or O(n)?
}
- 分析:
- 这次,
ret
被声明为static
。这意味着它在函数调用之间会保持其状态(即:ret
是持久化的,跨多个函数调用)。 ret.resize(100)
会调整ret
的大小为 100。- 然后使用
push_back
向ret
添加 100 个元素。注意:static
变量只会在第一次调用时初始化一次,此后的调用会重复使用同一个ret
,不需要重新初始化或分配内存。 return ret
:- 由于
ret
是静态的,编译器会返回对这个静态变量的引用(而不是复制它),通常这会导致 O(1) 的操作。 - 不过,即使
ret
是静态的,如果ret
存储了大量数据(比如此处的 100 个字符串),返回它时会涉及到对数据的移动或者拷贝操作。
- 由于
- 总结:
- O(1):因为
ret
是静态的,返回时只涉及到对现有数据的移动(或者引用传递),而不需要重新分配和初始化。 - 不会有 O(n) 的开销,因为数据的分配是一次性的,且返回时不会重复分配内存。
- O(1):因为
- 这次,
3. 使用 std::move
进行移动语义
std::vector<string> foo() {std::vector<string> ret;ret.resize(100);for (int i = 0; i < 100; ++i) {ret.push_back(std::to_string(i));}return ret;
}
void f() {auto vec = std::move(foo()); // O(1) or O(n)?
}
- 分析:
- 在这段代码中,
foo()
返回一个std::vector<string>
,然后通过std::move
将返回值从foo()
移动到vec
中。 std::move
实际上并不做移动操作,它只是强制将对象转换为一个右值引用,使得编译器可以调用移动构造函数而不是拷贝构造函数。- 因此,在这个例子中,
std::move(foo())
触发了对std::vector<string>
的移动构造。 - 移动构造函数通常会将资源的所有权从源对象转移到目标对象,而不会进行数据的深度复制,因此它是 O(1) 时间复杂度。
- 总结:
- O(1):由于
std::move
的存在,数据的所有权从foo()
中的临时对象转移到了vec
中,不涉及数据复制,因此是常数时间复杂度。
- O(1):由于
- 在这段代码中,
4. 两个额外的奇怪笔记
- 不触摸已移动的对象:
- 经过
std::move
的对象(如std::move(foo())
)会变成“已移动的对象”。这种对象处于不确定的状态,它们不再包含有效的数据。使用这些对象会导致未定义的行为。 - 通常建议在移动之后,不再使用这些对象,避免访问其“已移动的”内容。
- 经过
std::move
本身不做任何事情:std::move
只是一个类型转换,它将对象转换成右值引用。实际上,没有任何数据移动的操作,所有的工作都由对象的移动构造函数或移动赋值操作符完成。std::move
不会“移动”数据,它只是“标记”对象为右值,让编译器知道可以使用移动构造函数。
总结
- O(1) 或 O(n):返回
std::vector<string>
的时间复杂度取决于编译器是否能够进行优化(如移动语义),如果能优化就是 O(1),否则就是 O(n)。 - 静态变量:使用静态变量时,通常返回的是对该静态变量的引用,因此是 O(1) 操作。
std::move
:通过std::move
触发移动语义,通常是 O(1) 操作,因为只涉及资源的所有权转移,而没有复制数据。