MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++类构造函数的重载与调用顺序

2022-06-237.6k 阅读

C++ 类构造函数的重载

什么是构造函数重载

在 C++ 中,构造函数是一种特殊的成员函数,用于在创建对象时初始化对象的数据成员。构造函数重载允许我们在一个类中定义多个构造函数,这些构造函数具有相同的名称,但参数列表不同。通过这种方式,我们可以根据不同的初始化需求,选择合适的构造函数来创建对象。

例如,考虑一个表示点的类 Point,它有两个数据成员 xy 来表示点在二维平面上的坐标。我们可以定义多个构造函数来初始化这个点:

class Point {
private:
    int x;
    int y;
public:
    // 无参构造函数
    Point() {
        x = 0;
        y = 0;
    }

    // 带一个参数的构造函数
    Point(int value) {
        x = value;
        y = value;
    }

    // 带两个参数的构造函数
    Point(int a, int b) {
        x = a;
        y = b;
    }
};

在上述代码中,Point 类有三个构造函数:无参构造函数 Point(),它将 xy 初始化为 0;带一个参数的构造函数 Point(int value),它将 xy 初始化为相同的值 value;带两个参数的构造函数 Point(int a, int b),它将 x 初始化为 a,将 y 初始化为 b

构造函数重载的作用

  1. 灵活性:构造函数重载为对象的初始化提供了极大的灵活性。不同的使用场景可能需要不同的初始化方式,通过重载构造函数,我们可以满足各种需求。例如,在某些情况下,我们可能只知道点的一个坐标值,希望另一个坐标值与它相同,这时可以使用带一个参数的构造函数;而在其他情况下,我们可能确切知道点的横纵坐标,就可以使用带两个参数的构造函数。
  2. 代码简洁性:通过提供多个构造函数,我们可以避免在创建对象时编写复杂的初始化逻辑。例如,如果没有带一个参数的构造函数,在需要将 xy 初始化为相同值的情况下,我们可能需要先使用无参构造函数创建对象,然后再分别设置 xy 的值,这会使代码变得冗长。而有了合适的构造函数重载,我们可以直接通过简洁的语法完成对象的初始化。

重载构造函数的选择规则

当我们创建一个对象时,编译器会根据我们提供的参数列表来选择合适的构造函数。编译器选择构造函数的过程遵循以下规则:

  1. 精确匹配:编译器首先会尝试寻找一个参数列表与我们提供的参数完全匹配的构造函数。例如,如果我们使用 Point p(5, 10); 来创建 Point 对象,编译器会选择 Point(int a, int b) 这个构造函数,因为它的参数列表与我们提供的参数完全匹配。
  2. 隐式类型转换:如果没有精确匹配的构造函数,编译器会尝试进行隐式类型转换,看是否能找到一个构造函数可以接受经过隐式类型转换后的参数。例如,如果我们有一个构造函数 Point(double value),而我们使用 Point p(5); 来创建对象,编译器会将整数 5 隐式转换为 double 类型 5.0,然后调用 Point(double value) 构造函数。但需要注意的是,隐式类型转换可能会导致一些潜在的问题,例如精度损失等,所以在设计构造函数时应尽量避免过度依赖隐式类型转换。
  3. 最佳匹配:如果存在多个构造函数都可以通过隐式类型转换来匹配参数列表,编译器会选择一个最佳匹配的构造函数。最佳匹配的定义比较复杂,大致原则是选择需要最少和最合理隐式类型转换的构造函数。例如,如果有两个构造函数 Point(int value)Point(double value),而我们使用 Point p(5); 创建对象,编译器会选择 Point(int value),因为将 5 转换为 int 是精确匹配,而转换为 double 涉及到隐式类型转换,所以 Point(int value) 是最佳匹配。

C++ 类构造函数的调用顺序

基类与派生类构造函数的调用顺序

当一个类继承自另一个类时,构造函数的调用顺序变得尤为重要。在创建派生类对象时,首先会调用基类的构造函数,然后再调用派生类的构造函数。这是因为派生类对象包含了基类对象的所有成员,必须先初始化基类部分,才能正确初始化派生类新增的成员。

考虑以下代码示例:

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

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived constructor called" << std::endl;
    }
};

在上述代码中,Derived 类继承自 Base 类。当我们创建一个 Derived 对象时:

int main() {
    Derived d;
    return 0;
}

输出结果将是:

Base constructor called
Derived constructor called

这清楚地表明了在创建 Derived 对象时,先调用了基类 Base 的构造函数,然后调用了派生类 Derived 的构造函数。

调用基类特定构造函数

有时候,我们可能需要调用基类的特定构造函数而不是默认构造函数。我们可以在派生类构造函数的初始化列表中指定要调用的基类构造函数。例如:

class Base {
public:
    Base(int value) {
        std::cout << "Base constructor with int called: " << value << std::endl;
    }
};

class Derived : public Base {
public:
    Derived(int a) : Base(a) {
        std::cout << "Derived constructor called" << std::endl;
    }
};

在上述代码中,Derived 类的构造函数通过初始化列表 : Base(a) 调用了 Base 类带一个整数参数的构造函数。当我们创建 Derived 对象时:

int main() {
    Derived d(5);
    return 0;
}

输出结果将是:

Base constructor with int called: 5
Derived constructor called

成员对象构造函数的调用顺序

当一个类包含其他类类型的成员对象时,这些成员对象的构造函数也会按照特定顺序被调用。成员对象的构造函数在包含它们的类的构造函数体执行之前被调用,且调用顺序与成员对象在类中声明的顺序一致,而不是与初始化列表中的顺序一致。

考虑以下代码示例:

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

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

class Container {
private:
    Member1 m1;
    Member2 m2;
public:
    Container() {
        std::cout << "Container constructor called" << std::endl;
    }
};

在上述代码中,Container 类包含 Member1Member2 类型的成员对象。当我们创建一个 Container 对象时:

int main() {
    Container c;
    return 0;
}

输出结果将是:

Member1 constructor called
Member2 constructor called
Container constructor called

这表明 Member1Member2 的构造函数在 Container 构造函数体执行之前被调用,且按照它们在 Container 类中声明的顺序调用。

初始化列表对成员对象构造的影响

虽然成员对象构造函数的调用顺序由声明顺序决定,但我们可以在初始化列表中为成员对象传递参数,以调用成员对象的特定构造函数。例如:

class Member1 {
public:
    Member1(int value) {
        std::cout << "Member1 constructor with int called: " << value << std::endl;
    }
};

class Member2 {
public:
    Member2(double value) {
        std::cout << "Member2 constructor with double called: " << value << std::endl;
    }
};

class Container {
private:
    Member1 m1;
    Member2 m2;
public:
    Container(int a, double b) : m1(a), m2(b) {
        std::cout << "Container constructor called" << std::endl;
    }
};

在上述代码中,Container 类的构造函数通过初始化列表 : m1(a), m2(b) 调用了 Member1 带整数参数的构造函数和 Member2 带双精度浮点数参数的构造函数。当我们创建 Container 对象时:

int main() {
    Container c(5, 3.14);
    return 0;
}

输出结果将是:

Member1 constructor with int called: 5
Member2 constructor with double called: 3.14
Container constructor called

多层继承与成员对象混合时的调用顺序

当存在多层继承并且类中还包含成员对象时,情况会变得更加复杂,但调用顺序依然遵循一定的规则。首先,从最顶层的基类开始,按照继承层次依次调用基类的构造函数。然后,按照成员对象在类中声明的顺序调用成员对象的构造函数。最后,调用当前类的构造函数体。

考虑以下代码示例:

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

class MiddleBase : public TopBase {
public:
    MiddleBase() {
        std::cout << "MiddleBase constructor called" << std::endl;
    }
};

class BottomBase : public MiddleBase {
public:
    BottomBase() {
        std::cout << "BottomBase constructor called" << std::endl;
    }
};

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

class FinalDerived : public BottomBase {
private:
    Member m;
public:
    FinalDerived() {
        std::cout << "FinalDerived constructor called" << std::endl;
    }
};

在上述代码中,FinalDerived 类继承自 BottomBaseBottomBase 继承自 MiddleBaseMiddleBase 继承自 TopBase,并且 FinalDerived 类包含 Member 类型的成员对象。当我们创建一个 FinalDerived 对象时:

int main() {
    FinalDerived fd;
    return 0;
}

输出结果将是:

TopBase constructor called
MiddleBase constructor called
BottomBase constructor called
Member constructor called
FinalDerived constructor called

这清晰地展示了在多层继承和包含成员对象的情况下,构造函数的调用顺序。

构造函数调用顺序的重要性

理解构造函数的调用顺序对于编写正确、高效且易于维护的代码至关重要。

  1. 对象初始化的正确性:按照正确的顺序调用构造函数可以确保对象的各个部分都被正确初始化。例如,在派生类中,如果基类部分没有先正确初始化,派生类可能会访问到未初始化的基类成员,导致程序出现错误。同样,对于包含成员对象的类,如果成员对象没有在当前类构造函数体执行之前正确初始化,也会引发问题。
  2. 资源管理:在涉及资源管理(如内存分配、文件打开等)的类中,构造函数调用顺序直接影响资源的获取和释放顺序。如果顺序错误,可能会导致资源泄漏或其他未定义行为。例如,如果一个类在构造函数中打开了一个文件,而在析构函数中关闭文件,那么在对象创建过程中,必须确保相关资源(如文件句柄)在对象完全构造好之前正确获取,并且在对象销毁时按照相反顺序正确释放。
  3. 代码可读性和维护性:遵循构造函数调用顺序的规则可以使代码更具可读性和可维护性。其他开发人员在阅读和修改代码时,能够更容易理解对象是如何初始化的,从而降低出错的可能性。例如,通过合理安排成员对象的声明顺序和初始化列表,可以使代码的逻辑更加清晰,便于理解和调试。

复杂场景下构造函数重载与调用顺序的综合分析

多重继承下的构造函数重载与调用顺序

在多重继承的情况下,一个类可以从多个基类继承。此时,构造函数的重载和调用顺序会更加复杂。当创建一个多重继承的派生类对象时,首先会按照基类在派生类定义中继承列表的顺序依次调用各个基类的构造函数,然后调用派生类自身的构造函数。

例如,考虑以下代码:

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

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

class Derived : public Base1, public Base2 {
public:
    Derived() {
        std::cout << "Derived constructor called" << std::endl;
    }
};

在上述代码中,Derived 类从 Base1Base2 多重继承。当我们创建 Derived 对象时:

int main() {
    Derived d;
    return 0;
}

输出结果将是:

Base1 constructor called
Base2 constructor called
Derived constructor called

这表明先按照继承列表顺序调用了 Base1 的构造函数,然后调用了 Base2 的构造函数,最后调用了 Derived 的构造函数。

多重继承下构造函数重载的选择

在多重继承且存在构造函数重载的情况下,编译器同样会根据参数列表来选择合适的构造函数。例如:

class Base1 {
public:
    Base1(int value) {
        std::cout << "Base1 constructor with int called: " << value << std::endl;
    }
};

class Base2 {
public:
    Base2(double value) {
        std::cout << "Base2 constructor with double called: " << value << std::endl;
    }
};

class Derived : public Base1, public Base2 {
public:
    Derived(int a, double b) : Base1(a), Base2(b) {
        std::cout << "Derived constructor called" << std::endl;
    }
};

在上述代码中,Derived 类的构造函数通过初始化列表 : Base1(a), Base2(b) 调用了 Base1 带整数参数的构造函数和 Base2 带双精度浮点数参数的构造函数。当我们创建 Derived 对象时:

int main() {
    Derived d(5, 3.14);
    return 0;
}

输出结果将是:

Base1 constructor with int called: 5
Base2 constructor with double called: 3.14
Derived constructor called

菱形继承下的构造函数重载与调用顺序

菱形继承是多重继承的一种特殊情况,会导致数据冗余和歧义问题。在菱形继承结构中,构造函数的调用顺序也有其特点。

考虑以下菱形继承的代码示例:

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

class B : public A {
public:
    B() {
        std::cout << "B constructor called" << std::endl;
    }
};

class C : public A {
public:
    C() {
        std::cout << "C constructor called" << std::endl;
    }
};

class D : public B, public C {
public:
    D() {
        std::cout << "D constructor called" << std::endl;
    }
};

在上述代码中,D 类从 BC 继承,而 BC 又都从 A 继承,形成了菱形继承结构。当我们创建 D 对象时:

int main() {
    D d;
    return 0;
}

输出结果将是:

A constructor called
B constructor called
A constructor called
C constructor called
D constructor called

可以看到,A 的构造函数被调用了两次,这会导致数据冗余。为了解决这个问题,可以使用虚继承。

虚继承下的构造函数调用顺序

通过虚继承,我们可以确保在菱形继承结构中,基类 A 的对象只被构造一次。修改上述代码如下:

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

class B : virtual public A {
public:
    B() {
        std::cout << "B constructor called" << std::endl;
    }
};

class C : virtual public A {
public:
    C() {
        std::cout << "C constructor called" << std::endl;
    }
};

class D : public B, public C {
public:
    D() {
        std::cout << "D constructor called" << std::endl;
    }
};

当我们再次创建 D 对象时:

int main() {
    D d;
    return 0;
}

输出结果将是:

A constructor called
B constructor called
C constructor called
D constructor called

此时,A 的构造函数只被调用了一次。在虚继承中,最底层的派生类(这里是 D)负责调用虚基类(这里是 A)的构造函数,并且虚基类的构造函数会在其他基类(如 BC)的构造函数之前被调用。

模板类中的构造函数重载与调用顺序

模板类为我们提供了一种通用的编程方式,可以创建各种类型的对象。在模板类中,同样存在构造函数重载和特定的调用顺序。

例如,考虑一个简单的模板类 Stack

template <typename T>
class Stack {
private:
    T* data;
    int top;
public:
    Stack() {
        top = -1;
        data = nullptr;
        std::cout << "Stack default constructor called" << std::endl;
    }

    Stack(int size) {
        top = -1;
        data = new T[size];
        std::cout << "Stack constructor with size called" << std::endl;
    }

    ~Stack() {
        delete[] data;
    }
};

在上述代码中,Stack 模板类有两个构造函数:一个是无参构造函数,另一个是带一个整数参数的构造函数,用于指定栈的大小。当我们实例化 Stack 类时:

int main() {
    Stack<int> s1;
    Stack<double> s2(10);
    return 0;
}

对于 s1,会调用无参构造函数;对于 s2,会调用带参数的构造函数。

模板类继承中的构造函数调用顺序

当模板类存在继承关系时,构造函数的调用顺序与普通类继承类似。例如:

template <typename T>
class BaseTemplate {
public:
    BaseTemplate() {
        std::cout << "BaseTemplate constructor called" << std::endl;
    }
};

template <typename T>
class DerivedTemplate : public BaseTemplate<T> {
public:
    DerivedTemplate() {
        std::cout << "DerivedTemplate constructor called" << std::endl;
    }
};

当我们实例化 DerivedTemplate 类时:

int main() {
    DerivedTemplate<int> dt;
    return 0;
}

输出结果将是:

BaseTemplate constructor called
DerivedTemplate constructor called

这表明在模板类继承中,先调用基类模板类的构造函数,再调用派生类模板类的构造函数。

异常处理与构造函数调用顺序

在构造函数中,可能会发生异常。当异常发生时,构造函数调用顺序的相关规则仍然适用,并且会涉及到对象的部分构造和资源清理问题。

例如,考虑以下代码:

class Resource {
public:
    Resource() {
        std::cout << "Resource constructor called" << std::endl;
        throw std::runtime_error("Resource creation failed");
    }
    ~Resource() {
        std::cout << "Resource destructor called" << std::endl;
    }
};

class Container {
private:
    Resource res;
public:
    Container() {
        std::cout << "Container constructor called" << std::endl;
    }
};

在上述代码中,Container 类包含 Resource 类型的成员对象。由于 Resource 构造函数抛出异常,Container 对象无法完全构造。当我们尝试创建 Container 对象时:

int main() {
    try {
        Container c;
    } catch (const std::exception& e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }
    return 0;
}

输出结果将是:

Resource constructor called
Exception caught: Resource creation failed

可以看到,Resource 的构造函数被调用并抛出异常,Container 的构造函数体没有执行。同时,由于 Resource 对象没有完全构造成功,其析构函数也不会被调用。

异常安全的构造函数设计

为了确保在构造函数中发生异常时对象的状态是安全的,我们可以采用一些异常安全的设计模式。例如,使用“资源获取即初始化”(RAII)原则。在上述 Container 类中,如果 Resource 类采用 RAII 设计,即使构造函数抛出异常,资源也能得到正确管理。

另外,我们还可以在构造函数中进行部分构造,并在析构函数中处理部分构造的情况。例如:

class Resource {
private:
    bool isInitialized;
public:
    Resource() : isInitialized(false) {
        try {
            // 模拟资源初始化
            std::cout << "Resource initialization in progress" << std::endl;
            // 这里可能抛出异常
            if (/* 某个条件 */) {
                throw std::runtime_error("Resource creation failed");
            }
            isInitialized = true;
            std::cout << "Resource constructor called" << std::endl;
        } catch (...) {
            // 清理部分构造的资源
            std::cout << "Cleaning up partially constructed Resource" << std::endl;
            throw;
        }
    }
    ~Resource() {
        if (isInitialized) {
            std::cout << "Resource destructor called" << std::endl;
        } else {
            std::cout << "Resource not fully constructed, no need to clean up" << std::endl;
        }
    }
};

class Container {
private:
    Resource res;
public:
    Container() {
        std::cout << "Container constructor called" << std::endl;
    }
};

通过这种方式,我们可以在构造函数发生异常时,确保对象处于安全状态,并正确处理资源的清理。

综上所述,深入理解 C++ 类构造函数的重载与调用顺序,在各种复杂场景下进行合理设计和使用,对于编写高效、健壮、可维护的 C++ 程序至关重要。无论是在简单的类结构还是复杂的继承体系、模板类以及异常处理的情况下,遵循相关规则和最佳实践,能够帮助我们避免许多潜在的问题,提升代码的质量和可靠性。