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

C++类与对象的生命周期管理

2022-03-092.4k 阅读

C++ 类与对象的生命周期基础

类与对象的概念

在 C++ 中,类是一种用户自定义的数据类型,它将数据(成员变量)和操作这些数据的函数(成员函数)封装在一起。对象则是类的实例,每个对象都拥有自己独立的一套类成员变量的副本,而成员函数为对象提供了对数据进行操作的接口。例如:

class Rectangle {
private:
    int width;
    int height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    int area() {
        return width * height;
    }
};

在上述代码中,Rectangle 是一个类,它包含了两个私有成员变量 widthheight,以及一个构造函数 Rectangle(int w, int h) 和一个成员函数 area()。通过 Rectangle rect(5, 10); 这样的语句,我们创建了一个 Rectangle 类的对象 rect

对象的创建与初始化

构造函数

构造函数是一种特殊的成员函数,用于在创建对象时对对象进行初始化。构造函数的名称与类名相同,并且没有返回类型(包括 void 也不行)。例如:

class Circle {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    double getArea() {
        return 3.14159 * radius * radius;
    }
};

Circle 类中,Circle(double r) 就是构造函数,它在创建 Circle 对象时,将传入的半径值赋给成员变量 radius。构造函数可以有多个重载形式,以满足不同的初始化需求。比如:

class Point {
private:
    int x;
    int y;
public:
    Point() : x(0), y(0) {}
    Point(int a) : x(a), y(a) {}
    Point(int a, int b) : x(a), y(b) {}
};

这里 Point 类有三个构造函数,分别对应不同的初始化方式。第一个构造函数 Point() 是默认构造函数,当创建对象时没有提供任何参数时会调用它,将 xy 初始化为 0。第二个构造函数 Point(int a) 用同一个值初始化 xy,第三个构造函数 Point(int a, int b) 分别用不同的值初始化 xy

初始化列表

初始化列表是在构造函数定义中,紧跟在参数列表后的冒号后面的一系列初始化操作。它的作用是在进入构造函数体之前初始化成员变量。例如:

class Person {
private:
    std::string name;
    int age;
public:
    Person(const std::string& n, int a) : name(n), age(a) {
        // 构造函数体可以为空
    }
};

使用初始化列表有几个好处。首先,对于一些不能默认构造后再赋值的类型,如 const 成员变量或引用成员变量,必须使用初始化列表进行初始化。例如:

class Data {
private:
    const int value;
    int& ref;
public:
    Data(int v, int& r) : value(v), ref(r) {}
};

其次,使用初始化列表初始化成员变量效率更高,因为对于类类型的成员变量,直接在初始化列表中初始化可以避免先默认构造再赋值的额外开销。

成员初始化顺序

成员变量的初始化顺序是按照它们在类定义中声明的顺序,而不是按照初始化列表中的顺序。例如:

class Example {
private:
    int a;
    int b;
public:
    Example(int x) : b(x), a(b + 1) {}
};

在上述代码中,虽然在初始化列表中 b 先被初始化,a 后被初始化,但实际上 a 会先被初始化(因为它在类定义中先声明),此时 b 还未被初始化,a 的值是未定义的。所以,为了避免这种潜在的错误,建议在初始化列表中按照成员变量声明的顺序进行初始化。

析构函数与对象的销毁

析构函数的概念

析构函数是另一种特殊的成员函数,用于在对象销毁时执行清理操作。析构函数的名称是在类名前加上波浪号 ~,同样没有返回类型。例如:

class FileHandler {
private:
    FILE* file;
public:
    FileHandler(const char* filename) {
        file = fopen(filename, "r");
        if (file == nullptr) {
            throw std::runtime_error("Failed to open file");
        }
    }
    ~FileHandler() {
        if (file != nullptr) {
            fclose(file);
        }
    }
};

FileHandler 类中,构造函数打开一个文件,而析构函数在对象销毁时关闭该文件。当 FileHandler 对象的生命周期结束时,无论是因为作用域结束、对象被显式删除(如果是动态分配的),还是程序结束,析构函数都会被自动调用。

析构函数的调用时机

自动对象的销毁

当一个对象是在栈上定义的(即非动态分配的),它的生命周期在其作用域结束时结束。例如:

void function() {
    int localVar;
    {
        class LocalClass {
        public:
            ~LocalClass() {
                std::cout << "LocalClass destructor called" << std::endl;
            }
        };
        LocalClass obj;
    }
    // 这里 LocalClass 对象 obj 的作用域结束,析构函数被调用
}

在上述代码中,LocalClass 对象 obj 在其所在的花括号块结束时,析构函数被自动调用。

动态分配对象的销毁

当使用 new 运算符动态分配一个对象时,需要使用 delete 运算符来显式地释放内存并调用析构函数。例如:

class DynamicObject {
public:
    ~DynamicObject() {
        std::cout << "DynamicObject destructor called" << std::endl;
    }
};
int main() {
    DynamicObject* ptr = new DynamicObject();
    delete ptr;
    // 这里调用了 DynamicObject 的析构函数并释放了内存
    return 0;
}

如果忘记调用 delete,就会导致内存泄漏,因为对象占用的内存不会被自动释放,并且析构函数也不会被调用,可能导致资源(如文件句柄、网络连接等)没有被正确清理。

对象数组的销毁

当创建一个对象数组时,无论是静态数组还是动态分配的数组,数组中每个对象的析构函数都会在数组销毁时被调用。例如:

class ArrayElement {
public:
    ~ArrayElement() {
        std::cout << "ArrayElement destructor called" << std::endl;
    }
};
int main() {
    ArrayElement staticArray[3];
    ArrayElement* dynamicArray = new ArrayElement[5];
    delete[] dynamicArray;
    // 这里 staticArray 在作用域结束时,每个元素的析构函数被调用
    // dynamicArray 使用 delete[] 时,每个元素的析构函数也被调用
    return 0;
}

需要注意的是,对于动态分配的对象数组,必须使用 delete[] 来释放内存,这样才能确保数组中每个对象的析构函数都被调用。如果使用 delete 而不是 delete[],只有数组第一个元素的析构函数会被调用,其他元素的析构函数不会被调用,同样会导致内存泄漏和资源未清理的问题。

拷贝构造函数与赋值运算符重载

拷贝构造函数

拷贝构造函数的定义与作用

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

class CopyableClass {
private:
    int data;
public:
    CopyableClass(int value) : data(value) {}
    CopyableClass(const CopyableClass& other) : data(other.data) {
        std::cout << "Copy constructor called" << std::endl;
    }
};

在上述 CopyableClass 类中,CopyableClass(const CopyableClass& other) 就是拷贝构造函数。当通过以下方式创建对象时,拷贝构造函数会被调用:

int main() {
    CopyableClass obj1(10);
    CopyableClass obj2 = obj1;
    CopyableClass obj3(obj1);
    // 这里 obj2 和 obj3 的创建都调用了拷贝构造函数
    return 0;
}

拷贝构造函数的作用是确保新创建的对象与源对象在数据上是一致的,并且在需要时,对资源进行适当的处理,比如如果类中包含动态分配的内存,拷贝构造函数需要分配新的内存并复制数据,而不是简单地复制指针,以避免多个对象共享同一块内存导致的问题(如悬空指针、内存重复释放等)。

浅拷贝与深拷贝

浅拷贝是指在拷贝对象时,只是简单地复制对象中的成员变量的值。对于普通类型的成员变量,这通常是足够的。但如果类中包含指针类型的成员变量,浅拷贝就会出现问题。例如:

class ShallowCopyClass {
private:
    int* data;
public:
    ShallowCopyClass(int value) {
        data = new int(value);
    }
    ShallowCopyClass(const ShallowCopyClass& other) {
        data = other.data;
    }
    ~ShallowCopyClass() {
        delete data;
    }
};

在上述 ShallowCopyClass 类中,拷贝构造函数进行的是浅拷贝。如果有两个 ShallowCopyClass 对象 obj1obj2obj2 通过拷贝 obj1 创建,那么 obj1.dataobj2.data 将指向同一块内存。当 obj1obj2 先后被销毁时,同一块内存会被释放两次,导致程序崩溃。

深拷贝则是在拷贝对象时,不仅复制成员变量的值,对于指针类型的成员变量,还会分配新的内存并复制其所指向的数据。例如:

class DeepCopyClass {
private:
    int* data;
public:
    DeepCopyClass(int value) {
        data = new int(value);
    }
    DeepCopyClass(const DeepCopyClass& other) {
        data = new int(*other.data);
    }
    ~ DeepCopyClass() {
        delete data;
    }
};

DeepCopyClass 类中,拷贝构造函数进行深拷贝。这样,obj1obj2 虽然数据相同,但各自拥有独立的内存空间,避免了浅拷贝带来的问题。

赋值运算符重载

赋值运算符重载的定义

赋值运算符重载允许我们定义当使用 = 运算符对对象进行赋值时的行为。它是一个成员函数,其形式为:

class AssignmentClass {
private:
    int data;
public:
    AssignmentClass(int value) : data(value) {}
    AssignmentClass& operator=(const AssignmentClass& other) {
        if (this != &other) {
            data = other.data;
        }
        return *this;
    }
};

AssignmentClass 类中,AssignmentClass& operator=(const AssignmentClass& other) 就是赋值运算符重载函数。它首先检查是否是自我赋值(this != &other),如果不是,则将 other 对象的 data 成员变量的值赋给当前对象的 data 成员变量,最后返回当前对象的引用,以便支持链式赋值(如 a = b = c;)。

与拷贝构造函数的区别

拷贝构造函数用于创建一个新对象,而赋值运算符重载用于对已存在的对象进行赋值。例如:

AssignmentClass obj1(10);
AssignmentClass obj2 = obj1; // 调用拷贝构造函数
AssignmentClass obj3(20);
obj3 = obj1; // 调用赋值运算符重载

当创建 obj2 时,由于是创建新对象并初始化为 obj1 的副本,所以调用拷贝构造函数。而 obj3 已经存在,对其使用 = 进行赋值时,调用的是赋值运算符重载函数。

在处理包含动态分配资源的类时,赋值运算符重载也需要像拷贝构造函数一样,进行深拷贝以避免内存管理问题。例如:

class ResourceClass {
private:
    char* str;
public:
    ResourceClass(const char* s) {
        str = new char[strlen(s) + 1];
        strcpy(str, s);
    }
    ResourceClass(const ResourceClass& other) {
        str = new char[strlen(other.str) + 1];
        strcpy(str, other.str);
    }
    ResourceClass& operator=(const ResourceClass& other) {
        if (this != &other) {
            delete[] str;
            str = new char[strlen(other.str) + 1];
            strcpy(str, other.str);
        }
        return *this;
    }
    ~ResourceClass() {
        delete[] str;
    }
};

ResourceClass 类中,无论是拷贝构造函数还是赋值运算符重载,都为 str 指针分配了新的内存并复制字符串内容,确保对象之间的资源独立性,避免内存泄漏和悬空指针等问题。

移动语义与右值引用

右值引用

右值与左值的概念

在 C++ 中,左值是可以取地址的值,并且具有持久的状态。例如变量、数组元素、对象的成员等都是左值。右值则是临时的值,它们通常没有持久的状态,例如字面量(如 10"hello")、函数的临时返回值等。例如:

int a = 10; // a 是左值,10 是右值
int b = a + 5; // a + 5 是右值

右值在表达式结束后通常会被销毁,因为它们没有持久的存储位置。

右值引用的定义

右值引用是 C++11 引入的新特性,用于绑定到右值。它的语法是使用 &&。例如:

int&& rvalRef = 10;

这里 rvalRef 是一个右值引用,它绑定到了右值 10。右值引用使得我们可以在对象转移所有权时避免不必要的拷贝,从而提高效率。

移动构造函数与移动赋值运算符

移动构造函数

移动构造函数用于从一个右值对象中“窃取”资源,而不是像拷贝构造函数那样复制资源。它的参数是一个右值引用。例如:

class MoveableClass {
private:
    int* data;
public:
    MoveableClass(int value) {
        data = new int(value);
    }
    MoveableClass(const MoveableClass& other) {
        data = new int(*other.data);
    }
    MoveableClass(MoveableClass&& other) noexcept {
        data = other.data;
        other.data = nullptr;
    }
    ~MoveableClass() {
        delete data;
    }
};

MoveableClass 类中,MoveableClass(MoveableClass&& other) noexcept 就是移动构造函数。当使用一个右值对象初始化另一个对象时,移动构造函数会被调用。例如:

MoveableClass obj1(10);
MoveableClass obj2 = std::move(obj1);

这里 std::move 函数将 obj1 转换为右值,从而调用移动构造函数。移动构造函数将 obj1data 指针直接赋值给 obj2data 指针,并将 obj1data 指针置为 nullptr,这样 obj1 不再拥有原来的资源,而 obj2 拥有了这些资源,避免了拷贝带来的额外开销。

移动赋值运算符

移动赋值运算符与移动构造函数类似,用于将一个右值对象的资源“移动”到已存在的对象中。例如:

class MoveAssignClass {
private:
    int* data;
public:
    MoveAssignClass(int value) {
        data = new int(value);
    }
    MoveAssignClass& operator=(MoveAssignClass&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
        }
        return *this;
    }
    ~MoveAssignClass() {
        delete data;
    }
};

MoveAssignClass 类中,MoveAssignClass& operator=(MoveAssignClass&& other) noexcept 是移动赋值运算符。当对一个已存在的对象使用 = 运算符并传入一个右值对象时,移动赋值运算符会被调用。例如:

MoveAssignClass obj1(10);
MoveAssignClass obj2(20);
obj2 = std::move(obj1);

这里同样通过 std::moveobj1 转换为右值,然后调用移动赋值运算符,将 obj1 的资源移动到 obj2 中,提高了赋值操作的效率。

移动语义的应用场景

移动语义在处理大型对象或包含动态分配资源的对象时特别有用。例如,在标准库容器(如 std::vectorstd::string)中,移动语义使得容器之间的赋值和对象传递更加高效。当一个 std::vector 作为函数返回值时,如果使用移动语义,就可以避免不必要的拷贝,直接将内部的动态数组的所有权转移给调用者。这在性能敏感的代码中,如大型数据处理、游戏开发等场景中,能够显著提高程序的运行效率。

智能指针与对象生命周期管理

智能指针的概念

智能指针是 C++ 标准库提供的一种自动管理动态分配对象生命周期的机制,它通过封装指针,并在适当的时候自动释放所指向的对象,从而避免了手动管理内存带来的错误,如内存泄漏和悬空指针。C++11 引入了三种智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

std::unique_ptr

std::unique_ptr 的特点与使用

std::unique_ptr 是一种独占所有权的智能指针,它拥有对所指向对象的唯一所有权。当 std::unique_ptr 被销毁时,它所指向的对象也会被自动销毁。例如:

#include <memory>
class MyClass {
public:
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};
int main() {
    std::unique_ptr<MyClass> ptr = std::make_unique<MyClass>();
    // 当 ptr 离开作用域时,MyClass 对象会被自动销毁
    return 0;
}

std::make_unique 是 C++14 引入的函数,用于创建 std::unique_ptr 对象,它比直接使用 newstd::unique_ptr 构造函数更安全和高效。std::unique_ptr 不支持拷贝,但支持移动。例如:

std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
std::unique_ptr<MyClass> ptr2 = std::move(ptr1);
// 这里 ptr1 失去了对 MyClass 对象的所有权,ptr2 获得所有权

由于 std::unique_ptr 是独占所有权,它非常适合管理那些不需要共享的资源,比如文件句柄、网络连接等,因为这些资源在同一时间通常只应由一个对象管理。

std::shared_ptr

std::shared_ptr 的特点与使用

std::shared_ptr 是一种共享所有权的智能指针,多个 std::shared_ptr 可以指向同一个对象,通过引用计数来管理对象的生命周期。当引用计数降为 0 时,对象会被自动销毁。例如:

#include <memory>
class SharedClass {
public:
    ~SharedClass() {
        std::cout << "SharedClass destructor called" << std::endl;
    }
};
int main() {
    std::shared_ptr<SharedClass> ptr1 = std::make_shared<SharedClass>();
    std::shared_ptr<SharedClass> ptr2 = ptr1;
    // ptr1 和 ptr2 指向同一个 SharedClass 对象,引用计数为 2
    {
        std::shared_ptr<SharedClass> ptr3 = ptr1;
        // ptr3 也指向同一个对象,引用计数变为 3
    }
    // ptr3 离开作用域,引用计数减为 2
    // 当 ptr1 和 ptr2 都离开作用域时,引用计数降为 0,SharedClass 对象被销毁
    return 0;
}

std::make_shared 同样可用于创建 std::shared_ptr 对象,它会一次性分配对象和控制块(用于存储引用计数等信息),比直接使用 newstd::shared_ptr 构造函数更高效。std::shared_ptr 支持拷贝和赋值,每次拷贝或赋值都会增加引用计数,而当 std::shared_ptr 被销毁时,引用计数会减少。std::shared_ptr 适用于管理需要在多个对象之间共享的资源,比如在多线程环境中共享的数据。

std::weak_ptr

std::weak_ptr 的特点与使用

std::weak_ptr 是一种弱引用,它指向由 std::shared_ptr 管理的对象,但不会增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 可能出现的循环引用问题。例如:

#include <memory>
class B;
class A {
public:
    std::shared_ptr<B> ptrToB;
    ~A() {
        std::cout << "A destructor called" << std::endl;
    }
};
class B {
public:
    std::weak_ptr<A> ptrToA;
    ~B() {
        std::cout << "B destructor called" << std::endl;
    }
};
int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a->ptrToB = b;
    b->ptrToA = a;
    // 这里 a 和 b 之间形成了循环引用,如果没有 std::weak_ptr,a 和 b 永远不会被销毁
    return 0;
}

在上述代码中,如果 B 中的 ptrToA 也是 std::shared_ptr,那么 ab 之间会形成循环引用,导致它们的引用计数永远不会降为 0,对象永远不会被销毁。而使用 std::weak_ptrba 的引用不会增加 a 的引用计数,从而避免了循环引用问题。std::weak_ptr 可以通过 lock() 方法尝试获取一个 std::shared_ptr,如果对象还存在(即对应的 std::shared_ptr 的引用计数不为 0),lock() 会返回一个有效的 std::shared_ptr,否则返回一个空的 std::shared_ptr

异常处理与对象生命周期

异常对对象生命周期的影响

在 C++ 中,异常处理机制与对象的生命周期密切相关。当异常在构造函数中抛出时,对象的部分成员变量可能已经被初始化,此时需要确保已初始化的资源被正确清理。例如:

class Resource {
private:
    int* data;
public:
    Resource(int size) {
        data = new int[size];
        if (size < 0) {
            throw std::runtime_error("Negative size not allowed");
        }
        // 初始化数据的其他操作
    }
    ~Resource() {
        delete[] data;
    }
};

Resource 类的构造函数中,如果 size 为负数,会抛出异常。此时 data 已经分配了内存,如果没有适当的处理,就会导致内存泄漏。幸运的是,C++ 的异常处理机制会自动调用已经构造的成员变量的析构函数。在这个例子中,如果异常抛出,Resource 对象的析构函数会被调用,data 所指向的内存会被释放。

异常安全的设计原则

为了确保在异常情况下对象的生命周期管理正确,需要遵循一些异常安全的设计原则。

基本保证

基本保证是指在异常发生后,程序的状态保持有效,没有数据丢失,并且所有对象都处于一个可析构的状态。例如,在进行赋值操作时,如果发生异常,原对象的状态不应改变。

class ExceptionSafeClass {
private:
    int* data;
public:
    ExceptionSafeClass(int value) {
        data = new int(value);
    }
    ExceptionSafeClass& operator=(const ExceptionSafeClass& other) {
        ExceptionSafeClass temp(other);
        std::swap(data, temp.data);
        return *this;
    }
    ~ExceptionSafeClass() {
        delete data;
    }
};

在上述 ExceptionSafeClass 类的赋值运算符重载中,首先创建一个临时对象 temp,它是 other 的副本。如果在创建 temp 时发生异常,原对象 *this 的状态不会改变。然后通过 std::swap 交换 data 指针,确保即使在交换过程中发生异常,也不会导致内存泄漏。

强保证

强保证是指在异常发生后,程序的状态与异常发生前完全一样,就好像什么都没有发生过一样。实现强保证通常需要更多的工作,例如在进行复杂操作时,可能需要备份对象的原始状态,以便在异常发生时恢复。例如,在进行一系列数据库操作时,如果其中一个操作失败,需要回滚所有已执行的操作,使数据库状态回到操作前。

不抛出异常保证

不抛出异常保证是指函数永远不会抛出异常。这种保证在一些对异常处理要求严格的场景中非常重要,比如实时系统、底层库等。例如,std::unique_ptr 的移动构造函数和移动赋值运算符通常提供不抛出异常保证,因为它们只是简单地转移资源所有权,不会进行可能抛出异常的操作。通过标记函数为 noexcept,可以向编译器和调用者表明该函数不会抛出异常,这有助于编译器进行优化,并且使调用者能够更安全地调用该函数。

通过合理运用异常处理机制,并遵循异常安全的设计原则,我们可以在 C++ 程序中有效地管理对象的生命周期,确保程序在各种情况下都能正确运行,避免资源泄漏和其他与对象生命周期相关的错误。