警惕可变参数构造函数无限递归
警惕可变参数构造函数无限递归
文章目录
- 警惕可变参数构造函数无限递归
- 前言
- 问题模拟
- 完美转发与右值引用
- 无限递归的根因分析
- 解决方法
- 总结
前言
最近在写代码的时候发现了一个有意思的现象:如果类中的可变参数构造函数声明不当会在某些情况下导致无限递归。
问题模拟
我将用一个简化了的场景来说明这个问题,请看下面这段代码:
class A
{
private:std::vector<A> array_;public:A() = default;A(const A& object) noexcept { std::cout << "A(const A& object) called.\n"; }A(A&& object) { std::cout << "A(A&& object) called.\n"; }// 可变参数构造函数template<typename... Args>A(Args&&... args): array_{std::forward<Args>(args)...} {std::cout << "A(Args&&... args) called.\n";}A& operator=(const A& object) { std::cout << "A& operator=(const A& object) called.\n"; }A& operator=(A&& object) noexcept { std::cout << "A& operator=(A&& object) called.\n"; }
};int main()
{A a;auto b = a; // 当执行这条语句时发生无限递归return 0;
}
执行结果:
很明显是由于无限递归调用类A的可变参数构造函数导致栈溢出了,调用栈显示调用是函数A(Args&&... args)\textcolor{cornflowerblue}{A(Args\&\&...\ args)}A(Args&&... args)。所以确切地说,无限递归调用发生在12行的完美转发这里,也就是上诉12行的代码:std::forward<Args>(args)...\textcolor{orange}{std::forward<Args>(args)...}std::forward<Args>(args)...
要理解这个问题的核心,需要了解左值引用、右值引用、完美转发这些概念。
完美转发与右值引用
完美转发作用就是保持参数类型不变,传递到指定的函数中,避免类型转换、构造临时对象导致额外的开销。
完美转发需要的参数是右值引用类型的,配合c++内置方法std::forward。另外还需要了解以下基本概念:
什么是左值?什么是右值?
左值有名字,可以获取到地址。右值是具体的值,字面量,没有名字。例如:
int a = 12; class B; B b;
代码中的变量a和b均是左值,而数字12是右值。
左值引用:Type&,Type为具体的类型,例如:
int& float& const int& const float&
上诉代码中不带const的是左值引用,带const的是常量左值引用。
左值引用常用在函数的参数中,作用类似于指针,可以修改左值。如果一个函数指向读取参数中的数据而不需要修改,通常会将函数的参数声明为常量左值引用,这样即可避免临时对象的产生,提高效率。例如:
class A { public:int m_Value = 0;public:A() { std::cout << "A() called.\n"; }A(const A& object) { std::cout << "A(const A& object) called.\n"; }A(A&& object) { std::cout << "A(A&& object) called.\n"; }A& operator=(const A& object) { std::cout << "A& operator=(const A& object) called.\n"; return *this; }A& operator=(A&& object) noexcept { std::cout << "A& operator=(A&& object) called.\n"; return *this; } };void test1(A a) { std::cout << a.m_Value << std::endl; }void test2(A& a) { a.m_Value = 100; }void test3(const A& a) { std::cout << a.m_Value << std::endl; }int main() { A a;a.m_Value = 66;test1(a);test2(a);test3(a);return 0; }
执行结果:
A() called. A(const A& object) called. 0 100
- 执行结果@line:1是默认构造函数输出,对应代码中的@line:26
- 执行结果@line:2调用了拷贝构造函数,是因为代码@line:27调用了test1函数,其参数类型为A。这一步调用会生成一个临时对象,所以在test1中s输出的是临时对象的初始值。假如在test1去修改a的值,将会修改临时对象的值,对原来的对象没有影响。
- test2的参数是一个左值引用,它的效果可以等同于一个A类型的指针,没有临时对象产生,并且修改的就是缘对象的数据。
- test3的参数是一个常量左值引用,不会有临时对象产生,不能修改原对象数据。另外,常量左值引用可以引用右值。
右值引用:Type&&,Type为具体类型,例如:
int&& float&& const int&& const float&&
提示:一般不会用常量右值引用,因为这违背了右值引用的设计初衷。右值引用主要用在移动语义和完美转发中。其中移动语义会明显地修改原对象数据。另外,右值引用参数传递过程中也不会有临时对象产生,右值引用不能引用左值。
引用折叠
引用折叠是一套规则,它规定了在模板推导和
typedef
/using
中间接地创建引用的引用时,编译器应如何简化这些引用。规则如下
第一个引用 第二个引用 最终结果 & & & & && & && & & && && && 文字描述为,只要任何一个引用时左值引用,那么折叠结果就是左值引用;只有都是右值引用,折叠结果才是右值引用。\textcolor{BrickRed}{文字描述为,只要任何一个引用时左值引用,那么折叠结果就是左值引用;只有都是右值引用,折叠结果才是右值引用。}文字描述为,只要任何一个引用时左值引用,那么折叠结果就是左值引用;只有都是右值引用,折叠结果才是右值引用。
模板类型推导
模板类型推导中,将右值和右值引用都视为右值引用,左值和左值引用都推到为左值引用。
万能引用
模板类型推导+右值引用在引用折叠规则下形成万能引用。举例:
template<typename T> void test(T&& v);auto&& var = *; // *表示具体的表达式int a; const int b; test(1); // 最终类型为int&& test(a); // 最终类型为int& test(b); // 最终类型为const int&
上述代码中的v和var不论你传什么值它们都能接受,推导出对应类型的引用,所以叫万能引用。
注意:std::move移动语义会将所有类型都转为右值引用。\textcolor{BrickRed}{注意:std::move移动语义会将所有类型都转为右值引用。}注意:std::move移动语义会将所有类型都转为右值引用。
无限递归的根因分析
现在我们回过头来看一开始的问题模拟场景,发生这个BUG的根本原因其实是A(A&& object)\textcolor{cornflowerblue}{A(A\&\&\ object)}A(A&& object)函数本身就是一个万能转发,当执行auto b=a;\textcolor{orange}{auto\ b = a;}auto b=a;这一句代码时,等价于执行A b(a)\textcolor{orange}{A\ b(a)}A b(a),理应调用的是拷贝构造函数A(const A& object)\textcolor{cornflowerblue}{A(const\ A\&\ object)}A(const A& object),但编译器会优先匹配到万能引用,因为万能嘛,所以这种情况下类的拷贝构造和移动构造函数都会被屏蔽掉。然后在万能引用构造函数中使用了完美转发,调用std::vector的多参数构造函数。因为std::vector元素类型又是A
,所以在vector中构造元素的时候又会调用到A的构造函数,自然又会被A的万能引用构造函数捕获到,形成了递归调用。调用图:
解决方法
想办法让A类中不要出现万能引用构造函数,只需修改如下:
template<typename T, typename... Args>A(T param, Args&&... args): array_{std::forward<Args>(args)...} {std::cout << "A(Args&&... args) called.\n";}
总结
避免类中出现万能引用构造函数。