C++自定义分配器:从原理到实战实现内存管理优化
1. 项目概述:为什么我们需要关心allocator?
如果你写过C++,用过std::vector、std::list或者std::map,那么你其实已经在不知不觉中使用过std::allocator了。它是C++标准库中所有容器的默认内存分配器。但绝大多数时候,我们只是在使用它,却很少去深究它到底是什么、怎么工作,以及更重要的——我们为什么要自定义它。
简单来说,allocator是C++内存管理抽象层的一个核心接口。它把“申请内存”和“构造对象”这两件事分开了。new操作符一口气干了malloc(申请内存)和调用构造函数(构造对象)两件事,而allocator让你可以精细地控制这个过程。在性能要求极高的场景下,比如游戏引擎、高频交易系统或者嵌入式设备,这种控制力至关重要。默认的std::allocator直接调用全局的operator new和operator delete,对于大多数应用来说足够了,但它可能不是最优的。当你需要实现内存池、使用共享内存、或者进行特殊的内存对齐时,自定义allocator就成了必须掌握的技能。
这篇文章不会只停留在抄写cppreference的API文档。我会结合我这些年做高性能后端和游戏中间件时踩过的坑,带你从“知道有这么个东西”到“能自己写一个靠谱的allocator”,并解释清楚背后的设计哲学和实战要点。无论你是正在准备C++面试,被“自定义分配器”相关八股文困扰,还是在实际项目中遇到了内存碎片、分配效率的瓶颈,这篇文章都能给你提供直接的参考。
2. allocator的设计哲学与核心接口拆解
2.1 分离关注点:内存分配与对象构造
这是理解allocator最关键的一步。在C语言里,malloc和free只负责内存块的生命周期。在C++的早期,new和delete把内存分配和对象构造/析构耦合在了一起。allocator的出现,正是为了解耦。
想象一下你要盖房子(对象)。new相当于你直接找了一个“全包”的建筑公司,它既买地皮(分配内存),又按照图纸把房子盖好(调用构造函数)。而allocator模式则像是一个专业的项目管理流程:你先通过一个“土地管理局”(allocate)申请一块大小合适、位置合规的地皮(原始内存),然后自己或者委托另一个“施工队”(construct)在这块地皮上严格按照图纸盖房子。拆房子(destroy)和归还地皮(deallocate)也是分开的。
这种分离带来了巨大的灵活性:
- 批量操作:你可以一次性申请一大片内存(比如一个内存池),然后在这片内存上分批构造多个对象,避免了多次向系统申请内存的开销。
- 特殊内存:你申请的内存可能来自特殊的地方,比如共享内存(
shm)、内存映射文件(mmap)或者预先分配好的静态缓冲区。在这些内存上构造对象,必须使用placement new,这正是allocator::construct通常的实现方式。 - 效率优化:对于
std::vector这样的容器,当它resize时,可能需要分配新内存、将旧元素移动或复制过去、然后析构旧元素。如果分配和构造是分开的,容器可以更精细地控制这个过程,例如先分配新内存,然后逐个移动构造元素,最后批量析构旧内存上的对象,而不是简单地new和delete。
2.2 解剖std::allocator:成员类型与函数
我们直接看C++17/20之后的std::allocator模板。它变得异常简洁,因为很多职责转移到了std::allocator_traits这个“萃取机”上。这是现代C++ allocator设计的一个精髓:通过traits类提供默认实现,降低自定义allocator的编写难度。
核心成员类型(Nested Types):
value_type: 你将要分配/管理的对象类型,比如T。size_type: 用于表示大小的无符号整数类型,通常是std::size_t。difference_type: 用于表示指针差值的带符号整数类型,通常是std::ptrdiff_t。propagate_on_container_move_assignment: 一个std::true_type或std::false_type。它告诉容器,当容器被移动赋值时,是否应该用源容器的allocator替换掉目标容器的allocator。std::allocator是std::true_type,因为它无状态(stateless),所有实例等价,可以随意替换。如果你的自定义allocator有状态(比如持有一个内存池指针),你可能需要将其设为false。is_always_equal: 在C++17中,std::allocator的这个成员是std::true_type,同样源于其无状态性。它用于判断任意两个该类型的allocator实例是否总是相等。这个特性在C++20后主要通过std::allocator_traits来查询。
核心成员函数:
allocate(size_type n): 申请能容纳n个value_type对象的原始、未初始化的内存。返回一个T*类型的指针。这是最核心的分配函数。deallocate(T* p, size_type n): 释放之前由allocate分配的内存。p是当初返回的指针,n是当初申请的大小。注意,deallocate只负责释放内存,不负责调用析构函数。construct(U* p, Args&&... args)(C++17前)/ 通过allocator_traits::construct访问:在指针p指向的原始内存上,使用参数args...构造一个U类型的对象。典型的实现就是::new (static_cast<void*>(p)) U(std::forward<Args>(args)...);,即placement new。destroy(U* p)(C++17前)/ 通过allocator_traits::destroy访问:调用指针p所指向对象的析构函数,即p->~U()。它不释放内存。
注意:从C++17开始,
construct和destroy成员函数从std::allocator中移除了,转而鼓励通过std::allocator_traits来调用。这是为了简化allocator的接口,让自定义allocator只需关注最核心的allocate和deallocate。allocator_traits会为没有提供construct/destroy的allocator提供基于placement new和显式析构的默认实现。这是一个重要的兼容性变化点。
2.3 无状态(Stateless)与有状态(Stateful)分配器
std::allocator是一个典型的无状态分配器。它不包含任何非静态数据成员。这意味着:
- 所有
std::allocator<int>的实例都是完全等价的。 - 你可以用一个
std::allocator<int>实例去deallocate另一个std::allocator<int>实例allocate出来的内存。这是标准保证的。 - 它的拷贝、移动、赋值操作都非常廉价(啥也不做)。
无状态分配器简单、安全,但功能有限。它只能规规矩矩地调用全局的operator new。
有状态分配器则复杂得多,也强大得多。它内部通常持有某种资源句柄,比如:
- 一个指向特定内存池(Memory Pool)的指针。
- 一个用于分配共享内存的键(key)或句柄。
- 一个用于调试的内存标签(Memory Tag)或分配记录器。
例如,一个简单的内存池分配器可能长这样:
template <typename T> class PoolAllocator { public: using value_type = T; // ... 其他必要的类型定义 PoolAllocator(MemoryPool* pool) : pool_(pool) { if (!pool_) throw std::invalid_argument("Pool cannot be null"); } // 拷贝构造函数需要仔细考虑:是共享同一个pool,还是创建新的? PoolAllocator(const PoolAllocator& other) = default; // 浅拷贝,共享pool template <typename U> PoolAllocator(const PoolAllocator<U>& other) : pool_(other.pool_) {} // 泛化拷贝构造函数,用于rebind T* allocate(std::size_t n) { return static_cast<T*>(pool_->allocate(n * sizeof(T), alignof(T))); } void deallocate(T* p, std::size_t n) { pool_->deallocate(p, n * sizeof(T), alignof(T)); } // 不需要定义 construct 和 destroy,allocator_traits 会提供默认实现。 private: MemoryPool* pool_; // 状态!一个指向内存池的指针 };编写有状态分配器时,你必须仔细思考并定义它的拷贝、移动语义,以及关键的propagate_on_container_copy_assignment、propagate_on_container_move_assignment、propagate_on_container_swap这些类型特性。错误的设计会导致容器复制时内存管理混乱,引发崩溃或内存泄漏。
3. 深入allocator_traits:allocator的“智能外壳”
在C++11之后,std::allocator_traits成为了与allocator交互的推荐方式。你可以把它理解为allocator的一个“适配器”或“工具包”,它提供了统一的、带有合理默认值的接口来操作任何符合Allocator概念的类型。
3.1 为什么需要allocator_traits?
假设你写了一个自定义分配器MyAlloc。如果没有allocator_traits,容器(比如vector)需要直接调用MyAlloc::construct。但如果你的MyAlloc为了简洁,只实现了allocate和deallocate,忘了写construct,那么这个分配器就无法用于标准容器。
allocator_traits解决了这个问题。它会检查MyAlloc是否有某个成员(比如construct)。如果有,就直接用你的;如果没有,它就提供一个通用的默认实现(比如用placement new)。这样,你编写自定义分配器的负担就大大减轻了,只需实现最核心的部分,其他可以依赖默认行为。
3.2 关键接口与用法
std::allocator_traits<Alloc>是一个模板类,其中Alloc是你的分配器类型。以下是几个最常用的静态成员函数:
allocate(Alloc& a, size_type n): 调用a.allocate(n)。deallocate(Alloc& a, pointer p, size_type n): 调用a.deallocate(p, n)。construct(Alloc& a, U* p, Args&&... args):优先尝试调用a.construct(p, std::forward<Args>(args)...)。如果Alloc没有这个成员函数,则使用::new (static_cast<void*>(p)) U(std::forward<Args>(args)...)。destroy(Alloc& a, U* p):优先尝试调用a.destroy(p)。如果Alloc没有这个成员函数,则使用p->~U()。select_on_container_copy_construction(const Alloc& a): 当容器因拷贝构造而需要一个新的allocator实例时,调用这个函数。对于无状态分配器,通常返回a的一个拷贝;对于有状态分配器,你可能需要返回一个新的、独立的状态(比如一个新的内存池),而不是共享状态。
一个关键的类型萃取:rebind_alloc这是allocator机制中最巧妙也最令人困惑的部分之一。容器内部使用的对象类型,可能和你给容器的value_type不同。
例如,std::list<T, Alloc>。list的节点通常是一个结构体,比如ListNode<T>,它包含了T类型的元素和前后指针。当你声明std::list<int, MyAlloc<int>>时,list真正需要分配的是ListNode<int>,而不是int。它怎么获得一个能分配ListNode<int>的分配器呢?
这就是rebind的用武之地。传统上,分配器需要提供一个嵌套的rebind模板类。现代方式是通过allocator_traits::rebind_alloc。
// 假设我们有 Alloc<T> 是一个分配器类型 using MyAllocForInt = MyAlloc<int>; // list 内部需要分配 ListNode<int>,它会这样做: using NodeAllocType = std::allocator_traits<MyAllocForInt>::rebind_alloc<ListNode<int>>; // NodeAllocType 现在就是一个能分配 ListNode<int> 的分配器类型。 // 如果 MyAlloc 定义了 rebind,就使用它;否则,allocator_traits 会尝试将 MyAlloc<int> 中的 int 替换为 ListNode<int>,生成 MyAlloc<ListNode<int>>。因此,一个健壮的自定义分配器,通常需要提供一个泛化的拷贝构造函数(Converting Constructor),以支持rebind操作:
template <typename T> class MyAlloc { public: using value_type = T; MyAlloc(SomeResource* res) : resource_(res) {} // 泛化拷贝构造函数:对于 MyAlloc<U>,也能构造出 MyAlloc<T> template <typename U> MyAlloc(const MyAlloc<U>& other) : resource_(other.resource_) {} // ... allocate, deallocate ... private: SomeResource* resource_; };4. 手把手实现一个自定义allocator
理论说了这么多,是时候动手了。我们将实现一个简单但实用的“调试内存分配器”。它的功能是:在每次分配和释放内存时,在控制台输出日志,并记录总分配字节数。这在排查内存泄漏、分析内存分配模式时非常有用。
4.1 定义分配器框架与状态
首先,我们决定这个分配器是有状态的,状态就是一个指向共享统计信息的指针。我们使用一个独立的DebugMemoryStats结构体来存储状态,这样多个同类型的分配器实例可以共享同一份统计。
#include <cstddef> #include <iostream> #include <memory> // 调试统计信息 struct DebugMemoryStats { std::size_t total_allocated = 0; std::size_t total_deallocated = 0; std::size_t allocation_count = 0; std::size_t deallocation_count = 0; void print_summary() const { std::cout << "[DebugAlloc] Summary:\n"; std::cout << " Total Allocated: " << total_allocated << " bytes\n"; std::cout << " Total Deallocated: " << total_deallocated << " bytes\n"; std::cout << " Current In-Use: " << (total_allocated - total_deallocated) << " bytes\n"; std::cout << " Allocation Calls: " << allocation_count << "\n"; std::cout << " Deallocation Calls: " << deallocation_count << "\n"; } }; // 主分配器模板 template <typename T> class DebugAllocator { public: // 必须提供的类型定义 using value_type = T; using size_type = std::size_t; using difference_type = std::ptrdiff_t; // 我们允许传播,因为状态(stats_指针)是共享的,浅拷贝没问题。 using propagate_on_container_copy_assignment = std::true_type; using propagate_on_container_move_assignment = std::true_type; using propagate_on_container_swap = std::true_type; // 默认构造函数:使用一个全局的统计对象(需谨慎,非线程安全) DebugAllocator() noexcept : stats_(&global_stats()) {} // 接受外部stats的构造函数 explicit DebugAllocator(DebugMemoryStats& stats) noexcept : stats_(&stats) {} // 泛化拷贝构造函数:支持 rebind template <typename U> DebugAllocator(const DebugAllocator<U>& other) noexcept : stats_(other.stats_) {} // 获取底层统计信息的引用(只读) const DebugMemoryStats& stats() const noexcept { return *stats_; } private: DebugMemoryStats* stats_; // 一个简单的全局默认统计实例(仅用于演示,生产环境需要更安全的单例或线程本地存储) static DebugMemoryStats& global_stats() { static DebugMemoryStats instance; return instance; } // 让 DebugAllocator<U> 能访问我们的私有成员 template <typename U> friend class DebugAllocator; };4.2 实现allocate与deallocate
这是分配器的核心。我们使用::operator new和::operator delete进行实际的内存分配,但在此前后加入我们的调试逻辑。
template <typename T> class DebugAllocator { public: // ... 之前的类型定义和构造函数 ... // 1. 分配内存 T* allocate(size_type n) { if (n > max_size()) { throw std::bad_array_new_length(); } const size_type bytes_to_alloc = n * sizeof(T); T* ptr = static_cast<T*>(::operator new(bytes_to_alloc)); // 调用全局 operator new // 更新统计信息 stats_->total_allocated += bytes_to_alloc; stats_->allocation_count++; // 输出日志 std::cout << "[DebugAlloc] Allocated " << bytes_to_alloc << " bytes (" << n << " * sizeof(" << typeid(T).name() << ") = " << sizeof(T) << ")" << " at address " << static_cast<void*>(ptr) << "\n"; return ptr; } // 2. 释放内存 void deallocate(T* p, size_type n) noexcept { // deallocate 不应抛出异常 const size_type bytes_to_free = n * sizeof(T); ::operator delete(p); // 调用全局 operator delete // 更新统计信息 stats_->total_deallocated += bytes_to_free; stats_->deallocation_count++; // 输出日志 std::cout << "[DebugAlloc] Deallocated " << bytes_to_free << " bytes at address " << static_cast<void*>(p) << "\n"; } // 3. 支持的最大分配大小(可选,但建议实现) size_type max_size() const noexcept { return std::allocator_traits<std::allocator<T>>::max_size({}); // 或者直接返回一个很大的数,如 size_type(-1) / sizeof(T) } private: DebugMemoryStats* stats_; // ... global_stats 和 friend 声明 ... };4.3 实现比较操作符与rebind支持
为了让容器能正确地拷贝、移动和交换使用我们分配器的对象,我们需要提供比较操作符,并且我们已经通过泛化拷贝构造函数支持了rebind。
template <typename T> class DebugAllocator { public: // ... 之前的代码 ... // 比较操作符:比较底层的 stats_ 指针是否相同。 // 如果两个分配器共享同一个统计对象,则认为它们相等。 template <typename U> bool operator==(const DebugAllocator<U>& other) const noexcept { return stats_ == other.stats_; } template <typename U> bool operator!=(const DebugAllocator<U>& other) const noexcept { return !(*this == other); } };注意,我们没有手动定义rebind嵌套模板。因为std::allocator_traits能够自动从DebugAllocator<T>推导出DebugAllocator<U>,这要归功于我们提供的泛化拷贝构造函数template <typename U> DebugAllocator(const DebugAllocator<U>&)。这是现代C++中实现rebind的推荐方式。
4.4 在标准容器中使用自定义allocator
现在,我们可以像使用std::allocator一样使用我们的DebugAllocator了。
int main() { DebugMemoryStats my_stats; { // 使用自定义分配器创建一个vector std::vector<int, DebugAllocator<int>> vec(DebugAllocator<int>{my_stats}); std::cout << "Pushing back elements...\n"; vec.push_back(42); vec.push_back(100); vec.push_back(255); // 触发重新分配(如果初始容量不够) for(int i = 0; i < 10; ++i) vec.push_back(i); std::cout << "Vector goes out of scope...\n"; } // vec析构,会释放所有内存 my_stats.print_summary(); // 测试 list 的 rebind 机制 std::cout << "\n--- Testing std::list with DebugAllocator ---\n"; { std::list<std::string, DebugAllocator<std::string>> my_list(DebugAllocator<std::string>{my_stats}); my_list.push_back("Hello"); my_list.push_back("World"); // list的节点不是std::string,而是内部结构,但我们的分配器通过rebind能正常工作。 } my_stats.print_summary(); return 0; }运行这段代码,你会清晰地看到vector在push_back时分配内存、扩容时重新分配(分配更大块,拷贝/移动元素,释放旧块)以及析构时释放内存的完整过程。list的分配也会被记录。这比肉眼盯着代码或者用Valgrind等外部工具更直观,尤其适合在单元测试或特定模块中快速定位内存问题。
5. 高级话题与实战避坑指南
5.1 对齐(Alignment)处理
我们的简单DebugAllocator在分配时使用了::operator new,它保证返回的内存适合任何标量类型的对齐要求(通常是alignof(std::max_align_t))。但对于过度对齐的类型(Over-Aligned Types),比如使用了alignas(64)的结构体,全局operator new可能无法满足其对齐要求。C++17引入了带对齐参数的operator new和operator delete。
一个更健壮的allocate实现应该考虑对齐:
T* allocate(size_type n) { if (n > max_size()) throw std::bad_array_new_length(); const size_type bytes_to_alloc = n * sizeof(T); const size_type alignment = alignof(T); // 获取类型T的对齐要求 void* ptr; if (alignment > __STDCPP_DEFAULT_NEW_ALIGNMENT__) { // C++17 宏,表示默认对齐上限 // 对于过度对齐的类型,使用对齐的 operator new ptr = ::operator new(bytes_to_alloc, std::align_val_t(alignment)); } else { ptr = ::operator new(bytes_to_alloc); } // ... 记录日志和统计 ... return static_cast<T*>(ptr); } void deallocate(T* p, size_type n) noexcept { const size_type alignment = alignof(T); if (alignment > __STDCPP_DEFAULT_NEW_ALIGNMENT__) { ::operator delete(p, std::align_val_t(alignment)); } else { ::operator delete(p); } // ... 记录日志和统计 ... }5.2 有状态分配器的“传播”策略选择
这是自定义分配器最易出错的地方之一。当容器被拷贝、移动或交换时,它的allocator该如何处理?
propagate_on_container_copy_assignment:当容器进行拷贝赋值(a = b)时,是否用b的allocator替换a的allocator?对于无状态分配器(如std::allocator),设为true没问题。对于有状态分配器,如果allocator管理着特定的内存资源(如一个只能分配特定大小内存的池),盲目替换可能导致a中原有元素无法被新allocator正确释放。通常设为false,让容器使用自己的allocator去分配新内存并拷贝元素。propagate_on_container_move_assignment:移动赋值时呢?移动操作“窃取”资源,如果allocator也是资源的一部分,那么应该一起被窃取。对于共享状态的分配器(如我们DebugAllocator共享一个stats_指针),设为true是安全的。对于独占资源的分配器,可能需要更复杂的策略。propagate_on_container_swap:交换两个容器时,是否交换它们的allocator?如果两个allocator不相等(a != b),标准库的swap操作可能无法进行(除非你定义了这个特性为true)。通常,如果allocator是无状态的或总是相等,设为true;否则需要仔细设计。
一个经验法则:如果你的分配器是无状态的,或者状态是共享的、可复制的(比如一个指向全局内存管理器的指针),那么将这些传播特性设为std::true_type通常是安全的。如果你的分配器持有独占的、不可复制的资源(比如一个文件描述符、一个特定的内存池块),那么应该设为std::false_type,并确保你的分配器提供operator==和operator!=,使得容器在需要时能正确处理“不相等”分配器的情况。
5.3 与C++17的pmr(多态内存资源)对比
C++17引入了std::pmr(Polymorphic Memory Resources)命名空间,这是另一种更现代、更灵活的内存管理抽象。pmr::polymorphic_allocator是一个有状态的、多态的分配器,它的行为由关联的memory_resource对象决定。
核心区别:
- 接口风格:传统
Allocator是模板类,通过类型来区分行为。pmr::polymorphic_allocator是运行时多态,通过指向memory_resource的指针来决定行为,可以在运行时动态切换内存策略。 - 使用便利性:
pmr用起来更方便。你可以直接使用pmr::vector、pmr::string等别名模板,它们默认使用pmr::polymorphic_allocator。切换内存池只需要在构造容器时传入一个不同的memory_resource指针即可。 - 类型擦除:
polymorphic_allocator本身类型是固定的,不依赖于T,这简化了在接口中传递分配器的复杂度。
如何选择?
- 如果你需要运行时灵活切换内存分配策略(比如根据配置选择使用堆、池或监控分配器),
pmr是更好的选择。 - 如果你需要编译时确定的内存分配策略,或者需要与C++11/14的代码库兼容,传统的
Allocator概念更合适。 - 如果你的分配器逻辑非常复杂,或者需要深度集成到容器内部(比如实现一个侵入式容器),传统的
Allocator可能提供更细粒度的控制。
我们的DebugAllocator也可以用pmr风格实现:定义一个继承自std::pmr::memory_resource的DebugMemoryResource,在do_allocate和do_deallocate里输出日志,然后让pmr::polymorphic_allocator使用它。这样所有pmr容器都能自动获得调试功能。
5.4 常见问题与排查技巧实录
问题1:自定义分配器用于std::basic_string时编译错误或运行时崩溃。
原因:
std::basic_string(即std::string)在C++11之前和之后有不同的实现,特别是小字符串优化(SSO)。一些实现(如旧版本的GCC libstdc++)对分配器有特殊要求,或者内部使用了rebind到char以外的类型。如果你的分配器没有正确实现泛化拷贝构造函数或rebind机制,就会出问题。解决:确保你的分配器模板具有template <typename U> Alloc(const Alloc<U>&)形式的构造函数,并且比较操作符也是模板化的。使用std::allocator_traits来与分配器交互,而不是直接调用其成员。
问题2:容器移动后,内存似乎被错误释放或重复释放。
原因:这很可能与
propagate_on_container_move_assignment的设置有关。如果设为false,移动赋值后,目标容器仍然使用自己原来的分配器。如果源和目标容器的分配器不相等(a != b),标准库可能无法直接移动内存所有权,而是退化为元素级的移动构造/赋值,这可能导致额外的分配和释放,甚至错误。排查:仔细检查你的operator==逻辑。确保移动操作后,分配器的状态符合预期。在调试器中观察移动前后容器内部指针和分配器实例的变化。
问题3:自定义分配器导致STL算法或某些容器操作(如std::vector::insert)性能下降。
原因:标准库的某些实现可能对默认的
std::allocator有高度优化(比如使用特定的内存池或内联)。你的自定义分配器,尤其是添加了日志或统计功能的,会增加函数调用开销。此外,如果allocate/deallocate不是noexcept,容器在异常安全保证下可能会使用更保守的策略。优化:
- 确保
allocate和deallocate在成功路径上是noexcept(释放内存绝对不应抛出)。- 考虑将调试信息收集从每次分配/释放中剥离,改为抽样或仅在调试模式下启用。
- 对于性能关键路径,考虑提供两个版本的分配器:一个带调试,一个不带。
问题4:在多线程环境中使用有状态分配器,数据竞争(Data Race)。
原因:像我们的
DebugAllocator,如果多个线程共享同一个DebugMemoryStats实例,并且同时进行分配/释放,对total_allocated等计数器的++操作就不是原子操作,会导致数据竞争和未定义行为。解决:
- 线程局部存储:让每个线程拥有自己独立的
DebugMemoryStats实例。可以使用thread_local关键字。- 同步:在
DebugMemoryStats内部使用std::atomic<std::size_t>类型的计数器,或者用std::mutex保护所有操作。注意这会引入锁开销,影响性能。- 无状态化:设计分配器时不共享可变状态。每个容器实例使用独立的分配器,统计信息在容器生命周期结束后汇总。
编写自定义分配器就像在给C++的内存管理系统做“心脏手术”。它强大而危险,需要你对容器的生命周期、拷贝语义、异常安全有深刻的理解。从简单的调试分配器开始,逐步尝试实现一个固定大小的内存池分配器,是掌握这一高级特性的最佳路径。当你真正需要极致性能或特殊内存管理时,这项技能将变得不可或缺。