早在变量赋值与初始化中,我们讨论了基本类型对象的6种基本初始化方式:
int a; // 无初始化器(默认初始化)
int b = 5; // 等号后的初始化器(拷贝初始化)
int c( 6 ); // 圆括号中的初始化器(直接初始化)
// 列表初始化方法(C++11)
int d { 7 }; // 花括号中的初始化器(直接列表初始化)
int e = { 8 }; // 等号后花括号中的初始化器(拷贝列表初始化)
int f {}; // 初始化器为空花括号(值初始化)
所有这些初始化方式都适用于类类型对象:
#include <iostream>
class Foo
{
public:
// 默认构造函数
Foo()
{
std::cout << "Foo()\n";
}
// 普通构造函数
Foo(int x)
{
std::cout << "Foo(int) " << x << '\n';
}
// 拷贝构造函数
Foo(const Foo&)
{
std::cout << "Foo(const Foo&)\n";
}
};
int main()
{
// 调用Foo()默认构造函数
Foo f1; // 默认初始化
Foo f2{}; // 值初始化(推荐)
// 调用foo(int)普通构造函数
Foo f3 = 3; // 拷贝初始化(仅限非显式构造函数)
Foo f4(4); // 直接初始化
Foo f5{ 5 }; // 直接列表初始化(推荐)
Foo f6 = { 6 }; // 拷贝列表初始化(仅限非显式构造函数)
// 调用foo(const Foo&)拷贝构造函数
Foo f7 = f3; // 拷贝初始化
Foo f8(f3); // 直接初始化
Foo f9{ f3 }; // 直接列表初始化(推荐)
Foo f10 = { f3 }; // 拷贝列表初始化
return 0;
}
在现代C++中,拷贝初始化、直接初始化和列表初始化本质上做的是同一件事——它们初始化一个对象。
对于所有类型的初始化:
- 当初始化类类型时,会检查该类的构造函数集合,并使用重载解析来确定最佳匹配的构造函数。这可能涉及参数的隐式转换。
- 当初始化非类类型时,会使用隐式转换规则来确定是否存在隐式转换。
关键洞察:初始化形式之间有三个关键区别:
- 列表初始化禁止窄化转换。
- 拷贝初始化只考虑非显式构造函数/转换函数。我们将在第14.16课——转换构造函数和
explicit
关键字中讨论这一点。 - 列表初始化优先匹配列表构造函数而不是其他匹配的构造函数。我们将在第16.2课——
std::vector
的介绍和列表构造函数中讨论这一点。
还值得注意的是,在某些情况下,某些初始化形式是被禁止的(例如,在构造函数的成员初始化列表中,我们只能使用直接初始化形式,而不能使用拷贝初始化)。
不必要的拷贝
考虑以下简单程序:
#include <iostream>
class Something
{
int m_x{};
public:
Something(int x)
: m_x{ x }
{
std::cout << "Normal constructor\n";
}
Something(const Something& s)
: m_x { s.m_x }
{
std::cout << "Copy constructor\n";
}
void print() const { std::cout << "Something(" << m_x << ")\n"; }
};
int main()
{
Something s { Something { 5 } }; // 关注这一行
s.print();
return 0;
}
在上述变量s
的初始化中,我们首先构造了一个临时Something
对象,用值5初始化(这调用了Something(int)
构造函数)。然后这个临时对象被用来初始化s
。由于临时对象和s
具有相同的类型(它们都是Something
对象),通常会调用Something(const Something&)
拷贝构造函数来将临时对象中的值拷贝到s
中。最终结果是s
被初始化为值5。
如果没有进行任何优化,上述程序将输出:
Normal constructor
Copy constructor
Something(5)
然而,这个程序是不必要的低效,因为我们不得不调用了两次构造函数:一次是Something(int)
,一次是Something(const Something&)
。注意,上述程序的结果与以下写法相同:
Something s { 5 }; // 只调用Something(int),不需要拷贝构造函数
这个版本产生了相同的结果,但更高效,因为它只调用了Something(int)
(不需要拷贝构造函数)。
拷贝消除
由于编译器可以自由地重写语句以进行优化,人们可能会想知道编译器是否可以优化掉不必要的拷贝,并将Something s { Something{5} };
当作我们直接写了Something s { 5 }
来处理。
答案是肯定的,这个过程被称为拷贝消除。拷贝消除是一种编译器优化技术,允许编译器消除对象的不必要拷贝。换句话说,在编译器通常会调用拷贝构造函数的地方,编译器可以自由地重写代码,完全避免调用拷贝构造函数。当编译器优化掉拷贝构造函数的调用时,我们说构造函数被省略了。
与其他类型的优化不同,拷贝消除不受“as-if”规则的限制。也就是说,即使拷贝构造函数有副作用(例如打印文本到控制台),也允许拷贝消除省略拷贝构造函数!这就是为什么拷贝构造函数除了拷贝之外不应有其他副作用——如果编译器省略了对拷贝构造函数的调用,副作用将不会执行,程序的可观察行为将发生变化!
相关内容:我们在第5.4课——as-if规则和编译时优化中讨论了as-if规则。
我们可以在上述示例中看到这一点。如果你在C++17编译器上运行该程序,它将产生以下结果:
Normal constructor
Something(5)
编译器省略了拷贝构造函数以避免不必要的拷贝,因此打印“Copy constructor”的语句没有执行!由于拷贝消除,我们程序的可观察行为发生了变化!
按值传递和按值返回中的拷贝消除
当将同类型的参数按值传递或使用按值返回时,通常会调用拷贝构造函数。然而,在某些情况下,这些拷贝可能会被省略。以下程序展示了其中的一些情况:
#include <iostream>
class Something
{
public:
Something() = default;
Something(const Something&)
{
std::cout << "Copy constructor called\n";
}
};
Something rvo()
{
return Something{}; // 调用Something()和拷贝构造函数
}
Something nrvo()
{
Something s{}; // 调用Something()
return s; // 调用拷贝构造函数
}
int main()
{
std::cout << "Initializing s1\n";
Something s1 { rvo() }; // 调用拷贝构造函数
std::cout << "Initializing s2\n";
Something s2 { nrvo() }; // 调用拷贝构造函数
return 0;
}
在C++14或更早版本中,如果禁用了拷贝消除,上述程序将调用拷贝构造函数4次:
- 当
rvo
返回Something
到main
时。 - 当
rvo()
的返回值用于初始化s1
时。 - 当
nrvo
返回s
到main
时。 - 当
nrvo()
的返回值用于初始化s2
时。
然而,由于拷贝消除,你的编译器可能会省略大部分或所有的这些拷贝构造函数调用。Visual Studio 2022省略了3种情况(它没有省略nrvo()
按值返回的情况),而GCC省略了全部4种。
记住编译器何时进行/不进行拷贝消除并不重要。只需知道这是编译器会执行的一种优化。如果你期望看到拷贝构造函数被调用,但它没有被调用,拷贝消除可能是原因。
C++17中的强制拷贝消除
在C++17之前,拷贝消除是编译器可以选择进行的一种优化。在C++17中,拷贝消除在某些情况下变得强制性。在这些情况下,拷贝消除将自动执行(即使你告诉编译器不要进行拷贝消除)。
在C++17或更新版本中运行上述相同示例时,当rvo()
返回以及当用该值初始化s1
时