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 两个操作符完成堆内存的申请和释放。#includeiostream 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.2new和delete操作自定义类型当操作自定义类时new 会在分配堆内存后自动调用对应构造函数完成对象初始化配套 delete 释放内存时会自动执行析构函数完成对象内部资源清理而 malloc 仅开辟对应大小的裸内存、不会执行构造函数free 仅回收内存空间、不会触发析构函数。比如用new来快速手搓一个链表就非常方便#includeiostream 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在创建自定义类型的时候会自动调用默认构造函数如果没有默认构造函数就必须初始化以下代码中展示了初始化的三种方式。#includeiostream 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申请内存失败的情况平常一般不会失败需要写捕获机制#includeiostream 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; }可以试试看最多能申请到多少空间#includeiostream using namespace std; void func() { // throw try/catch int n 1; while (1) { void* p1 new char[1024 * 1024]; cout p1 - nendl; 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 new与operator deletenew、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/free和new/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 释放这块内存想要销毁对象需要手动调用对象的析构函数再单独回收这片底层原始内存。#includeiostream 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 的实际类型再生成对应类型的专属函数实体。就拿这份通用加法模板举例templateclass 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 的实际类型强制指定实例化出的函数类型。还是刚才参数类型不一致的场景我们写Addint(a1, b)就明确告诉编译器这次 T 要用 int 类型此时编译器就会尝试把 double 类型的 b 隐式转换成 int 类型来匹配参数要求如果类型能顺利完成转换就正常编译运行要是类型没法合法转换编译器依旧会抛出编译报错。int main(void) { int a 10; double b 20.0; // 显式实例化 Addint(a, b); return 0; }有些情况下必须采用显示实例化如下面这种情况不显式实例化编译器就不知道是什么类型templateclass T T* func1(int n) { return new T[n]; } int main() { int* p func1int(5); return 0; }2.1.3 模板参数的匹配规则C 中允许同名的普通非模板函数与函数模板同时定义同一名称下二者可以共存函数模板也能够实例化出和普通非模板函数签名完全一致的版本。编译器在处理同名调用时有着固定的匹配优先级规则当调用时传入的实参能够完美匹配普通非模板函数的参数列表编译器会优先选择直接调用这个普通函数不会去实例化对应的模板版本就像示例中同时存在接收两个 int 参数的普通 Add 函数和通用 Add 模板直接写 Add (1,2) 时会命中普通 int 版本函数不会启用模板只有我们主动在函数名后加int显式指定模板类型 Addint(1,2)才会强制调用由模板实例化生成的函数。如果实参无法和普通非模板函数形成最优匹配但函数模板能够推演生成适配当前实参、匹配度更高的函数版本编译器就会转而选用模板实例化的函数例如普通 Add 函数仅支持两个 int 入参模板支持两种不同类型参数调用 Add (1,2.0) 时普通函数无法同时接收 int 与 double 参数而双类型参数的模板可以推演生成适配该调用的函数编译器就会选择模板生成的版本。除此之外普通函数和模板函数还有一处关键区别模板函数在类型推演阶段不会自动做隐式类型转换必须保证实参类型和模板推演类型完全统一否则会编译报错而普通非模板函数支持编译器自动执行合法的隐式类型转换能够兼容类型略有差异的入参这也是二者匹配逻辑里不可忽视的核心区分点。// 专门处理int的加法函数 int Add(int left, int right) { return left right; } // 通用加法函数 templateclass T T Add(T left, T right) { return left right; } void Test() { Add(1, 2); // 与非模板函数匹配编译器不需要特化 Addint(1, 2); // 调用编译器特化的Add版本 }2.2 类模板类模板是 C 中用来创建通用容器类的模板工具拥有固定的定义格式代码开头必须用 template 关键字搭配尖括号声明模板参数尖括号内可以使用 class 或者 typename 定义一个或多个类型参数随后紧跟 class 与类模板名称大括号内部编写类的成员变量与成员函数。以通用栈容器 Stack 为例我们通过 template定义单一模板参数 T类内的数组、容量、尺寸成员全部使用 T 作为数据类型构造函数会根据传入的容量开辟 T 类型的数组同时声明 Push 入栈成员函数。这里有一条重要编码规范类模板的声明与实现不建议拆分到头文件和源文件两个文件中拆分后会产生链接报错若需要在类外定义成员函数开头同样要带上 template 模板声明并且在类名后添加T标识这是模板类的成员完整写法为 template void StackT::Push (const T data)函数内部完成数据存入数组并更新元素数量的逻辑。#includeiostream using namespace std; templatetypename 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; }; templateclass T void StackT::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() { // 类模板都是显示实例化 Stackint st1; // int st1.Push(1); st1.Push(2); st1.Push(3); Stackdouble st2; // double st2.Push(1.1); st2.Push(1.1); st2.Push(1.1); Stackdouble* pst new Stackdouble; //... delete pst; return 0; }类模板的实例化规则和函数模板存在明显区别函数模板大多可以依靠实参隐式推演类型类模板无法自动推演必须在类模板名称后方加上尖括号在括号内部写明要实例化的具体数据类型模板本身的类名 Stack 并不是程序能识别的合法完整类型只有加上类型参数后的 Stackint、Stackdouble才是编译器认可的真实类类型我们在主函数中定义变量时Stackint st1 代表创建存储 int 类型数据的栈对象Stackdouble st2 代表创建存储 double 类型数据的栈对象编译器会根据尖括号内指定的类型分别生成对应数据类型的独立栈类代码依靠一套模板代码就能适配任意数据类型不用为每一种类型重复编写功能完全一致的容器类。END