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

C++ 类的构造函数和析构函数

2021-04-111.2k 阅读

C++ 类的构造函数

构造函数的定义与作用

在 C++ 中,构造函数是一种特殊的成员函数,它与类同名,且没有返回类型(包括 void 也没有)。构造函数的主要作用是在创建对象时初始化对象的成员变量。当一个对象被创建时,C++ 编译器会自动调用其构造函数,这确保了对象在使用前处于一个合理的、初始化的状态。

例如,我们定义一个简单的 Point 类来表示二维平面上的点,包含 xy 坐标:

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

在上述代码中,Point() 就是 Point 类的构造函数。当创建 Point 对象时,这个构造函数会被自动调用,将 xy 初始化为 0。

带参数的构造函数

构造函数可以接受参数,这样我们就可以在创建对象时为成员变量赋不同的初始值。例如,我们可以修改 Point 类的构造函数,使其接受 xy 的初始值:

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

现在,我们可以这样创建 Point 对象:

Point p1(10, 20);

这里,p1 对象的 x 初始化为 10,y 初始化为 20。

构造函数的重载

与普通函数一样,构造函数也可以重载。这意味着一个类可以有多个构造函数,只要它们的参数列表不同。例如,我们可以为 Point 类添加更多的构造函数:

class Point {
public:
    int x;
    int y;
    // 无参数构造函数
    Point() {
        x = 0;
        y = 0;
    }
    // 带参数构造函数
    Point(int a, int b) {
        x = a;
        y = b;
    }
    // 复制构造函数(特殊的构造函数,后面详细介绍)
    Point(const Point& other) {
        x = other.x;
        y = other.y;
    }
};

这样,我们就可以根据不同的需求选择不同的构造函数来创建对象:

Point p1; // 使用无参数构造函数
Point p2(10, 20); // 使用带参数构造函数
Point p3(p2); // 使用复制构造函数

初始化列表

在构造函数中,除了在函数体内部赋值外,还可以使用初始化列表来初始化成员变量。初始化列表位于构造函数参数列表之后,函数体之前,使用冒号 : 分隔。例如:

class Point {
public:
    int x;
    int y;
    // 使用初始化列表的构造函数
    Point(int a, int b) : x(a), y(b) {
        // 函数体可以为空,成员变量已经在初始化列表中初始化
    }
};

使用初始化列表有几个优点:

  1. 效率更高:对于一些类型,如 const 成员变量、引用成员变量以及没有默认构造函数的类类型成员变量,必须使用初始化列表进行初始化。因为在函数体内部赋值实际上是先调用默认构造函数初始化,然后再进行赋值操作,而初始化列表直接进行初始化,减少了不必要的构造和赋值开销。
  2. 语法更清晰:初始化列表将成员变量的初始化与函数体中的其他操作分开,使代码结构更清晰,更易于阅读和维护。

委托构造函数

C++11 引入了委托构造函数的概念。委托构造函数是指一个构造函数可以调用同一个类的其他构造函数来完成部分或全部的初始化工作。例如:

class Point {
public:
    int x;
    int y;
    // 基础构造函数
    Point(int a, int b) : x(a), y(b) {
    }
    // 委托构造函数
    Point() : Point(0, 0) {
    }
};

在上述代码中,Point() 构造函数委托了 Point(int, int) 构造函数来完成初始化,将 xy 初始化为 0。委托构造函数可以减少代码重复,使代码更加简洁和易于维护。

构造函数与继承

在继承体系中,构造函数的调用顺序有特定的规则。当创建一个派生类对象时,首先会调用基类的构造函数,然后再调用派生类的构造函数。如果基类有带参数的构造函数,派生类的构造函数必须在其初始化列表中显式调用基类的构造函数,并传递适当的参数。例如:

class Shape {
public:
    int color;
    Shape(int c) : color(c) {
    }
};

class Circle : public Shape {
public:
    int radius;
    Circle(int c, int r) : Shape(c), radius(r) {
    }
};

在上述代码中,Circle 类继承自 Shape 类。Circle 的构造函数在初始化列表中先调用 Shape 的构造函数来初始化 color,然后再初始化自己的 radius

C++ 类的析构函数

析构函数的定义与作用

析构函数是与构造函数相对的另一种特殊成员函数。它与类同名,但在前面加上波浪号 ~,同样没有返回类型。析构函数的作用是在对象被销毁时执行清理工作,例如释放对象占用的动态内存、关闭文件句柄等资源。当一个对象的生命周期结束(例如对象所在的函数结束、对象被删除等情况),C++ 编译器会自动调用其析构函数。

例如,我们定义一个简单的 DynamicArray 类来管理动态分配的整数数组:

class DynamicArray {
public:
    int* arr;
    int size;
    DynamicArray(int s) {
        size = s;
        arr = new int[size];
    }
    ~DynamicArray() {
        delete[] arr;
    }
};

在上述代码中,DynamicArray 类的构造函数动态分配了一个整数数组 arr。当 DynamicArray 对象被销毁时,析构函数 ~DynamicArray() 会被调用,它释放了 arr 所占用的动态内存,避免了内存泄漏。

析构函数的调用时机

  1. 自动对象:当一个对象是在函数内部定义的自动对象(非动态分配的对象),当函数执行结束时,该对象的析构函数会被自动调用。例如:
void func() {
    DynamicArray arr(5);
    // 函数执行到这里,arr 的析构函数会被调用
}
  1. 动态分配的对象:当使用 new 运算符动态分配的对象,使用 delete 运算符删除该对象时,其析构函数会被调用。例如:
DynamicArray* arrPtr = new DynamicArray(10);
delete arrPtr;
// 这里 arrPtr 所指向对象的析构函数会被调用
  1. 对象数组:对于对象数组,当数组对象被销毁时,数组中每个元素的析构函数都会被依次调用。例如:
DynamicArray arrArr[3] = {DynamicArray(2), DynamicArray(3), DynamicArray(4)};
// 当 arrArr 生命周期结束时,每个 DynamicArray 对象的析构函数会依次被调用

析构函数与继承

在继承体系中,析构函数的调用顺序与构造函数相反。当销毁一个派生类对象时,首先会调用派生类的析构函数,然后再调用基类的析构函数。这确保了派生类对象的清理工作先完成,然后再清理基类对象。例如:

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

class Circle : public Shape {
public:
    ~Circle() {
        std::cout << "Circle destructor" << std::endl;
    }
};

当创建并销毁一个 Circle 对象时:

Circle c;
// 输出:
// Circle destructor
// Shape destructor

可以看到,先调用了 Circle 的析构函数,然后调用了 Shape 的析构函数。

虚析构函数

在多态编程中,如果通过基类指针删除派生类对象,可能会出现问题。例如:

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

class Circle : public Shape {
public:
    ~Circle() {
        std::cout << "Circle destructor" << std::endl;
    }
};

如果这样使用:

Shape* s = new Circle();
delete s;
// 输出:
// Shape destructor

可以发现,只调用了基类 Shape 的析构函数,而没有调用派生类 Circle 的析构函数,这可能会导致资源泄漏(如果 Circle 类在析构函数中有动态内存释放等清理操作)。

为了解决这个问题,我们需要将基类的析构函数声明为虚函数:

class Shape {
public:
    virtual ~Shape() {
        std::cout << "Shape destructor" << std::endl;
    }
};

class Circle : public Shape {
public:
    ~Circle() {
        std::cout << "Circle destructor" << std::endl;
    }
};

现在,当通过基类指针删除派生类对象时:

Shape* s = new Circle();
delete s;
// 输出:
// Circle destructor
// Shape destructor

可以看到,先调用了派生类 Circle 的析构函数,然后调用了基类 Shape 的析构函数,确保了资源的正确释放。

析构函数中的异常处理

析构函数中一般不应该抛出异常。因为析构函数是在对象生命周期结束时自动调用的,如果析构函数抛出异常,可能会导致程序处于未定义行为的状态。例如,如果在析构函数中抛出异常,而此时另一个异常正在被处理,C++ 标准规定程序将调用 std::terminate() 函数,这会导致程序非正常终止。

如果在析构函数中需要执行一些可能会失败的操作,应该尽量在析构函数内部处理错误,而不是抛出异常。例如,可以记录错误日志等方式来处理异常情况。

复制构造函数与移动构造函数

复制构造函数

复制构造函数是一种特殊的构造函数,用于创建一个新对象,该对象是另一个同类型对象的副本。它的参数是一个对同类型对象的常量引用。例如:

class Point {
public:
    int x;
    int y;
    Point(int a, int b) : x(a), y(b) {
    }
    // 复制构造函数
    Point(const Point& other) : x(other.x), y(other.y) {
    }
};

当我们使用一个已有的 Point 对象来创建一个新的 Point 对象时,复制构造函数会被调用:

Point p1(10, 20);
Point p2(p1); // 调用复制构造函数

复制构造函数在很多情况下会被自动调用,比如函数参数按值传递、函数返回对象等情况。例如:

Point func(Point p) {
    return p;
}
Point p1(10, 20);
Point p3 = func(p1);

在上述代码中,func 函数的参数 p 是按值传递的,这会调用 Point 的复制构造函数来创建 p 的副本。函数返回 p 时,也会调用复制构造函数来创建返回值的副本。

移动构造函数

C++11 引入了移动构造函数的概念,它用于将一个对象的资源所有权转移到另一个对象,而不是进行深拷贝。移动构造函数的参数是一个对同类型对象的右值引用。例如:

class DynamicArray {
public:
    int* arr;
    int size;
    DynamicArray(int s) {
        size = s;
        arr = new int[size];
    }
    // 移动构造函数
    DynamicArray(DynamicArray&& other) noexcept : size(other.size), arr(other.arr) {
        other.size = 0;
        other.arr = nullptr;
    }
    ~DynamicArray() {
        delete[] arr;
    }
};

在上述代码中,移动构造函数将 other 对象的 arr 指针和 size 成员变量直接转移到新对象,然后将 other 对象的 arr 置为 nullptrsize 置为 0,这样 other 对象在析构时就不会释放已经转移的资源。

移动构造函数在对象的所有权需要转移时会被调用,比如函数返回一个临时对象。例如:

DynamicArray func() {
    DynamicArray arr(5);
    return arr;
}
DynamicArray d = func();

在上述代码中,func 函数返回的临时 DynamicArray 对象会通过移动构造函数将资源转移给 d,而不是进行深拷贝,提高了效率。

复制构造函数与移动构造函数的选择

编译器会根据对象是左值还是右值来选择调用复制构造函数还是移动构造函数。左值是可以取地址的表达式,右值是不能取地址的临时表达式。例如:

DynamicArray arr1(5);
DynamicArray arr2(arr1); // 调用复制构造函数,arr1 是左值
DynamicArray arr3 = func(); // 调用移动构造函数,func() 返回的是右值

如果一个类没有显式定义移动构造函数,当需要进行移动操作时,编译器可能会使用复制构造函数来代替,这可能会导致不必要的深拷贝操作,降低效率。因此,对于管理动态资源的类,建议同时定义复制构造函数和移动构造函数。

总结构造函数、析构函数、复制构造函数与移动构造函数的关系

构造函数负责对象的初始化,确保对象在创建后处于可用状态。析构函数则在对象销毁时进行资源清理,防止资源泄漏。复制构造函数用于创建对象的副本,而移动构造函数则优化了对象资源的转移,提高了效率。

在实际编程中,正确设计和实现这些特殊成员函数对于确保程序的正确性、性能和资源管理至关重要。特别是在涉及动态资源管理、继承和多态的场景中,更需要深入理解它们的工作原理和相互关系,以编写高质量的 C++ 代码。例如,在继承体系中,要注意构造函数和析构函数的调用顺序,以及虚析构函数的使用;在处理动态资源时,要合理使用复制构造函数和移动构造函数来避免资源泄漏和提高效率。通过对这些特殊成员函数的深入掌握,开发者能够更好地利用 C++ 语言的特性,编写出健壮、高效的程序。