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

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

2021-09-287.7k 阅读

C++ 构造函数

构造函数的基本概念

在 C++ 中,构造函数是一种特殊的成员函数,它与类名相同,没有返回类型(包括 void 也没有)。构造函数的主要作用是在创建对象时初始化对象的数据成员。当使用 new 运算符创建对象或在栈上声明对象时,构造函数会自动被调用。

例如,考虑一个简单的 Point 类,它表示二维平面上的一个点:

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

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

int main() {
    Point p; // 调用构造函数
    return 0;
}

带参数的构造函数

构造函数可以接受参数,这使得我们可以在创建对象时为数据成员提供初始值。例如,我们可以修改 Point 类的构造函数,使其接受 xy 的初始值:

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

现在,我们可以在创建 Point 对象时传递参数:

int main() {
    Point p(10, 20); // 调用带参数的构造函数
    return 0;
}

构造函数的重载

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

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

int main() {
    Point p1; // 调用默认构造函数
    Point p2(10); // 调用带单个参数的构造函数
    Point p3(10, 20); // 调用带两个参数的构造函数
    return 0;
}

初始化列表

在构造函数中,除了在函数体中赋值,还可以使用初始化列表来初始化数据成员。初始化列表位于构造函数参数列表之后,以冒号开头,多个初始化项之间用逗号分隔。

例如,我们可以用初始化列表来初始化 Point 类的数据成员:

class Point {
private:
    int x;
    int y;
public:
    // 使用初始化列表的构造函数
    Point(int a, int b) : x(a), y(b) {}
};

初始化列表的好处之一是效率更高。对于某些类型,如 const 成员变量或引用成员变量,必须使用初始化列表进行初始化,因为这些变量在对象创建后不能被赋值。

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

委托构造函数

C++11 引入了委托构造函数,允许一个构造函数调用同一个类的其他构造函数。这在多个构造函数有一些共同的初始化逻辑时非常有用。

例如,考虑一个 Date 类,它表示日期:

class Date {
private:
    int year;
    int month;
    int day;
public:
    // 主构造函数
    Date(int y, int m, int d) : year(y), month(m), day(d) {}

    // 委托构造函数
    Date() : Date(1970, 1, 1) {}
};

在上述代码中,Date() 构造函数委托 Date(int y, int m, int d) 构造函数进行初始化,将日期初始化为 1970 年 1 月 1 日。

拷贝构造函数

拷贝构造函数是一种特殊的构造函数,用于通过另一个同类型对象创建新对象。它的参数是对同类型对象的引用。

例如,对于 Point 类,拷贝构造函数可以定义如下:

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

    // 拷贝构造函数
    Point(const Point& other) : x(other.x), y(other.y) {}
};

拷贝构造函数在以下几种情况下会被调用:

  1. 对象作为函数参数传递
void printPoint(Point p) {
    std::cout << "x: " << p.x << ", y: " << p.y << std::endl;
}

int main() {
    Point p(10, 20);
    printPoint(p); // 调用拷贝构造函数
    return 0;
}
  1. 函数返回对象
Point createPoint() {
    Point p(10, 20);
    return p; // 调用拷贝构造函数
}

int main() {
    Point p = createPoint();
    return 0;
}

移动构造函数

C++11 引入了移动语义和移动构造函数。移动构造函数用于将一个对象的资源(如动态分配的内存)“移动”到另一个对象,而不是进行深拷贝。这在处理大型对象或动态资源时可以提高效率。

例如,考虑一个 MyString 类,它动态分配内存来存储字符串:

class MyString {
private:
    char* data;
    size_t length;
public:
    MyString(const char* str) {
        length = std::strlen(str);
        data = new char[length + 1];
        std::strcpy(data, str);
    }

    // 拷贝构造函数
    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        std::strcpy(data, other.data);
    }

    // 移动构造函数
    MyString(MyString&& other) noexcept {
        data = other.data;
        length = other.length;
        other.data = nullptr;
        other.length = 0;
    }

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

在上述代码中,移动构造函数 MyString(MyString&& other) noexceptother 对象中窃取了 datalength,并将 otherdata 设置为 nullptrlength 设置为 0。这样,other 对象不再拥有动态分配的内存,避免了不必要的拷贝。

移动构造函数在以下情况下会被调用:

  1. 通过 std::move 函数将左值转换为右值
MyString getString() {
    MyString str("Hello");
    return str;
}

int main() {
    MyString s1 = getString(); // 调用移动构造函数
    MyString s2 = std::move(s1); // 调用移动构造函数
    return 0;
}

C++ 析构函数

析构函数的基本概念

析构函数是与构造函数相对应的特殊成员函数,它在对象销毁时自动被调用。析构函数的名称与类名相同,但前面加上波浪号 ~。析构函数没有参数,也没有返回类型。

析构函数的主要作用是释放对象在生命周期中分配的资源,如动态分配的内存、打开的文件句柄等。例如,对于前面的 MyString 类,我们已经定义了一个析构函数来释放动态分配的 data

class MyString {
private:
    char* data;
    size_t length;
public:
    MyString(const char* str) {
        length = std::strlen(str);
        data = new char[length + 1];
        std::strcpy(data, str);
    }

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

MyString 对象超出作用域或被 delete 时,析构函数会被调用,释放 data 所指向的内存。

int main() {
    {
        MyString s("Hello");
    } // s 超出作用域,调用析构函数

    MyString* ptr = new MyString("World");
    delete ptr; // 调用析构函数
    return 0;
}

析构函数的调用时机

  1. 对象超出作用域:当在栈上声明的对象超出其作用域时,析构函数会被自动调用。例如:
void someFunction() {
    MyString s("Local string");
} // s 超出作用域,调用析构函数
  1. 使用 delete 运算符:当使用 new 运算符创建的对象被 delete 时,析构函数会被调用。例如:
MyString* ptr = new MyString("Dynamic string");
delete ptr; // 调用析构函数
  1. 容器元素移除:当从标准库容器(如 std::vectorstd::list 等)中移除元素时,元素的析构函数会被调用。例如:
std::vector<MyString> vec;
vec.emplace_back("Element 1");
vec.emplace_back("Element 2");
vec.pop_back(); // 移除最后一个元素,调用其析构函数

虚析构函数

在继承体系中,如果基类指针指向派生类对象,当通过基类指针删除对象时,只有基类的析构函数会被调用,而派生类的析构函数不会被调用,这可能导致资源泄漏。为了避免这种情况,基类的析构函数应该声明为虚函数。

例如,考虑以下继承体系:

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

class Derived : public Base {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
    }

    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor" << std::endl;
    }
};

在上述代码中,Base 类的析构函数被声明为虚函数。这样,当通过 Base 指针删除 Derived 对象时,会先调用 Derived 类的析构函数,再调用 Base 类的析构函数。

int main() {
    Base* ptr = new Derived();
    delete ptr;
    return 0;
}

输出结果为:

Derived destructor
Base destructor

如果 Base 类的析构函数不是虚函数,输出结果将只有 Base destructor,导致 Derived 类中动态分配的 data 没有被释放。

纯虚析构函数

在抽象类中,可以定义纯虚析构函数。纯虚析构函数必须在类外提供定义,即使它没有实际的代码。

例如:

class AbstractClass {
public:
    virtual ~AbstractClass() = 0;
};

AbstractClass::~AbstractClass() {
    // 可以在这里添加清理代码
}

class ConcreteClass : public AbstractClass {
public:
    ~ConcreteClass() override {
        // 具体类的析构函数
    }
};

在上述代码中,AbstractClass 是一个抽象类,它有一个纯虚析构函数。虽然析构函数是纯虚的,但仍然需要在类外定义。ConcreteClassAbstractClass 的派生类,它必须实现自己的析构函数。

析构函数与异常

析构函数应该尽量避免抛出异常。因为在析构函数中抛出异常可能导致程序终止或未定义行为,特别是当析构函数是在栈展开过程中被调用时(例如在异常处理过程中)。

如果析构函数中必须进行可能抛出异常的操作,应该捕获异常并进行适当处理,例如记录错误日志,而不是让异常传播出去。

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

    ~Resource() {
        try {
            if (file) {
                std::fclose(file);
            }
        } catch (...) {
            // 捕获可能的异常并处理
            std::cerr << "Error closing file in destructor" << std::endl;
        }
    }
};

在上述代码中,Resource 类的析构函数在关闭文件时捕获可能抛出的异常,并输出错误信息,避免异常传播。

析构函数的顺序

在继承体系中,析构函数的调用顺序与构造函数相反。首先调用派生类的析构函数,然后调用基类的析构函数。如果类中包含成员对象,成员对象的析构函数会在类自身的析构函数之前调用。

例如,考虑以下继承体系和包含成员对象的情况:

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

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

class Derived : public Base {
private:
    Member member;
public:
    ~Derived() {
        std::cout << "Derived destructor" << std::endl;
    }
};

Derived 对象被销毁时,析构函数的调用顺序为:

  1. Derived 类的析构函数
  2. Member 对象的析构函数
  3. Base 类的析构函数
int main() {
    Derived d;
    return 0;
}

输出结果为:

Derived destructor
Member destructor
Base destructor

通过深入理解 C++ 的构造函数和析构函数,我们能够更好地控制对象的初始化和销毁过程,合理管理资源,编写高效、健壮的 C++ 程序。无论是简单的类还是复杂的继承体系和包含动态资源的类,构造函数和析构函数都起着至关重要的作用。在实际编程中,需要根据具体需求选择合适的构造函数和析构函数形式,并注意它们的调用时机和相互影响。同时,在处理异常和资源管理时,构造函数和析构函数的正确实现能够确保程序的稳定性和可靠性。例如,在编写网络应用程序时,可能会在构造函数中初始化网络连接,在析构函数中关闭连接,以确保资源的正确使用和释放。在大型项目中,良好的构造函数和析构函数设计有助于提高代码的可维护性和可扩展性,减少潜在的内存泄漏和资源管理问题。总之,熟练掌握构造函数和析构函数是成为优秀 C++ 程序员的关键一步。

此外,在模板类中,构造函数和析构函数的行为也遵循相同的规则,但需要注意模板参数对它们的影响。例如,模板类可能会根据不同的模板参数类型有不同的初始化需求,这就需要在构造函数中进行适当的处理。同时,对于嵌套类,内部类的构造函数和析构函数与外部类的关系也需要特别关注,内部类对象的生命周期可能与外部类对象紧密相关,合理设计它们的构造和析构逻辑能够确保整个类体系的正确性。在多线程环境下,构造函数和析构函数的调用可能会涉及到线程安全问题,例如多个线程同时创建或销毁对象时,需要考虑同步机制以避免数据竞争和未定义行为。

另外,C++ 的标准库容器和智能指针也利用了构造函数和析构函数来实现资源的自动管理。例如,std::unique_ptr 在其析构函数中自动释放所指向的对象,std::vector 在其析构函数中释放内部动态分配的数组。了解这些库组件如何利用构造函数和析构函数,有助于我们更好地使用它们,并编写更简洁、高效的代码。例如,当我们自定义一个类需要与标准库容器一起使用时,就需要确保该类的构造函数和析构函数能够与容器的操作相兼容。

在实际项目中,可能会遇到复杂的对象初始化和销毁场景,例如对象之间存在依赖关系。在这种情况下,合理安排构造函数的调用顺序以及在析构函数中正确处理依赖关系的解除是非常重要的。有时候,可能需要使用一些设计模式来解决这些复杂问题,例如单例模式中构造函数的设计需要确保只有一个实例被创建,析构函数可能需要特殊处理以避免多次销毁。

总之,C++ 的构造函数和析构函数是一个非常丰富且深入的话题,涉及到语言的许多核心概念和编程实践。通过不断学习和实践,我们能够更加熟练地运用它们,编写出高质量的 C++ 代码。无论是小型的工具程序还是大型的企业级应用,构造函数和析构函数的正确使用都是程序稳定运行的基础。我们需要根据具体的业务需求和系统架构,精心设计构造函数和析构函数,以实现高效的资源管理、良好的代码可读性和可维护性。同时,随着 C++ 语言的不断发展,新的特性和最佳实践也在不断涌现,我们需要持续关注并学习,以跟上技术的步伐,充分发挥 C++ 的强大功能。

例如,在 C++20 中引入的模块(module)特性,虽然主要是用于改进代码的组织和编译效率,但在模块内部的类定义中,构造函数和析构函数的使用规则依然适用,并且可能会因为模块的封装性等特点而有一些新的应用场景。比如,模块内部的类可能对其构造函数和析构函数的访问控制有更严格的要求,以确保模块的接口一致性和安全性。

在面向对象编程中,构造函数和析构函数是构建对象生命周期管理的重要基石。从简单的变量初始化到复杂的资源分配与释放,从单一类的操作到继承体系和模板编程中的应用,它们贯穿了 C++ 编程的各个层面。通过深入研究它们的细节,我们可以更好地理解 C++ 语言的底层机制,从而编写出更加健壮、高效且易于维护的代码。无论是在游戏开发、系统软件编程还是数据分析等领域,对构造函数和析构函数的熟练掌握都是不可或缺的技能。

再比如,在开发图形用户界面(GUI)应用程序时,窗口类、控件类等都需要合理设计构造函数和析构函数。窗口的构造函数可能需要初始化窗口的位置、大小、样式等属性,析构函数则要负责释放相关的系统资源,如窗口句柄等。控件类也类似,在构造时进行初始化设置,析构时清理资源。同时,在这些类之间可能存在父子关系等依赖,构造和析构的顺序就需要仔细规划,以确保整个 GUI 系统的正常运行。

在数据库应用开发中,连接类的构造函数用于建立与数据库的连接,可能需要配置连接字符串、认证信息等,析构函数则要关闭连接,释放相关的数据库资源。如果在构造函数中连接失败,可能需要抛出合适的异常,并且在析构函数中正确处理异常情况,以避免资源泄漏和程序崩溃。

总之,构造函数和析构函数在 C++ 编程的各个领域都有着广泛而重要的应用,深入理解并合理运用它们是成为优秀 C++ 开发者的必经之路。我们需要不断地在实际项目中积累经验,针对不同的场景和需求,优化构造函数和析构函数的设计,以提升程序的整体质量和性能。