Why and when move constructor need to be noexcept
一个noexcept
引发的血案。
前两天在看type traits
的时候,里面提到type traits
其中一个作用是:在特定类型的处理是能提供经过优化的代码。举一个非常常见的例子std::vector::push_back
在处理左值和右值的时候处理方法就不同。
从一个例子说起
push_back
lvalue
如下所示,其中的n
只在构造和复制构造时自增,用于统计总共构造了多少个对象(移动构造不计入)
#include <vector>
#include <iostream>
struct S {
S() { puts("S()"); n++; }
~S() { puts("~S()"); }
S(const S &) noexcept { puts("S(const S &)"); n++; }
S(S &&) noexcept { puts("S(S&&)"); }
static int n;
};
int S::n = 0;
int main() {
std::vector<S> sv;
S s;
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
std::cout << "S::n " << S::n << std::endl;
return 0;
}
为了方便分析,我们在每次push_back
前后加一点分隔符,输出结果实际如下
S()
----------------------
S(const S &)
----------------------
S(const S &)
S(S&&)
~S()
----------------------
S(const S &)
S(S&&)
S(S&&)
~S()
~S()
----------------------
S(const S &)
----------------------
S(const S &)
S(S&&)
S(S&&)
S(S&&)
S(S&&)
~S()
~S()
~S()
~S()
----------------------
S::n 6
~S()
~S()
~S()
~S()
~S()
~S()
可以看到一开始vector
在push_back
第一次之后,调用了复制构造,此时size()
和capacity()
已经相同,如果再次加入新元素,会触发vector
的reallocation。
所以在第二次push_back
时,vector
通过移动构造函数将原来vector
中的元素移动到了新的地方,所以多出来了一次移动构造和一次老元素的析构。细心的同学可以发现,是先调用了构造新元素的复制构造,再触发了reallocation。
S(const S &)
S(S&&)
~S()
同样的当size()
为2和4时,再添加元素也都会触发reallocation。
S::n
为6 (最开始定义的s加上五次push_back
)
rvalue
我们将main
函数中换成右值
int main() {
std::vector<S> sv;
S s;
sv.push_back(std::move(s));
sv.push_back(std::move(s));
sv.push_back(std::move(s));
sv.push_back(std::move(s));
sv.push_back(std::move(s));
std::cout << "S::n " << S::n << std::endl;
return 0;
}
输出结果如下
S()
----------------------
S(S&&)
----------------------
S(S&&)
S(S&&)
~S()
----------------------
S(S&&)
S(S&&)
S(S&&)
~S()
~S()
----------------------
S(S&&)
----------------------
S(S&&)
S(S&&)
S(S&&)
S(S&&)
S(S&&)
~S()
~S()
~S()
~S()
----------------------
S::n 1
~S()
~S()
~S()
~S()
~S()
~S()
可以看到原先构造新元素时都是走复制构造,现在则是走的移动构造,区别仅此而已。
S::n
为1 (最开始定义的s)
what happens?
如果我们去掉S的移动构造的noexcept
,又会发生啥?
我们先看lvalue
的情况
struct S {
S() { puts("S()"); n++; }
~S() { puts("~S()"); }
S(const S &) noexcept { puts("S(const S &)"); n++; }
S(S &&) { puts("S(S&&)"); }
static int n;
};
int S::n = 0;
int main() {
std::vector<S> sv;
S s;
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
sv.push_back(s);
std::cout << "S::n " << S::n << std::endl;
return 0;
}
输出结果如下:
S()
----------------------
S(const S &)
----------------------
S(const S &)
S(const S &)
~S()
----------------------
S(const S &)
S(const S &)
S(const S &)
~S()
~S()
----------------------
S(const S &)
----------------------
S(const S &)
S(const S &)
S(const S &)
S(const S &)
S(const S &)
~S()
~S()
~S()
~S()
----------------------
S::n 13
~S()
~S()
~S()
~S()
~S()
~S()
可以看到由于把nocept
去掉之后,在vector
进行reallocation时,不再走复制构造,而只能走移动构造。而总共进行reallocation的时候需要移动的元素个数为1 + 2 + 4 = 7
次,所以这时S::n
就是之前lvalue的6 + 7 = 13
。
rvalue
的情况我们省略,和lvalue
一样,都是多出来了额外reallocation造成的7次,S::n = 1 + 7 = 8
,感兴趣的自己试下就好。
为什么会出现这样的情况我们最后来说。
emplace_back
如果我们把之前的例子换成使用emplace_back
会有什么大的不同吗?其实都一样
emplace_back | push_back | |
---|---|---|
w/i noexcept + lvalue | 6 | 6 |
w/i noexcept + rvalue | 1 | 1 |
w/o noexcept + lvalue | 13 | 13 |
w/o noexcept + rvalue | 8 | 8 |
noexcept
What is noexcept
noexcept
在C++11被引入,一个是noexcept operator
(本文先不涉及),另一个是noexcept specifier
。
noexcept specifier
就是在函数右边加上noexcept
关键字,将一个函数标记为noexcept:
void doSomething() noexcept; // this function is non-throwing
需要说明的是这个关键字并不阻止doSomething
抛异常,如果在一个标记为noexcept
的函数中抛异常,std::terminate
就会触发。
And note that if std::terminate is called from inside a noexcept function, stack unwinding may or may not occur (depending on implementation and optimizations), which means your objects may or may not be destructed properly prior to termination.
另外只有exception specification
不同的两个函数是不能重载的。
non-throwing vs potentially throwing
默认情况下,的所有特殊成员函数都是不会抛异常的(还有些特殊情况详见https://en.cppreference.com/w/cpp/language/noexcept_spec),包括如下
- default constructors
- copy constructors
- move constructors
- destructors
- copy assignment operators
- move assignment operators
但如果这些函数直接或间接调用了一个可能会抛异常的函数,那么上面的函数也会被认为可能会抛异常。
而下面的函数是可能会抛异常的
- Normal functions
- User-defined constructors
- Some operators, such as new
Exception safety guarantees
当一个方法抛异常时,总共有四种级别:
- Nothrow (or nofail) exception guarantee – the function never throws exceptions. Nothrow is expected of
destructors
andother functions that may be called during stack unwinding
. Nofail (the function always succeeds) is expected ofswaps
,move constructors
, andother functions used by those that provide strong exception guarantee
. - Strong exception guarantee – If the function throws an exception, the state of the program is rolled back to the state just before the function call. (for example, std::vector::push_back)
This means the function must either completely succeed or have no side effects if it fails. This is easy if the failure happens before anything is modified in the first place, but can also be achieved by rolling back any changes so the program is returned to the pre-failure state.
- Basic exception guarantee – If the function throws an exception, the program is in a valid state. No resources are leaked, and all objects’ invariants are intact.
- No exception guarantee – If the function throws an exception, the program may not be in a valid state: resource leaks, memory corruption, or other invariant-destroying errors may have occurred.
当我们将一个函数定义为noexcept
之后,这个函数也就拥有了Nothrow exception guarantee
需要保证Nothrow
的函数:
- destructors and memory deallocation/cleanup functions
- functions that higher-level no-throw functions need to call
需要保证Nofail
的函数
- move constructors and move assignment
- swap functions
- clear/erase/reset functions on containers
- operations on std::unique_ptr
- functions that higher-level no-fail functions need to call
When to use noexcept
一句话总结就是:只在需要保证Nothrow
或者Nofail
的时候再用noexcept
Use the noexcept specifier in specific cases where you want to express a no-fail or no-throw guarantee.
将一个函数标记为nocexpt
的好处
Nothrow
函数可以被其他非异常安全的函数所调用Nothrow
函数可以在编译阶段得到优化Nothrow
函数会对一些类的方法有影响,比如本文中的std::vector
再回到开头的例子
对于push_back
或者emplace_back
,他们都是strong exception guarantee
,也就是如果发生异常,原有的vector不会有任何变化,仍然可用。
If an exception is thrown (which can be due to Allocator::allocate() or element copy/move constructor/assignment), this function has no effect (strong exception guarantee).
这两个方法由于都会涉及到reallocation,需要复制整个vector:
- 如果调用的是元素的复制构造,那么原有的vector不会受到影响。所以如果在复制过程中抛异常,可以直接把新的vector析构掉(析构是noexcept的,不会rethrow),此时原有的vector不变,也就保证
strong exception guarantee
- 如果调用的是元素的移动构造,那么原有的vector什么都无法保证。一旦老的vector中元素被移动后,原有的vector已经被修改。一旦后面抛异常,我们也不能把新vector再移动回老vector(无法保证这次不抛异常),此时无论是新的vector还是老的vector都处于一个无效状态,无法保证
strong exception guarantee
因此如果元素的移动构造被标记为noexcept
,那么编译器不会再考虑在使用移动构造过程中可能会出现的异常(如果抛异常后果自负),通过这样的方式就能够加速复制vector的过程。
STL的实现
https://github.com/microsoft/STL
template <class... _Valty>
_CONSTEXPR20 decltype(auto) emplace_back(_Valty&&... _Val) {
// insert by perfectly forwarding into element at end, provide strong guarantee
_Ty& _Result = _Emplace_one_at_back(_STD forward<_Valty>(_Val)...);
#if _HAS_CXX17
return _Result;
#else // ^^^ _HAS_CXX17 ^^^ // vvv !_HAS_CXX17 vvv
(void) _Result;
#endif // _HAS_CXX17
}
template <class... _Valty>
_CONSTEXPR20 _Ty& _Emplace_one_at_back(_Valty&&... _Val) {
// insert by perfectly forwarding into element at end, provide strong guarantee
auto& _My_data = _Mypair._Myval2;
pointer& _Mylast = _My_data._Mylast;
if (_Mylast != _My_data._Myend) {
return _Emplace_back_with_unused_capacity(_STD forward<_Valty>(_Val)...);
}
return *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);
}
我们重点看当需要reallocate的函数,first
/last
/end
分别对应begin
/size
/capacity
的地址
template <class... _Valty>
_CONSTEXPR20 pointer _Emplace_reallocate(const pointer _Whereptr, _Valty&&... _Val) {
// reallocate and insert by perfectly forwarding _Val at _Whereptr
_Alty& _Al = _Getal();
auto& _My_data = _Mypair._Myval2;
pointer& _Myfirst = _My_data._Myfirst;
pointer& _Mylast = _My_data._Mylast;
_STL_INTERNAL_CHECK(_Mylast == _My_data._Myend); // check that we have no unused capacity
const auto _Whereoff = static_cast<size_type>(_Whereptr - _Myfirst);
const auto _Oldsize = static_cast<size_type>(_Mylast - _Myfirst);
// 达到vector的最大容量 直接抛异常
if (_Oldsize == max_size()) {
_Xlength();
}
const size_type _Newsize = _Oldsize + 1;
const size_type _Newcapacity = _Calculate_growth(_Newsize);
// 分配新内存
const pointer _Newvec = _Al.allocate(_Newcapacity);
const pointer _Constructed_last = _Newvec + _Whereoff + 1;
pointer _Constructed_first = _Constructed_last;
_TRY_BEGIN
_Alty_traits::construct(_Al, _Unfancy(_Newvec + _Whereoff), _STD forward<_Valty>(_Val)...);
_Constructed_first = _Newvec + _Whereoff;
if (_Whereptr == _Mylast) { // at back, provide strong guarantee
// 如果是从尾部添加元素 对于emplace_back走这个分支
// 如果元素类型有nothrow的移动构造 或者元素类型不能复制构造 那么通过移动构造将老vector元素移动到新的
if constexpr (is_nothrow_move_constructible_v<_Ty> || !is_copy_constructible_v<_Ty>) {
_Uninitialized_move(_Myfirst, _Mylast, _Newvec, _Al);
} else {
_Uninitialized_copy(_Myfirst, _Mylast, _Newvec, _Al);
}
} else { // provide basic guarantee
// 而如果emplace的位置不是在vector最后 那么会将从要插入的位置拆成两段分别move
// 由于使用了move 只能保证basic guarantee
_Uninitialized_move(_Myfirst, _Whereptr, _Newvec, _Al);
_Constructed_first = _Newvec;
_Uninitialized_move(_Whereptr, _Mylast, _Newvec + _Whereoff + 1, _Al);
}
_CATCH_ALL
// 如果有任何异常 将新分配的内存释放
_Destroy_range(_Constructed_first, _Constructed_last, _Al);
_Al.deallocate(_Newvec, _Newcapacity);
_RERAISE;
_CATCH_END
_Change_array(_Newvec, _Newsize, _Newcapacity);
return _Newvec + _Whereoff;
}
clang
没找到如何通过nothrow加速的代码
template <class _Tp, class _Allocator>
template <class... _Args>
inline
#if _LIBCPP_STD_VER > 14
typename vector<_Tp, _Allocator>::reference
#else
void
#endif
vector<_Tp, _Allocator>::emplace_back(_Args&&... __args)
{
if (this->__end_ < this->__end_cap())
{
__construct_one_at_end(_VSTD::forward<_Args>(__args)...);
}
else
__emplace_back_slow_path(_VSTD::forward<_Args>(__args)...);
#if _LIBCPP_STD_VER > 14
return this->back();
#endif
}
对应的reallocation函数
template <class _Tp, class _Allocator>
template <class... _Args>
void
vector<_Tp, _Allocator>::__emplace_back_slow_path(_Args&&... __args)
{
allocator_type& __a = this->__alloc();
// 构建一个新的buffer
__split_buffer<value_type, allocator_type&> __v(__recommend(size() + 1), size(), __a);
// __v.emplace_back(_VSTD::forward<_Args>(__args)...);
// 在新的buffer对应位置构造新的元素
__alloc_traits::construct(__a, _VSTD::__to_address(__v.__end_), _VSTD::forward<_Args>(__args)...);
__v.__end_++;
// 将新buffer的begin/end/capacity通过swap换给原始buffer
__swap_out_circular_buffer(__v);
}
template <class _Tp, class _Allocator>
void
vector<_Tp, _Allocator>::__swap_out_circular_buffer(__split_buffer<value_type, allocator_type&>& __v)
{
__annotate_delete();
_VSTD::__construct_backward_with_exception_guarantees(this->__alloc(), this->__begin_, this->__end_, __v.__begin_);
_VSTD::swap(this->__begin_, __v.__begin_);
_VSTD::swap(this->__end_, __v.__end_);
_VSTD::swap(this->__end_cap(), __v.__end_cap());
__v.__first_ = __v.__begin_;
__annotate_new(size());
__invalidate_all_iterators();
}
How to choose use move ctor or copy ctor
从上面的STL实现中(至少微软的STL)中可以看到,通过判断is_nothrow_move_constructible_v
,编译器会选择使用移动构造还是复制构造。
在STL容器中,任何涉及到可能会调整容器大小的操作都是strong exception safety guarantee
。换句话说就是如果移动构造不是nothrow
,在调整容器大小过程中就不会使用移动构造。
将移动构造标记为noexcept
就会告知编译器它“不应该抛任何异常”,这个属性可以通过std::is_no_throw_move_constructible
这个type_traits
来告知编译器。
如果没有自己实现移动构造,默认的移动构造std::is_no_throw_move_constructible为true
对于STL的容器,会在调整容器大小的操作中,通过std::move_if_noexcept
来判断是否移动构造是noexcept
,决定调用哪个构造函数。
This is used, for example, by std::vector::resize, which may have to allocate new storage and then move or copy elements from old storage to new storage. If an exception occurs during this operation, std::vector::resize undoes everything it did to this point, which is only possible if std::move_if_noexcept was used to decide whether to use move construction or copy construction. (unless copy constructor is not available, in which case move constructor is used either way and the strong exception guarantee may be waived)
相关的type_traits
我们可以把相关的type_traits都放到一个类中
#include <iostream>
#include <utility>
template<typename T>
void traits() {
std::cout << __PRETTY_FUNCTION__ << " supported operations: "
// ctor
<< "\nis_constructible: " << std::is_constructible<T>::value
<< "\nis_trivially_constructible: " << std::is_trivially_constructible<T>::value
<< "\nis_nothrow_constructible: " << std::is_nothrow_constructible<T>::value
<< "\nis_default_constructible: " << std::is_default_constructible<T>::value
<< "\nis_trivially_default_constructible: " << std::is_trivially_default_constructible<T>::value
<< "\nis_nothrow_default_constructible: " << std::is_nothrow_default_constructible<T>::value
// copy ctor
<< "\nis_copy_constructible: " << std::is_copy_constructible<T>::value
<< "\nis_trivially_copy_constructible: " << std::is_trivially_copy_constructible<T>::value
<< "\nis_nothrow_copy_constructible: " << std::is_nothrow_copy_constructible<T>::value
// move ctor
<< "\nis_move_constructible: " << std::is_move_constructible<T>::value
<< "\nis_trivially_move_constructible: " << std::is_trivially_move_constructible<T>::value
<< "\nis_nothrow_move_constructible: " << std::is_nothrow_move_constructible<T>::value
// copy assign operator
<< "\nis_trivially_copy_assignable: " << std::is_trivially_copy_assignable<T>::value
<< "\nis_nothrow_copy_assignable: " << std::is_nothrow_copy_assignable<T>::value
<< "\nis_move_assignable: " << std::is_move_assignable<T>::value
// move assign operator
<< "\nis_trivially_move_assignable: " << std::is_trivially_move_assignable<T>::value
<< "\nis_nothrow_move_assignable: " << std::is_nothrow_move_assignable<T>::value
<< "\nis_destructible: " << std::is_destructible<T>::value
// dctor
<< "\nis_trivially_destructible: " << std::is_trivially_destructible<T>::value
<< "\nis_nothrow_destructible: " << std::is_nothrow_destructible<T>::value
<< "\nhas_virtual_destructor: " << std::has_virtual_destructor<T>::value
<< "\n----------------------\n";
}
struct Bad {
Bad() {}
Bad(Bad&&) {
std::cout << "Throwing move constructor called\n";
}
Bad(const Bad&) {
std::cout << "Throwing copy constructor called\n";
}
};
struct Good {
Good() {}
Good(Good&&) noexcept {
std::cout << "Non-throwing move constructor called\n";
}
Good(const Good&) noexcept {
std::cout << "Non-throwing copy constructor called\n";
}
};
int main() {
traits<Good>();
traits<Bad>();
Good g;
Bad b;
Good g2 = std::move_if_noexcept(g);
// Non-throwing move constructor called
Bad b2 = std::move_if_noexcept(b);
// Throwing copy constructor called
}
运行之后可以看到Good
由于是is_nothrow_copy_constructible
和is_nothrow_move_constructible
为true,所以在构造g2
时可以调用移动构造
。而Bad
的is_nothrow_copy_constructible
和is_nothrow_move_constructible
为false,所以在构造b2
时只能调用复制构造。