类外堆内存与继承中的动态内存管理
类外堆内存
基本概念
- 定义:当类对象的成员变量是指针/引用,且指向通过
malloc()、new、new[]等操作符分配的额外堆内存时,这些内存被称为「类外堆内存」。 - 核心特点:类外堆内存不会随类对象的生命周期结束而自动释放,必须手动调用
free()、delete、delete[]等操作释放,否则会导致内存泄漏。
- 重要性:类外堆内存是 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;
// 无需手动调用基类析构,子类析构后自动执行
}
};
关键注意点
- 拷贝构造函数:必须在「初始化列表」中显式调用基类拷贝构造(
Base(r)),否则编译器会调用基类普通构造,导致基类部分初始化错误; - 赋值运算符函数:必须在函数体内显式调用基类赋值运算符(
Base::operator=(r)),否则基类部分的动态内存不会被正确拷贝; - 析构函数:子类析构仅需释放自身的堆内存,基类析构会在子类析构后自动调用(因基类析构是虚函数,确保多态场景下的正确析构)。
拓展
深拷贝 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运算符,记录内存分配地址和行数,程序结束时输出未释放的内存。
核心总结
- 类外堆内存需手动释放:分配与释放必须匹配(
new-delete、new[]-delete[]、malloc-free); - 含类外堆内存的类必须重写:拷贝构造函数(深拷贝)、赋值运算符函数(深拷贝)、析构函数;
- 继承场景中:
- 基类析构必须声明为
virtual; - 子类有动态内存时,需显式调用基类的拷贝构造和赋值运算符函数;
- 基类析构必须声明为
- 智能指针是规避内存泄漏的推荐方案,优先使用
unique_ptr/shared_ptr替代裸指针; - 内存泄漏排查:常用 Valgrind 工具,重点关注「未释放的堆内存块」。

浙公网安备 33010602011771号