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

C++构造函数的调用顺序及其影响

2021-09-053.7k 阅读

C++ 构造函数调用顺序基础

在 C++ 中,构造函数的调用顺序对于对象的初始化和程序的正确运行至关重要。当创建一个对象时,会按照特定的顺序调用相关的构造函数。

简单类的构造函数调用

首先,考虑一个简单的类:

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

在主函数中创建 Simple 类的对象时:

int main() {
    Simple s;
    return 0;
}

输出结果为:Simple constructor called,很明显,这里只有一个构造函数被调用,即 Simple 类自身的构造函数。

包含成员对象的类

当一个类包含其他类的对象作为成员时,构造函数的调用顺序会发生变化。例如:

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

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

在主函数中创建 Container 类的对象:

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

输出结果为:

Member constructor called
Container constructor called

可以看到,成员对象 m 的构造函数先于 Container 类自身的构造函数被调用。这是因为在进入 Container 构造函数体之前,必须先初始化其成员对象。

成员对象初始化顺序

需要注意的是,成员对象的初始化顺序是由它们在类定义中声明的顺序决定的,而不是由构造函数初始化列表中出现的顺序决定。例如:

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

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

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

在主函数中创建 Container2 类的对象:

int main() {
    Container2 c2;
    return 0;
}

输出结果为:

Member1 constructor called
Member2 constructor called
Container2 constructor called

尽管在构造函数初始化列表中 m2 先于 m1 出现,但由于 m1 在类定义中先声明,所以 m1 的构造函数先被调用。

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

单一继承

在单一继承关系中,构造函数的调用顺序遵循特定规则。考虑以下示例:

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 类的对象:

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

输出结果为:

Base constructor called
Derived constructor called

可以看出,在创建派生类对象时,首先调用基类的构造函数,然后调用派生类自身的构造函数。这是因为派生类对象包含基类子对象,在初始化派生类部分之前,必须先初始化基类部分。

多重继承

多重继承情况下,构造函数的调用顺序更为复杂。假设有如下多重继承关系:

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

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

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

在主函数中创建 DerivedMulti 类的对象:

int main() {
    DerivedMulti dm;
    return 0;
}

输出结果为:

Base1 constructor called
Base2 constructor called
DerivedMulti constructor called

在多重继承中,基类构造函数按照它们在派生类声明中出现的顺序被调用,然后调用派生类自身的构造函数。

菱形继承(虚拟继承)

菱形继承会引入一些特殊情况,虚拟继承用于解决菱形继承中的二义性和数据冗余问题。例如:

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

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

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

class DerivedDiamond : public BaseA, public BaseB {
public:
    DerivedDiamond() {
        std::cout << "DerivedDiamond constructor called" << std::endl;
    }
};

在主函数中创建 DerivedDiamond 类的对象:

int main() {
    DerivedDiamond dd;
    return 0;
}

输出结果为:

GrandBase constructor called
BaseA constructor called
BaseB constructor called
DerivedDiamond constructor called

在虚拟继承中,虚基类的构造函数会在最顶层的派生类构造函数调用之前被调用,并且只会调用一次,以避免数据冗余和二义性。

构造函数调用顺序的影响

资源初始化顺序

构造函数调用顺序影响资源的初始化顺序。例如,一个类可能依赖于另一个类初始化的资源。

class Resource {
public:
    Resource() {
        std::cout << "Resource initialized" << std::endl;
    }
};

class User {
private:
    Resource r;
public:
    User() {
        std::cout << "User using Resource" << std::endl;
    }
};

由于 Resource 的构造函数先于 User 的构造函数调用,所以在 User 构造函数执行时,Resource 已经初始化完毕,User 可以安全地使用 Resource 的资源。

多态与构造函数

在多态场景下,构造函数调用顺序也有重要影响。考虑以下代码:

class BasePoly {
public:
    BasePoly() {
        std::cout << "BasePoly constructor called" << std::endl;
        virtualFunction();
    }
    virtual void virtualFunction() {
        std::cout << "BasePoly virtualFunction" << std::endl;
    }
};

class DerivedPoly : public BasePoly {
public:
    DerivedPoly() {
        std::cout << "DerivedPoly constructor called" << std::endl;
    }
    void virtualFunction() override {
        std::cout << "DerivedPoly virtualFunction" << std::endl;
    }
};

在主函数中创建 DerivedPoly 类的对象:

int main() {
    DerivedPoly dp;
    return 0;
}

输出结果为:

BasePoly constructor called
BasePoly virtualFunction
DerivedPoly constructor called

可以看到,在 BasePoly 构造函数中调用 virtualFunction 时,调用的是 BasePoly 的版本,而不是 DerivedPoly 的版本。这是因为在基类构造期间,对象的类型被视为基类类型,多态机制尚未完全建立。这可能导致一些意想不到的行为,在设计类时需要特别注意。

依赖关系与错误处理

构造函数调用顺序反映了对象之间的依赖关系。如果依赖关系处理不当,可能会导致运行时错误。例如,假设一个数据库连接类 DBConnection 和一个数据访问类 DataAccessDataAccess 依赖于 DBConnection 的正确初始化:

class DBConnection {
public:
    DBConnection() {
        std::cout << "DBConnection initialized" << std::endl;
    }
};

class DataAccess {
private:
    DBConnection db;
public:
    DataAccess() {
        std::cout << "DataAccess trying to access DB" << std::endl;
        // 假设这里有实际的数据库访问操作
    }
};

由于 DBConnection 的构造函数先调用,DataAccess 可以在其构造函数中安全地依赖已经初始化的 DBConnection。但如果这种依赖关系被破坏,例如在 DataAccess 构造函数中提前进行数据库访问而 DBConnection 尚未完全初始化,就会导致错误。

多层继承下的构造函数调用顺序及影响

多层继承的调用顺序

当存在多层继承时,构造函数的调用顺序会更加复杂,但依然遵循一定规律。例如:

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

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

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

在主函数中创建 Layer3 类的对象:

int main() {
    Layer3 l3;
    return 0;
}

输出结果为:

Layer1 constructor called
Layer2 constructor called
Layer3 constructor called

可以看出,在多层继承中,从最顶层的基类开始,按照继承层次依次调用构造函数,直到最底层的派生类。

多层继承对对象初始化的影响

多层继承的构造函数调用顺序确保了对象的正确初始化。每个层次的基类子对象在派生类子对象之前被初始化,这对于对象的完整性非常重要。例如,假设 Layer1 初始化一些基本资源,Layer2 基于这些资源进行进一步的设置,Layer3 最终使用这些资源进行特定操作。如果构造函数调用顺序不正确,可能会导致资源未初始化或初始化不完整,从而引发运行时错误。

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

构造函数中的异常

当构造函数中抛出异常时,构造函数调用顺序会对异常处理产生影响。例如:

class Throwing {
public:
    Throwing() {
        std::cout << "Throwing constructor start" << std::endl;
        throw std::runtime_error("Exception in Throwing constructor");
        std::cout << "Throwing constructor end" << std::endl;
    }
};

class ContainerWithThrow {
private:
    Throwing t;
public:
    ContainerWithThrow() {
        std::cout << "ContainerWithThrow constructor" << std::endl;
    }
};

在主函数中尝试创建 ContainerWithThrow 类的对象:

int main() {
    try {
        ContainerWithThrow cwt;
    } catch (const std::runtime_error& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

输出结果为:

Throwing constructor start
Caught exception: Exception in Throwing constructor

Throwing 构造函数抛出异常时,ContainerWithThrow 的构造函数不会继续执行,因为 Throwing 对象尚未成功构造。这确保了对象的一致性,避免了部分构造对象的出现。

异常对构造函数调用顺序的影响

在复杂的继承和包含关系中,异常会按照构造函数调用的逆序进行处理。例如,在一个多层继承且包含成员对象的类中,如果某个构造函数抛出异常,已经构造的对象(按照构造函数调用顺序)会被正确析构。假设:

class MemberForException {
public:
    MemberForException() {
        std::cout << "MemberForException constructor" << std::endl;
    }
    ~MemberForException() {
        std::cout << "MemberForException destructor" << std::endl;
    }
};

class BaseForException {
public:
    BaseForException() {
        std::cout << "BaseForException constructor" << std::endl;
    }
    ~BaseForException() {
        std::cout << "BaseForException destructor" << std::endl;
    }
};

class DerivedForException : public BaseForException {
private:
    MemberForException m;
public:
    DerivedForException() {
        std::cout << "DerivedForException constructor start" << std::endl;
        throw std::runtime_error("Exception in DerivedForException constructor");
        std::cout << "DerivedForException constructor end" << std::endl;
    }
    ~DerivedForException() {
        std::cout << "DerivedForException destructor" << std::endl;
    }
};

在主函数中尝试创建 DerivedForException 类的对象:

int main() {
    try {
        DerivedForException dfe;
    } catch (const std::runtime_error& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

输出结果为:

BaseForException constructor
MemberForException constructor
DerivedForException constructor start
MemberForException destructor
BaseForException destructor
Caught exception: Exception in DerivedForException constructor

可以看到,当 DerivedForException 构造函数抛出异常时,已经构造的 MemberForExceptionBaseForException 对象会被正确析构,按照构造函数调用顺序的逆序进行。

动态内存分配与构造函数调用顺序

动态分配对象的构造函数调用

当使用 new 运算符动态分配对象时,构造函数的调用顺序依然遵循基本规则。例如:

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

在主函数中动态分配 Dynamic 类的对象:

int main() {
    Dynamic* d = new Dynamic();
    delete d;
    return 0;
}

输出结果为:

Dynamic constructor called

这里,Dynamic 类的构造函数在 new 运算符分配内存后被调用,初始化新创建的对象。

动态分配包含成员对象的类

当动态分配一个包含成员对象的类时,成员对象的构造函数调用顺序不变。例如:

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

class DynamicContainer {
private:
    MemberForDynamic m;
public:
    DynamicContainer() {
        std::cout << "DynamicContainer constructor" << std::endl;
    }
};

在主函数中动态分配 DynamicContainer 类的对象:

int main() {
    DynamicContainer* dc = new DynamicContainer();
    delete dc;
    return 0;
}

输出结果为:

MemberForDynamic constructor
DynamicContainer constructor

可以看到,MemberForDynamic 的构造函数先于 DynamicContainer 的构造函数被调用,即使对象是动态分配的。

动态分配在继承体系中的情况

在继承体系中动态分配对象时,构造函数调用顺序同样遵循继承规则。例如:

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

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

在主函数中动态分配 DerivedDynamic 类的对象:

int main() {
    DerivedDynamic* dd = new DerivedDynamic();
    delete dd;
    return 0;
}

输出结果为:

BaseDynamic constructor
DerivedDynamic constructor

这里,基类 BaseDynamic 的构造函数先于派生类 DerivedDynamic 的构造函数被调用,符合继承体系下构造函数的调用顺序。

构造函数调用顺序与性能优化

减少不必要的构造

了解构造函数调用顺序有助于减少不必要的构造操作,从而提高性能。例如,避免在构造函数初始化列表中进行不必要的对象创建或复杂计算。

class Expensive {
public:
    Expensive() {
        std::cout << "Expensive constructor" << std::endl;
        // 假设这里有一些耗时操作
    }
};

class ContainerOptimize {
private:
    Expensive e;
public:
    ContainerOptimize() {
        std::cout << "ContainerOptimize constructor" << std::endl;
    }
};

如果 Expensive 对象在 ContainerOptimize 的某些情况下并不需要立即创建,可以考虑使用延迟初始化等技术,避免在 ContainerOptimize 构造函数调用时不必要地创建 Expensive 对象。

优化资源初始化顺序

根据构造函数调用顺序,合理安排资源初始化顺序也能提高性能。例如,如果一些资源初始化依赖于其他资源,确保这些依赖关系在构造函数调用顺序中得到正确处理,避免等待或重复初始化。假设一个图形渲染类 Renderer 依赖于图形上下文 Context 的初始化:

class Context {
public:
    Context() {
        std::cout << "Context initialized" << std::endl;
        // 实际的上下文初始化操作
    }
};

class Renderer {
private:
    Context c;
public:
    Renderer() {
        std::cout << "Renderer ready to render" << std::endl;
    }
};

通过确保 ContextRenderer 之前正确初始化,可以避免在 Renderer 构造函数中因等待 Context 初始化而造成的性能损耗。

构造函数调用顺序的调试技巧

使用日志输出

在构造函数中添加日志输出是调试构造函数调用顺序的基本方法。通过输出构造函数的进入和离开信息,可以清晰地看到调用顺序。例如:

class DebugClass {
public:
    DebugClass() {
        std::cout << "DebugClass constructor entered" << std::endl;
        // 构造函数逻辑
        std::cout << "DebugClass constructor exited" << std::endl;
    }
};

在主函数中创建 DebugClass 对象时,输出的日志可以帮助确定构造函数是否按照预期顺序调用。

使用调试工具

现代的集成开发环境(IDE)通常提供调试工具,可以在调试过程中观察构造函数的调用顺序。通过设置断点在构造函数的入口和关键代码行,可以逐行跟踪构造函数的执行,检查变量的值和调用顺序是否正确。例如,在 Visual Studio 中,可以在构造函数代码行设置断点,然后启动调试,通过调试窗口观察调用堆栈和变量状态。

分析对象生命周期

通过分析对象的生命周期,可以间接推断构造函数的调用顺序。例如,在析构函数中添加日志输出,结合构造函数的日志,可以完整地了解对象从创建到销毁的过程,从而确认构造函数调用顺序是否符合预期。

class LifecycleDebug {
public:
    LifecycleDebug() {
        std::cout << "LifecycleDebug constructor" << std::endl;
    }
    ~LifecycleDebug() {
        std::cout << "LifecycleDebug destructor" << std::endl;
    }
};

在主函数中创建和销毁 LifecycleDebug 对象,并观察日志输出,有助于理解构造函数和析构函数的调用顺序以及对象的生命周期。

通过深入理解 C++ 构造函数的调用顺序及其影响,开发者能够编写出更健壮、高效且易于调试的代码。无论是在简单的类设计还是复杂的继承体系和动态内存管理场景中,正确把握构造函数调用顺序都是至关重要的。