(三)C++对象内存模型那些事儿:特殊成员函数

(三)C++对象内存模型那些事儿:特殊成员函数

0. concepts

在 C++编程中,编译器会自动为类生成一些特殊成员函数,以支持诸如对象的初始化、拷贝、移动和销毁等基本操作。然而,一旦用户为类定义了特定特殊成员函数的自定义版本,编译器便不会再生成该函数。此外,即便用户未定义某些特殊成员函数,在特定条件下,编译器也不会自动生成它们。

以下通过一个类Example的示例代码来展示各类特殊成员函数的常见定义形式:

class Example {
public:
    int value;

    // 一般构造函数
    Example() : value(0) {}

    // 析构函数
    ~Example() {}

    // 拷贝构造函数
    Example(const Example& other) : value(other.value) {}

    // 拷贝赋值运算符
    Example& operator=(const Example& other) {
        if (this != &other) {
            value = other.value;
        }
        return *this;
    }

    // 移动构造函数(C++11 及以后)
    Example(Example&& other) noexcept : value(other.value) {
        other.value = 0;
    }

    // 移动赋值运算符(C++11 及以后)
    Example& operator=(Example&& other) noexcept {
        if (this != &other) {
            value = other.value;
            other.value = 0;
        }
        return *this;
    }
};

C++中的特殊成员函数主要包括以下几种:

  1. 默认构造函数T(),若类中未定义任何构造函数,编译器将为该类生成默认构造函数。
  2. 析构函数~T(),当类中未定义析构函数时,编译器会自动生成析构函数。
  3. 拷贝构造函数T(const T& other),在类中未定义拷贝构造函数且满足某些特定条件时,编译器会生成拷贝构造函数。
  4. 拷贝赋值运算符T& operator=(const T& other),若类中未定义拷贝赋值运算符且满足特定条件,编译器将生成该运算符。
  5. 移动构造函数(C++11 及以后)T(T&& other),在类中未定义移动构造函数且满足特定条件时,编译器会生成移动构造函数。
  6. 移动赋值运算符(C++11 及以后)T& operator=(T&& other),若类中未定义移动赋值运算符且满足特定条件,编译器将生成移动赋值运算符。

接下来,我们将围绕这些特殊成员函数,详细探讨它们在何时会被默认生成,何时不会被生成,以及它们各自的行为和意义。

1. 构造函数

1.1 默认构造函数

1.1 默认构造函数

在 C++中,通常情况下编译器会为类生成默认构造函数,但在以下几种特定情形下无法生成:

  1. 类中有一个没有默认构造函数的成员对象:如果类中包含其他类类型的成员对象,且该成员对象所属的类没有默认构造函数,编译器就不能为当前类生成默认构造函数。因为在创建当前类对象时,需要初始化这个成员对象,但由于其没有默认构造函数,编译器不知道如何进行初始化操作。
  2. 基类没有默认构造函数:当一个类继承自某个基类,若基类没有默认构造函数,编译器无法为派生类生成默认构造函数。因为派生类对象在构造时,首先要调用基类的构造函数来初始化基类部分,如果基类没有默认构造函数,编译器就无法确定如何初始化基类部分。
  3. 类中有一个const引用类型的成员const成员在初始化后其值不能改变,引用必须在初始化时绑定到一个对象。编译器生成的默认构造函数无法为const成员或引用成员提供合适的初始化方式,所以在这种情况下不会生成默认构造函数。
  4. 类中有一个delete的默认构造函数:如果在类中显式地将默认构造函数定义为delete,这表示不允许使用默认构造函数来创建对象,编译器自然不会再生成默认构造函数。
  5. 类中有一个私有的默认构造函数:当默认构造函数被声明为私有,意味着只有类内部的成员函数或友元函数可以访问它,外部代码无法使用默认构造函数创建对象,编译器也不会生成默认构造函数。

简单概括,当父类或成员变量的特性导致无法进行默认初始化时,编译器就无法生成合法的默认构造函数。除上述情况外,编译器通常会为类生成默认构造函数。

1.2 对象的构造顺序

C cObj;
    C::C()
        B::B()
            A::A()
                vptr = A::vftable;
                cout << "A::A()" << endl;
            // done A ctor
            vptr = B::vftable;
            cout << "B::B()" << endl;
        // done B ctor
        vptr = C::vftable;
        m_c = 11;
        cout << "C::C()" << endl;
    // done C ctor

在上述代码中,C类继承自B类,B类又继承自A类。当创建C类对象cObj时,构造顺序如下:

  1. 首先调用A类的构造函数。在A类构造函数执行时,对象的虚函数表指针指向A类的虚函数表。此时如果调用虚函数print,会调用A类版本的print函数。这是因为在父类构造期间,对象的虚函数表指针指向的是父类的虚函数表。
  2. 接着调用B类的构造函数。此时虚函数表指针更新为指向B类的虚函数表,但由于在B类构造函数执行前,A类构造函数已经执行完毕,所以在A类构造函数里调用虚函数时,依然调用的是A类版本的虚函数。
  3. 最后调用C类的构造函数,对C类特有的成员变量m_c进行初始化。

综上所述,在继承体系中,对象的构造是从基类开始,逐步向派生类进行的,并且在构造过程中虚函数表指针会根据当前正在构造的类进行相应更新,这对于理解多态在构造函数中的行为非常关键。

因此,在父类构造函数里调用虚函数时,还是调用父类版本的虚函数。

2. 析构函数

当类未定义自定义析构函数时,编译器会自动生成一个合成析构函数。

2.1 合成析构函数行为

  • 空函数情况:若类中不存在自定义的资源管理操作(例如动态分配内存等情况),那么合成的析构函数实际上是一个空函数。这表明它不会执行针对类成员资源的清理操作。
  • 成员变量的析构:当类包含非静态成员变量,且这些成员变量具有各自的析构函数(无论是用户自定义的还是编译器合成的)时,合成析构函数会按照成员在类声明中的逆序,调用它们各自的析构函数。这种顺序保证了每个成员所占用的资源都能得到妥善释放。
  • 基类析构函数的调用:在继承体系里,如果基类拥有合成析构函数,派生类的合成析构函数会在执行自身的析构操作之后,自动调用基类的析构函数,同样遵循逆序原则。

2.2 自定义析构函数的扩展行为

为了更好地理解自定义析构函数的扩展行为,假设存在类 A,类 A 包含一个类类型成员 m_jm_j 所属的类为 JI,且 JI 带有析构函数 ~JI()。同时,类 A 继承自某个带有析构函数的基类。

当我们为类 A 自定义析构函数 ~A() 时,编译器会在合适的时机对该析构函数代码进行扩展,以确保所有资源都能被正确释放。

  • 成员变量的析构:编译器会扩展类 A 的析构函数 ~A() 的代码,使其先执行类 A 自定义析构函数的代码逻辑,之后再执行 JI 类针对成员 m_j 的析构函数 ~JI() 的代码,以此保证类 A 中成员变量 m_j 的资源得到正确释放。
  • 基类析构函数的调用:编译器会对类 A 的析构函数进行扩展,使其在执行完类 A 自身的析构操作后,调用基类的析构函数。通过这种方式,确保基类的资源也能被正确释放。

3. 拷贝构造函数

3.1 什么时候不会生成默认拷贝构造函数?

  1. 显式声明或删除:若在类中显式声明了拷贝构造函数,或者将拷贝构造函数声明为delete,编译器将不会生成默认的拷贝构造函数。
  2. 成员或基类问题:当类成员变量或基类包含没有拷贝构造函数的成员对象时,编译器无法生成默认拷贝构造函数。因为在拷贝时,需要对所有成员进行拷贝,若存在无法拷贝的成员,就无法完成默认拷贝构造。
  3. 特殊成员类型:如果类中含有const引用类型的成员,编译器也不会生成默认拷贝构造函数。因为const成员一旦初始化后值不能改变,引用必须在初始化时绑定到特定对象,这两种情况都不适合默认的拷贝构造方式。

简单概括,若没有显式定义拷贝函数,且类中的所有成员都支持拷贝操作,编译器会为类生成默认拷贝构造函数。反之,若类中存在无法拷贝的成员,如std::unique_ptr类型成员,或定义了删除拷贝构造函数的类型成员,或有const引用类型成员变量,编译器就不会生成。

3.2 生成的拷贝构造函数有什么特殊行为?

  • 逐位拷贝构造(浅拷贝):当满足以下所有条件时,编译器生成的拷贝构造函数会进行逐位拷贝,这种方式有时也被视为一种特殊的浅拷贝:

    • 类没有用户自定义的拷贝构造函数。
    • 类没有虚函数。
    • 类没有基类,或者基类没有自定义的拷贝构造函数。
    • 类的所有非静态数据成员都支持逐位拷贝。在此情况下,拷贝行为类似于memset(),直接按位复制数据,不会调用通常意义上的拷贝构造函数,所以有些资料会说此过程不调用拷贝构造函数。
  • 一般拷贝构造(深拷贝或复杂拷贝)

    • 成员拷贝:对于能够逐位拷贝的成员,进行逐位拷贝。
    • 虚函数指针处理:类的虚函数指针不会被覆盖,以保证多态性的正确实现。
    • 继承体系拷贝:先调用父类的拷贝构造函数,再调用子类自身的拷贝构造函数,确保继承体系中各级对象都被正确拷贝。

3.3 什么时候会调用拷贝构造函数?

以下代码展示了拷贝构造函数的常见调用场景:

Foo func(Foo foo){  // foo 以值传递方式传入函数,此时会调用拷贝构造函数创建 foo 的副本
    return Foo{};
}

Foo foo;
Foo copiedFoo = foo; // 通过拷贝方式进行赋值操作,调用拷贝构造函数创建 copiedFoo
auto newFoo = func(foo);  // 函数返回值被拷贝到 newFoo,调用拷贝构造函数

3.4 为什么拷贝的形参是const T&

  1. 性能考量:当拷贝构造函数的参数采用值传递时,每次调用函数都需要创建该对象的一个全新副本,这涉及对象的完整复制操作,在对象较大时,会消耗大量的时间和内存资源,导致性能下降。而使用常量引用const T&作为参数,仅传递对象的引用,无需复制整个对象,大大提高了性能,尤其在处理大型对象时优势明显。
  2. 避免递归调用:若拷贝构造函数的参数是值传递,在调用拷贝构造函数创建新对象时,又会触发对新对象的拷贝构造,从而形成无限递归调用,最终导致程序崩溃。而采用常量引用作为参数,不会触发拷贝构造函数的递归调用,有效地避免了这种问题。

综上所述,拷贝构造函数的参数通常设计为常量引用const T&,以兼顾性能和程序稳定性。

4. 移动构造函数

4.1 什么时候会生成移动构造函数/移动赋值函数?

  • 没有析构、拷贝构造、拷贝赋值的时候
  • 所有成员变量都可以移动。

简单来说,从 C++11 开始,如果你没有显式定义析构、拷贝构造、拷贝赋值这三个函数,且类中的所有成员都可以被移动,编译器就会为你生成这两个函数。反过来说,如果类中有不能被移动的成员(例如,定义了删除的移动构造函数的类型的成员);或者你已经定义了拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会生成这两个函数。

4.2 怎么理解移动构造函数?

移动构造函数的核心意义在于实现对象资源所有权的转移。它在语义层面与传统的拷贝操作有着本质区别,其重点并非在于优化性能,尽管在某些场景下可能带来性能提升。

从性能角度看,移动构造函数的开销往往与浅拷贝相近。有一种常见误解,认为移动构造函数依赖于编译器的特殊实现,能够像返回值优化(RVO)那样,完全避免开辟新的内存空间,仅通过更改所有权来完成操作。然而实际情况是,对于自定义类型,在移动过程中通常仍需先开辟空间,之后才对资源所有权进行处理。

以包含纯粹基本类型数据的自定义类型为例,其拷贝操作与移动操作在开销上几乎没有差异。这是因为基本类型数据的拷贝本身就较为高效,不存在复杂的资源管理问题,所以移动构造函数在此场景下无法体现出显著的性能优势。

因此,理解移动构造函数时,不应单纯从性能优化的角度出发,而要着重把握其语义,即实现资源所有权从一个对象到另一个对象的转移。这种语义上的改变,在处理动态资源(如动态分配的内存、文件句柄等)时,能够有效避免不必要的资源复制,从而在某些情况下提升程序的整体效率,并简化资源管理流程。例如,当一个对象持有动态分配的内存,在移动构造过程中,该内存的所有权直接转移到新对象,原对象不再拥有对这块内存的控制权,避免了重复的内存分配与释放操作,同时确保了资源的正确管理,防止内存泄漏等问题的发生。

4.3 为什么移动的构造赋值用 noexcept,但是拷贝的没有?

在 C++中,移动构造和移动赋值通常使用noexcept关键字进行修饰,而拷贝操作则不然,这主要基于以下几方面原因:

移动操作的特性:移动操作的本质通常是转移资源指针,这一过程一般不会抛出异常。例如,std::vector的移动构造函数就保证了noexcept。由于移动操作相对简单且稳定,声明noexcept能够让编译器针对此类操作进行优化。以std::vector为例,在空间不足时,编译器会优先选择移动元素而非拷贝元素,因为移动操作不抛出异常,编译器可以更高效地进行优化处理,从而提升程序性能。

拷贝操作的特性:拷贝操作往往涉及堆资源的分配,比如使用new关键字进行内存分配。在这个过程中,可能会因为内存不足等原因抛出异常。所以,拷贝操作不能简单地标记为noexcept,否则一旦出现异常,程序将面临未定义行为的风险。

标准库的约束:C++标准对容器的操作有明确要求,如果移动构造函数未标记为noexcept,像std::vector这样的容器在扩容时会退化为拷贝操作。这意味着原本可以通过移动操作实现的高效资源转移将无法实现,从而丧失移动优化带来的性能优势。

综上所述,由于移动操作和拷贝操作本身的特性差异,以及 C++标准库的相关约束,使得移动构造和移动赋值使用noexcept,而拷贝操作不使用该关键字。

5. 成员初始化列表

5.1 何时需要初始化列表

在 C++编程中,理解何时使用初始化列表至关重要。以下通过具体代码示例来详细说明。

class Base {
public:
    explicit Base(int value) : baseValue(value) {}

private:
    int baseValue;
};

class AnotherClass {
public:
    explicit AnotherClass(int num) : number(num) {}

private:
    int number;
};

class Derived : public Base {
public:
    // 3. 类继承自一个基类,基类中有构造函数,构造函数里还有参数
    Derived(int& refValue, int constValue, int baseInitValue,
            int anotherClassInitValue)
        : Base(baseInitValue),
          ref(refValue),
          constMember(constValue),
          another(anotherClassInitValue) {
        std::cout << "Derived constructor called" << std::endl;
    }

private:
    int& ref;               // 1. 成员变量是引用类型
    const int constMember;  // 2. 成员变量是 const
    // 4. 类中成员变量的类型是某个类类型,类类型的构造函数有参数
    AnotherClass another;
};

在 C++中,对象的成员变量在进入构造函数体之前就会进行初始化。这是因为若成员变量未初始化,在构造函数内使用中会引发错误。而初始化列表就是一种介入并控制这种初始化行为的有效方式。具体在以下几种情况下必须使用初始化列表:

  1. 成员变量为引用类型:引用在创建时就必须绑定到一个已存在的对象,无法在构造函数体中进行赋值操作。因此,需要通过初始化列表在构造时完成绑定。例如上述代码中Derived类的ref成员变量。

  2. 成员变量为const类型const成员变量一旦初始化后其值就不能改变,所以必须在初始化阶段就赋予其初始值,只能通过初始化列表来实现。如Derived类中的constMember成员变量。

  3. 类继承自带有参数构造函数的基类:当派生类继承自一个基类,且基类的构造函数带有参数时,派生类必须在初始化列表中调用基类的构造函数,并传递相应参数,以确保基类部分能正确初始化。就像Derived类对Base类的继承关系。

  4. 成员变量为类类型且该类构造函数有参数:如果类中包含其他类类型的成员变量,并且该成员变量所属类的构造函数需要参数,那么就需要在初始化列表中为其传递参数来完成初始化。例如Derived类中的AnotherClass类型成员变量another

若在上述情况下不使用初始化列表,代码将无法通过编译。因此,也不需要一一记忆。只需要大致理解即可。

5.2 初始化列表的优势

初始化列表在C++编程中具有显著优势,主要体现在提升程序运行效率方面,尤其对于类类型的数据,效果更为明显,而对于内置类型,效率提升相对不显著。

  • 类类型与内置类型的效率差异

    • 类类型:将类类型的成员变量放在初始化列表中初始化,效率提升较为明显。这是因为若不在初始化列表中进行初始化,在进入当前类的构造函数之前,会先通过默认构造函数创建类类型成员变量的临时对象,在构造函数体中可能还会涉及类似拷贝构造或赋值的操作,最后临时对象析构,这一系列过程产生了较大的开销。例如,若有一个自定义类MyClass,其构造函数有参数,若不在初始化列表中初始化,会先默认构造一个临时对象,然后在构造函数体中再对其进行赋值操作,而临时对象的创建和析构都需要额外的时间和资源。
    • 内置类型:对于内置类型,使用初始化列表和在构造函数体内初始化的效率基本一致。因为内置类型的初始化相对简单,通常只是进行简单的赋值操作,不存在复杂的构造和析构过程。
  • 初始化列表的执行特点

    • 执行位置:初始化列表中的代码实际上是由编译器安插在构造函数之中的,并且在构造函数的函数体代码执行之前就会被执行。这意味着在构造函数体开始执行时,所有成员变量已经通过初始化列表完成了初始化,构造函数体中可以直接使用这些已初始化的成员变量。
    • 初始化顺序:初始化列表中成员变量的初始化顺序取决于它们在类中定义的顺序,而非在初始化列表中出现的顺序。特别地,对于类类型的成员变量,在进入构造函数体前,会先调用其默认构造函数进行初始化(若不在初始化列表中显式指定构造方式)。例如:
class Example {
    int a;
    MyClass obj; // MyClass为自定义类
    int b;
public:
    Example(int val1, int val2) : b(val2), a(val1), obj(val1) {
        // 构造函数体
    }
};

在上述代码中,虽然在初始化列表中b先出现,a后出现,但实际初始化顺序是aobjb,因为它们在类中定义的顺序是a在前,obj其次,b最后。

6. 成员函数的调用

6.1 成员函数的调用方式

非静态成员函数: 非静态成员函数依赖于对象实例进行调用。C++ 确保非静态成员函数的调用效率至少与普通函数相当,为此编译器会对其进行一系列转换:

  1. 插入 this 指针参数:编译器将非静态成员函数改写为普通函数形式,并插入一个额外参数,即 this 指针。this 指针指向调用该成员函数的对象实例,为访问对象的成员变量和其他成员函数提供通道。例如,对于类 MyClass 中的非静态成员函数 void func(),编译器可能将其改写为 void func(MyClass* this)
  2. 通过 this 指针访问成员:在改写后的函数里,所有对非静态成员变量和成员函数的访问操作,都调整为通过 this 指针来实现。比如,原函数中访问成员变量 data,会被改写为 this->data
  3. 生成唯一外部函数名:编译器会把成员函数转变为外部函数,并为其生成独一无二的名称,防止与其他函数重名。这个独特名称一般包含类名与成员函数名相关信息。例如,类 MyClass 中的 func 函数,可能被命名为 MyClass_func

6.2 虚函数成员函数的调用

虚函数成员函数的调用机制与非静态成员函数有所不同,它主要用于实现多态性。当通过基类指针或引用调用虚函数时,实际调用的函数版本取决于指针或引用所指向对象的实际类型,而非指针或引用本身的类型。

这一过程依赖于虚函数表(vtable)和虚函数表指针(vptr)。每个包含虚函数的类都有一个虚函数表,表中存储着该类虚函数的地址。对象中则包含一个虚函数表指针,指向所属类的虚函数表。

在运行时,当通过基类指针或引用调用虚函数时,程序首先根据对象的虚函数表指针找到对应的虚函数表,然后在表中查找并调用实际对象类型对应的虚函数版本。例如:

class Base {
public:
    virtual void print() {
        std::cout << "Base::print()" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived::print()" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    ptr->print(); // 实际调用 Derived::print()
    delete ptr;
    return 0;
}

在上述代码中,虽然 ptrBase 类型的指针,但由于 print 函数是虚函数,且 ptr 实际指向 Derived 类型的对象,所以调用 ptr->print() 时,会执行 Derived::print() 函数,体现了虚函数实现多态的特性。

99. quiz

1. 为什么基类的析构函数要声明virtual?

在C++的多态机制里,通过基类指针操作对象时,该指针实际可能指向某个派生类对象。例如:

Base* ptr = new Derived();
delete ptr;

在此情况下,如果基类的析构函数未声明为virtual,当执行delete ptr时,只会调用基类的析构函数,派生类的析构函数不会被调用。这是因为在非虚析构函数的情况下,编译器依据指针类型(即基类类型)来确定调用的析构函数。而派生类在构造函数中可能分配了一些资源,若其析构函数未被调用,这些资源将无法在析构时得到正确释放,进而引发资源泄漏问题。

值得留意的是,C++中派生类的析构函数在执行完毕后会隐式调用其父类的析构函数。这表明父类资源一般由父类自身的析构函数负责清理,子类通常无需直接管理父类资源。但在多态场景下,前提是基类析构函数必须声明为virtual,如此才能保证在销毁对象层次结构时,每个类的析构函数都能被正确调用。

这里简单回顾一下构造和析构的顺序:创建对象时,先调用父类构造函数,再调用子类构造函数;销毁对象时,顺序则相反,先调用子类析构函数,再调用父类析构函数。在对象拷贝时,同样先执行父类的拷贝操作,再执行子类的拷贝操作。即构造和析构顺序为:父构 - 子构 - 子析 - 父析;拷贝顺序为:父拷 - 子拷。

2. 为什么要声明override?

在C++中,使用override关键字具有重要意义,主要体现在以下几个方面:

对开发人员而言

  • 明确意图override关键字清晰地表明派生类中的函数是对基类虚函数的重写,显著提高了代码的可读性和可维护性。开发人员在阅读代码时,能迅速了解函数之间的重写关系,使代码意图一目了然。例如,当看到派生类函数声明中有override关键字,就可明确该函数是对基类虚函数的重新定义,方便理解代码逻辑。

对编译器来说

  • 编译器检查override关键字可让编译器对函数签名进行检查,确保其与基类中的虚函数正确匹配。若函数签名不匹配,编译器会报错,有效避免潜在错误。例如,若基类虚函数有特定参数列表,而派生类重写函数的参数列表与之不同,使用override关键字时编译器就能及时发现并提示错误。
  • 防止意外重载:若派生类中的函数签名与基类虚函数不匹配,编译器会将其视为新函数,而非重写函数。使用override关键字可防止这种意外重载情况的发生,保证代码按照预期的多态行为运行。

虽然使用override和不使用override生成的代码在运行时行为上并无差异,去掉已有代码中的所有override关键字也不会改变程序的运行结果,但为提升代码质量,避免潜在问题,强烈建议在重写基类虚函数时使用override关键字。

3. 如果父类声明了虚析构,那么同样地,拷贝,移动也需要是虚的吗?

答案是不需要。

父类声明虚析构函数,主要目的是确保在多态场景下析构方法能被正确调用。以std::vector<Animal*>容器为例,当释放其中存储的对象资源时,如果Animal类(作为父类)的析构函数不是虚函数,且容器中实际存放的是派生类对象指针,那么在析构时就只能调用到父类的析构函数,派生类的析构函数不会被执行,进而无法正确释放派生类特有的资源。这是因为对于多态类型,析构操作通常在RAII(资源获取即初始化)管理器中进行,或者在对象自身被销毁时执行。若此时对象的静态类型是基类类型,而非实际的派生类类型,就会出现资源释放不完整的问题。

然而,拷贝构造函数和移动构造函数本质上属于构造方法,在构造过程中不能使用虚函数。这是由于对象构造过程遵循特定顺序:先调用基类的构造函数,再调用派生类的构造函数。在基类构造函数执行阶段,派生类部分尚未构造完成。

具体来说,在父类构造函数执行时,虚函数指针指向的是基类虚函数表。因为子类虚函数可能依赖子类成员变量,而此时子类成员变量还未初始化,所以子类虚函数不应被调用。从安全角度考虑,父类构造时虚函数指针只能指向父类方法。只有在子类构造完成后,虚函数指针才会指向子类虚函数表。所以,即便在构造函数中调用虚函数,父类构造调用的是父类虚方法,子类构造调用的是子类虚方法,无法体现多态特性。因此,拷贝和移动构造函数不需要声明为虚函数。

4. 为什么虚函数指针不能被拷贝?

#include <iostream>

class Animal {
   public:
    Animal() { std::cout << "Animal constructor" << std::endl; }

    Animal(const Animal& other) {
        std::cout << "Animal copy constructor" << std::endl;
    }

    virtual void eat() { std::cout << "Animal eats" << std::endl; }
};

class Dog : public Animal {
   public:
    Dog() { std::cout << "Dog constructor" << std::endl; }

    void eat() override { std::cout << "Dog eats" << std::endl; }
};

int main() {
    Animal dog = Dog();  // 对象切片,调用 Animal 的拷贝构造函数
    dog.eat();           // 调用 Animal::eat()
    return 0;
}

  • 为什么调用的是 Animal::eat() 方法?Animal dog = Dog(); 这个过程发生了什么?

当执行 Animal dog = Dog(); 时,此过程实际是拷贝构造,会调用 Animal 的拷贝构造函数。由于 Animal 类的拷贝构造函数形参为 const Animal&,所以需要将 Dog 对象转换为 Animal 对象。在此过程中,Dog 对象会发生一次隐式转换,即所谓的“对象切片”。Dog 对象中属于 Dog 类特有的部分会被“切掉”,仅保留 Animal 类的部分,并拷贝到 dog 中。这是因为 dog 在栈上申请内存时,是按照 Animal 类的大小进行申请的,其内存布局也遵循 Animal 类的结构。

从结果可知,虚函数指针同样被“切掉”。若派生类存在虚函数指针,而父类没有虚函数指针,那么 Dog 的虚函数指针被切掉相对容易理解。但即便父类存在虚函数指针,为何不能直接拷贝子类的虚函数指针呢?

这主要是出于安全性的考量,编译器禁止了此类行为。假设子类的虚函数指针被拷贝到父类对象中,那么该对象便可能调用子类的虚函数。然而,子类的虚函数很可能依赖于子类特有的成员变量,而父类对象中并不包含这些成员变量。例如,若 Dog 类的 eat 虚函数需要访问 Dog 类特有的成员变量 breed(假设存在),当通过拷贝了 Dog 虚函数指针的 Animal 对象调用 Dog::eat 时,由于该 Animal 对象中不存在 breed 成员变量,就会导致程序出现未定义行为,如内存访问错误等,严重影响程序的稳定性和正确性。因此,编译器在设计时,为避免此类潜在问题,直接将子类的虚函数指针在对象切片过程中“切掉”,以确保程序的安全性和可靠性。

5. 成员函数模板可以为虚函数吗?

成员函数模板不能成为虚函数。原因在于,若允许成员函数模板为虚函数,每次以不同的模板类型调用该虚函数模板时,都会生成一个新的虚函数实例。这将使得虚函数表的内容无法在编译阶段确定,而只能在程序链接阶段才能明确。

虚函数表在 C++ 中起着关键作用,它存储了类的虚函数地址。正常情况下,虚函数表的内容在编译阶段就已确定,以便在运行时能够高效地进行虚函数调用。然而,若虚函数表的内容需在链接阶段确定,链接器就需要重新解析和调整所有涉及虚函数调用的代码。这无疑会极大地增加链接器的复杂性和工作量,对程序的构建过程产生不利影响。所以,基于这种机制和实际操作的考量,成员函数模板不能被定义为虚函数。

6. 类的构造、析构出现异常会怎么样?怎么处理?

这个问题的考量在于一旦构造函数抛出异常,对象的创建就会失败,它的析构函数不会被调用。而析构函数不调用就会有潜在的内存泄露风险。

面对这种问题,一般得这么做:(1)构造函数内部捕获异常,如果异常就delete资源。如果有多个资源,实现哪些资源需要delete的时候,会比较麻烦。(2)智能指针管理资源。因为智能指针对于这个类来说,是栈上的资源。栈上的资源即使抛出异常也会正常释放。这个时候资源的管理是安全的。

当构造函数中使用 new 操作符分配内存时,如果分配成功,资源会被分配给指针变量。如果在构造函数的某个 new 操作之后抛出异常,构造函数会立即退出,且不会调用析构函数,因为对象还没有完全构造出来。

然而,已经成功构造的成员变量会被自动销毁,这意味着它们的析构函数会被调用,从而释放已经分配的资源。可如果成员变量是堆上的资源,则会出现资源泄露问题。因此最好是使用智能指针去管理。

类的析构函数也是类似的。

7. 为什么拷贝赋值、移动赋值要判断this指针不相同?

在拷贝赋值操作中,如果不先判断this指针与传入对象的指针是否相同,而是直接先释放自身资源再进行拷贝,就可能出现严重问题。例如,当进行自赋值(即this指针与传入对象指针相同)时,释放自身资源后,就无法从传入对象(此时自身已被释放)拷贝数据,从而导致程序出错。

对于移动赋值操作同样如此。若不判断this指针与传入对象指针是否相同,直接窃取传入对象的资源,在自赋值情况下,会出现“自己释放自己资源”的错误。比如执行data = other.data; other.data = nullptr;语句时,如果是自赋值,data会被错误地置空。所以,在拷贝赋值和移动赋值操作中,判断this指针是否相同是非常必要的,以避免这些潜在错误。

8. 如果拷贝构造函数使用T& other作为形参会怎样?

在C++中,拷贝构造函数即便使用T& other作为形参,也是可以声明的。然而,通常我们会选择覆盖编译器默认生成的拷贝构造函数,并且使用const T& other作为形参具有明显优势。

const T& other这种形式能够从任何对象(包括const对象和临时对象)进行拷贝。这是因为在C++语言规则中,临时对象(右值)不能绑定到非const的左值引用上,但可以绑定到const的左值引用上。这样设计主要是为了防止临时对象被意外修改。若使用T& other作为形参,就无法从临时对象进行拷贝,限制了拷贝构造函数的使用场景。所以,从通用性和安全性角度考虑,const T& other是更优的选择。

9. 拷贝构造函数实现方式可以用memcpy()方法吗?

不建议在拷贝构造函数中使用memcpy()方法。如前文所述,编译器会为类添加一些隐藏成员变量,例如虚函数指针。当使用memcpy()进行逐位拷贝时,虚函数指针往往无法得到正确处理。虚函数指针对于类的多态性实现至关重要,不正确的拷贝可能导致运行时错误,使程序在调用虚函数时出现未定义行为。因此,为确保拷贝构造函数的正确性和可靠性,不应使用memcpy()来实现。

10. 继承的析构函数一般要怎么处理?继承的特殊成员函数怎么处理?

在继承体系中,对于析构函数及其他特殊成员函数,需遵循特定的处理方式。

析构函数:若存在基类,基类的析构函数通常应声明为虚函数。这是为了保证在通过基类指针或引用释放派生类对象时,能够正确调用派生类的析构函数,防止资源泄漏。例如,当在基类指针上执行delete操作时,如果基类析构函数不是虚函数,就只会调用基类的析构函数,派生类中分配的资源可能无法释放。

构造函数

  • 默认构造函数:派生类无论是使用默认的构造函数,还是自定义构造函数,都会自动调用父类的默认构造函数,无需额外操作。
  • 有参构造函数:由于基类无法得知派生类构造时应传入的参数,因此派生类在使用有参构造函数时,必须手动调用父类的有参构造函数。实际开发中,常出现父类同时具备默认构造函数和有参构造函数,而派生类使用有参构造函数时,未显式调用父类有参构造函数,从而导致默认调用父类的默认构造函数。为避免重复调用父类构造函数,应通过初始化列表调用父类的有参构造函数。

拷贝构造、拷贝赋值、移动构造、移动赋值函数:在派生类中实现这些函数时,首先要在初始化列表中调用父类的相应函数,然后再实现派生类自身的逻辑。以移动操作为例,虽然使用std::move(other)看似将整个对象的资源都移动走了,但实际上父类只会处理父类的资源,子类资源不受影响,可继续安全使用。也就是说,初始化列表中的std::move操作仅对父类资源进行移动,子类仍可利用other对象完成自身资源的移动。

总结如下:

  • 基类析构函数应声明为虚函数,确保派生类析构函数能正确调用。
  • 派生类构造函数,特别是有参构造函数,需显式调用基类的构造函数。
  • 派生类的拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符,应在初始化列表中显式调用基类的相应函数,以保证基类部分能正确复制或移动。

11. 如果在构造函数中调用 memset(this, 0, sizeof(*this))来初始化内存空间,有什么问题吗?

在构造函数中调用memset(this, 0, sizeof(*this))初始化内存空间存在诸多问题:

  • 对象成员初始化混乱:对于类中的非POD(Plain Old Data,平凡旧数据类型,如包含构造函数、虚函数等的类)成员,memset会破坏对象的内部结构。例如,若类中包含std::string类型成员,std::string内部有自己的管理机制,memset将其内存置零后,会导致std::string对象处于无效状态,后续使用时可能引发未定义行为,如内存访问错误。

  • 虚函数表指针问题:如果类中有虚函数,虚函数表指针对于实现多态至关重要。memset将内存清零会破坏虚函数表指针,使得程序在调用虚函数时无法正确找到对应的函数地址,从而导致运行时错误。

  • 基类部分初始化问题:在继承体系中,这样做无法正确初始化基类部分。memset会覆盖基类已经初始化好的成员变量和虚函数表等重要信息,导致基类部分功能异常。

  • 初始化不完整memset只能对基本数据类型进行简单的按位清零操作,对于类中的指针成员,只是将指针值清零,而不会释放指针所指向的内存,可能导致内存泄漏。

综上所述,虽然memset在某些特定场景下对POD类型数据的初始化有用,但在类的构造函数中使用它来初始化整个对象内存空间,会带来一系列严重问题,应避免这种做法。

12. 普通继承与虚继承构造顺序的区别

todo:




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好