一个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()

可以看到一开始vectorpush_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

当一个方法抛异常时,总共有四种级别:

https://en.cppreference.com/w/cpp/language/exceptions

  • Nothrow (or nofail) exception guarantee – the function never throws exceptions. Nothrow is expected of destructors and other functions that may be called during stack unwinding. Nofail (the function always succeeds) is expected of swaps, move constructors, and other 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的好处

  1. Nothrow函数可以被其他非异常安全的函数所调用
  2. Nothrow函数可以在编译阶段得到优化
  3. 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_constructibleis_nothrow_move_constructible为true,所以在构造g2时可以调用移动构造 。而Badis_nothrow_copy_constructibleis_nothrow_move_constructible为false,所以在构造b2时只能调用复制构造。

reference

Tags:

Categories:

Updated: