学习:C++ 标准库第 19 章:分配器(Allocators))
目录什么是分配器作为应用程序员使用分配器编写自定义分配器作为库程序员使用分配器未初始化内存的辅助工具总结什么是分配器核心概念分配器Allocator是 C 标准库中对内存管理方式的抽象。用生活类比容器vector、map等是仓库负责存放数据分配器是搬运工合同定义了仓库的货架从哪里来、怎么建、怎么拆默认情况下所有容器都使用标准分配器std::allocatorT它内部调用new和delete。但你可以替换成自己的分配器实现使用共享内存多进程共享数据映射到持久化数据库内存池避免频繁new/delete的性能开销调试内存分配记录所有分配行为分配器在架构中的位置应用代码Application Code容器 Containervector / map / string ...分配器 Allocator负责内存的申请与释放默认: std::allocator调用全局 new / delete自定义: MyAlloc共享内存 / 内存池 / 数据库堆内存 Heap自定义内存区域作为应用程序员使用分配器基本用法传入模板参数分配器作为容器的最后一个模板参数传入#includevector#includemap#includestring// 假设 MyAllocT 是你自定义的分配器// 具体实现见下一节// 使用自定义分配器的 vector存放 intstd::vectorint,MyAllocintv;// 使用自定义分配器的 mapint-float 映射// 注意map 存储的元素类型是 pairconst Key, Valuestd::mapint,float,std::lessint,// 比较器第3个参数MyAllocstd::pairconstint,float// 分配器第4个参数m;// 使用自定义分配器的 string// basic_string 是 string 的底层模板std::basic_stringchar,std::char_traitschar,// 字符特性第2个参数MyAllocchar// 分配器第3个参数s;用 typedef 简化类型名每次写这么长的类型名很麻烦可以用typedef或using定义别名#includemap#includestring// 给使用自定义分配器的 string起一个短名字typedefstd::basic_stringchar,std::char_traitschar,MyAlloccharMyString;// 给使用自定义分配器的 MyString-MyString 映射起名typedefstd::mapMyString,MyString,std::lessMyString,MyAllocstd::pairconstMyString,MyStringMyMap;// 使用时就像普通类型一样简洁MyMap mymap;C11 更优雅的方式别名模板#includevector// 别名模板固定分配器保留元素类型为参数// 这样 Vecint 就等同于 std::vectorint, MyAllocinttemplatetypenameTusingVecstd::vectorT,MyAllocT;Vecintint_vec;// 等同于 std::vectorint, MyAllocintVecdoubledbl_vec;// 等同于 std::vectordouble, MyAllocdouble检查两个分配器是否兼容如果两个分配器兼容返回 true则可以用其中一个释放另一个分配的内存// get_allocator() 返回容器内部使用的分配器if(mymap.get_allocator()s.get_allocator()){// mymap 和 s 使用相同或可互换的分配器// 可以跨容器释放内存}C11 类型特征检查类型是否接受分配器#includememory// 检查类型 T 的 allocator_type 是否可以由 Alloc 转换而来// 值为 true 表示T 接受这种分配器std::uses_allocatorT,Alloc::value编写自定义分配器分配器必须提供的接口操作说明a.allocate(num)为num个元素申请内存不构造对象a.construct(p, val)在地址p处用值val构造对象a.destroy(p)析构地址p处的对象不释放内存a.deallocate(p, num)释放p指向的num个元素的内存申请内存和构造对象是两步独立的操作同样析构对象和释放内存也是两步独立的操作这正是分配器设计的精髓——把内存管理和对象生命周期分离开来。内存与对象生命周期的关系申请内存 (allocate) | v [原始内存无对象] | 构造对象 (construct / placement new) | v [内存中有有效对象] | 使用对象... | 析构对象 (destroy) | v [原始内存对象已销毁] | 释放内存 (deallocate) | v [内存归还]完整的自定义分配器实现C11// 文件: myalloc11.hpp#includecstddef// std::size_t#includenew// ::operator new, ::operator deletetemplatetypenameTclassMyAlloc{public:// -----------------------------------------------// 必须提供value_type 类型定义// 容器通过这个类型知道分配器管理的是什么类型// -----------------------------------------------typedefT value_type;// -----------------------------------------------// 构造函数// 这个分配器没有内部状态所以构造函数什么都不做// noexcept 告诉编译器这个函数不会抛出异常// -----------------------------------------------MyAlloc()noexcept{// 无状态不需要初始化任何东西}// -----------------------------------------------// 模板拷贝构造函数// 用途当容器需要把 MyAllocint 转换成 MyAllocpairint,float 时调用// 注意这个模板构造函数不会屏蔽编译器生成的普通拷贝构造函数// -----------------------------------------------templatetypenameUMyAlloc(constMyAllocU)noexcept{// 无状态不需要复制任何东西}// -----------------------------------------------// 核心函数 1申请内存不构造对象// 参数num 需要的元素个数// 返回指向原始内存的指针// -----------------------------------------------T*allocate(std::size_t num){// ::operator new 申请原始字节内存// num * sizeof(T) 需要的总字节数// static_castT* 把 void* 转换为 T*returnstatic_castT*(::operatornew(num*sizeof(T)));}// -----------------------------------------------// 核心函数 2释放内存对象已经被外部析构了// 参数p 指向要释放内存的指针// num 当初申请的元素个数这里不需要但接口要求传入// -----------------------------------------------voiddeallocate(T*p,std::size_t num){// ::operator delete 释放原始字节内存::operatordelete(p);// num 参数在这里没用到但接口定义要求有这个参数(void)num;}// construct() 和 destroy() 不需要手动提供// C11 起allocator_traits 会提供默认实现// construct(p, val) → new(p) T(val) // placement new// destroy(p) → p-~T() // 显式析构};// -----------------------------------------------// 必须提供相等比较运算符// 如果两个分配器返回 true说明可以互相释放对方分配的内存// 对于无状态分配器所有实例都是等价的所以始终返回 true// -----------------------------------------------templatetypenameT1,typenameT2booloperator(constMyAllocT1,constMyAllocT2)noexcept{returntrue;// 无状态分配器所有实例等价}templatetypenameT1,typenameT2booloperator!(constMyAllocT1,constMyAllocT2)noexcept{returnfalse;// 等价则不不等}C11 分配器要求总结要素是否必须说明value_type类型定义必须就是模板参数 T默认构造函数必须即使是空的模板拷贝构造函数必须用于类型间转换allocate(num)必须申请内存deallocate(p, num)必须释放内存operator和!必须判断是否兼容construct(p, val)不需要C11 起有默认实现destroy(p)不需要C11 起有默认实现C11 之前需要提供更多成员但方式相当机械pointer、reference、rebind 等类型定义C11 引入allocator_traits后大幅简化了这些要求。作为库程序员使用分配器这一节展示容器内部是如何使用分配器的帮助理解分配器的工作原理。vector 的简化内部结构namespacestd{templatetypenameT,typenameAllocatorallocatorT// 默认用标准分配器classvector{private:Allocator alloc;// 保存分配器对象通常是无状态的几乎不占空间T*elems;// 指向实际存储元素的内存size_type numElems;// 当前元素个数size_type sizeElems;// 已申请的内存能容纳的元素个数容量public:// 构造函数分配器可以从外部传入explicitvector(constAllocatorAllocator());explicitvector(size_type num,constTvalT(),constAllocatorAllocator());templatetypenameInputIteratorvector(InputIterator beg,InputIterator end,constAllocatorAllocator());vector(constvectorT,Allocatorv);// ...};}vector 构造函数的实现手动版namespacestd{templatetypenameT,typenameAllocatorvectorT,Allocator::vector(size_type num,constTval,constAllocatora):alloc(a)// 保存分配器{// 第一步申请内存只是一块原始内存还没有对象sizeElemsnumElemsnum;elemsalloc.allocate(num);// 申请 num 个 T 大小的内存// 第二步在申请的内存上逐个构造对象for(size_type i0;inum;i){// construct 相当于new(elems[i]) T(val)// 即placement new在指定地址构造对象不申请新内存alloc.construct(elems[i],val);}}}vector 构造函数的实现使用便利函数C 标准库提供了对未初始化内存的批量操作函数可以简化上面的代码namespacestd{templatetypenameT,typenameAllocatorvectorT,Allocator::vector(size_type num,constTval,constAllocatora):alloc(a){// 申请内存sizeElemsnumElemsnum;elemsalloc.allocate(num);// 用 uninitialized_fill_n 批量初始化从 elems 开始// 初始化 num 个元素每个元素的值都是 val// 比手动循环 construct 更简洁std::uninitialized_fill_n(elems,num,val);}}vector::reserve() 的实现reserve()用于预分配内存而不改变元素个数是分配器使用的经典场景namespacestd{templatetypenameT,typenameAllocatorvoidvectorT,Allocator::reserve(size_type size){// reserve 不缩小内存if(sizesizeElems){return;}// 第一步申请新的、更大的内存块T*newmemalloc.allocate(size);// 第二步把旧内存中的元素复制构造到新内存// uninitialized_copy从 [elems, elemsnumElems) 复制到 newmem 起始处// 注意newmem 是原始内存所以要用未初始化内存复制而非普通赋值std::uninitialized_copy(elems,elemsnumElems,newmem);// 第三步析构旧内存中的每个对象但不释放内存for(size_type i0;inumElems;i){alloc.destroy(elems[i]);// 相当于 elems[i].~T()}// 第四步释放旧内存块alloc.deallocate(elems,sizeElems);// 第五步更新指针和容量sizeElemssize;elemsnewmem;// numElems 不变元素个数没变只是容量扩大了}}reserve() 操作流程图初始状态 elems → [ A | B | C | _ | _ ] 0 1 2 3 4 numElems3, sizeElems5 调用 reserve(8) 第一步申请新内存 newmem → [ _ | _ | _ | _ | _ | _ | _ | _ ] 0 1 2 3 4 5 6 7 第二步复制旧元素到新内存 newmem → [ A | B | C | _ | _ | _ | _ | _ ] 第三步析构旧内存中的对象 elems → [ ~ | ~ | ~ | _ | _ ] ~表示已析构但内存未释放 第四步释放旧内存 elems 内存块被归还 第五步更新 elems newmem sizeElems 8 numElems 3 (不变)未初始化内存的辅助工具未初始化内存填充/复制函数函数效果uninitialized_fill(beg, end, val)在[beg, end)范围的原始内存中用val构造每个元素uninitialized_fill_n(beg, num, val)从beg开始初始化num个元素为valuninitialized_copy(beg, end, mem)把[beg, end)中的元素复制构造到从mem开始的原始内存中uninitialized_copy_n(beg, num, mem)从beg开始复制num个元素到memC11 起这些函数与普通的fill、copy的区别普通 fill/copy目标内存已有对象使用赋值运算符 dest[i] val; ← 调用 operator uninitialized_fill/copy目标是原始内存使用构造函数 new(dest[i]) T(val); ← 调用构造函数placement new原始存储迭代器raw_storage_iteratorraw_storage_iterator允许把任何输出算法的结果写入未初始化的内存#includememory// raw_storage_iterator#includealgorithm// copy#includevector// 假设 elems 是已申请但未初始化的原始内存类型为 T*// x 是一个已有数据的 vectorT// 把 x 的内容复制到 elems 指向的原始内存中// raw_storage_iteratorT*, T// 第一个模板参数 T* 是底层输出迭代器类型需为输出迭代器// 第二个模板参数 T 是元素类型std::copy(x.begin(),x.end(),std::raw_storage_iteratorT*,T(elems));临时缓冲区了解即可现代代码不推荐#includememory// get_temporary_buffer, return_temporary_buffervoidf(){intnum100;// 申请临时内存用于存放最多 num 个 MyType 对象// 注意实际申请到的可能少于 num 个// 返回值是 pairT*, ptrdiff_t// first 内存起始地址// second 实际能存放的元素个数std::pairMyType*,std::ptrdiff_tpstd::get_temporary_bufferMyType(num);if(p.second0){// 一个元素都申请不到处理错误}elseif(p.secondnum){// 申请到了但比期望的少// 仍然需要在用完后释放}// ... 使用内存 p.first ...// 释放临时内存如果申请到了的话if(p.first!nullptr){std::return_temporary_buffer(p.first);}}这对函数在实践中很少使用因为编写异常安全的代码非常困难——一旦中途抛出异常return_temporary_buffer就不会被调用导致内存泄漏。现代 C 推荐使用std::vector配合自定义分配器代替。总结三类使用者的视角分配器 Allocator应用程序员Application Programmer库程序员Library Programmer分配器作者Allocator Author只需传入模板参数使用时感知不到差异调用 allocate / constructdestroy / deallocate实现 allocate实现 deallocate提供 operator !分配器的四个核心操作对比普通 new/delete融合在一起: new T(val) 申请内存 构造对象 两步合一 delete p 析构对象 释放内存 两步合一 分配器分离开来: allocate(n) 只申请内存 不构造对象 construct(p) 只构造对象 不申请内存 destroy(p) 只析构对象 不释放内存 deallocate(p) 只释放内存 对象已被析构这种分离使得容器可以预先申请一大块内存内存池然后逐个构造对象析构对象后不立即释放内存而是留给下一个元素复用自定义分配器的应用场景std::allocator默认 └── 调用全局 new / delete └── 适合大多数情况 内存池分配器 └── 预分配一大块内存从中切割 └── 避免频繁 new/delete 的系统调用开销 └── 适合高频创建销毁小对象的场景 共享内存分配器 └── 从共享内存段shared memory中分配 └── 适合多进程共享数据结构 调试分配器 └── 记录每次分配/释放的位置和大小 └── 检测内存泄漏、越界访问 └── 适合开发调试阶段