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

C++中delete与delete[]的区别

2021-01-191.2k 阅读

C++ 内存管理基础

在深入探讨 deletedelete[] 的区别之前,我们先来回顾一下 C++ 内存管理的基础知识。C++ 允许程序员手动管理内存,这给予了开发者极大的灵活性,但同时也带来了更高的出错风险。

栈内存与堆内存

  1. 栈内存:栈内存是自动分配和释放的。当一个函数被调用时,其局部变量会在栈上分配内存。当函数返回时,这些变量占用的内存会自动被释放。例如:
void func() {
    int num = 10; // num 在栈上分配内存
} // 函数结束,num 占用的栈内存自动释放

栈内存的优点是分配和释放速度快,因为它遵循后进先出(LIFO)的原则,操作简单。缺点是其生命周期由函数调用决定,不能在函数外部访问。

  1. 堆内存:堆内存需要手动分配和释放。通过 new 操作符在堆上分配内存,使用完后需要通过 delete 操作符释放。例如:
int* ptr = new int;
*ptr = 20;
// 使用完后需要释放
delete ptr;

堆内存的优点是其生命周期可以由程序员控制,适合需要在不同函数间共享数据或需要动态分配内存大小的场景。缺点是手动管理容易出错,如果忘记释放内存,会导致内存泄漏。

new 操作符

new 操作符用于在堆上分配内存。它会做两件事:

  1. 调用 operator new 函数来分配足够大小的内存。operator new 是一个标准库函数,其行为类似于 malloc,但它会抛出异常而不是返回 NULL 来表示分配失败。
  2. 调用对象的构造函数(如果分配的是对象类型)来初始化这块内存。

例如,分配一个简单的 int 类型:

int* numPtr = new int;

这里 new int 首先分配了一个 int 大小的内存空间,然后返回指向这块内存的指针。

如果要分配一个自定义类的对象:

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

MyClass* objPtr = new MyClass;

这里 new MyClass 不仅分配了内存,还调用了 MyClass 的构造函数进行初始化。

delete 操作符

delete 操作符用于释放通过 new 分配的堆内存。它会做两件事:

  1. 调用对象的析构函数(如果是对象类型)。
  2. 调用 operator delete 函数来释放内存。operator deleteoperator new 的对应函数,用于回收内存,其行为类似于 free

例如:

int* numPtr = new int;
*numPtr = 30;
delete numPtr;

对于自定义类对象:

MyClass* objPtr = new MyClass;
delete objPtr;

这里 delete objPtr 首先调用 MyClass 的析构函数,然后释放对象占用的内存。

delete 与 delete[] 的区别

语法形式

  1. delete:用于释放单个对象的内存。语法为 delete pointer;,其中 pointer 是通过 new 分配的指向单个对象的指针。
  2. delete[]:用于释放数组对象的内存。语法为 delete[] pointer;,其中 pointer 是通过 new[] 分配的指向数组首元素的指针。

底层实现差异

  1. delete:当使用 delete 释放单个对象时,它只调用对象的析构函数一次,然后释放对应的内存。
  2. delete[]delete[] 会遍历数组的每个元素,为每个元素调用析构函数,然后释放整个数组占用的内存。这是因为数组中的每个元素都可能需要进行自己的清理工作,例如释放内部资源等。

示例代码分析

  1. 基本数据类型数组
int* intArray = new int[5];
for (int i = 0; i < 5; i++) {
    intArray[i] = i * 2;
}
// 使用 delete[] 释放数组
delete[] intArray;
// 如果错误地使用 delete intArray; 
// 对于基本数据类型,可能不会立即导致明显错误,但这是未定义行为

在这个例子中,我们使用 new[] 分配了一个 int 类型的数组。delete[] 会正确地释放整个数组的内存。如果使用 delete 而不是 delete[],对于基本数据类型数组,可能不会立即引发错误,因为基本数据类型没有析构函数,但这仍然是未定义行为,在某些情况下可能导致程序崩溃或其他难以调试的问题。

  1. 自定义类数组
class Book {
public:
    Book(const char* title) {
        this->title = new char[strlen(title) + 1];
        strcpy(this->title, title);
        std::cout << "Book constructor for " << this->title << std::endl;
    }
    ~Book() {
        delete[] title;
        std::cout << "Book destructor for " << title << std::endl;
    }
private:
    char* title;
};

Book* books = new Book[3] {
    Book("C++ Primer"),
    Book("Effective C++"),
    Book("Modern C++ Design")
};

// 使用 delete[] 释放数组
delete[] books;
// 如果错误地使用 delete books;
// 只会调用第一个对象的析构函数,其他对象的资源不会被释放,导致内存泄漏

在这个例子中,Book 类有一个构造函数来分配内存用于存储书名,并有一个析构函数来释放这块内存。当我们使用 new[] 分配 Book 对象数组时,delete[] 会为数组中的每个 Book 对象调用析构函数,确保每个对象的资源都被正确释放。如果错误地使用 delete books,只会调用 books[0] 的析构函数,books[1]books[2] 的资源不会被释放,从而导致内存泄漏。

常见错误与陷阱

混用 delete 和 delete[]

  1. 用 delete 释放 new[] 分配的数组:如前面 Book 类数组的例子,如果用 delete 释放 new[] 分配的数组,只会调用数组第一个元素的析构函数,其余元素的析构函数不会被调用,可能导致内存泄漏。
  2. 用 delete[] 释放 new 分配的单个对象:这同样是未定义行为。delete[] 会尝试按照数组的方式去调用析构函数,可能会导致程序崩溃,因为对象的内存布局并非数组形式。

内存泄漏风险

  1. 忘记释放内存:无论是使用 new 还是 new[] 分配内存,如果忘记使用相应的 deletedelete[] 释放,都会导致内存泄漏。例如:
int* ptr = new int;
// 没有调用 delete ptr;
  1. 异常导致内存泄漏:在分配内存后,如果在对象初始化或后续操作过程中抛出异常,而没有正确处理内存释放,也会导致内存泄漏。例如:
try {
    Book* book = new Book("Exception Book");
    // 假设这里抛出异常
    throw std::runtime_error("Some error");
    delete book; // 这行代码不会执行,导致内存泄漏
} catch (const std::exception& e) {
    std::cerr << e.what() << std::endl;
}

为了避免这种情况,可以使用智能指针(如 std::unique_ptrstd::shared_ptr),它们会在对象生命周期结束时自动释放内存。

如何正确使用 delete 和 delete[]

匹配使用 new 和 delete

  1. 单个对象:如果使用 new 分配单个对象,就使用 delete 释放。例如:
MyClass* obj = new MyClass;
delete obj;
  1. 数组对象:如果使用 new[] 分配数组对象,就使用 delete[] 释放。例如:
MyClass* objArray = new MyClass[10];
delete[] objArray;

智能指针的使用

  1. std::unique_ptrstd::unique_ptr 是 C++11 引入的智能指针,它独占所指向的对象。当 std::unique_ptr 离开作用域时,会自动调用 deletedelete[] 释放对象(取决于初始化时使用的是 new 还是 new[])。例如:
std::unique_ptr<int> numPtr(new int);
std::unique_ptr<MyClass[]> objArrayPtr(new MyClass[5]);
  1. std::shared_ptrstd::shared_ptr 允许多个指针共享指向同一个对象。它使用引用计数来管理对象的生命周期,当引用计数为 0 时,会自动释放对象。同样,它也能正确处理单个对象和数组对象的释放。例如:
std::shared_ptr<int> sharedNumPtr(new int);
std::shared_ptr<MyClass[]> sharedObjArrayPtr(new MyClass[3]);

通过使用智能指针,可以大大减少手动内存管理的错误,尤其是在处理复杂的对象层次结构和异常情况时。

内存分配与释放的性能考量

内存碎片问题

  1. 频繁分配与释放:在程序中,如果频繁地进行内存分配和释放操作,尤其是大小不同的内存块,可能会导致内存碎片。例如,先分配一个大的内存块,然后释放它,再分配多个小的内存块,可能会使堆内存变得碎片化,降低后续内存分配的效率。
  2. deletedelete[] 对碎片的影响:虽然 deletedelete[] 本身并不会直接导致内存碎片,但如果使用不当,例如混用 deletedelete[],可能会导致内存释放不正确,间接影响内存碎片情况。正确使用 deletedelete[] 可以确保内存按照预期的方式释放,有助于减少内存碎片的产生。

内存分配策略

  1. 不同的分配策略:现代操作系统和 C++ 运行时库采用了多种内存分配策略,如伙伴系统、堆管理器等。这些策略旨在提高内存分配和释放的效率,减少内存碎片。例如,伙伴系统通过将大的内存块分割成较小的块来满足分配请求,并在释放时尝试合并相邻的空闲块。
  2. deletedelete[] 的影响:了解内存分配策略有助于我们更好地理解 deletedelete[] 的行为。例如,某些分配策略可能会在内存块头部存储额外的元数据,用于管理内存块的大小、状态等信息。deletedelete[] 在释放内存时,需要正确处理这些元数据,以确保内存能够被正确回收和重新利用。

跨平台与编译器差异

不同平台的内存模型

  1. Windows、Linux 和 macOS:不同操作系统的内存模型存在差异。例如,Windows 使用虚拟内存管理器来管理内存,而 Linux 使用基于页的内存管理系统。这些差异可能会影响 deletedelete[] 的底层实现和行为。在不同平台上进行开发时,需要注意这些差异,以确保程序的正确性和可移植性。
  2. 嵌入式系统:嵌入式系统通常有自己独特的内存管理需求和限制。例如,一些嵌入式系统可能使用静态内存分配,而不是动态内存分配,以避免内存碎片和提高系统的稳定性。在嵌入式系统中使用 deletedelete[] 时,需要特别小心,确保符合系统的内存管理规则。

编译器实现差异

  1. GCC、Clang 和 Visual C++:不同的编译器对 deletedelete[] 的实现可能存在细微差异。例如,在处理内存对齐、对象布局等方面,不同编译器可能有不同的策略。这些差异可能会影响到 deletedelete[] 的行为,尤其是在处理复杂的数据结构和对象类型时。
  2. 编译器优化选项:编译器的优化选项也可能影响 deletedelete[] 的行为。例如,某些优化选项可能会对内存分配和释放的代码进行优化,改变其执行顺序或行为。在进行编译时,需要了解编译器的优化选项对内存管理的影响,以确保程序的正确性。

特殊情况与高级应用

多态与 delete

  1. 基类指针指向派生类对象:在多态编程中,经常会使用基类指针指向派生类对象。当释放这样的对象时,需要确保调用正确的析构函数。如果基类的析构函数不是虚函数,使用 delete 时可能只会调用基类的析构函数,而不会调用派生类的析构函数,导致资源泄漏。例如:
class Animal {
public:
    Animal() {
        std::cout << "Animal constructor" << std::endl;
    }
    // 如果这里析构函数不是虚函数,会有问题
    ~Animal() {
        std::cout << "Animal destructor" << std::endl;
    }
};

class Dog : public Animal {
public:
    Dog() {
        std::cout << "Dog constructor" << std::endl;
    }
    ~Dog() {
        std::cout << "Dog destructor" << std::endl;
    }
};

Animal* animalPtr = new Dog;
delete animalPtr;
// 如果 Animal 的析构函数不是虚函数,
// 这里只会调用 Animal 的析构函数,Dog 的析构函数不会被调用

为了避免这种情况,基类的析构函数应该声明为虚函数:

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

class Dog : public Animal {
public:
    Dog() {
        std::cout << "Dog constructor" << std::endl;
    }
    ~Dog() {
        std::cout << "Dog destructor" << std::endl;
    }
};

Animal* animalPtr = new Dog;
delete animalPtr;
// 现在会正确调用 Dog 的析构函数,然后调用 Animal 的析构函数
  1. 数组中的多态对象:当处理多态对象数组时,同样需要注意析构函数的虚函数特性。并且,必须使用 delete[] 来释放数组内存,以确保每个对象的析构函数都被正确调用。例如:
Animal* animals = new Dog[3];
delete[] animals;
// 如果 Animal 的析构函数不是虚函数,
// 每个 Dog 对象的析构函数不会被正确调用

重载 operator delete 和 operator delete[]

  1. 自定义内存管理:在某些情况下,我们可能需要自定义内存管理策略,例如使用特定的内存池或优化内存分配和释放的性能。这时可以重载 operator deleteoperator delete[]。例如:
class MyMemoryManager {
public:
    static void* operator new(size_t size) {
        // 自定义内存分配逻辑,例如从内存池中获取内存
        void* ptr = std::malloc(size);
        return ptr;
    }
    static void operator delete(void* ptr) {
        // 自定义内存释放逻辑,例如将内存返回内存池
        std::free(ptr);
    }
    static void* operator new[](size_t size) {
        // 自定义数组内存分配逻辑
        void* ptr = std::malloc(size);
        return ptr;
    }
    static void operator delete[](void* ptr) {
        // 自定义数组内存释放逻辑
        std::free(ptr);
    }
};

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor" << std::endl;
    }
    // 使用自定义内存管理器
    void* operator new(size_t size) {
        return MyMemoryManager::operator new(size);
    }
    void operator delete(void* ptr) {
        MyMemoryManager::operator delete(ptr);
    }
    void* operator new[](size_t size) {
        return MyMemoryManager::operator new[](size);
    }
    void operator delete[](void* ptr) {
        MyMemoryManager::operator delete[](ptr);
    }
};

MyClass* obj = new MyClass;
delete obj;
MyClass* objArray = new MyClass[5];
delete[] objArray;
  1. 注意事项:在重载 operator deleteoperator delete[] 时,需要确保其行为与标准库的要求兼容。例如,operator delete 应该能够处理 NULL 指针,并且不能抛出异常。同时,要注意内存对齐等问题,以确保对象的正确构造和析构。

总结与最佳实践

总结

  1. deletedelete[] 的区别delete 用于释放单个对象的内存,只调用一次析构函数;delete[] 用于释放数组对象的内存,会为数组中的每个元素调用析构函数。混用 deletedelete[] 会导致未定义行为,可能引发内存泄漏、程序崩溃等问题。
  2. 内存管理基础:理解栈内存和堆内存的区别,以及 newdelete 的底层实现,对于正确使用 deletedelete[] 至关重要。同时,要注意内存碎片、内存分配策略等问题,以提高程序的性能和稳定性。
  3. 跨平台与编译器差异:不同平台和编译器对 deletedelete[] 的实现可能存在差异,在进行跨平台开发时需要特别注意。了解这些差异可以帮助我们编写更可移植和健壮的代码。

最佳实践

  1. 匹配使用:始终确保使用 new 分配的对象用 delete 释放,使用 new[] 分配的数组用 delete[] 释放。这是避免内存管理错误的最基本规则。
  2. 智能指针优先:在现代 C++ 编程中,尽量使用智能指针(如 std::unique_ptrstd::shared_ptr)来管理内存。智能指针可以自动处理内存释放,大大减少手动内存管理的错误。
  3. 虚析构函数:在多态编程中,基类的析构函数应该声明为虚函数,以确保正确调用派生类的析构函数。这对于释放多态对象和多态对象数组的内存非常重要。
  4. 谨慎重载:如果需要重载 operator deleteoperator delete[],要确保其行为符合标准库的要求,并进行充分的测试。自定义内存管理可以提高性能,但也增加了代码的复杂性和出错风险。

通过遵循这些最佳实践,可以有效地避免 deletedelete[] 使用不当带来的问题,编写出更健壮、高效的 C++ 程序。