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

C++ delete与delete []的差异解析

2024-05-102.5k 阅读

C++ 内存管理基础

在深入探讨 deletedelete [] 的差异之前,我们先来回顾一下 C++ 内存管理的基础知识。C++ 提供了两种动态内存分配方式:newmalloc,与之对应的释放内存的方式分别是 deletefreenewdelete 是 C++ 运算符,而 mallocfree 是 C 标准库函数。

动态内存分配的基本原理

  1. new 运算符
    • 当使用 new 分配内存时,它做了两件事:首先在堆上分配一块足够大小的内存空间,然后调用对象的构造函数(如果分配的是对象类型)。例如:
    int* num = new int;
    *num = 10;
    
    这里 new int 在堆上分配了一个 int 类型大小的内存空间,并返回一个指向该空间的指针 num
    • 如果要分配对象类型,比如自定义类 MyClass
    class MyClass {
    public:
        MyClass() { std::cout << "MyClass constructor" << std::endl; }
        ~MyClass() { std::cout << "MyClass destructor" << std::endl; }
    };
    MyClass* obj = new MyClass;
    
    此时 new MyClass 不仅分配了内存,还调用了 MyClass 的构造函数。
  2. malloc 函数
    • malloc 只负责在堆上分配指定大小的内存空间,不会调用构造函数。它的原型是 void* malloc(size_t size),返回一个指向分配内存起始地址的 void* 指针。例如:
    int* num2 = (int*)malloc(sizeof(int));
    if (num2!= nullptr) {
        *num2 = 20;
    }
    
    这里通过 malloc 分配了一个 int 大小的内存空间,并将返回的 void* 指针转换为 int* 指针。

动态内存释放的基本原理

  1. delete 运算符
    • delete 用于释放 new 分配的单个对象的内存。它先调用对象的析构函数(如果是对象类型),然后释放内存。例如:
    delete num;
    
    对于自定义类对象:
    delete obj;
    
    会先调用 MyClass 的析构函数,然后释放内存。
  2. free 函数
    • free 用于释放 malloc 分配的内存。它不会调用析构函数,直接释放内存。例如:
    free(num2);
    

deletedelete [] 的基础差异

delete 用于单个对象

delete 是专门用来释放通过 new 分配的单个对象的内存。例如:

int* singleInt = new int;
*singleInt = 42;
delete singleInt;

在这个例子中,new int 分配了一个 int 类型的内存空间,delete singleInt 释放了该空间。如果 singleInt 指向的是一个自定义类对象,delete 会先调用该类的析构函数,然后释放内存。

class SimpleClass {
public:
    SimpleClass() { std::cout << "SimpleClass constructor" << std::endl; }
    ~SimpleClass() { std::cout << "SimpleClass destructor" << std::endl; }
};
SimpleClass* singleObj = new SimpleClass;
delete singleObj;

这里 delete singleObj 先调用 SimpleClass 的析构函数,输出 “SimpleClass destructor”,然后释放内存。

delete [] 用于数组对象

delete [] 用于释放通过 new [] 分配的数组内存。例如:

int* intArray = new int[10];
for (int i = 0; i < 10; i++) {
    intArray[i] = i;
}
delete [] intArray;

在这个例子中,new int[10] 分配了一个包含 10 个 int 元素的数组内存,delete [] intArray 释放了整个数组的内存。对于自定义类数组,delete [] 会依次调用每个数组元素的析构函数,然后释放内存。

class ComplexClass {
public:
    ComplexClass() { std::cout << "ComplexClass constructor" << std::endl; }
    ~ComplexClass() { std::cout << "ComplexClass destructor" << std::endl; }
};
ComplexClass* complexArray = new ComplexClass[5];
delete [] complexArray;

这里 delete [] complexArray 会依次调用 5 个 ComplexClass 对象的析构函数,输出 5 次 “ComplexClass destructor”,然后释放内存。

深入剖析 deletedelete [] 的本质差异

内存布局与对象信息存储

  1. 单个对象的内存布局
    • 当使用 new 分配单个对象时,内存布局相对简单。对于基本数据类型,如 int,只是在堆上分配一个 int 大小的空间。对于自定义类对象,除了对象本身的数据成员占用的空间外,可能还会有一些用于支持多态等特性的额外信息(如虚函数表指针)。例如:
    class Base {
    public:
        virtual void print() { std::cout << "Base" << std::endl; }
    };
    Base* baseObj = new Base;
    
    在这种情况下,new Base 分配的内存空间包含对象的数据成员(在这个简单例子中没有显式数据成员)和虚函数表指针(因为有虚函数 print)。delete baseObj 知道如何释放这个特定对象的内存,因为它与 new Base 是对应的操作。
  2. 数组对象的内存布局
    • 使用 new [] 分配数组时,内存布局更为复杂。除了数组元素占用的空间外,还需要额外存储数组的大小信息。这是因为 delete [] 需要知道数组中有多少个元素,以便正确调用每个元素的析构函数(如果是对象数组)。例如:
    int* intArray2 = new int[20];
    
    这里分配的内存块不仅包含 20 个 int 元素的空间,还在某个地方(具体实现依赖于编译器)存储了数组大小 20。对于自定义类数组:
    class AnotherClass {
    public:
        AnotherClass() { std::cout << "AnotherClass constructor" << std::endl; }
        ~AnotherClass() { std::cout << "AnotherClass destructor" << std::endl; }
    };
    AnotherClass* anotherArray = new AnotherClass[3];
    
    分配的内存块包含 3 个 AnotherClass 对象的空间以及数组大小信息。delete [] anotherArray 会根据存储的数组大小信息,依次调用 3 个 AnotherClass 对象的析构函数。

析构函数调用机制

  1. delete 对单个对象析构函数的调用
    • 当使用 delete 释放单个对象时,它直接调用该对象的析构函数。例如对于 SimpleClass 对象:
    SimpleClass* singleSimple = new SimpleClass;
    delete singleSimple;
    
    这里 delete singleSimple 会立即调用 SimpleClass 的析构函数。这是一个简单直接的过程,因为只涉及一个对象。
  2. delete [] 对数组对象析构函数的调用
    • delete [] 在释放数组对象时,会遍历数组,对每个元素调用析构函数。例如对于 ComplexClass 数组:
    ComplexClass* complexArray2 = new ComplexClass[4];
    delete [] complexArray2;
    
    delete [] complexArray2 会从数组的起始位置开始,根据数组大小信息,依次对 4 个 ComplexClass 对象调用析构函数。这个过程需要额外的逻辑来遍历数组并逐个调用析构函数。

错误使用 deletedelete [] 的后果

使用 delete 释放数组内存

如果错误地使用 delete 来释放通过 new [] 分配的数组内存,会导致未定义行为。例如:

int* intArray3 = new int[5];
delete intArray3; // 错误使用,会导致未定义行为

对于对象数组,后果更为严重。考虑如下代码:

class Resource {
public:
    Resource() { std::cout << "Resource constructor" << std::endl; }
    ~Resource() { std::cout << "Resource destructor" << std::endl; }
};
Resource* resourceArray = new Resource[3];
delete resourceArray; // 错误使用,会导致未定义行为

在这种情况下,delete resourceArray 只会调用第一个 Resource 对象的析构函数,而其余两个对象的析构函数不会被调用,这可能会导致资源泄漏(如果 Resource 对象持有需要释放的资源,如文件句柄、动态分配的内存等)。

使用 delete [] 释放单个对象内存

同样,使用 delete [] 释放通过 new 分配的单个对象内存也是错误的,会导致未定义行为。例如:

int* singleNum = new int;
delete [] singleNum; // 错误使用,会导致未定义行为

对于自定义类对象:

class MyObject {
public:
    MyObject() { std::cout << "MyObject constructor" << std::endl; }
    ~MyObject() { std::cout << "MyObject destructor" << std::endl; }
};
MyObject* singleMyObj = new MyObject;
delete [] singleMyObj; // 错误使用,会导致未定义行为

这里 delete [] singleMyObj 会尝试按照数组的方式去调用析构函数,但实际上只有一个对象,这会导致程序行为不可预测,可能出现内存访问错误等问题。

实际应用场景与最佳实践

动态数组的正确管理

  1. 基本数据类型数组
    • 当处理基本数据类型数组时,如 intdouble 数组,正确的方式是使用 new [] 分配内存,delete [] 释放内存。例如:
    double* doubleArray = new double[100];
    // 使用数组
    for (int i = 0; i < 100; i++) {
        doubleArray[i] = i * 1.5;
    }
    delete [] doubleArray;
    
    这样可以确保内存正确释放,避免内存泄漏。
  2. 自定义类数组
    • 对于自定义类数组,同样需要使用 new [] 分配内存,delete [] 释放内存。例如,假设有一个 Employee 类:
    class Employee {
    private:
        std::string name;
        int id;
    public:
        Employee(const std::string& n, int i) : name(n), id(i) {
            std::cout << "Employee constructor for " << name << std::endl;
        }
        ~Employee() {
            std::cout << "Employee destructor for " << name << std::endl;
        }
    };
    Employee* employees = new Employee[3]{Employee("Alice", 1), Employee("Bob", 2), Employee("Charlie", 3)};
    // 使用数组
    for (int i = 0; i < 3; i++) {
        // 进行一些操作
    }
    delete [] employees;
    
    这里 delete [] employees 会正确调用每个 Employee 对象的析构函数,释放相关资源。

智能指针与内存管理

  1. std::unique_ptr
    • std::unique_ptr 是 C++ 11 引入的智能指针,它可以自动管理动态分配的内存。对于单个对象,可以使用 std::unique_ptr<T>,对于数组,可以使用 std::unique_ptr<T[]>。例如:
    std::unique_ptr<int> singlePtr(new int(42));
    std::unique_ptr<int[]> arrayPtr(new int[5]);
    for (int i = 0; i < 5; i++) {
        arrayPtr[i] = i * 2;
    }
    // 当 singlePtr 和 arrayPtr 超出作用域时,内存会自动释放
    
    对于自定义类,同样适用:
    std::unique_ptr<MyClass> myObjPtr(new MyClass);
    std::unique_ptr<ComplexClass[]> complexArrayPtr(new ComplexClass[4]);
    
    std::unique_ptr 会根据对象是单个还是数组,正确调用 deletedelete []
  2. std::shared_ptr
    • std::shared_ptr 也是 C++ 11 引入的智能指针,用于实现共享所有权的内存管理。它同样可以处理单个对象和数组。例如:
    std::shared_ptr<int> sharedSingle(new int(100));
    std::shared_ptr<int[]> sharedArray(new int[8]);
    for (int i = 0; i < 8; i++) {
        sharedArray[i] = i + 1;
    }
    // 当引用计数为 0 时,内存会自动释放
    
    对于自定义类:
    std::shared_ptr<SimpleClass> sharedSimple(new SimpleClass);
    std::shared_ptr<AnotherClass[]> sharedAnotherArray(new AnotherClass[6]);
    
    std::shared_ptr 内部会根据对象类型正确调用 deletedelete [] 来释放内存。

编译器相关实现细节

不同的编译器在实现 newdeletenew []delete [] 时可能会有一些细微的差异,尤其是在处理内存布局和数组大小存储方面。

GCC 编译器

  1. 内存布局
    • 在 GCC 编译器中,对于 new [] 分配的数组,数组大小通常存储在数组对象之前的某个位置。例如,对于 int 数组 int* arr = new int[10];,编译器会在 arr 指向的内存地址之前分配额外的空间来存储数组大小 10。当 delete [] arr 被调用时,编译器能够找到这个数组大小信息,从而正确地释放数组内存并调用每个元素的析构函数(如果是对象数组)。
  2. 析构函数调用优化
    • GCC 编译器在处理对象数组的析构函数调用时,会进行一些优化。例如,如果数组元素是简单类型(没有自定义析构函数),编译器可能会跳过析构函数调用的步骤,直接释放内存,以提高效率。

Visual Studio 编译器

  1. 内存布局
    • Visual Studio 编译器在处理 new [] 分配的数组时,也会在数组对象之前或之后的某个位置存储数组大小信息。具体的存储位置和方式可能与 GCC 不同,但目的都是为了让 delete [] 能够获取数组大小并正确释放内存。例如,对于 double 数组 double* dArr = new double[20];,Visual Studio 会以特定的方式存储数组大小 20,以便 delete [] dArr 能够正确工作。
  2. 调试信息与内存管理
    • Visual Studio 在调试模式下,会对动态内存分配和释放进行更详细的跟踪。如果出现错误使用 deletedelete [] 的情况,调试器可能会给出更明确的错误提示,帮助开发者定位问题。例如,如果使用 delete 释放数组内存,调试器可能会指出这是一个潜在的内存错误,并提供相关的调用栈信息。

跨平台开发中的注意事项

在进行跨平台开发时,由于不同编译器对 deletedelete [] 的实现可能存在差异,需要特别注意以下几点:

确保代码一致性

  1. 正确使用 deletedelete []
    • 在编写代码时,严格遵循 newdelete 对应、new []delete [] 对应的原则。无论在哪个平台上,错误使用都会导致未定义行为。例如,在所有平台上,都不应该使用 delete 来释放通过 new [] 分配的数组内存。
    // 正确写法
    int* intArr = new int[10];
    delete [] intArr;
    
  2. 避免依赖特定编译器行为
    • 不要依赖某个编译器在处理 deletedelete [] 时的特殊实现细节。例如,不要假设某个编译器总是将数组大小存储在数组对象之前,因为其他编译器可能有不同的存储方式。代码应该以标准的方式使用 deletedelete [],这样才能保证跨平台的兼容性。

使用智能指针增强可移植性

  1. 统一内存管理方式
    • 使用 std::unique_ptrstd::shared_ptr 等智能指针可以增强代码的可移植性。智能指针在不同平台上的行为是一致的,它们会根据对象类型正确调用 deletedelete []。例如:
    std::unique_ptr<MyClass> myClassPtr(new MyClass);
    std::unique_ptr<MyClass[]> myClassArrayPtr(new MyClass[5]);
    
    无论在 GCC 编译的 Linux 平台,还是 Visual Studio 编译的 Windows 平台,智能指针都能正确管理内存,减少因平台差异导致的内存管理问题。
  2. 智能指针的兼容性
    • C++ 标准库中的智能指针在不同编译器和平台上都有很好的兼容性。只要代码使用的是标准库中的智能指针,并且遵循 C++ 标准,就可以在不同平台上顺利编译和运行,减少因 deletedelete [] 差异带来的潜在问题。

与其他内存管理机制的对比

std::vector 的对比

  1. 内存管理方式
    • std::vector 是 C++ 标准库中的动态数组容器,它内部使用 new []delete [] 来管理内存,但对用户隐藏了这些细节。std::vector 会自动根据需要分配和释放内存,并且在插入和删除元素时会处理内存的重新分配和释放。例如:
    std::vector<int> vec;
    for (int i = 0; i < 10; i++) {
        vec.push_back(i);
    }
    // 当 vec 超出作用域时,其内部使用的内存会自动释放
    
    而直接使用 new []delete [] 需要开发者手动管理内存的分配和释放。
  2. 灵活性与效率
    • std::vector 提供了较高的灵活性,如动态增长、支持随机访问等。但在某些场景下,直接使用 new []delete [] 可能更高效,例如在已知数组大小且不需要动态增长的情况下。例如,如果需要分配一个固定大小的大型数组,直接使用 new [] 可能会避免 std::vector 动态增长带来的额外开销。

boost::pool 的对比

  1. 内存分配策略
    • boost::pool 是 Boost 库中的内存池实现。它采用内存池的方式分配内存,将大块内存划分成多个小块,以减少内存碎片并提高内存分配效率。例如:
    #include <boost/pool/pool.hpp>
    boost::pool<> myPool(sizeof(int));
    int* numFromPool = static_cast<int*>(myPool.malloc());
    *numFromPool = 42;
    myPool.free(numFromPool);
    
    newdelete 是基于堆的内存分配方式,每次分配和释放可能会导致内存碎片。
  2. 适用场景
    • boost::pool 适用于需要频繁分配和释放小块内存的场景,通过内存池可以减少系统调用和内存碎片。而 newdelete 适用于一般的动态内存分配场景,对于对象数组等情况,new []delete [] 提供了直接的内存管理方式。在实际应用中,需要根据具体的需求选择合适的内存管理机制。

通过对 deletedelete [] 差异的深入解析,我们了解了它们在内存管理中的重要性和正确使用方法。在实际编程中,正确使用这两个运算符,结合智能指针等工具,可以有效地避免内存泄漏和未定义行为,提高程序的稳定性和性能。同时,在跨平台开发和与其他内存管理机制对比时,也能更好地选择合适的内存管理策略。