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

C++对象成员初始化的先后顺序探究

2022-02-202.0k 阅读

C++对象成员初始化的先后顺序探究

1. 类的基本成员初始化顺序

在C++中,当创建一个类的对象时,其成员变量的初始化顺序是非常重要的概念。类的成员变量初始化顺序遵循声明顺序,而非初始化列表中的顺序。

#include <iostream>

class Example {
    int a;
    int b;
public:
    Example(int x, int y) : b(y), a(x) {
        std::cout << "a = " << a << ", b = " << b << std::endl;
    }
};

int main() {
    Example ex(10, 20);
    return 0;
}

在上述代码中,Example类有两个成员变量ab。在构造函数的初始化列表中,b先被初始化,然后是a。然而,由于a在类中声明在前,所以实际初始化顺序是a先被初始化,然后是b。运行结果会输出a = 10, b = 20,符合声明顺序的初始化规则。

这种按照声明顺序初始化的规则是为了保证代码的一致性和可预测性。如果初始化顺序依赖于初始化列表的顺序,那么代码的维护和理解将会变得更加困难,特别是在复杂的类层次结构和大量成员变量的情况下。

2. 基类与派生类的初始化顺序

当涉及到继承关系时,初始化顺序变得更加复杂。基类的构造函数总是在派生类构造函数之前被调用,进行基类部分的初始化。

#include <iostream>

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

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

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

在上述代码中,Derived类继承自Base类。当创建Derived类的对象d时,首先调用Base类的构造函数,输出Base constructor called,然后调用Derived类的构造函数,输出Derived constructor called

如果基类有多个基类(多继承),那么基类的初始化顺序按照它们在派生类定义中的声明顺序进行。

#include <iostream>

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;
    }
};

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

在这个多继承的例子中,Derived类继承自Base1Base2。初始化顺序是先Base1,再Base2,最后是Derived自己的构造函数。输出结果为Base1 constructor calledBase2 constructor calledDerived constructor called

3. 成员对象的初始化顺序

当一个类包含其他类类型的成员对象时,这些成员对象的初始化顺序同样遵循声明顺序。

#include <iostream>

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

class Outer {
    Inner inner1;
    Inner inner2;
public:
    Outer() : inner2(), inner1() {
        std::cout << "Outer constructor called" << std::endl;
    }
};

int main() {
    Outer o;
    return 0;
}

在上述代码中,Outer类包含两个Inner类型的成员对象inner1inner2。尽管在构造函数的初始化列表中inner2先出现,但由于inner1声明在前,所以初始化顺序是先inner1,再inner2,最后是Outer的构造函数体。输出结果为Inner constructor called(对应inner1)、Inner constructor called(对应inner2)、Outer constructor called

4. 静态成员的初始化

静态成员变量在程序开始执行时就进行初始化,并且只初始化一次。它们的初始化独立于类对象的创建。

#include <iostream>

class StaticExample {
public:
    static int staticVar;
    StaticExample() {
        std::cout << "StaticExample constructor called" << std::endl;
    }
};

int StaticExample::staticVar = 10;

int main() {
    StaticExample ex1;
    StaticExample ex2;
    std::cout << "Static variable value: " << StaticExample::staticVar << std::endl;
    return 0;
}

在上述代码中,StaticExample类有一个静态成员变量staticVar。它在类定义外部进行初始化,值为10。无论创建多少个StaticExample类的对象,staticVar都只初始化一次。输出结果为StaticExample constructor called(两次,分别对应ex1ex2的创建),然后输出Static variable value: 10

如果静态成员变量是一个类类型的对象,那么其初始化遵循该类的初始化规则。

#include <iostream>

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

class OuterStatic {
public:
    static InnerStatic staticInner;
    OuterStatic() {
        std::cout << "OuterStatic constructor called" << std::endl;
    }
};

InnerStatic OuterStatic::staticInner;

int main() {
    OuterStatic ex1;
    OuterStatic ex2;
    return 0;
}

在这个例子中,OuterStatic类有一个静态成员对象staticInnerstaticInner在程序开始时就进行初始化,输出InnerStatic constructor called,然后在创建ex1ex2时,分别输出OuterStatic constructor called

5. 初始化列表与构造函数体赋值的区别

初始化列表用于对成员变量进行初始化,而构造函数体中的赋值是在成员变量已经初始化之后进行的操作。

#include <iostream>

class InitVsAssign {
    int num;
public:
    // 使用初始化列表
    InitVsAssign(int n) : num(n) {
        std::cout << "Using initialization list, num = " << num << std::endl;
    }
    // 使用构造函数体赋值
    InitVsAssign(int m) {
        num = m;
        std::cout << "Using assignment in constructor body, num = " << num << std::endl;
    }
};

int main() {
    InitVsAssign obj1(10);
    InitVsAssign obj2(20);
    return 0;
}

在上述代码中,第一个构造函数使用初始化列表初始化num,第二个构造函数在构造函数体中进行赋值。从功能上看,两者似乎相同,但在性能和语义上存在差异。

对于基本数据类型,这种差异可能不太明显。但对于类类型的成员变量,使用初始化列表可以避免先默认初始化再赋值的额外开销。例如:

#include <iostream>
#include <string>

class StringHolder {
    std::string str;
public:
    // 使用初始化列表
    StringHolder(const char* s) : str(s) {
        std::cout << "Using initialization list, str = " << str << std::endl;
    }
    // 使用构造函数体赋值
    StringHolder(const char* s) {
        str = s;
        std::cout << "Using assignment in constructor body, str = " << str << std::endl;
    }
};

int main() {
    StringHolder obj1("Hello");
    StringHolder obj2("World");
    return 0;
}

在这个例子中,使用初始化列表直接用传入的字符串初始化str,而使用构造函数体赋值时,str会先进行默认初始化,然后再赋值,这会产生额外的开销。

6. 初始化顺序的复杂性与注意事项

在复杂的类层次结构和包含多个成员对象的情况下,初始化顺序可能变得难以追踪。

例如,在多重继承且成员对象也有继承关系的场景下:

#include <iostream>

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

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

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

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

class FinalDerived : public DerivedA, public DerivedB {
    DerivedA innerA;
    DerivedB innerB;
public:
    FinalDerived() {
        std::cout << "FinalDerived constructor called" << std::endl;
    }
};

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

在上述代码中,FinalDerived类继承自DerivedADerivedB,同时包含DerivedADerivedB类型的成员对象。初始化顺序为:BaseADerivedA的基类)、DerivedAFinalDerived的基类之一)、BaseBDerivedB的基类)、DerivedBFinalDerived的基类之一)、DerivedA(成员对象innerA)、DerivedB(成员对象innerB)、FinalDerived的构造函数体。

为了避免初始化顺序带来的潜在问题,建议:

  • 保持类的设计尽量简单,减少不必要的继承层次和复杂的成员对象组合。
  • 在初始化列表中按照成员变量的声明顺序进行初始化,即使顺序看起来无关紧要,这样可以增强代码的可读性和可维护性。
  • 对于复杂的类,详细记录成员变量的初始化顺序和依赖关系,以便其他开发者理解。

7. 初始化顺序与常量成员和引用成员

常量成员和引用成员必须在初始化列表中进行初始化,因为它们一旦初始化后就不能再被赋值。

#include <iostream>

class ConstRefExample {
    const int constant;
    int& reference;
public:
    ConstRefExample(int val, int& refVal) : constant(val), reference(refVal) {
        std::cout << "constant = " << constant << ", reference = " << reference << std::endl;
    }
};

int main() {
    int num = 10;
    ConstRefExample cre(20, num);
    return 0;
}

在上述代码中,constant是常量成员,reference是引用成员。它们都在构造函数的初始化列表中进行初始化。如果试图在构造函数体中对它们进行赋值,将会导致编译错误。

这种对常量成员和引用成员初始化的严格要求,进一步强调了初始化列表在C++对象初始化过程中的重要性。同时也提醒开发者,在设计包含常量成员和引用成员的类时,要仔细考虑如何正确地初始化它们,确保对象的状态从一开始就是合法且一致的。

8. 动态内存分配与初始化顺序

当类的成员变量涉及动态内存分配时,初始化顺序同样重要。

#include <iostream>

class DynamicMem {
    int* ptr;
public:
    DynamicMem(int size) {
        ptr = new int[size];
        for (int i = 0; i < size; ++i) {
            ptr[i] = i;
        }
        std::cout << "DynamicMem constructor: Memory allocated" << std::endl;
    }
    ~DynamicMem() {
        delete[] ptr;
        std::cout << "DynamicMem destructor: Memory deallocated" << std::endl;
    }
};

class Container {
    DynamicMem dm1;
    DynamicMem dm2;
public:
    Container() : dm2(5), dm1(3) {
        std::cout << "Container constructor" << std::endl;
    }
    ~Container() {
        std::cout << "Container destructor" << std::endl;
    }
};

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

在上述代码中,Container类包含两个DynamicMem类型的成员对象dm1dm2。由于dm1声明在前,所以先初始化dm1,分配3个int大小的内存,然后初始化dm2,分配5个int大小的内存。当Container对象被销毁时,析构函数的调用顺序与初始化顺序相反,先调用dm2的析构函数释放5个int的内存,再调用dm1的析构函数释放3个int的内存。

如果在动态内存分配的类成员初始化顺序上处理不当,可能会导致内存泄漏或悬空指针等问题。例如,如果在dm1的初始化过程中依赖于dm2已经初始化完成的状态,但实际初始化顺序相反,就可能引发错误。

9. 初始化顺序在模板类中的应用

模板类在C++中广泛应用,其成员的初始化顺序同样遵循一般的规则。

#include <iostream>

template <typename T>
class TemplateClass {
    T data1;
    T data2;
public:
    TemplateClass(const T& value1, const T& value2) : data2(value2), data1(value1) {
        std::cout << "data1 = " << data1 << ", data2 = " << data2 << std::endl;
    }
};

int main() {
    TemplateClass<int> tc(10, 20);
    return 0;
}

在上述模板类TemplateClass中,尽管在初始化列表中data2先初始化,但由于data1声明在前,所以实际初始化顺序是data1先被初始化。当实例化TemplateClass<int>时,输出data1 = 10, data2 = 20

模板类的继承和成员对象初始化顺序也遵循常规的规则。例如:

#include <iostream>

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

template <typename T>
class DerivedTemplate : public BaseTemplate<T> {
    T member;
public:
    DerivedTemplate(const T& value) : member(value) {
        std::cout << "DerivedTemplate constructor, member = " << member << std::endl;
    }
};

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

在这个例子中,DerivedTemplate继承自BaseTemplate,并且有一个模板类型的成员变量member。初始化顺序是先调用BaseTemplate的构造函数,然后初始化member,最后执行DerivedTemplate的构造函数体。输出结果为BaseTemplate constructorDerivedTemplate constructor, member = 10

10. 初始化顺序与异常处理

初始化顺序与异常处理之间也存在关联。如果在对象初始化过程中抛出异常,已经成功初始化的成员对象会按照初始化顺序的相反顺序进行析构。

#include <iostream>

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

class ContainerWithException {
    ThrowingClass tc;
    int num;
public:
    ContainerWithException() : num(10) {
        std::cout << "ContainerWithException constructor start" << std::endl;
    }
    ~ContainerWithException() {
        std::cout << "ContainerWithException destructor" << std::endl;
    }
};

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

在上述代码中,ContainerWithException类包含一个ThrowingClass类型的成员对象tc和一个int类型的成员变量num。当ContainerWithException的构造函数试图初始化tc时,ThrowingClass的构造函数抛出异常。此时,num还未初始化,而tc已经开始初始化但未完成。由于异常,ContainerWithException的构造函数不会继续执行。在异常处理过程中,C++会确保已经成功初始化的部分(这里没有完全成功初始化的部分)按照初始化顺序的相反顺序进行清理。输出结果为ContainerWithException constructor startThrowingClass constructor startCaught exception: Exception in ThrowingClass constructor

理解初始化顺序与异常处理的关系对于编写健壮的C++代码至关重要。开发者需要确保在对象初始化过程中,即使发生异常,也不会导致资源泄漏或其他未定义行为。例如,如果ThrowingClass在构造函数中分配了动态内存,并且在异常发生时没有正确释放,就会导致内存泄漏。

通过深入了解C++对象成员初始化的先后顺序,包括类的基本成员、基类与派生类、成员对象、静态成员等的初始化顺序,以及初始化列表与构造函数体赋值的区别,还有在动态内存分配、模板类和异常处理中的应用,开发者能够编写出更加可靠、高效且易于维护的C++程序。在实际项目中,遵循良好的初始化顺序原则,仔细处理复杂的类结构和初始化逻辑,可以避免许多潜在的错误和性能问题。