类初始化与拷贝消除

早在变量赋值与初始化中,我们讨论了基本类型对象的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返回Somethingmain时。
  • rvo()的返回值用于初始化s1时。
  • nrvo返回smain时。
  • nrvo()的返回值用于初始化s2时。

然而,由于拷贝消除,你的编译器可能会省略大部分或所有的这些拷贝构造函数调用。Visual Studio 2022省略了3种情况(它没有省略nrvo()按值返回的情况),而GCC省略了全部4种。

记住编译器何时进行/不进行拷贝消除并不重要。只需知道这是编译器会执行的一种优化。如果你期望看到拷贝构造函数被调用,但它没有被调用,拷贝消除可能是原因。

C++17中的强制拷贝消除

在C++17之前,拷贝消除是编译器可以选择进行的一种优化。在C++17中,拷贝消除在某些情况下变得强制性。在这些情况下,拷贝消除将自动执行(即使你告诉编译器不要进行拷贝消除)。

在C++17或更新版本中运行上述相同示例时,当rvo()返回以及当用该值初始化s1

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"