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

C++类缺省函数的功能与使用

2023-02-074.9k 阅读

C++类缺省函数概述

在C++中,类的缺省函数是编译器自动为类生成的特殊成员函数。当我们定义一个类而没有显式声明这些函数时,编译器会根据需要隐式地为该类生成它们。这些缺省函数包括默认构造函数、拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符以及析构函数。它们在对象的创建、复制、移动和销毁过程中起着至关重要的作用,帮助我们管理对象的资源并确保代码的正确性和效率。

默认构造函数

默认构造函数是一种特殊的构造函数,它不需要任何参数。当我们定义一个类的对象而没有提供初始化列表时,就会调用默认构造函数。如果类中没有显式定义任何构造函数,编译器会自动生成一个默认构造函数。这个自动生成的默认构造函数会对类的数据成员进行默认初始化。例如,对于内置类型的数据成员,其值不会被初始化(在C++11之前),而对于类类型的数据成员,会调用其默认构造函数进行初始化。

下面是一个简单的示例:

class MyClass {
public:
    int data;
    // 编译器会自动生成默认构造函数
};

int main() {
    MyClass obj; // 调用默认构造函数
    // 在C++11之前,obj.data的值是未定义的
    // 在C++11之后,obj.data会被默认初始化为0
    return 0;
}

如果类中定义了任何构造函数(即使是带参数的构造函数),编译器将不会自动生成默认构造函数。此时,如果我们仍然需要默认构造函数,就必须显式定义它。例如:

class AnotherClass {
public:
    int value;
    AnotherClass(int v) : value(v) {} // 带参数的构造函数
    // 由于定义了带参数的构造函数,编译器不会生成默认构造函数
    // 若需要,需显式定义
    AnotherClass() : value(0) {}
};

拷贝构造函数

拷贝构造函数用于创建一个新对象,该新对象是另一个已存在对象的副本。其参数是一个与该类类型相同的对象的引用。当我们以值传递的方式将对象作为函数参数传递,或者通过表达式创建对象的副本时,就会调用拷贝构造函数。

如果类中没有显式定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数会执行成员级别的拷贝,即依次拷贝每个数据成员。对于内置类型的数据成员,直接进行值拷贝;对于类类型的数据成员,会调用其拷贝构造函数。

以下是一个示例:

class CopyClass {
public:
    int num;
    // 编译器自动生成的拷贝构造函数
};

void printClass(CopyClass obj) {
    std::cout << "num: " << obj.num << std::endl;
}

int main() {
    CopyClass original;
    original.num = 10;
    printClass(original); // 调用拷贝构造函数创建original的副本传递给printClass
    return 0;
}

然而,在某些情况下,默认的拷贝构造函数可能无法满足需求。例如,当类中包含指针成员并且该指针指向动态分配的资源时,默认的成员级拷贝会导致多个对象指向同一块动态内存,这可能会引发内存泄漏和悬空指针等问题。在这种情况下,我们需要显式定义拷贝构造函数来进行深拷贝,即每个对象拥有自己独立的动态分配资源。

class DeepCopyClass {
public:
    int* data;
    DeepCopyClass(int value) {
        data = new int(value);
    }
    // 显式定义拷贝构造函数进行深拷贝
    DeepCopyClass(const DeepCopyClass& other) {
        data = new int(*other.data);
    }
    ~DeepCopyClass() {
        delete data;
    }
};

移动构造函数

移动构造函数是C++11引入的新特性,用于高效地从一个对象转移资源到另一个对象,而不是进行拷贝。它的参数是一个右值引用,这使得我们可以区分是在进行拷贝操作还是移动操作。

当我们有一个临时对象(右值)需要传递给另一个对象进行初始化时,移动构造函数会被调用。如果类中没有显式定义移动构造函数,并且满足一定条件(例如类的所有非静态数据成员都是可移动构造的),编译器会自动生成一个默认的移动构造函数。这个默认的移动构造函数会对每个数据成员进行移动构造(对于内置类型数据成员进行简单的赋值,对于类类型数据成员调用其移动构造函数)。

以下是一个示例:

class MoveClass {
public:
    int* data;
    MoveClass(int value) {
        data = new int(value);
    }
    // 显式定义移动构造函数
    MoveClass(MoveClass&& other) noexcept {
        data = other.data;
        other.data = nullptr;
    }
    ~MoveClass() {
        if (data) {
            delete data;
        }
    }
};

MoveClass createObject() {
    MoveClass temp(42);
    return temp; // 返回临时对象,调用移动构造函数
}

int main() {
    MoveClass obj = createObject();
    return 0;
}

通过移动构造函数,我们可以避免不必要的深拷贝操作,提高程序的性能,尤其是在处理大对象或动态分配资源的对象时。

拷贝赋值运算符

拷贝赋值运算符用于将一个已存在对象的值赋给另一个同类型的已存在对象。它的函数原型通常为 类名& operator=(const 类名& other)

如果类中没有显式定义拷贝赋值运算符,编译器会自动生成一个默认的拷贝赋值运算符。默认的拷贝赋值运算符会执行成员级别的赋值,与默认拷贝构造函数类似,对于内置类型数据成员进行值赋值,对于类类型数据成员调用其拷贝赋值运算符。

以下是示例代码:

class AssignmentClass {
public:
    int num;
    // 编译器自动生成的拷贝赋值运算符
};

int main() {
    AssignmentClass a, b;
    a.num = 5;
    b = a; // 调用拷贝赋值运算符
    return 0;
}

同样,当类中包含动态分配的资源时,默认的拷贝赋值运算符可能会导致问题,需要我们显式定义进行深拷贝的赋值运算符。

class DeepAssignmentClass {
public:
    int* data;
    DeepAssignmentClass(int value) {
        data = new int(value);
    }
    // 显式定义拷贝赋值运算符进行深拷贝
    DeepAssignmentClass& operator=(const DeepAssignmentClass& other) {
        if (this != &other) {
            delete data;
            data = new int(*other.data);
        }
        return *this;
    }
    ~DeepAssignmentClass() {
        delete data;
    }
};

移动赋值运算符

移动赋值运算符也是C++11引入的,它允许我们高效地将一个对象的资源移动到另一个对象,而不是进行拷贝。其函数原型通常为 类名& operator=(类名&& other) noexcept

当我们将一个右值对象赋值给另一个对象时,移动赋值运算符会被调用。如果类中没有显式定义移动赋值运算符,并且满足一定条件,编译器会自动生成一个默认的移动赋值运算符。默认的移动赋值运算符会对每个数据成员进行移动赋值(对于内置类型数据成员进行简单的赋值,对于类类型数据成员调用其移动赋值运算符)。

以下是示例代码:

class MoveAssignmentClass {
public:
    int* data;
    MoveAssignmentClass(int value) {
        data = new int(value);
    }
    // 显式定义移动赋值运算符
    MoveAssignmentClass& operator=(MoveAssignmentClass&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
        }
        return *this;
    }
    ~MoveAssignmentClass() {
        if (data) {
            delete data;
        }
    }
};

int main() {
    MoveAssignmentClass a(10);
    MoveAssignmentClass b(20);
    b = std::move(a); // 调用移动赋值运算符
    return 0;
}

移动赋值运算符在处理临时对象和动态分配资源时能显著提高效率。

析构函数

析构函数用于在对象销毁时释放对象所占用的资源。当对象的生命周期结束(例如对象离开其作用域),或者使用 delete 操作符删除动态分配的对象时,析构函数会被调用。

如果类中没有显式定义析构函数,编译器会自动生成一个默认的析构函数。默认析构函数会对类的所有非静态数据成员调用其析构函数。

以下是一个简单示例:

class DestructorClass {
public:
    int num;
    // 编译器自动生成的析构函数
};

int main() {
    {
        DestructorClass obj;
        // 当obj离开这个作用域时,调用默认析构函数
    }
    return 0;
}

当类中包含动态分配的资源(如指针指向动态分配的内存)时,我们需要显式定义析构函数来释放这些资源,以避免内存泄漏。

class ResourceClass {
public:
    int* data;
    ResourceClass(int value) {
        data = new int(value);
    }
    ~ResourceClass() {
        delete data;
    }
};

缺省函数的特殊情况与注意事项

合成缺省函数的条件

编译器并非在任何情况下都会自动生成缺省函数。例如,当类中定义了用户自定义的拷贝构造函数、拷贝赋值运算符或析构函数时,编译器通常不会自动生成移动构造函数和移动赋值运算符。这是因为这些用户自定义函数可能暗示了对象资源管理的特殊方式,编译器无法确定默认的移动操作是否合适。

同样,如果类中有引用成员或常量成员,编译器生成的默认构造函数可能无法满足需求,因为引用和常量必须在初始化时就确定其值,而默认构造函数可能无法提供合适的初始化方式。在这种情况下,我们通常需要显式定义构造函数来处理这些特殊成员。

显式控制缺省函数的生成

在C++11中,我们可以使用 = default 语法来显式要求编译器生成缺省函数。这在某些情况下非常有用,比如我们希望使用默认的行为,但又想明确表示我们是有意使用默认函数而不是依赖编译器隐式生成。

例如,我们可以这样显式要求编译器生成默认构造函数:

class ExplicitDefaultClass {
public:
    int value;
    ExplicitDefaultClass() = default;
};

类似地,我们也可以对其他缺省函数使用 = default,如拷贝构造函数、移动构造函数等。

class ExplicitCopyClass {
public:
    int data;
    ExplicitCopyClass(const ExplicitCopyClass& other) = default;
};

同时,我们还可以使用 = delete 来阻止编译器生成某些缺省函数。比如,当我们不希望类支持拷贝或赋值操作时,可以这样做:

class NoCopyClass {
public:
    int num;
    NoCopyClass(const NoCopyClass& other) = delete;
    NoCopyClass& operator=(const NoCopyClass& other) = delete;
};

这样,任何尝试拷贝或赋值 NoCopyClass 对象的操作都会导致编译错误。

缺省函数与继承

在继承体系中,缺省函数的行为会变得更加复杂。当派生类没有显式定义某些缺省函数时,编译器生成的缺省函数会首先调用基类的相应缺省函数,然后再处理派生类自身的数据成员。

例如,对于默认构造函数,编译器生成的派生类默认构造函数会先调用基类的默认构造函数,然后对派生类的数据成员进行默认初始化。同样,拷贝构造函数和移动构造函数会先调用基类的相应构造函数,再处理派生类的数据成员的拷贝或移动。

以下是一个示例:

class Base {
public:
    int baseData;
    Base() : baseData(0) {}
    Base(const Base& other) : baseData(other.baseData) {}
};

class Derived : public Base {
public:
    int derivedData;
    // 编译器生成的默认构造函数会先调用Base的默认构造函数
    // 然后默认初始化derivedData
    // 编译器生成的拷贝构造函数会先调用Base的拷贝构造函数
    // 然后拷贝derivedData
};

然而,如果基类的某些缺省函数被声明为 privateprotected,派生类可能无法直接调用这些函数,这可能会导致编译器无法生成派生类的某些缺省函数,或者需要我们在派生类中显式定义这些函数来处理基类部分的初始化或赋值。

缺省函数与多态

在多态的场景下,析构函数的设计尤为重要。如果一个类被设计为基类,并且希望通过基类指针或引用来正确销毁派生类对象,那么基类的析构函数必须声明为 virtual。否则,当通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致派生类对象所占用的资源无法被正确释放。

class PolymorphicBase {
public:
    virtual ~PolymorphicBase() {}
};

class PolymorphicDerived : public PolymorphicBase {
public:
    int* data;
    PolymorphicDerived() {
        data = new int(0);
    }
    ~PolymorphicDerived() {
        delete data;
    }
};

int main() {
    PolymorphicBase* ptr = new PolymorphicDerived();
    delete ptr; // 正确调用派生类析构函数
    return 0;
}

如果基类析构函数不是 virtual,上述代码在 delete ptr 时只会调用 PolymorphicBase 的析构函数,而不会调用 PolymorphicDerived 的析构函数,从而导致内存泄漏。

缺省函数的性能优化与应用场景

性能优化

合理利用缺省函数可以显著提高程序的性能。例如,移动构造函数和移动赋值运算符通过避免不必要的深拷贝操作,能够在对象传递和赋值过程中节省大量的时间和空间。特别是在处理包含大量数据或动态分配资源的对象时,移动语义的使用可以使程序运行得更加高效。

另外,编译器生成的默认缺省函数通常经过了优化,它们会尽可能高效地执行成员级别的操作。对于简单的类,默认的拷贝构造函数、拷贝赋值运算符等可能已经能够满足性能要求,我们无需进行额外的优化。

然而,在某些情况下,我们可能需要对缺省函数进行手动优化。比如,当类的数据成员之间存在复杂的依赖关系时,默认的成员级拷贝或赋值可能不是最优的,我们可以通过显式定义缺省函数来采用更高效的算法。

应用场景

  1. 资源管理类:在实现资源管理类(如智能指针的自定义实现)时,缺省函数起着关键作用。默认构造函数可以初始化资源指针为 nullptr,拷贝构造函数和拷贝赋值运算符可以实现引用计数的增加和减少,移动构造函数和移动赋值运算符可以高效地转移资源所有权,析构函数则负责在引用计数为0时释放资源。
template <typename T>
class MySmartPtr {
public:
    T* ptr;
    int* refCount;
    MySmartPtr() : ptr(nullptr), refCount(new int(1)) {}
    MySmartPtr(T* p) : ptr(p), refCount(new int(1)) {}
    MySmartPtr(const MySmartPtr& other) : ptr(other.ptr), refCount(other.refCount) {
        ++(*refCount);
    }
    MySmartPtr(MySmartPtr&& other) noexcept : ptr(other.ptr), refCount(other.refCount) {
        other.ptr = nullptr;
        other.refCount = nullptr;
    }
    MySmartPtr& operator=(const MySmartPtr& other) {
        if (this != &other) {
            if (--(*refCount) == 0) {
                delete ptr;
                delete refCount;
            }
            ptr = other.ptr;
            refCount = other.refCount;
            ++(*refCount);
        }
        return *this;
    }
    MySmartPtr& operator=(MySmartPtr&& other) noexcept {
        if (this != &other) {
            if (--(*refCount) == 0) {
                delete ptr;
                delete refCount;
            }
            ptr = other.ptr;
            refCount = other.refCount;
            other.ptr = nullptr;
            other.refCount = nullptr;
        }
        return *this;
    }
    ~MySmartPtr() {
        if (--(*refCount) == 0) {
            delete ptr;
            delete refCount;
        }
    }
};
  1. 容器类:在实现自定义容器类(如链表、数组等)时,缺省函数用于管理容器中的元素。默认构造函数可以初始化容器的状态,拷贝构造函数和拷贝赋值运算符可以实现容器内容的复制,移动构造函数和移动赋值运算符可以高效地转移容器的所有权,析构函数则负责释放容器中元素所占用的资源。
class MyArray {
public:
    int* data;
    int size;
    MyArray(int s) : size(s) {
        data = new int[s];
    }
    MyArray(const MyArray& other) : size(other.size) {
        data = new int[size];
        for (int i = 0; i < size; ++i) {
            data[i] = other.data[i];
        }
    }
    MyArray(MyArray&& other) noexcept : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }
    MyArray& operator=(const MyArray& other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            for (int i = 0; i < size; ++i) {
                data[i] = other.data[i];
            }
        }
        return *this;
    }
    MyArray& operator=(MyArray&& other) noexcept {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = other.data;
            other.size = 0;
            other.data = nullptr;
        }
        return *this;
    }
    ~MyArray() {
        delete[] data;
    }
};
  1. 简单数据存储类:对于只包含基本数据类型的数据存储类,默认的缺省函数通常就足够了。这些类可以利用编译器自动生成的函数来进行对象的创建、复制、移动和销毁,减少了代码量,同时保证了效率。
class Point {
public:
    int x;
    int y;
    // 编译器自动生成的缺省函数足以满足需求
};

通过深入理解C++类缺省函数的功能与使用,我们能够更好地编写高效、正确且易于维护的C++代码。在实际编程中,根据具体的需求和场景,合理地利用缺省函数,以及在必要时显式定义这些函数,是C++程序员需要掌握的重要技能。无论是资源管理、容器实现还是简单的数据存储,缺省函数都在其中扮演着不可或缺的角色。