14-15 类初始化和复制省略
早在第1.4课——变量赋值与初始化中,我们就讨论了基本类型对象的6种基础初始化方式:
int a; // no initializer (default initialization)
int b = 5; // initializer after equals sign (copy initialization)
int c( 6 ); // initializer in parentheses (direct initialization)
// List initialization methods (C++11)
int d { 7 }; // initializer in braces (direct list initialization)
int e = { 8 }; // initializer in braces after equals sign (copy list initialization)
int f {}; // initializer is empty braces (value initialization)
所有这些初始化类型对于具有类型的对象都是有效的:
#include <iostream>
class Foo
{
public:
// Default constructor
Foo()
{
std::cout << "Foo()\n";
}
// Normal constructor
Foo(int x)
{
std::cout << "Foo(int) " << x << '\n';
}
// Copy constructor
Foo(const Foo&)
{
std::cout << "Foo(const Foo&)\n";
}
};
int main()
{
// Calls Foo() default constructor
Foo f1; // default initialization
Foo f2{}; // value initialization (preferred)
// Calls foo(int) normal constructor
Foo f3 = 3; // copy initialization (non-explicit constructors only)
Foo f4(4); // direct initialization
Foo f5{ 5 }; // direct list initialization (preferred)
Foo f6 = { 6 }; // copy list initialization (non-explicit constructors only)
// Calls foo(const Foo&) copy constructor
Foo f7 = f3; // copy initialization
Foo f8(f3); // direct initialization
Foo f9{ f3 }; // direct list initialization (preferred)
Foo f10 = { f3 }; // copy list initialization
return 0;
}

在现代C++中,复制初始化、直接初始化和列表初始化本质上实现相同功能——它们都用于初始化对象。
所有初始化类型均遵循以下规则:
初始化类类型时,系统会检查该类的构造函数集合,并通过重载解析确定最匹配的构造函数。此过程可能涉及参数的隐式转换。
初始化非类类型时,则通过隐式转换规则判断是否存在隐式转换。
核心要点:
三种初始化形式存在三大关键差异:
- 列表初始化禁止收窄转换。
- 复制初始化仅考虑非显式构造函数/转换函数。详见第14.16节——转换构造函数与显式关键字。
- 列表初始化优先匹配列表构造函数而非其他构造函数。详见第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 } }; // focus on this line
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 }; // only invokes Something(int), no copy constructor
此版本产生相同的结果,但效率更高,因为它仅调用Something(int)方法(无需复制构造函数)。

复制省略 Copy elision
由于编译器可以自由重写语句以实现优化,人们可能会好奇:编译器能否优化掉不必要的复制操作,将 Something s { Something{5} }; 视为我们最初就写了 Something s { 5 }。
答案是肯定的,这一过程称为复制省略。复制省略 Copy elision是编译器优化技术,允许编译器移除对象的冗余复制操作。换言之,在编译器通常会调用复制构造函数的场景中,编译器可自由重写代码以完全避免该调用。当编译器优化掉复制构造函数调用时,我们称该构造函数已被省略elided。
与其他优化不同,复制省略不受“如同”规则限制。即即使复制构造函数存在副作用(如向控制台打印文本),仍允许进行省略!正因如此,复制构造函数除复制操作外不应包含其他副作用——若编译器省略了对复制构造函数的调用,这些副作用将无法执行,导致程序的可观察行为发生改变!
相关内容:
我们在第5.4课《“如同”规则与编译时优化》中讨论过“如同”规则。
在上例中可见此现象。若使用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{}; // calls Something() and copy constructor
}
Something nrvo()
{
Something s{}; // calls Something()
return s; // calls copy constructor
}
int main()
{
std::cout << "Initializing s1\n";
Something s1 { rvo() }; // calls copy constructor
std::cout << "Initializing s2\n";
Something s2 { nrvo() }; // calls copy constructor
return 0;
}

禁止复制构造的结果:
# 构建(编译连接)
clang++ -std=c++14 -fno-elide-constructors main.cpp -o main
# 运行
./main

在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用该值初始化时本应发生的复制构造函数调用必须被省略。而使用nvro()初始化s2的情况不属于强制省略场景,因此此处发生的两次复制构造函数调用是否被省略取决于编译器和优化设置。
在可选省略场景中,即使实际调用被省略,也必须存在可访问的复制构造函数(例如未被删除)。
在强制省略场景中,无需存在可访问的复制构造函数(换言之,即使复制构造函数已被删除,强制省略仍可能发生)。
进阶读者须知:
当可选复制省略未发生时,移动语义仍可能允许对象被移动而非复制。移动语义将在第16.5节——《返回std::vector与移动语义导论》中详细介绍。

浙公网安备 33010602011771号