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

C++ delete与delete []的误用案例

2022-08-022.9k 阅读

C++ delete 与 delete [] 的基本概念

在 C++ 中,deletedelete [] 是用于释放动态分配内存的操作符。delete 主要用于释放通过 new 操作符分配的单个对象的内存,而 delete [] 则专门用于释放通过 new [] 操作符分配的数组内存。

delete 操作符

当我们使用 new 创建一个单个对象时,例如:

int* num = new int;
*num = 10;

之后,我们使用 delete 来释放这块内存:

delete num;

这里 delete 会调用对象的析构函数(如果对象有析构函数的话),然后释放分配的内存。

delete [] 操作符

当我们创建一个数组时,比如:

int* nums = new int[5];
for (int i = 0; i < 5; i++) {
    nums[i] = i;
}

要释放这个数组的内存,就需要使用 delete []

delete [] nums;

delete [] 会为数组中的每个元素调用析构函数(如果元素类型有析构函数),然后释放整个数组占用的内存空间。

误用案例一:用 delete 释放数组内存

简单类型数组的情况

考虑下面这段代码:

#include <iostream>

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

    // 错误:使用 delete 释放数组内存
    delete arr;

    return 0;
}

在这段代码中,我们使用 new [] 分配了一个包含 10 个 int 类型元素的数组,但却错误地使用 delete 来释放它。对于简单类型(如 int),这种误用可能不会立即导致明显的错误,因为 int 类型没有析构函数。然而,这仍然是未定义行为。在某些情况下,可能会导致内存泄漏,因为 delete 只会释放数组的第一个元素所占用的内存,而其他元素的内存并没有被正确释放。

自定义类型数组的情况

当数组元素是自定义类型时,问题就会更加严重。例如:

#include <iostream>
#include <string>

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

int main() {
    MyClass* arr = new MyClass[3];

    // 错误:使用 delete 释放数组内存
    delete arr;

    return 0;
}

运行这段代码,你会发现只有第一个 MyClass 对象的析构函数被调用,而其他两个对象的析构函数没有被调用。这会导致资源泄漏,如果 MyClass 对象在析构函数中负责释放一些外部资源(如文件句柄、网络连接等),这些资源将不会被正确释放,从而造成内存泄漏和潜在的系统资源耗尽问题。

误用案例二:用 delete [] 释放单个对象内存

简单类型单个对象的情况

看下面的代码:

#include <iostream>

int main() {
    int* num = new int;
    *num = 42;

    // 错误:使用 delete [] 释放单个对象内存
    delete [] num;

    return 0;
}

这里我们使用 new 创建了一个单个的 int 对象,却错误地使用 delete [] 来释放它。这同样是未定义行为。虽然对于简单类型 int,这种误用可能不会导致立即崩溃,但在某些系统或编译器实现下,可能会引起程序异常,因为 delete [] 期望处理的是一个数组结构,它会尝试按照数组的方式去释放内存,而单个对象的内存布局与数组是不同的。

自定义类型单个对象的情况

对于自定义类型,情况更为复杂。比如:

#include <iostream>

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

int main() {
    MySimpleClass* obj = new MySimpleClass;

    // 错误:使用 delete [] 释放单个对象内存
    delete [] obj;

    return 0;
}

在这段代码中,delete [] 会尝试多次调用 MySimpleClass 的析构函数,因为它认为 obj 指向的是一个数组。这会导致程序崩溃,因为实际上只有一个对象,多次调用析构函数会破坏对象的状态,访问已释放的内存等,从而引发未定义行为。

深层次原因分析

内存管理机制

在 C++ 中,newnew [] 在分配内存时,不仅分配了对象或数组所需的内存空间,还会在内存中记录一些额外的信息。对于 new [],编译器会记录数组的大小等信息,以便 delete [] 能够正确地为每个元素调用析构函数并释放整个数组的内存。而 new 分配单个对象时,记录的信息是针对单个对象的。

当使用 delete 释放数组内存时,由于 delete 只知道如何释放单个对象,它不会去查找数组大小的信息,也不会为数组中的每个元素调用析构函数,这就导致了内存释放不完整。相反,当使用 delete [] 释放单个对象内存时,delete [] 会按照数组的方式去处理内存,试图多次调用析构函数,这与单个对象的内存布局和生命周期管理规则相冲突。

编译器和运行时库的实现

不同的编译器和运行时库在实现 deletedelete [] 时可能会有一些差异,但它们都遵循 C++ 标准的基本规则。然而,这些差异可能会影响误用情况下程序的具体表现。例如,某些编译器可能会对内存进行更严格的检查,在误用 deletedelete [] 时更容易检测到错误并引发程序崩溃,而有些编译器可能在简单类型数组误用 delete 时不会立即报错,但这并不意味着代码是正确的,只是在这种特定编译器和运行环境下未表现出明显问题。

避免误用的方法

养成良好的编码习惯

在编写代码时,始终确保 deletenew 配对使用,delete []new [] 配对使用。这需要在编写动态内存分配代码时保持高度的警惕性。例如,在定义一个函数用于释放动态分配的内存时,要明确参数是单个对象指针还是数组指针,并使用正确的释放操作符。

void freeSingleObject(int* ptr) {
    delete ptr;
}

void freeArray(int* ptr) {
    delete [] ptr;
}

使用智能指针

C++ 提供了智能指针(如 std::unique_ptrstd::shared_ptr 等)来帮助管理动态内存。智能指针能够自动处理内存的释放,大大减少了手动使用 deletedelete [] 带来的错误。

例如,使用 std::unique_ptr 来管理单个对象:

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> num(new int(10));
    std::cout << *num << std::endl;
    // 当 num 离开作用域时,内存会自动释放,无需手动调用 delete
    return 0;
}

使用 std::unique_ptr 来管理数组:

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int[]> nums(new int[5]);
    for (int i = 0; i < 5; i++) {
        nums[i] = i;
    }
    // 当 nums 离开作用域时,数组内存会自动释放,无需手动调用 delete []
    return 0;
}

智能指针内部会根据对象类型(单个对象或数组)来选择正确的释放方式,从而避免了 deletedelete [] 误用的问题。

复杂场景下的误用分析

函数参数传递中的误用

考虑这样一个函数,它接受一个指针并负责释放内存:

void processData(int* data) {
    // 假设这里进行了一些数据处理
    // 错误:未根据 data 是单个对象还是数组来正确选择释放方式
    delete data;
}

如果调用这个函数时传入的是一个数组指针,就会出现用 delete 释放数组内存的错误。为了避免这种情况,函数接口应该设计得更加清晰,比如可以通过函数名或者额外的参数来明确传入指针的类型。

void processSingleData(int* data) {
    delete data;
}

void processArrayData(int* data) {
    delete [] data;
}

继承体系中的误用

在继承体系中,动态内存管理会变得更加复杂。假设我们有一个基类和一个派生类:

#include <iostream>

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

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

现在,如果我们动态分配一个派生类对象数组,并使用基类指针来操作它:

int main() {
    Base* arr = new Derived[3];

    // 错误:使用 delete 释放数组内存,这里应该用 delete []
    delete arr;

    return 0;
}

在这种情况下,由于基类的析构函数是虚函数,delete arr 会调用 Base 的析构函数三次,但不会调用 Derived 的析构函数,导致资源泄漏。正确的做法是使用 delete [] arr,这样会为每个 Derived 对象调用其析构函数,先调用 Derived 的析构函数,再调用 Base 的析构函数。

总结常见误用场景及后果

常见误用场景

  1. delete 释放 new [] 分配的数组内存:无论是简单类型数组还是自定义类型数组,这种误用都会导致内存释放不完整,可能引发内存泄漏,特别是对于自定义类型数组,部分对象的析构函数不会被调用。
  2. delete [] 释放 new 分配的单个对象内存:这会导致未定义行为,可能引发程序崩溃,尤其是对于自定义类型对象,会导致析构函数被错误调用多次。
  3. 函数参数传递中未正确区分单个对象指针和数组指针:在函数内部错误地使用 deletedelete [] 释放传入的指针,可能导致内存释放错误。
  4. 继承体系中使用基类指针操作派生类对象数组时,释放内存方式错误:如果使用 delete 而不是 delete [] 来释放派生类对象数组,会导致派生类对象的析构函数不能正确调用,从而引发资源泄漏。

后果

  1. 内存泄漏:部分内存没有被正确释放,随着程序运行,可用内存逐渐减少,最终可能导致程序因内存不足而崩溃。
  2. 程序崩溃:错误的内存释放操作可能导致访问已释放的内存、多次调用析构函数等问题,这些都会破坏程序的内存状态,引发程序崩溃。
  3. 数据损坏:由于内存管理错误,可能导致数据在内存中的存储和访问出现异常,从而损坏数据,影响程序的正确性。

为了编写健壮的 C++ 代码,开发者必须深入理解 deletedelete [] 的正确使用方法,避免这些常见的误用场景。在复杂的项目中,可以借助代码审查、静态分析工具以及智能指针等手段来确保动态内存管理的正确性。