C++ 内存管理与模板

一、内存管理

1.1 内存分布

我们编写的 C/C++ 程序运行起来之后,操作系统会给这个进程划分出几块功能完全不同的内存区域,从高地址到低地址依次是无法被用户代码访问修改的内核空间、栈、内存映射段、堆、数据段、代码段,每一块区域存放的数据类型、内存管理方式都有明确区分。

结合图中代码示例就能清晰分清各自用途:首先是栈区域,也常被叫做堆栈,用来存放函数里的非静态局部变量、函数入参、函数临时返回值,就像图里 Test 函数中的 localVar、num1 数组、char2 数组这类普通局部变量全都存在栈上,栈的内存由编译器自动分配释放,函数执行完毕对应局部变量就自动销毁,并且栈内存地址是向下增长的。

紧随栈下方的是内存映射段,这是一块高效的 IO 映射内存区域,操作系统会在这里加载动态共享库,我们开发者也能通过系统调用手动创建共享内存,以此实现多个进程之间的数据通信,这块内容在 Linux 系统编程中会深入学习,入门阶段只需简单了解即可。

再往下就是堆,堆是专门给程序运行时手动动态分配内存的区域,地址向上增长,所有通过 malloc、calloc、realloc 申请出来的内存都会放在堆中。堆内存不会自动回收,必须开发者主动调用 free 释放,一旦忘记释放就会产生内存泄漏。

接下来是数据段,这块区域专门存放全部全局变量和所有静态变量,不管是写在函数外部的 globalVar、staticGlobalVar 全局静态变量,还是定义在函数内部的 staticVar 局部静态变量,程序启动时就完成内存分配,直到整个程序运行结束才会释放。

最底部的代码段用来存放程序的可执行指令,同时还会存放字符串字面量这类只读常量,我们只能读取不能修改,一旦尝试修改程序就会直接崩溃,整体来看栈、堆、数据段、代码段、内存映射段分工明确,理解每块内存存放的数据与生命周期,能帮我们彻底分清局部变量、静态变量、动态内存、字符串常量的底层存储差异,也能更好理解内存泄漏、段错误这类常见程序问题产生的根源。

1.2 C++内存管理方式

1.2.1 new、delete操作内置类型

我们在 C++ 开发中依然可以沿用 C 语言里 malloc、calloc 这类动态内存管理接口,但这套原生 C 的内存操作在面向对象场景下存在明显短板,使用起来繁琐且无法自动调用类的构造、析构函数,所以 C++ 专门设计了一套专属的动态内存管理机制,依靠 new 与 delete 两个操作符完成堆内存的申请和释放。

#include<iostream> using namespace std; void Test() { // 动态申请一个int类型的空间 int* ptr4 = new int; // 动态申请一个int类型的空间并初始化为10 int* ptr5 = new int(10); // 动态申请10个int类型的空间 int* ptr6 = new int[3]; int* ptr7 = new int[3]{0}; int* ptr8 = new int[3]{0,1,2}; delete ptr4; delete ptr5; delete[] ptr6; delete[] ptr7; delete[] ptr8 } int main() { Test(); return 0; }

针对内置基础数据类型有清晰规范的使用区分,单独申请单个内置类型空间时,我们可以写int* ptr4 = new int;仅开辟一块 int 大小的堆内存,也能写成int* ptr5 = new int(10);在开辟内存的同时直接给这块空间完成数值初始化;而如果需要一段连续的同类型数组空间,就要搭配方括号写成int* ptr6 = new int[3];,一次性分配能存放 3 个 int 的连续堆内存,内存释放环节有着严格的匹配规则,用不带方括号的 new 申请的单个变量空间,必须用普通 delete 释放,对应代码里delete ptr4;delete ptr5;,带方括号 new 出来的数组连续空间,则必须使用delete[]来回收内存,也就是示例中的delete[] ptr6;,二者绝对不能混用,否则会引发内存错乱、内存泄漏甚至程序崩溃。

拆解 new 语法结构就能直观看懂各部分作用,new 关键字是核心操作符,后面紧跟的数据类型代表我们要开辟的内存类型,括号内的数字是给单块内存赋予的初始值,方括号里的数字代表数组要创建的元素总个数。delete 同样是释放内存的操作符,数组释放时额外添加方括号用来告知编译器这是一段连续内存。

1.2.2newdelete操作自定义类型

当操作自定义类时,new 会在分配堆内存后自动调用对应构造函数完成对象初始化,配套 delete 释放内存时会自动执行析构函数,完成对象内部资源清理,而 malloc 仅开辟对应大小的裸内存、不会执行构造函数,free 仅回收内存空间、不会触发析构函数。

比如用new来快速手搓一个链表就非常方便:

#include<iostream> using namespace std; struct ListNode { int val; ListNode* next; ListNode(int x) :val(x) , next(nullptr) { } }; int main() { ListNode* n1 = new ListNode(1); ListNode* n2 = new ListNode(1); ListNode* n3 = new ListNode(1); ListNode* n4 = new ListNode(1); n1->next = n2; n2->next = n3; n3->next = n4; return 0; }

new在创建自定义类型的时候会自动调用默认构造函数,如果没有默认构造函数,就必须初始化,以下代码中展示了初始化的三种方式。

#include<iostream> using namespace std; class A { public: A(int a1, int a2 = 0) :_a1(a1) , _a2(a2) { cout << "A(int a1 = 0, int a2 = 0)" << endl; } A(const A& aa) :_a1(aa._a1) { cout << "A(const A& aa)" << endl; } A& operator=(const A& aa) { cout << "A& operator=(const A& aa)" << endl; if (this != &aa) { _a1 = aa._a1; } return *this; } ~A() { //delete _ptr; cout << "~A()" << endl; } void Print() { cout << "A::Print->" << _a1 << endl; } A& operator++() { _a1 += 100; return *this; } private: int _a1 = 1; int _a2 = 1; }; struct ListNode { int val; ListNode* next; ListNode(int x) :val(x) , next(nullptr) { } }; int main() { A* p1 = new A(1); A* p2 = new A(2,2); A aa1(1, 1); A aa2(2, 2); A aa3(3, 3); A* p3 = new A[3]{aa1, aa2, aa3}; A* p4 = new A[3]{ A(1,1), A(2,2), A(3,3)}; A* p5 = new A[3]{ {1,1}, {2,2}, {3,3} }; return 0; }

为了防止new申请内存失败的情况(平常一般不会失败),需要写捕获机制:

#include<iostream> using namespace std; int main() { try { // throw try/catch void* p1 = new char[1024 * 1024 * 1024]; cout << p1 << endl; void* p2 = new char[1024 * 1024 * 1024]; cout << p2 << endl; void* p3 = new char[1024 * 1024 * 1024]; cout << p3 << endl; } catch (const exception& e) { cout << e.what() << endl; } return 0; }

可以试试看最多能申请到多少空间:

#include<iostream> using namespace std; void func() { // throw try/catch int n = 1; while (1) { void* p1 = new char[1024 * 1024]; cout << p1 << "->"<< n<<endl; ++n; } } int main() { try { func(); } catch (const exception& e) { cout << e.what() << endl; } return 0; }

必须要注意,不带方括号的new和delete以及带方括号的new和delete一定要配套使用,不能错配使用。因为,使用带方括号的new会多申请四个字节来存放申请的元素的个数,用来告诉编译器delete的时候要调用多少次析构函数。使用带方括号的delete会把这四个字节一起释放掉,而不带方括号的delete不会。

总之,一定要配套使用就对了。

1.3operator newoperator delete

new、delete 是我们日常写代码用来申请、释放动态内存的操作符,而 operator new、operator delete 是 C++ 标准库提供的底层全局函数,我们写的 new 关键字在底层会自动调用 operator new 完成堆空间分配,delete 关键字底层则依靠 operator delete 回收内存。

先讲 operator new 全局函数的底层逻辑,这个函数内部实际封装了 C 语言的 malloc 来完成内存开辟,执行逻辑分为三步:调用 malloc 尝试分配指定字节大小的内存,如果 malloc 成功拿到内存,直接返回这块内存的地址;如果 malloc 分配失败,会先执行用户自定义的内存不足处理函数,若用户配置了对应的处理逻辑,就重新循环尝试申请内存;如果用户没有设置补救方案,函数会抛出 std::bad_alloc 类型异常,告知程序内存分配失败。

再看 operator delete 全局函数,它的底层最终依赖 C 语言 free 完成内存释放,内部附带完善的安全校验与多线程保护机制:首先会执行内存释放的钩子回调做调试检测,如果传入的待释放指针是空指针,函数会直接结束返回,不会触发报错;之后会加堆锁防止多线程同时操作堆内存引发冲突,读取内存块头部校验信息验证内存合法性,确认无误后调用封装好的调试版 free 接口回收内存,释放完成后解锁堆锁,解除多线程阻塞。

从源码定义能清晰理清层级关系,free 本身也只是一层宏定义,底层指向调试版的内存释放函数_free_dbg,所以整条调用链路可以串联起来:上层 new 操作符→operator new 全局函数→malloc;上层 delete 操作符→operator delete 全局函数→free。

1.4 new和delete的实现原理

对于 int、double 这类内置基础类型,new 底层仅调用 operator new 分配内存,delete 底层仅调用 operator delete 回收内存,整体执行逻辑和 malloc、free 流程相近,底层分配失败的处理逻辑存在差异,new 分配内存失败会抛出异常,malloc 仅返回空指针,同时语法层面区分单元素与连续数组,单元素使用 new/delete,数组形式使用 new []/delete []。

处理自定义类类型时,new 存在固定两步底层执行流程,第一步调用 operator new 全局函数向堆申请对应大小的原始内存空间,第二步在已分配完成的内存地址上执行类的构造函数,完成对象初始化;delete 执行流程顺序相反,第一步先在对象内存上调用析构函数,清理对象持有的资源,第二步调用 operator delete 函数归还堆内存。

数组形式new T[N]的底层执行分为两步,首先调用 operator new [] 函数,该函数内部会调用 operator new 一次性申请容纳 N 个对象的连续内存,之后在整片连续内存上循环执行 N 次构造函数,完成数组内所有对象的初始化;配套的delete[]同样分为两步,先遍历数组全部 N 个对象,依次执行 N 次析构函数释放每个对象资源,再调用 operator delete [] 函数,其内部最终调用 operator delete 释放整片连续内存。

1.5malloc/freenew/delete的区别

malloc/free 和 new/delete 存在一处核心共同点:二者均是从堆区分配内存,且分配后的内存都需要开发者手动调用对应接口释放,否则会产生内存泄漏,而二者之间存在多处关键区别,首先从语法本质来说,malloc 与 free 是 C 标准库提供的普通函数,调用遵循函数传参规则,new 和 delete 则是 C++ 内置操作符,编译器会为其配套专属语法解析与底层逻辑处理。

其次,在内存初始化层面,malloc 仅分配一块未经处理的原始内存,内存中会保留随机残留脏数据,new 则支持直接完成内存初始化,内置类型可直接赋予初始值,自定义类还会自动执行构造函数完成对象初始化。

在内存大小的填写方式上,使用 malloc 时开发者必须借助 sizeof 手动计算所需字节数,数组场景还需要手动计算总字节,new 只需直接写明目标数据类型即可由编译器自动计算占用空间,数组仅需在方括号内填写元素数量,省去手动计算的步骤。

二者返回值的处理逻辑也不相同,malloc 返回通用 void * 无类型指针,在 C++ 代码中使用时必须手动强制转换为目标类型指针,否则会编译报错,new 会根据指定类型直接返回匹配的类型指针,无需额外强转,类型安全性更高。

内存分配失败时二者的反馈机制也有明显区分,malloc 分配失败会返回 NULL 空指针,代码中必须手动增加判空逻辑避免空指针访问崩溃,new 分配失败不会返回空指针,而是抛出 std::bad_alloc 异常,需要依靠 try-catch 机制捕获异常完成容错。

二者最核心的差异体现在自定义类对象的处理上,malloc 和 free 仅负责原始内存的开辟与回收,完全不会调用类的构造函数和析构函数,无法完成对象初始化与内部资源清理,而 new 在分配内存完成后会自动调用构造函数构建对象,delete 在释放内存前会先执行析构函数,清理对象持有的各类资源,贴合 C++ 面向对象的生命周期管理逻辑。

开发中需要注意两套接口不可交叉混用,malloc 分配的内存只能用 free 释放,new、new [] 分配的内存需要分别配对 delete、delete [] 释放,混用会破坏堆内存结构,引发程序崩溃等未定义行为。

1.6定位new表达式(placement-new)

定位 new 也叫作 placement-new,它和普通 new 最大的区别就是不会主动向堆申请全新内存,它的核心作用是在一块已经提前分配好的原始空白内存地址上,主动调用对应类的构造函数完成对象初始化。

它拥有两种标准书写格式,分别是 new (place_address) type 和 new (place_address) type (initializer-list),格式中括号内部的 place_address 必须是合法的内存指针,代表我们指定好的、已经存在的内存位置;后半段括号里的 initializer-list 是用来给对象传参的初始化列表,用来匹配类的构造函数完成赋值,定位 new 不会开辟新内存,因此日常开发里最典型的使用场景是配合内存池技术,内存池会一次性预先划分出一大片连续内存,后续从池中取出的内存都只是未初始化的裸内存,没有执行过任何构造逻辑。如果要在这片内存上创建自定义类对象,就必须依靠定位 new 显式触发构造函数完成对象初始化,同时要注意定位 new 不会配套分配堆内存,因此也不能使用普通 delete 释放这块内存,想要销毁对象需要手动调用对象的析构函数,再单独回收这片底层原始内存。

#include<iostream> using namespace std; class A { public: A(int a = 0) : _a(a) { cout << "A():" << this << endl; } ~A() { cout << "~A():" << this << endl; } private: int _a; }; int main() { A* p1 = new A(1); delete p1; //用定位new来初始化 A* p2 = (A*)operator new(sizeof(A)); new(p2)A(1); //需要手动调用析构函数,再收回内存 p2->~A(); operator delete(p2); return 0; }

二、模板

2.1 函数模板

2.1.1 概念、格式与原理

函数模板可以看作一套完整的函数家族,它本身和具体数据类型相互独立,属于可被参数化的通用代码框架,在代码实际调用时编译器会根据传入实参的类型,自动生成对应专属类型的实体函数。

函数模板有着固定的书写格式,开头需要使用 template 关键字引出尖括号,括号内使用 typename 标识各类模板参数,多个参数之间用逗号隔开,紧随其后再书写常规的函数返回值、函数名与参数列表,这里用来声明模板参数的关键字除了 typename 之外也可以替换成 class,但需要注意不能使用 struct 来替代 class 完成模板参数定义。

以交换数据的 Swap 模板函数为例就能直观看懂完整写法,template先行声明类型参数 T,函数内部全部使用 T 代表任意待适配的数据类型,实现通用的数值交换逻辑。

从底层原理来看,函数模板本质只是一段生成具体函数的蓝图模具,它自身并不是可直接调用的真实函数,原本需要开发者针对每一种数据类型重复编写功能一致的重载函数,这份重复工作会交由编译器自动完成。在程序编译阶段,只要代码中出现了对模板函数的调用,编译器就会读取传入实参的数据类型做类型推演,自动把模板里的通用占位类型替换为实际类型并生成一份独立函数代码。比如,调用时传入 double 类型变量,编译器就会推导出模板参数 T 等于 double,生成一份只处理 double 类型的交换函数,同理使用 char、int、自定义类等不同类型调用模板时,编译器都会分别生成对应类型的专属实体函数,以此实现一套模板代码适配所有数据类型,省去大量重复冗余的重载代码编写。

2.1.2 实例化

当我们用不同类型的参数去调用同一个函数模板时,这个过程就叫做函数模板的实例化,它具体分为隐式实例化和显式实例化两类,我们结合代码案例把两者的区别和使用细节讲清楚。

先来说隐式实例化,这种方式是交给编译器自主干活,编译器会根据你调用模板时传入的实参,自动推演敲定模板参数 T 的实际类型,再生成对应类型的专属函数实体。就拿这份通用加法模板举例:

template<class T> T Add(const T& left, const T& right) { return left + right; }

在主函数里,我们传入两个 int 类型变量Add(a1, a2),编译器就会把 T 推演为 int,生成 int 版本的 Add 函数;传入两个 double 类型变量Add(d1, d2),就会推演 T 为 double,生成 double 版本的 Add 函数,全程不用我们额外标注类型,写起来很省心。

但隐式实例化有严格的限制:模板推演时不会主动做类型转换,这是编译器的保护机制 —— 如果随意做类型转换,后续出问题编译器很难定位责任,所以像Add(a1, d1)这种一个 int、一个 double 的调用写法是直接编译报错的,编译器一边能从第一个实参推出 T 是 int,一边又能从第二个推出 T 是 double,唯一的模板参数 T 没法同时对应两种类型,就会陷入推演矛盾。遇到这种参数类型不一致的情况,隐式实例化走不通,我们有两种解决办法:要么手动给其中一个参数强制类型转换,让两个实参类型统一,比如Add(a1, (int)d1);要么改用显式实例化的方案。

接下来介绍显式实例化,这种方式由我们开发者主动说了算,在调用函数名后的尖括号<>里直接写明模板参数 T 的实际类型,强制指定实例化出的函数类型。还是刚才参数类型不一致的场景,我们写Add<int>(a1, b),就明确告诉编译器这次 T 要用 int 类型,此时编译器就会尝试把 double 类型的 b 隐式转换成 int 类型来匹配参数要求,如果类型能顺利完成转换就正常编译运行,要是类型没法合法转换,编译器依旧会抛出编译报错。

int main(void) { int a = 10; double b = 20.0; // 显式实例化 Add<int>(a, b); return 0; }

有些情况下,必须采用显示实例化,如下面这种情况,不显式实例化编译器就不知道是什么类型:

template<class T> T* func1(int n) { return new T[n]; } int main() { int* p = func1<int>(5); return 0; }

2.1.3 模板参数的匹配规则

C++ 中允许同名的普通非模板函数与函数模板同时定义,同一名称下二者可以共存,函数模板也能够实例化出和普通非模板函数签名完全一致的版本。编译器在处理同名调用时有着固定的匹配优先级规则:当调用时传入的实参能够完美匹配普通非模板函数的参数列表,编译器会优先选择直接调用这个普通函数,不会去实例化对应的模板版本,就像示例中同时存在接收两个 int 参数的普通 Add 函数和通用 Add 模板,直接写 Add (1,2) 时会命中普通 int 版本函数,不会启用模板,只有我们主动在函数名后加<int>显式指定模板类型 Add<int>(1,2),才会强制调用由模板实例化生成的函数。

如果实参无法和普通非模板函数形成最优匹配,但函数模板能够推演生成适配当前实参、匹配度更高的函数版本,编译器就会转而选用模板实例化的函数,例如普通 Add 函数仅支持两个 int 入参,模板支持两种不同类型参数,调用 Add (1,2.0) 时,普通函数无法同时接收 int 与 double 参数,而双类型参数的模板可以推演生成适配该调用的函数,编译器就会选择模板生成的版本。

除此之外,普通函数和模板函数还有一处关键区别,模板函数在类型推演阶段不会自动做隐式类型转换,必须保证实参类型和模板推演类型完全统一,否则会编译报错,而普通非模板函数支持编译器自动执行合法的隐式类型转换,能够兼容类型略有差异的入参,这也是二者匹配逻辑里不可忽视的核心区分点。

// 专门处理int的加法函数 int Add(int left, int right) { return left + right; } // 通用加法函数 template<class T> T Add(T left, T right) { return left + right; } void Test() { Add(1, 2); // 与非模板函数匹配,编译器不需要特化 Add<int>(1, 2); // 调用编译器特化的Add版本 }

2.2 类模板

类模板是 C++ 中用来创建通用容器类的模板工具,拥有固定的定义格式:代码开头必须用 template 关键字搭配尖括号声明模板参数,尖括号内可以使用 class 或者 typename 定义一个或多个类型参数,随后紧跟 class 与类模板名称,大括号内部编写类的成员变量与成员函数。

以通用栈容器 Stack 为例,我们通过 template定义单一模板参数 T,类内的数组、容量、尺寸成员全部使用 T 作为数据类型,构造函数会根据传入的容量开辟 T 类型的数组,同时声明 Push 入栈成员函数。

这里有一条重要编码规范:类模板的声明与实现不建议拆分到头文件和源文件两个文件中,拆分后会产生链接报错;若需要在类外定义成员函数,开头同样要带上 template 模板声明,并且在类名后添加<T>标识这是模板类的成员,完整写法为 template void Stack<T>::Push (const T& data),函数内部完成数据存入数组并更新元素数量的逻辑。

#include<iostream> using namespace std; template<typename T> class Stack { public: Stack(int n = 4) :_array(new T[n]) ,_size(0) ,_capacity(n) {} ~Stack() { delete[] _array; _array = nullptr; _size = _capacity = 0; } void Push(const T& x); private: T* _array; size_t _capacity; size_t _size; }; template<class T> void Stack<T>::Push(const T& x) { if (_size == _capacity) { T* tmp = new T[_capacity * 2]; memcpy(tmp, _array, sizeof(T) * _size); delete[] _array; _array = tmp; _capacity *= 2; } _array[_size++] = x; } int main() { // 类模板都是显示实例化 Stack<int> st1; // int st1.Push(1); st1.Push(2); st1.Push(3); Stack<double> st2; // double st2.Push(1.1); st2.Push(1.1); st2.Push(1.1); Stack<double>* pst = new Stack<double>; //... delete pst; return 0; }

类模板的实例化规则和函数模板存在明显区别,函数模板大多可以依靠实参隐式推演类型,类模板无法自动推演,必须在类模板名称后方加上尖括号,在括号内部写明要实例化的具体数据类型;模板本身的类名 Stack 并不是程序能识别的合法完整类型,只有加上类型参数后的 Stack<int>、Stack<double>才是编译器认可的真实类类型,我们在主函数中定义变量时,Stack<int> st1 代表创建存储 int 类型数据的栈对象,Stack<double> st2 代表创建存储 double 类型数据的栈对象,编译器会根据尖括号内指定的类型,分别生成对应数据类型的独立栈类代码,依靠一套模板代码就能适配任意数据类型,不用为每一种类型重复编写功能完全一致的容器类。

END