基于C++ map中key使用指针问题的详解
前言std::map的键可以是任意可比较类型包括指针。但当键类型是T*时默认比较的是指针地址不是指针指向的值。这意味着两个内容完全相同的对象只要地址不同就会被当成两个不同的键反过来用另一个地址去查已经插入的键通常查不到。常见误解有三个。第一以为std::mapconst char*, int会按字符串内容比较于是用s.c_str()去查字面量插入的键结果查不到。第二以为 map 会接管指针、负责释放指向的对象实际上 map 只保存指针值不管理生命周期。第三以为键是const就安全实际上map的键确实不可修改但指针指向的对象仍可被外部修改如果比较器依赖对象内容修改后会破坏 map 的有序性。本文讲清默认比较的行为、三类典型事故以及「值键、自定义比较器、智能指针」三种正确做法。一、默认比较的是地址std::mapKey*, V默认使用std::lessKey*。标准为指针的std::less提供了特化保证在所有指针之间给出一个严格全序即使两个指针指向不相关的对象。直接使用内置的比较不相关对象的指针结果未指定std::less则保证一致的全序所以 map 的内部排序是良定义的。但「良定义」不等于「按内容」。两个不同的std::string对象即使内容都是alice地址也不同std::less会认为它们是两个键。map 判断两个键是否等价用的是「互相都不小于」!comp(a,b) !comp(b,a)。对指针来说这等价于地址相等。// 适用于 C17#include iostream#include map#include stringint main() {std::mapconst char*, int by_name;by_name[alice] 1; // 键是字面量的地址std::string name alice;std::cout by_name.count(name.c_str()) \n; // 按语义通常为 0}字面量alice和name.c_str()指向两块不同的内存地址不同因此查不到。某些实现会把相同字面量合并到同一地址但这是实现行为不能依赖。即便这次恰好相等换一个字符串来源从文件读入、拼接生成就必然不同。二、三类典型事故第一类内容键误用指针。上面的const char*是典型。std::mapstd::string*, int也一样用s1插入用s2查询即使s1 s2也查不到。第二类悬垂键。map 不拥有指针指向的对象。如果键指向一个局部变量函数返回后对象销毁map 里留下悬垂指针。之后任何比较、查找、遍历都会解引用它如果比较器按内容比较或者至少让键失去意义。删除对象前必须先erase对应的键顺序不能反。第三类比较器依赖内容但内容被修改。即使用自定义比较器按字符串内容排序只要键指向的字符串在插入后被修改map 的排序不变量就被破坏。后续查找可能找不到本应存在的元素行为不可预测。map 的键类型是const Key但const std::string*只表示「不能通过这个指针改」不表示「对象不可改」。三、三种正确做法做法一直接用值类型作键。绝大多数场景下这是最简单也最安全的方案// 适用于 C17#include map#include stringstd::mapstd::string, int by_name;by_name[alice] 1;by_name[std::string(alice)] 2; // 同一个键值被覆盖std::string自带内容比较和哈希拷贝进来后与外部对象完全解耦不存在生命周期问题。代价是插入时复制一次字符串通常可以接受。做法二保留指针键但提供按内容比较的比较器。适用于对象很大、不想复制且能保证对象生命周期覆盖 map 的场景// 适用于 C17#include cstring#include mapstruct CStrLess {bool operator()(const char* a, const char* b) const {return std::strcmp(a, b) 0;}};std::mapconst char*, int, CStrLess by_name;比较器必须满足严格弱序comp(a,a)为假且传递性成立。strcmp(a,b) 0满足这些条件前提是两个指针都非空。如果键可能为空指针比较器要先定义空指针的次序或禁止空键否则strcmp解引用空指针是 UB。做法三用智能指针表达所有权。如果希望 map 持有键对象又要避免复制大对象可以用std::shared_ptrconst std::string作键并配一个按内容比较的比较器// 适用于 C17#include map#include memory#include stringstruct PtrContentLess {bool operator()(const std::shared_ptrconst std::string a,const std::shared_ptrconst std::string b) const {return *a *b;}};std::mapstd::shared_ptrconst std::string, int, PtrContentLess table;shared_ptr保证对象在 map 持有期间存活const保证内容不被修改比较器按内容排序。代价是每个键多一次引用计数和一次间接访问。注意两个内容相同但地址不同的shared_ptr会被比较器判为等价map 只保留先插入的那个键对象这正是按内容作键的语义。四、选择与边界方案比较依据生命周期额外开销适用std::string值键内容map 拥有副本一次拷贝绝大多数场景裸指针 内容比较器内容调用方保证无拷贝大对象、生命周期明确shared_ptr 内容比较器内容共享所有权引用计数需要共享且防悬垂裸指针 默认比较地址调用方保证无确实要按对象身份索引最后一行不是错误如果业务就是「按对象身份」索引例如每个连接对象一个状态指针地址正是想要的键。问题只出在「想按内容查却用了指针默认比较」。另外std::unordered_map的指针键有同样的问题默认std::hashT*哈希地址std::equal_toT*比较地址。要按内容索引需要同时提供哈希函数和相等谓词两者必须一致。常见坑点❌std::mapconst char*, int用s.c_str()查询字面量插入的键。✅ 改用std::mapstd::string, int或提供strcmp比较器。❌ 用局部变量的地址作键函数返回后继续使用 map。✅ 让键对象生命周期覆盖 map或改用值键 / 智能指针。❌ 删除键指向的对象却忘记先erase对应的 map 条目。✅ 先erase再释放对象或让 map 拥有对象。❌ 自定义比较器按内容比较插入后又修改了键指向的内容。✅ 键指向的对象视为不可变需要修改就erase后重新插入。❌ 比较器写成strcmp(a,b) 0。✅ 严格弱序要求相等时返回 false用 0。❌ 比较器不处理空指针对空键调用strcmp。✅ 禁止空键或在比较器里先定义空指针的次序。❌ 认为map会delete键指针。✅ map 不拥有指针释放责任在调用方。❌ 用内置比较不相关对象的指针。✅ 用std::lessT*map 默认就是它提供严格全序。总结问题结论默认比较指针地址不是指向的值等价判断地址相同默认比较器下所有权map 不拥有指针指向的对象内容索引值键或自定义内容比较器生命周期键对象必须活得比 map 条目久不可变性比较器依赖的内容插入后不能改指针作 map 键本身没有错错的是「想按内容索引却用了默认比较」。先把「我要按身份还是按内容」这个问题回答清楚再决定用裸指针、值类型还是智能指针如果选内容比较就必须同时保证对象生命周期和内容不可变。这三点定下来指针键的坑基本就填平了。