16

C++ 从 vector 扩容看 noexcept 应用场景

 3 years ago
source link: https://mp.weixin.qq.com/s/cws-hN77Jh2OQiT_PAX4oA
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

c++11提供了关键字 noexcept ,用来指明某个函数无法——或不打算——抛出异常:

void foo() noexcept;             // a function specified as will never throw
void foo2() noexcept(true);      // same as foo
void bar();                      // a function might throw exception
void bar2() noexcept(false);     // same as bar

所以我们需要了解以下两点:

  • noexcept 有什么优点,例如性能、可读性等等。

  • 需不需要在代码中大量使用 noexcept

noexcept优点

我们先从std::vector入手来看一下第一点。

我们知道,vector有自己的capacity,当我们调用 push_back 但是vector容量满时,vector会申请一片更大的空间给新容器,将容器内原有的元素copy到新容器内:

e6Z3IfN.png!mobile

但是如果在扩容元素时出现异常怎么办?

  • 申请新空间时出现异常:旧vector还是保持原有状态,抛出的异常交由用户自己处理。

  • copy元素时出现异常:所有已经被copy的元素利用元素的析构函数释放,已经分配的空间释放掉,抛出的异常交由用户自己处理。

这种扩容方式 比较 完美,有异常时也会保持上游调用 push_back 时原有的状态。

但是为什么说 比较 完美,因为这里扩容还是copy的,当vector内是一个类且持有资源较多时,这会很耗时。所以c++11推出了一个新特性: move ,它会将资源从旧元素中“偷”给新元素(对move不熟悉的同学可以自己查下资料,这里不展开说了)。应用到vector扩容的场景中:当vector中的元素的移动拷贝构造函数是 noexcept 时,vector就不会使用copy方式,而是使用move方式将旧容器的元素放到新容器中:

AVnYBrQ.png!mobile

利用 move 的交换类资源所有权的特性,使用vector扩容效率大大提高,但是当发生异常时怎么办:

原有容器的状态已经被破坏,有部分元素的资源已经被偷走。若要恢复会极大增加代码的复杂性和不可预测性。所以只有当vector中元素的 move constructornoexcept 时,vector扩容才会采取move方式来提高性能。

刚才总结了利用 noexcept 如何提高vector扩容。实际上, noexcept 还大量应用在 swap 函数和 move assignment 中,原理都是一样的。

noexcept使用场景

上面提到了 noexcept 可以使用的场景:

  • move constructor

  • move assignment

  • swap

很多人的第一念头可能是:我的函数现在看起来明显不会抛异常,又说声明 noexcept 编译器可以生成更高效的代码,那能加就加呗。但是事实是这样吗?

这个问题想要讨论清楚,我们首先需要知道以下几点:

  • 函数自己不抛异常,但是不代表它们内部的调用不会抛出异常,并且编译器不会提供调用者与被调用者的 noexcept 一致性检查,例如下述代码是合法的:

void g(){
    ...       //some code
}
void f() noexcept
{
    … 			//some code
    g();
}
  • 当一个声明为 noexcept 的函数抛出异常时,程序会被终止并调用std::terminate();

所以在我们的代码内部调用复杂,链路较长,且随时有可能加入新feature时,过早给函数加上 noexcept 可能不是一个好的选择,因为 noexcept 一旦加上,后续再去掉也会变得困难 : 调用方有可能看到你的函数声明为noexcept,调用方也会声明为 noexcept 。但是当你把函数的 noexcept 去掉却没有修改调用方的代码时,当异常抛出到调用方会导致程序终止。

目前主流的观点是:

  • 加noexcept

    • 函数在c++98版本中已经被声明为 throw()

    • 上文提到过的三种情况:move constructor、move assignmemt、swap。如果这些实现不抛出异常,一定要使用 noexcept

    • leaf function. 例如获取类成员变量,类成员变量的简单运算等。下面是stl的正向iterator中的几个成员函数:

# if __cplusplus >= 201103L
#  define _GLIBCXX_NOEXCEPT noexcept
# else
#  define _GLIBCXX_NOEXCEPT

 reference
      operator*() const _GLIBCXX_NOEXCEPT
      { return *_M_current; }

      pointer
      operator->() const _GLIBCXX_NOEXCEPT
      { return _M_current; }

      __normal_iterator&
      operator++() _GLIBCXX_NOEXCEPT
      {
	++_M_current;
	return *this;
      }

      __normal_iterator
      operator++(int) _GLIBCXX_NOEXCEPT
      { return __normal_iterator(_M_current++); }
  • 不加noexcept

    除了上面的要加的情况,其余的函数不要加 noexcept 就可以。

最后我们看一下vector如何实现利用 noexcept move constructor 扩容以及 move constructor 是否声明 noexcept 对扩容的性能影响。

如何实现利用 noexcept move constructor 扩容

这里就不贴大段的代码了,每个平台的实现可能都不一样,我们只关注vector是怎么判断调用 copy constructor 还是 move constructor 的。

其中利用到的核心技术有:

  • type trait

  • iterator trait

  • move iterator

  • std::forward

核心代码:

template <typename _Iterator, typename _ReturnType = typename conditional<
                                  __move_if_noexcept_cond<typename iterator_traits<_Iterator>::value_type>::value,
                                  _Iterator, move_iterator<_Iterator>>::type>
inline _GLIBCXX17_CONSTEXPR _ReturnType __make_move_if_noexcept_iterator(_Iterator __i) {
  return _ReturnType(__i);
}

template <typename _Tp>
struct __move_if_noexcept_cond
    : public __and_<__not_<is_nothrow_move_constructible<_Tp>>, is_copy_constructible<_Tp>>::type {};

这里用 type traititerator trait 联合判断:假如元素有 noexcept move constructor ,那么 is_nothrow_move_constructible=1 =>  __move_if_noexcept_cond=0 =>  __make_move_if_noexcept_iterator 返回一个 move iterator 。这里 move iterator 迭代器适配器也是一个c++11新特性,用来将任何对底层元素的处理转换为一个move操作,例如:

std::list<std::string> s;
std::vector<string> v(make_move_iterator(s.begin()),make_move_iterator(s.end()));     //make_move_iterator返回一个std::move_iterator

然后上游利用生成的 move iterator 进行循环元素move:

{
  for (; __first != __last; ++__first, (void)++__cur) std::_Construct(std::__addressof(*__cur), *__first);
  return __cur;
}

template <typename _T1, typename... _Args>
inline void _Construct(_T1 *__p, _Args &&... __args) {
  ::new (static_cast<void *>(__p)) _T1(std::forward<_Args>(__args)...);      //实际copy(或者move)元素
}

其中 _Construct 就是实际copy(或者move)元素的函数。 这里很关键的一点是:对move iterator进行解引用操作,返回的是一个右值引用。 ,这也就保证了,当 __first 类型是 move iterator 时,用 _T1(std::forward<_Args>(__args)... 进行“完美转发”才调用 _T1 类型的 move constructor ,生成的新对象被放到新vector的 __p 地址中。

总结一下过程就是:

  • 利用 type traititerator trait 生成指向旧容器的 normal iterator 或者 move iterator

  • 循环将旧容器的元素搬到新容器。如果指向旧容器的是 move iterator ,那么解引用会返回右值引用,会调用元素的 move constructor ,否则调用 copy constructor

大家可以用下面这段简单的代码在自己的平台打断点调试一下:

class A {
 public:
  A() { std::cout << "constructor" << std::endl; }
  A(const A &a) { std::cout << "copy constructor" << std::endl; }
  A(const A &&a) noexcept { std::cout << "move constructor" << std::endl; }
};

int main() {
  std::vector<A> v;
  for (int i = 0; i < 10; i++) {
    A a;
    v.push_back(a);
  }

  return 0;
}

noexcept move constructor 对性能的影响

这篇文章C++ NOEXCEPT AND MOVE CONSTRUCTORS EFFECT ON PERFORMANCE IN STL CONTAINERS介绍了noexcept move constructor对耗时以及内存的影响,这里不重复赘述了,感兴趣的可以自己试一下。

jEjEr2N.png!mobile

参考资料:

  • When to Use noexcept And When to Not

  • Does noexcept improve performance?

  • EffectiveModernCppChinese Item14

  • C++11的noexcept标识符与操作符应如何正确使用?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK