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

C++编译器自动生成的四大默认函数详解

2021-04-232.9k 阅读

构造函数

在 C++ 中,当我们定义一个类时,如果没有显式地定义构造函数,编译器会为该类自动生成一个默认构造函数。这个默认构造函数的作用是初始化对象的成员变量。

  1. 默认构造函数的特点 默认构造函数没有参数。它会按成员变量在类中声明的顺序依次初始化它们。对于基本数据类型(如 int、double 等),如果没有进行显式初始化,其值是未定义的。对于类类型的成员变量,会调用其默认构造函数进行初始化。

  2. 代码示例

#include <iostream>

class MyClass {
    int num;
    double dbl;
public:
    // 编译器自动生成的默认构造函数,num 和 dbl 值未定义
};

int main() {
    MyClass obj;
    // 这里访问 num 和 dbl 会得到未定义的值
    // std::cout << obj.num << std::endl;  // 错误,未定义行为
    return 0;
}

在上述代码中,MyClass 没有显式定义构造函数,编译器自动生成了默认构造函数。但由于基本数据类型 numdbl 没有显式初始化,它们的值是未定义的。

  1. 何时需要显式定义构造函数 当我们希望对成员变量进行特定的初始化时,就需要显式定义构造函数。例如:
#include <iostream>

class MyClass {
    int num;
    double dbl;
public:
    MyClass() : num(10), dbl(3.14) {
        // 显式初始化成员变量
    }
};

int main() {
    MyClass obj;
    std::cout << "num: " << obj.num << ", dbl: " << obj.dbl << std::endl;
    return 0;
}

在这个例子中,我们显式定义了构造函数,并对 numdbl 进行了初始化。这样,每次创建 MyClass 对象时,num 都会被初始化为 10,dbl 会被初始化为 3.14。

析构函数

析构函数与构造函数相对,用于在对象生命周期结束时释放对象占用的资源。同样,如果类中没有显式定义析构函数,编译器会自动生成一个默认析构函数。

  1. 默认析构函数的特点 默认析构函数没有参数,也没有返回值。它会按成员变量在类中声明的顺序的逆序依次调用成员变量的析构函数(如果成员变量是类类型)。对于基本数据类型的成员变量,不需要额外的清理操作。

  2. 代码示例

#include <iostream>

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

class MyClass {
    int num;
    MyInnerClass inner;
public:
    // 编译器自动生成的默认析构函数
};

int main() {
    {
        MyClass obj;
    }
    // 当 obj 离开作用域时,编译器自动生成的析构函数会被调用
    // 首先调用 MyInnerClass 的析构函数,然后结束
    return 0;
}

在上述代码中,MyClass 没有显式定义析构函数,当 obj 离开作用域时,编译器自动生成的析构函数会被调用,它会先调用 MyInnerClass 的析构函数。

  1. 何时需要显式定义析构函数 当类中包含动态分配的资源(如通过 new 操作符分配的内存)时,需要显式定义析构函数来释放这些资源,以避免内存泄漏。例如:
#include <iostream>

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

int main() {
    {
        MyClass obj(5);
    }
    // 当 obj 离开作用域时,显式定义的析构函数会释放动态分配的数组
    return 0;
}

在这个例子中,MyClass 在构造函数中通过 new 分配了一个整数数组,在析构函数中通过 delete[] 释放了该数组,避免了内存泄漏。

拷贝构造函数

拷贝构造函数用于创建一个新对象,该新对象是另一个已存在对象的副本。如果类中没有显式定义拷贝构造函数,编译器会自动生成一个默认拷贝构造函数。

  1. 默认拷贝构造函数的特点 默认拷贝构造函数会执行成员变量的逐成员拷贝。对于基本数据类型的成员变量,直接进行值拷贝;对于类类型的成员变量,会调用其拷贝构造函数。

  2. 代码示例

#include <iostream>

class MyInnerClass {
    int value;
public:
    MyInnerClass(int v) : value(v) {}
    MyInnerClass(const MyInnerClass& other) : value(other.value) {
        std::cout << "MyInnerClass copy constructor called" << std::endl;
    }
};

class MyClass {
    int num;
    MyInnerClass inner;
public:
    MyClass(int n, int innerValue) : num(n), inner(innerValue) {}
    // 编译器自动生成的默认拷贝构造函数
};

int main() {
    MyClass obj1(10, 20);
    MyClass obj2 = obj1;  // 调用默认拷贝构造函数
    // 这里会先调用 MyInnerClass 的拷贝构造函数,然后完成 MyClass 的逐成员拷贝
    return 0;
}

在上述代码中,MyClass 没有显式定义拷贝构造函数,当执行 MyClass obj2 = obj1; 时,编译器自动生成的默认拷贝构造函数会被调用,它会先调用 MyInnerClass 的拷贝构造函数来拷贝 inner 成员变量,然后拷贝 num 成员变量。

  1. 何时需要显式定义拷贝构造函数 当类中包含动态分配的资源时,如果使用默认拷贝构造函数进行逐成员拷贝,可能会导致多个对象指向同一块动态分配的内存,从而在析构时出现多次释放同一内存的错误(双重释放)。例如:
#include <iostream>

class MyClass {
    int* arr;
public:
    MyClass(int size) {
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = i;
        }
    }
    // 没有显式定义拷贝构造函数,使用默认的逐成员拷贝
    ~MyClass() {
        delete[] arr;
    }
};

int main() {
    MyClass obj1(5);
    MyClass obj2 = obj1;  // 这里使用默认拷贝构造函数
    // 现在 obj1 和 obj2 的 arr 指向同一块内存
    // 当 obj1 和 obj2 析构时,会两次释放同一块内存,导致错误
    return 0;
}

为了避免这种情况,我们需要显式定义拷贝构造函数,进行深拷贝:

#include <iostream>

class MyClass {
    int* arr;
public:
    MyClass(int size) {
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = i;
        }
    }
    MyClass(const MyClass& other) {
        int size = sizeof(other.arr) / sizeof(other.arr[0]);
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = other.arr[i];
        }
    }
    ~MyClass() {
        delete[] arr;
    }
};

int main() {
    MyClass obj1(5);
    MyClass obj2 = obj1;  // 调用显式定义的拷贝构造函数,进行深拷贝
    // 现在 obj1 和 obj2 的 arr 指向不同的内存,析构时不会出错
    return 0;
}

在这个改进的例子中,显式定义的拷贝构造函数为 obj2 分配了新的内存,并将 obj1 的数组内容复制到新内存中,从而避免了双重释放的问题。

赋值运算符重载

赋值运算符 = 用于将一个对象的值赋给另一个已存在的对象。如果类中没有显式定义赋值运算符重载函数,编译器会自动生成一个默认的赋值运算符重载函数。

  1. 默认赋值运算符重载函数的特点 默认赋值运算符重载函数也会执行成员变量的逐成员赋值。对于基本数据类型的成员变量,直接进行值赋值;对于类类型的成员变量,会调用其赋值运算符重载函数。

  2. 代码示例

#include <iostream>

class MyInnerClass {
    int value;
public:
    MyInnerClass(int v) : value(v) {}
    MyInnerClass& operator=(const MyInnerClass& other) {
        if (this != &other) {
            value = other.value;
        }
        std::cout << "MyInnerClass assignment operator called" << std::endl;
        return *this;
    }
};

class MyClass {
    int num;
    MyInnerClass inner;
public:
    MyClass(int n, int innerValue) : num(n), inner(innerValue) {}
    // 编译器自动生成的默认赋值运算符重载函数
};

int main() {
    MyClass obj1(10, 20);
    MyClass obj2(30, 40);
    obj2 = obj1;  // 调用默认赋值运算符重载函数
    // 这里会先调用 MyInnerClass 的赋值运算符重载函数,然后完成 MyClass 的逐成员赋值
    return 0;
}

在上述代码中,MyClass 没有显式定义赋值运算符重载函数,当执行 obj2 = obj1; 时,编译器自动生成的默认赋值运算符重载函数会被调用,它会先调用 MyInnerClass 的赋值运算符重载函数来赋值 inner 成员变量,然后赋值 num 成员变量。

  1. 何时需要显式定义赋值运算符重载函数 与拷贝构造函数类似,当类中包含动态分配的资源时,默认的逐成员赋值可能会导致多个对象指向同一块动态分配的内存,从而引发问题。例如:
#include <iostream>

class MyClass {
    int* arr;
public:
    MyClass(int size) {
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = i;
        }
    }
    // 没有显式定义赋值运算符重载函数,使用默认的逐成员赋值
    ~MyClass() {
        delete[] arr;
    }
};

int main() {
    MyClass obj1(5);
    MyClass obj2(3);
    obj2 = obj1;  // 这里使用默认赋值运算符重载函数
    // 现在 obj2 的 arr 会指向 obj1 的 arr 所指向的内存,obj1 原有的内存会泄漏
    // 并且当 obj1 和 obj2 析构时,会两次释放同一块内存,导致错误
    return 0;
}

为了避免这些问题,我们需要显式定义赋值运算符重载函数,进行深赋值:

#include <iostream>

class MyClass {
    int* arr;
public:
    MyClass(int size) {
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = i;
        }
    }
    MyClass& operator=(const MyClass& other) {
        if (this != &other) {
            delete[] arr;
            int size = sizeof(other.arr) / sizeof(other.arr[0]);
            arr = new int[size];
            for (int i = 0; i < size; ++i) {
                arr[i] = other.arr[i];
            }
        }
        return *this;
    }
    ~MyClass() {
        delete[] arr;
    }
};

int main() {
    MyClass obj1(5);
    MyClass obj2(3);
    obj2 = obj1;  // 调用显式定义的赋值运算符重载函数,进行深赋值
    // 现在 obj2 有了自己独立的内存,不会出现内存泄漏和双重释放问题
    return 0;
}

在这个改进的例子中,显式定义的赋值运算符重载函数先释放 obj2 原有的动态分配内存,然后为 obj2 分配新的内存,并将 obj1 的数组内容复制到新内存中,从而避免了内存泄漏和双重释放的问题。

在实际的 C++ 编程中,深入理解这四大默认函数以及何时需要显式定义它们是非常重要的,这有助于我们编写出高效、健壮且没有内存泄漏等问题的代码。特别是在处理包含动态资源的类时,正确地定义这些函数可以确保程序的正确性和稳定性。同时,了解编译器自动生成的默认函数的行为,也能帮助我们更好地调试和优化代码。例如,在调试过程中,如果发现对象的初始化或赋值行为不符合预期,可以检查是否正确定义了这些默认函数,或者是否依赖了编译器自动生成的可能不符合需求的版本。在性能优化方面,对于频繁创建和销毁对象的场景,合理优化这些函数(如在拷贝构造函数和赋值运算符重载中避免不必要的深拷贝操作)可以显著提高程序的运行效率。总之,对这四大默认函数的深入掌握是 C++ 程序员进阶的重要一步。

此外,在现代 C++ 中,还引入了移动语义相关的函数,如移动构造函数和移动赋值运算符。移动语义允许我们在对象所有权转移时避免不必要的深拷贝操作,进一步提高性能。例如,当一个函数返回一个临时对象时,可以通过移动构造函数将临时对象的资源直接“移动”到接收对象中,而不是进行深拷贝。这与四大默认函数的概念密切相关,因为在某些情况下,移动语义相关函数的正确实现也依赖于对默认函数的理解。比如,如果类的成员变量中有动态分配的资源,在移动构造函数和移动赋值运算符中也需要正确处理这些资源的转移,避免资源泄漏和错误的内存访问。这也提醒我们,在学习和使用 C++ 时,要将这些知识体系作为一个整体来理解和运用,不断提升自己的编程能力。

同时,在编写大型项目时,不同模块之间的类交互可能会频繁涉及到对象的创建、拷贝、赋值和销毁操作。这就要求我们在设计类时,要从整体架构的角度出发,考虑这些操作对系统性能和稳定性的影响。例如,如果一个类在多个模块中被频繁拷贝和赋值,那么优化其拷贝构造函数和赋值运算符重载函数就显得尤为重要。此外,在多线程环境下,这些默认函数的实现还需要考虑线程安全性。例如,在拷贝构造函数或赋值运算符重载函数中,如果涉及到共享资源的操作,就需要采取适当的同步机制(如互斥锁)来避免数据竞争问题。

总之,C++ 编译器自动生成的四大默认函数看似基础,但却蕴含着丰富的细节和重要的编程思想。深入理解它们并能根据实际需求正确定义和使用,是编写高质量 C++ 代码的关键之一。无论是初学者还是有一定经验的程序员,都应该不断回顾和深入研究这些知识,以提升自己在 C++ 编程领域的技能水平。