类外堆内存与继承中的动态内存管理

类外堆内存

基本概念

  • 定义:当类对象的成员变量是指针/引用,且指向通过 malloc()newnew[] 等操作符分配的额外堆内存时,这些内存被称为「类外堆内存」。
  • 核心特点:类外堆内存不会随类对象的生命周期结束而自动释放,必须手动调用 free()deletedelete[] 等操作释放,否则会导致内存泄漏。
类对象与类外堆内存关系图(类对象在栈/堆中,成员指针指向独立堆内存)
  • 重要性:类外堆内存是 C++ 内存泄漏的主要诱因之一,也是智能指针设计的核心背景。

默认情况下的内存泄漏

当类中分配了类外堆内存,但未在析构函数中释放时,会导致内存泄漏。以下是典型示例及检测结果:

示例代码(内存泄漏)

// memoryLeak.cpp
#include <iostream>
using namespace std;

class A {
    char *p; // 指向类外堆内存的指针
public:
    A() { p = new char[1000]; } // 分配 1000 字节堆内存
    ~A() { cout << "析构" << endl; } // 未释放 p 指向的堆内存
};

int main(int argc, char const *argv[]) {
    A a; // 局部对象,生命周期结束时自动调用 ~A()
    return 0;
}

内存泄漏检测(valgrind 工具)

gec@ubuntu:~$ valgrind ./memoryLeak
==154251== Memcheck, a memory error detector
==154251== Copyright (C) 2002-2017, and GNU GPLd, by Julian Seward et al.
==154251== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==154251== Command: ./memoryLeak
==154251==
析构
==154251==
==154251== HEAP SUMMARY:
==154251==     in use at exit: 1,000 bytes in 1 blocks
==154251==   total heap usage: 2 allocs, 1 frees, 73,704 bytes allocated
==154251==
==154251== LEAK SUMMARY:
==154251==    definitely lost: 1,000 bytes in 1 blocks
==154251==    indirectly lost: 0 bytes in 0 blocks
==154251==      possibly lost: 0 bytes in 0 blocks
==154251==    still reachable: 0 bytes in 0 blocks
==154251==         suppressed: 0 bytes in 0 blocks
  • 结论:即使析构函数执行,未释放的类外堆内存仍会泄漏(此处泄漏 1000 字节)。

析构函数正确释放内存

核心原则

只要类中分配了类外堆内存,必须重写析构函数,并在其中按「分配方式匹配释放方式」的规则释放内存。

正确示例代码

// memoryLeak2.cpp
#include <iostream>
using namespace std;

class A {
    char *p;
public:
    A() { p = new char[1000]; } // new[] 分配连续空间
    ~A() { 
        cout << "析构" << endl; 
        delete [] p; // 匹配 new[],释放连续空间
    }
};

int main(int argc, char const *argv[]) {
    A a;
    return 0;
}

关键注意事项(分配与释放必须匹配)

构造函数中分配方式 析构函数中释放方式 说明
malloc() / calloc() free() C 风格堆内存分配,需搭配 free
new delete C++ 单个对象堆内存分配
new[] delete[] C++ 连续对象/数组堆内存分配
  • 错误示例:new 分配用 delete[] 释放,或 malloc 分配用 delete 释放,会导致内存 corruption 或未定义行为。

继承关系中的动态内存管理

前提:基类的动态内存设计

假设基类 Base 使用了类外堆内存,需显式定义「构造函数、拷贝构造函数、赋值运算符函数、虚析构函数」以管理动态内存:

class Base {
    char *data; // 指向类外堆内存
    int size;
public:
    // 普通构造函数
    Base(const char *data = "null", int size = 0) {
        this->size = size;
        this->data = new char[size];
        memcpy(this->data, data, size);
    }

    // 拷贝构造函数(深拷贝)
    Base(const Base &r) {
        this->size = r.size;
        this->data = new char[size]; // 重新分配堆内存
        memcpy(this->data, r.data, size); // 拷贝数据
    }

    // 赋值运算符函数(深拷贝)
    Base &operator=(const Base &r) {
        if (this == &r) return *this; // 防止自赋值

        // 释放当前对象的堆内存
        delete[] this->data;

        // 拷贝目标对象的状态
        this->size = r.size;
        this->data = new char[size];
        memcpy(this->data, r.data, size);

        return *this;
    }

    // 虚析构函数(确保子类析构时能正确调用基类析构)
    virtual ~Base() {
        delete[] data;
        cout << "Base 析构" << endl;
    }
};
基类与类外堆内存关系图

子类无动态内存的情况

当子类不包含自身的类外堆内存时,无需显式定义构造、拷贝构造、赋值运算符、析构函数,依赖编译器默认生成的函数即可。

原理

  • 子类默认构造/拷贝构造/赋值运算符函数,会自动调用基类对应的函数,无需手动干预;
  • 子类析构函数(即使是默认空析构)执行完毕后,会自动调用基类的析构函数,确保基类堆内存释放。

示例代码

// 子类无动态内存
class Derived : public Base {
    // 无需显式定义任何构造、拷贝构造、赋值运算符、析构函数
};

// 测试代码
int main() {
    Derived d("test", 4);
    Derived d2 = d; // 调用默认拷贝构造,自动触发 Base 的拷贝构造(深拷贝)
    d2 = d; // 调用默认赋值运算符,自动触发 Base 的赋值运算符(深拷贝)
    return 0;
}
子类无动态内存时的继承关系与内存管理流程图

子类有动态内存的情况

当子类包含自身的类外堆内存时,必须显式定义「拷贝构造函数、赋值运算符函数、析构函数」,且需手动调用基类对应的函数以管理基类的动态内存。

子类完整示例代码

class Derived : public Base {
    char *info; // 子类自身的类外堆内存
    int len;
public:
    // 1. 普通构造函数
    Derived(const char *baseData = "null", int baseSize = 0, 
            const char *info = "null", int len = 0) 
        : Base(baseData, baseSize) { // 调用基类构造函数初始化基类部分
        this->len = len;
        this->info = new char[len];
        memcpy(this->info, info, len);
    }

    // 2. 拷贝构造函数(必须显式调用基类拷贝构造)
    Derived(const Derived &r) 
        : Base(r) { // 关键:显式调用基类拷贝构造(子类对象可向上转型为基类)
        this->len = r.len;
        this->info = new char[len];
        memcpy(this->info, r.info, len);
    }

    // 3. 赋值运算符函数(必须显式调用基类赋值运算符)
    Derived &operator=(const Derived &r) {
        if (this == &r) return *this; // 防止自赋值

        // 关键:显式调用基类赋值运算符,处理基类部分的动态内存
        Base::operator=(r);

        // 处理子类自身的动态内存
        delete[] this->info; // 释放当前对象的 info 堆内存
        this->len = r.len;
        this->info = new char[len];
        memcpy(this->info, r.info, len);

        return *this;
    }

    // 4. 析构函数(仅处理子类自身的动态内存)
    ~Derived() {
        delete[] info;
        cout << "Derived 析构" << endl;
        // 无需手动调用基类析构,子类析构后自动执行
    }
};
子类有动态内存时的继承关系与内存管理流程图

关键注意点

  1. 拷贝构造函数:必须在「初始化列表」中显式调用基类拷贝构造(Base(r)),否则编译器会调用基类普通构造,导致基类部分初始化错误;
  2. 赋值运算符函数:必须在函数体内显式调用基类赋值运算符(Base::operator=(r)),否则基类部分的动态内存不会被正确拷贝;
  3. 析构函数:子类析构仅需释放自身的堆内存,基类析构会在子类析构后自动调用(因基类析构是虚函数,确保多态场景下的正确析构)。

拓展

深拷贝 vs 浅拷贝(核心区别)

动态内存管理的核心是「深拷贝」,避免浅拷贝导致的双重释放或内存泄漏:

对比维度 浅拷贝 深拷贝
本质 仅拷贝指针地址,不拷贝指针指向的堆内存 不仅拷贝指针地址,还重新分配堆内存并拷贝数据
风险 多个对象共享同一块堆内存,可能导致双重释放、内存篡改 每个对象拥有独立的堆内存,无共享风险
适用场景 无动态内存的类 包含类外堆内存的类(尤其是继承场景)
示例(拷贝构造) Derived(const Derived &r) { this->info = r.info; } 见 2.3 中子类拷贝构造函数

虚析构函数的核心作用

基类析构函数必须声明为 virtual 的原因:

  • 当使用「基类指针指向子类对象」时,删除指针会触发「多态析构」:先调用子类析构,再调用基类析构;
  • 若基类析构非虚函数,删除基类指针时仅调用基类析构,子类的堆内存会泄漏。

错误示例(非虚析构)

class Base {
public:
    ~Base() { delete[] data; } // 非虚析构
};

class Derived : public Base {
    char *info;
public:
    ~Derived() { delete[] info; } // 子类析构未被调用
};

// 测试:基类指针指向子类对象
int main() {
    Base *ptr = new Derived();
    delete ptr; // 仅调用 Base::~Base(),Derived 的 info 内存泄漏
    return 0;
}

智能指针基础(解决堆内存泄漏)

原文档提到「智能指针是解决堆内存泄漏的机制」,此处补充核心智能指针的使用:

std::shared_ptr(共享所有权)

  • 多个智能指针可指向同一对象,内部维护引用计数,计数为 0 时自动释放对象;
  • 适用场景:多个对象共享同一堆内存资源。

std::unique_ptr(独占所有权)

  • 仅一个智能指针可指向对象,禁止拷贝,支持移动语义;
  • 适用场景:单一对象独占堆内存资源(效率高于 shared_ptr)。

示例(用智能指针替代裸指针)

#include <memory> // 智能指针头文件

class A {
public:
    ~A() { cout << "A 析构" << endl; }
};

int main() {
    // unique_ptr 独占所有权
    std::unique_ptr<A> ptr1 = std::make_unique<A>();
    // std::unique_ptr<A> ptr2 = ptr1; // 编译错误:禁止拷贝

    // shared_ptr 共享所有权
    std::shared_ptr<A> ptr3 = std::make_shared<A>();
    std::shared_ptr<A> ptr4 = ptr3; // 引用计数变为 2

    return 0; // 智能指针超出作用域,自动释放 A 对象,无内存泄漏
}
  • 优势:无需手动调用 delete,避免遗忘释放或释放时机错误导致的内存泄漏。

常见内存泄漏场景与排查

常见内存泄漏场景

  • 类外堆内存未在析构函数中释放;
  • 继承场景中基类析构非虚函数,子类堆内存未释放;
  • 自赋值导致的内存泄漏(赋值运算符函数未处理自赋值);
  • 动态内存分配后异常抛出,未释放内存(可通过 RAII 机制解决,如智能指针)。

排查工具与方法

  • Valgrind:Linux 下经典内存检测工具(如 1.2 中的示例),命令:valgrind --leak-check=full ./程序名
  • Visual Studio 调试器:Windows 下可通过「内存诊断工具」跟踪堆内存分配与释放;
  • 自定义内存跟踪:重载 new/delete 运算符,记录内存分配地址和行数,程序结束时输出未释放的内存。

核心总结

  1. 类外堆内存需手动释放:分配与释放必须匹配(new-deletenew[]-delete[]malloc-free);
  2. 含类外堆内存的类必须重写:拷贝构造函数(深拷贝)、赋值运算符函数(深拷贝)、析构函数;
  3. 继承场景中:
    • 基类析构必须声明为 virtual
    • 子类有动态内存时,需显式调用基类的拷贝构造和赋值运算符函数;
  4. 智能指针是规避内存泄漏的推荐方案,优先使用 unique_ptr/shared_ptr 替代裸指针;
  5. 内存泄漏排查:常用 Valgrind 工具,重点关注「未释放的堆内存块」。

posted @ 2025-12-20 09:01  Jaklin  阅读(17)  评论(0)    收藏  举报