std::map/set/unordered_map/unordered_set 的改进

std::map/set/unordered_map/unordered_set 的改进
苏丙榅1. 节点句柄
在 C++17 之前,当我们尝试从一个 std::map 或 std::unordered_map 中提取元素并进行修改时,往往不得不面对代码冗余、效率低下甚至查找两次的尴尬局面。C++17 针对关联容器(包括 map, set, unordered_map, unordered_set)引入了一组至关重要且细节满满的改进。这些改进的核心宗旨是:拒绝冗余查找,拒绝不必要的拷贝,让接口更加统一和易用。
C++17 引入了节点句柄的概念,它是一种轻量级对象,允许在容器间安全转移单个元素的所有权,而无需复制或移动元素本身。这类似于直接操作底层数据结构的节点。
map的节点句柄类型是node_type。set的节点句柄类型是node_type。- 对于
map,句柄拥有键和值的完整所有权;对于set,它拥有键的所有权。
节点句柄的关键特性有以下几点:
- 独占所有权:当一个节点被
extract出来后,它在原容器中就不复存在了。 - 内存保留:句柄持有该节点的内存分配器。这意味着当你把句柄插入到另一个容器时,不需要重新分配内存,只是修改指针指向。这是零分配的操作。
- 修改键的特权:这是唯一能修改
const键的方法。因为节点被提取出来后,它不属于任何容器,此时你可以通过句柄的key()方法修改键(仅限map,set的键永远是const),然后再插入回去。
1.1 提取节点
在 C++17 中std::map、std::set、std::multimap、std::multiset、std::unordered_set、std::unordered_multiset、std::unordered_map、std::unordered_multimap容器中提取节点有两种重载方式,API 如下:
1 | // 节点提取成功之后,该节点就从容器中移除了,但节点并未释放 |
参数:
position:指向此容器的有效迭代器
k:用于标识要提取节点的键
返回值:一个拥有被提取元素的节点句柄,如果在 (2) 中未找到元素,则为空节点句柄。
对于 std::map<K, V> 和 std::unordered_map<K, V> 的节点句柄,这些容器存储 key-value 对,所以它们的节点句柄提供:
1 | node.key() // 返回键(key)的引用,可修改(对于 map,提取后可以安全修改键) |
对于 std::set<T> 和 std::unordered_set<T> 的节点句柄,这些容器只存储 key(值本身),所以它们的节点句柄只提供:
1 | node.value() // 返回值的引用,可修改 |
下面是伪代码,为大家展示了节点句柄的设计:
1 | template<typename Key, typename Value> |
在 C++17 之前,map 的 key 是 const 的,无法修改。如果想修改 key,必须先 erase 旧数据,再 insert 新数据。这不仅麻烦,而且如果 new 插入失败(比如已经有相同的 key),就把原来的数据弄丢了。C++17 允许我们安全的修改 Key 的值,如果插入会失败,提取的节点中依然持有数据,因此数据是安全的。
1 |
|
与传统方式的对比:
传统方式(需要删除 + 插入):
1
2
3
4
5
6
7
8// 1. 查找并获取值
auto it = m.find(1);
if (it != m.end())
{
std::string value = it->second; // 复制值
m.erase(it); // 删除节点(内存释放)
m[10] = value; // 插入新节点(内存分配 + 复制)
}- 两次内存操作(释放 + 分配)
- 值被复制(可能开销大)
- 键的
const限制无法绕过
节点句柄方式:
1
2
3
4auto node = m.extract(1); // 提取节点(无内存分配/释放)
node.key() = 10; // 直接修改键
node.mapped() = "Apricot"; // 直接修改值
m.insert(std::move(node)); // 重新插入(无内存分配)- 零内存分配/释放(性能优势)
- 可以直接修改键
- 原地修改值,无需复制
- 保持了节点原有的内存位置
1.2 合并容器
merge() 允许我们将一个容器中的所有节点剪切到另一个容器中,具体行为如下:
- 无拷贝,无移动构造函数调用:由于是转移所有权,不会触发
Key或Value的拷贝/移动构造函数,只转移内存控制权。 - 冲突处理:
- 如果源容器和目标容器中有重复的
Key(对于map/set),源节点不会被插入,也不会被销毁,而是留在源容器中。这是merge的不修改冲突源原则。 - 对于
unordered_map,如果发生哈希冲突或Key冲突,行为同上。
- 如果源容器和目标容器中有重复的
1 |
|
map1: {1:"A", 2:"B", 3:"D"}:键冲突时保留map1的值map2: {2:"C"}:键2的节点因冲突留在map2中set1: {1, 2, 3, 4, 5}:元素值冲突时保留set1的值set2: {3}:重复元素留在set2
2. try_emplace()
在 C++17 之前,我们经常使用 emplace 来原地构造元素。但 emplace 有一个缺陷:即使 key 已经存在,它也会构造 value 对象,然后再丢弃。这在 value 构造成本高昂时是巨大的性能浪费。
emplace 的痛点示例:
1 | struct BigData |
- 如果
key 1已存在,Heavy Data依然会被用来构造BigData对象。 - 由于添加数据时
key冲突,这个刚构造出来的BigData瞬间被销毁,浪费资源。
解决方案就是使用 C++17 中为容器添加的新函数 try_emplace,它能够保证只有当插入成功(即 key 不存在)时,才会调用 value 的构造函数。函数签名如下:
1 | template <class... Args> |
下面示例代码中演示了try_emplace函数的使用:
1 | int main() |
与 emplace 类似,try_emplace 支持直接传递 tuple 参数来构造复杂的 value 类型(如 std::pair 或更复杂的 value_type)。
1 |
|
3. insert_or_assign()
operator[] 是 map 最常用的操作,但它有两个副作用:
- 如果
key不存在,它会默认构造一个value。 - 它总是返回一个引用,无法让你知道这次是插入了新元素,还是赋值更新了旧元素。
C++17 中给map容器添加了新的 API 函数insert_or_assign,它结合了 insert 和 try_emplace 的优点,专门用于赋值语义。函数签名如下:
1 | template <class M> |
- 如果 Key 不存在:将
obj转发给value的构造函数(类似于try_emplace,不会先默认构造再赋值,效率更高)。 - 如果 Key 存在:将
obj赋值给已有的value(调用operator=)。 - 返回
pair<iterator, bool>:bool表示是否发生了插入(true)还是赋值(false)。
代码示例:
1 |
|
inserted1为true,it1指向新元素inserted2为false,it1的值变成了Worldm1[1] = 10;:编译错误,operator[]需要默认构造RefType()m1.insert_or_assign(1, 10);:直接调用RefType(int)构造

















