STL 源码分析
SGI STL 的设计哲学极其统一:底层提供一个高度泛型的容器引擎,上层通过不同的模板参数适配出功能各异的容器
虽然在 C++11 标准中它们被命名为 unordered_map/set,但在 SGI STL 源码中,它们被称为 hash_map/set。以下是对这段源码的深度解构
cpp
// ========== 哈希表节点定义 (stl_hashtable.h) ==========
template <class Value>
struct __hashtable_node {
__hashtable_node* next; // 链表下一个节点指针
Value val; // 节点存储的值
};
// ========== 哈希表核心模板类 (stl_hashtable.h) ==========
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc>
class hashtable {
public:
// 对外暴露的类型别名(符合STL容器规范)
typedef Key key_type;
typedef Value value_type;
typedef HashFcn hasher;
typedef EqualKey key_equal;
typedef size_t size_type;
// 迭代器类型(前置声明+定义)
template <class V, class K, class HF, class ExK, class EqK, class A>
struct __hashtable_iterator;
typedef __hashtable_iterator<Value, Key, HashFcn, ExtractKey, EqualKey, Alloc> iterator;
typedef __hashtable_iterator<const Value, const Key, HashFcn, ExtractKey, EqualKey, Alloc> const_iterator;
private:
// 哈希表核心成员
hasher hash; // 哈希函数对象
key_equal equals; // 键比较函数对象
ExtractKey get_key; // 从Value中提取Key的函数对象
typedef __hashtable_node<Value> node; // 节点类型别名
vector<node*, Alloc> buckets; // 桶数组(存储节点指针)
size_type num_elements; // 哈希表中元素总数
public:
// 核心接口
pair<iterator, bool> insert_unique(const value_type& obj); // 唯一插入(不允许重复键)
const_iterator find(const key_type& key) const; // 按键查找
};
// ========== 哈希set容器 (stl_hash_set) ==========
template <class Value, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc = alloc>
class hash_set {
private:
// 底层哈希表实现:Key=Value,用identity提取键(键=值)
typedef hashtable<Value, Value, HashFcn, identity<Value>,
EqualKey, Alloc> ht;
ht rep; // 哈希表对象(核心存储)
public:
// 类型别名(透传底层哈希表的类型)
typedef typename ht::key_type key_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher;
typedef typename ht::key_equal key_equal;
typedef typename ht::const_iterator iterator; // hash_set迭代器只读
typedef typename ht::const_iterator const_iterator;
// 接口:获取哈希函数和键比较函数
hasher hash_funct() const { return rep.hash_funct(); }
key_equal key_eq() const { return rep.key_eq(); }
};
// ========== 哈希map容器 (stl_hash_map) ==========
template <class Key, class T, class HashFcn = hash<Key>,
class EqualKey = equal_to<Key>, class Alloc = alloc>
class hash_map {
private:
// 底层哈希表实现:Value=pair<const Key, T>,用select1st提取Key
typedef hashtable<pair<const Key, T>, Key, HashFcn,
select1st<pair<const Key, T>>, EqualKey, Alloc> ht;
ht rep; // 哈希表对象(核心存储)
public:
// 类型别名(符合STL map规范)
typedef typename ht::key_type key_type;
typedef T data_type;
typedef T mapped_type; // map的值类型
typedef typename ht::value_type value_type; // 键值对(Key不可修改)
typedef typename ht::hasher hasher;
typedef typename ht::key_equal key_equal;
typedef typename ht::iterator iterator; // hash_map迭代器可写
typedef typename ht::const_iterator const_iterator;
};
1. 核心引擎:hashtable 的六参数模板
hashtable 是所有哈希容器的核心。它的模板参数比红黑树更丰富,也更灵活:
cpp
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc>
class hashtable;
-
Value:节点实际存储的数据类型(set 是 K,map 是 pair)
-
Key:用于查找和哈希的主键类型
-
HashFcn:哈希函数,负责将 Key 转换为哈希值
-
ExtractKey:键值提取器(类似于红黑树的 KeyOfValue),负责从 Value 中抠出 Key
-
EqualKey:等值比较器。因为哈希表只管碰撞,不管大小,所以它需要知道两个 Key 什么时候是相等的
2. 哈希桶(Hash Buckets)
通过 __hashtable_node 的定义,我们可以瞬间看穿它的底层实现:
cpp
template <class Value>
struct __hashtable_node {
__hashtable_node* next; // 单向链表指针
Value val; // 数据域
};
-
开散列:节点中只有一个 next 指针,这说明 SGI STL 采用的是哈希桶方案,即每个桶位维护一个单向链表来处理哈希冲突
-
数据存储:在 hashtable 类中,vector<node*, Alloc> buckets 证明了它是一个动态增长的指针数组,每个元素都是一个桶的头指针
3. 封装层
hash_set 和 hash_map 的区别,本质上就是对 hashtable 参数的填空题
(1)hash_set:纯粹的集合
cpp
template <class Value, class HashFcn = hash<Value>,
class EqualKey = equal_to<Value>, class Alloc = alloc>
class hash_set {
private:
typedef hashtable<Value, Value, HashFcn, identity<Value>,
EqualKey, Alloc> ht;
ht rep; // 哈希表对象(核心存储)
public:
// 类型别名(透传底层哈希表的类型)
typedef typename ht::key_type key_type;
typedef typename ht::value_type value_type;
typedef typename ht::hasher hasher;
typedef typename ht::key_equal key_equal;
typedef typename ht::const_iterator iterator;
typedef typename ht::const_iterator const_iterator;
};
-
键值同步:存储的键即为哈希计算所用的键
-
identity:提取器直接返回自身,因为值本身就是键
-
迭代器注意点:源码中 typedef typename ht::const_iterator iterator;。这意味着 hash_set 的迭代器全是 const 的,禁止修改元素,否则会破坏哈希位置
(2)hash_map:灵活的映射
cpp
template <class Key, class T, class HashFcn = hash<Key>,
class EqualKey = equal_to<Key>, class Alloc = alloc>
class hash_map {
private:
// 底层哈希表实现:Value=pair<const Key, T>,用select1st提取Key
typedef hashtable<pair<const Key, T>, Key, HashFcn,
select1st<pair<const Key, T>>, EqualKey, Alloc> ht;
ht rep; // 哈希表对象(核心存储)
public:
// 类型别名(符合STL map规范)
typedef typename ht::key_type key_type;
typedef T data_type;
typedef T mapped_type; // map的值类型
typedef typename ht::value_type value_type; // 键值对(Key不可修改)
typedef typename ht::hasher hasher;
typedef typename ht::key_equal key_equal;
typedef typename ht::iterator iterator; // hash_map迭代器可写
typedef typename ht::const_iterator const_iterator;
};
-
Value 以键值对的形式存储数据
-
**select1st:**提取器只取 pair.first 来进行哈希和比较
-
**Key 的不可变性:**注意到 pair<const Key, T>,强制保证了键的只读属性
为什么 hash_map 的迭代器可以是可变的
在红黑树和哈希表中,元素的存储位置完全由 Key 决定。如果用户擅自修改 Key 而容器未获知,就会导致数据结构失效:修改后的 Key 会使节点留在原来的桶中,但当用新 Key 查找时,哈希函数会指向新的桶,最终导致查找失败
在 hash_set 中,Value 实际上就是 Key。由于二者本质相同,如果允许通过迭代器修改 Value,就相当于直接修改了 Key。为了避免这种潜在的数据结构破坏,STL 采取了最严格的防范措施:直接将 iterator 定义为 const_iterator
而在 hash_map 中,节点存储的是 pair<const Key, T>。请注意这里的细节:
-
Key 的部分:被显式声明为了 const Key。即便迭代器本身是可变的,你试图修改 it->first 时,编译器也会因为 const 关键字而报错
-
T(mapped_type)的部分:它是我们要映射的值。T 的改变完全不会影响哈希值的计算,也不会改变节点所在的桶
在哈希表内部,接口设计严格区分了语义功能:insert_unique 专用于 hash_map / set,确保键值的唯一性并接收完整的 value_type 数据;而 find 方法则仅需接收 key_type 主键参数
这与红黑树的设计如出一辙,体现了极高的工业审美
一. 基于 hashtable 封装的结构
STL 中的 unordered_set 和 unordered_map 并非独立实现,而是基于底层哈希表的封装。这些容器内部维护了一个功能完整的哈希表(通常采用开链法实现),通过组合关系将哈希表作为成员变量进行管理
基本结构设计
cpp
template<class K, class Hash = HashFunc<K>>
class unordered_set {
// 告诉哈希表如何拿 Key
struct SetKeyOfValue {};
private:
// T 是 K,因为 set 存的就是 key 本身
HashTable<K, K, SetKeyOfValue, Hash> _ht;
};
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map {
struct MapKeyOfValue {};
private:
// T 是 pair<const K, V>
HashTable<K, std::pair<const K, V>, MapKeyOfValue, Hash> _ht;
};
KeyOfValue 的设计
底层 hashtable 是一个泛型容器。它在执行 insert 或 find 时,需要根据 Key 来计算哈希地址
-
对于 unordered_set:存的是 K,它的 Key 就是它自己。
-
对于 unordered_map:存的是 pair<K, V>,它的 Key 是 pair.first
hashtable 怎么知道什么时候该直接用数据,什么时候该取 first? 因此,我们需要通过模板参数传入一个取出 Key 的提取器(即 KeyOfValue 仿函数)
unordered_set 的 KeyOfValue
在 set 中,数据就是 Key,所以提取器非常简单:
cpp
struct SetKeyOfValue {
const K& operator()(const K& key) {
return key; // 直接返回数据本身
}
};
unordered_map 的 KeyOfValue
在 map 中,数据是键值对,我们需要告诉哈希表 "去拿 pair 的第一个成员" :
cpp
struct MapKeyOfValue{
const K& operator()(const pair<const K, V>& kv){
return kv.first;
}
}
这种设计实现了极高的复用性。hashtable 内部只需要调用 KeyOfValue()(data) 就能拿到 Key,它不需要关心外面到底是 set 还是 map
为什么 Key 不允许被修改
在 unordered_set 中,你拿不到非 const 的引用;在 unordered_map 中,pair 的第一个成员永远是 const K。这背后的原因非常直接:为了保证哈希桶的逻辑一致性
一旦 Key 被修改,哈希表就会陷入混乱:
-
查找失败:当你调用 find(新 Key)时,底层会计算新 Key 的哈希值,去对应的新桶找,但数据在旧桶里,找不到。删除操作也是同理
-
重复键风险:可能把 Key 改成了另一个已经存在的 Key,破坏了唯一性约束。
-
内存泄漏:在某些极端实现下,这会导致你永远无法通过正常接口释放或管理这个节点
为了防止用手抖改了 Key,我们在封装 unordered_set 和 unordered_map 时,需要采取不同的防御策略:
对于 unordered_set
unordered_set 的 T 就是 K。为了防止修改,它的普通迭代器其实就是 const 迭代器
cpp
// 在 unordered_set 内部
typedef typename HashTable<K, K, SetKeyOfValue>::const_iterator iterator;
typedef typename HashTable<K, K, SetKeyOfValue>::const_iterator const_iterator;
这意味着当你 *it 时,拿到的是 const K&,编译器直接拒绝你的修改请求
对于 unordered_map
unordered_map 存储的是 pair<K, V>。我们不需要锁死整个 pair(因为 Value 是可以改的),只需要锁死 first。所以在传给底层 HashTable 的模板参数时,我们要手动加上 const
cpp
// 注意这里的 const K
HashTable<K, pair<const K, V>, MapKeyOfValue> _ht;
由于 pair 的第一个成员是 const K,即便你拿到了 pair&,你也只能改 second,改不了 first
二. 迭代器的实现
哈希表的迭代器比 vector 或 list 的要复杂一些。因为哈希表(开链法)的物理结构是不连续的:它是一个数组 + 多个单链表
迭代器不仅要能在单链表里遍历,还要能在链表走到头时,跳到下一个非空的桶里
迭代器的遍历逻辑
-
begin():从数组的第 0 号桶开始往后找,直到遇到第一个非空的桶,返回该桶第一个节点的迭代器
-
operator++:如果是在链表中,当前节点有 next,直接去 next。如果是在链表尾的话,计算当前桶下标,从下一个下标开始,在 vector 中寻找下一个非空桶
-
end():直接构造一个包含 nullptr 的迭代器即可
迭代器的成员设计
实现的大框架和 list 思路是一致的,用一个类型封装结点的指针,再通过重载运算符实现迭代器像指针一样访问的行为
而为了实现跳桶逻辑,迭代器除了需要当前的节点指针,还需要能看到整个哈希表(以便找到下一个桶)
cpp
template<class K, class T, class Ref, class Ptr, class KeyOfValue, class Hash>
struct HTIterator{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfValue, Hash> HT;
typedef HTIterator<K, T, Ref, Ptr, KeyOfValue, Hash> Self;
Node* _node;
HT* _ht;
HTIterator(Node* node, HT* ht) :_node(node), _ht(ht)
{}
Ref operator*() { return _node->_data; }
Ptr operator->() { return &_node->_data; }
bool operator!=(const Self& self) { return _node != self._node; }
}
通过增加 Ref(引用类型)和 Ptr(指针类型)两个模板参数,你可以只写一套代码,就同时生成 iterator 和 const_iterator
这种设计被称为 模板的实例化复用
而在 HashTable 中,我们就可以这样派发:
cpp
template<class K, class T, class KeyOfValue, class Hash>
class HashTable {
public:
// 1. 普通迭代器:传入 T& 和 T*
typedef HTIterator<K, T, T&, T*, KeyOfValue, Hash> iterator;
// 2. const 迭代器:传入 const T& 和 const T*
typedef HTIterator<K, T, const T&, const T*, KeyOfValue, Hash> const_iterator;
}:
核心逻辑:operator++
operator++ 的逻辑可以总结如下:
-
如果在当前链表中:_node->next 不为空,直接移向下一个节点
-
如果当前链表走完了:需在哈希表的数组中找到下一个非空桶
cpp
Self& operator++()
{
// 情况 1:链表没走完,直接找下一个节点
if(_node->_next)
{
_node = _node->_next;
}
// 情况 2:当前桶走完了,找下一个非空的桶
else
{
// 先计算当前再哪个桶
KeyOfValue kov;
Hash hash;
size_t index = hash(kov(_node->_data)) % _ht->_table.size();
// 遍历数组找到下一个非空桶的位置
++index;
while(index < _ht->_table.size())
{
if(_ht->_table[index])
{
_node = _ht->_table[index];
return *this;
}
++index;
}
// 如果后面全都是空桶说明迭代器走到了 end
_node = nullptr;
}
return *this;
}
在这里我们发现一个尴尬的问题:HTIterator 需要访问 HashTable 的私有成员 _table
有两种解决方法:
-
粗暴式的把 _buckets 设为 public(不推荐)
-
在 HashTable 类中把 HTIterator 声明为友元类
cpp
template<class K, class T, class KeyOfValue, class Hash>
class HashTable {
// 授予迭代器访问私有成员的权利
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct HTIterator;
// ... 其他成员
};
begin() / end()
begin()
哈希表的 begin() 并不是简单的 _tables[0],因为前几个桶可能是空的。需要从下标 0 开始遍历哈希表的底层 vector,并找到第一个 _tables[i] != nullptr 的位置,用这个桶的第一个节点构造并返回迭代器。如果整个表都是空的,直接返回 end()
cpp
iterator begin() {
for (size_t i = 0; i < _tables.size(); ++i) {
if (_tables[i]) {
return iterator(_tables[i], this); // 找到第一个非空桶
}
}
return end();
}
end()
在 C++ 的容器规范中,end() 永远指向最后一个元素的下一个位置。对于哈希表,最简单的处理方式就是用空指针,所以直接构造一个 _node 为 nullptr 的迭代器即可
无论是 operator++ 走到了表的尽头,还是 begin() 没找到数据,最终都会汇聚到这个 nullptr 状态
cpp
iterator end() {
return iterator(nullptr, this);
}
扩容与迭代器失效分析
目前的 operator++ 在计算下标时用了 % _ht->_table.size()。如果我们在遍历的过程中,哈希表发生了扩容,这个迭代器还会有效吗
在 C++ STL 标准中,当 unordered_map/set 执行 insert 操作触发扩容时,所有迭代器都会失效。这种失效的根本原因在于元素在容器中的逻辑位置与其实际物理存储位置的映射关系被破坏
在我们的 operator++ 代码中:
cpp
size_t index = hash(kov(_node->_data)) % _ht->_buckets.size();
如果 insert 导致 _ht->_buckets(即 vector)扩容,旧的 vector 内存被释放。虽然 _node 是指向堆上内存的指针可能依然有效,但 _ht->_buckets.size() 已经变了
因为节点被重新分配了桶,index++ 可能会让你跳过某些节点,或者反复遍历到同一个节点
STL 的设计原则明确指出:任何可能导致容器大小改变的操作(如 insert 或 erase)都会使现有迭代器失效。开发者需要特别注意,在完成这类操作后,必须重新获取迭代器
三. 接口封装
在封装之前,底层的 HashTable 必须先升级它的 insert 返回值,以支持 map 的 [] 访问
底层哈希表的核心接口改造
cpp
// 改造返回值为迭代器
iterator find(const K& key)
{
if(_table.empty()) return end();
Hash hash;
KeyOfValue kov;
size_t index = hash(key) % _table.size();
Node* cur = _table[index];
while(cur)
{
if(kov(cur->_data) == key) return iterator(cur, this);
cur = cur->_next;
}
return end();
}
// 改造返回值为 pair,支持 map 的 []
pair<iterator, bool> insert(const T& data)
{
KeyOfValue kov;
iterator it = find(kov(data));
if (it != end()) return make_pair(it, false);
// if (_n == _buckets.size())
{ /* 扩容逻辑 */ }
Hash hash;
size_t index = hash(kov(data)) % _buckets.size();
Node* newnode = new Node(data);
// 头插操作
return make_pair(iterator(newnode, this), true);
}
上层封装:unordered_set
unordered_set 的特点是:它既是 Key 也是 Value,且迭代器全部是 const 的
cpp
template<class K, class Hash = HashFunc<K>>
class unordered_set {
struct SetKeyOfValue {
const K& operator()(const K& key) { return key; }
};
public:
// 强制让 iterator 和 const_iterator 都是底层 HashTable 的 const_iterator
typedef typename HashTable<K, K, SetKeyOfValue, Hash>::const_iterator iterator;
typedef typename HashTable<K, K, SetKeyOfValue, Hash>::const_iterator const_iterator;
iterator begin() const { return _ht.begin(); }
iterator end() const { return _ht.end(); }
pair<iterator, bool> insert(const K& key) {
return _ht.insert(key);
}
iterator find(const K& key) const { return _ht.find(key); }
bool erase(const K& key) { return _ht.erase(key); }
private:
HashTable<K, K, SetKeyOfValue, Hash> _ht;
};
上层封装:unordered_map
unordered_map 的难点在于 operator[]
cpp
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map {
struct MapKeyOfValue {
const K& operator()(const pair<const K, V>& kv) { return kv.first; }
};
public:
typedef typename HashTable<K, pair<const K, V>, MapKeyOfValue, Hash>::iterator iterator;
typedef typename HashTable<K, pair<const K, V>, MapKeyOfValue, Hash>::const_iterator const_iterator;
iterator begin() { return _ht.begin(); }
iterator end() { return _ht.end(); }
pair<iterator, bool> insert(const pair<const K, V>& kv) {
return _ht.insert(kv);
}
// 重点:operator[]
V& operator[](const K& key) {
// 调用 insert,如果 key 存在,返回已有节点;不存在,插入默认构造的 V()
pair<iterator, bool> ret = _ht.insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const K& key) { return _ht.find(key); }
bool erase(const K& key) { return _ht.erase(key); }
private:
HashTable<K, pair<const K, V>, MapKeyOfValue, Hash> _ht;
};
unordered_map 的 insert 必须返回 pair<iterator, bool>,这不仅是为了返回是否成功,更是为了 operator[] 能够拿到指向该元素的引用
为什么底层使用拉链法而不是开放定址法
这是一个非常经典的设计决策问题。虽然开放定址法在 CPU 缓存利用率上更有优势,但 STL 以及大多数工业级哈希表(如 Java 的 HashMap)几乎全都倒向了拉链法
其核心原因可以归纳为以下几点:
1. 负载因子
-
开放定址法 :非常脆弱。当负载因子超过 0.7 时,由于聚集效应,冲突会呈指数级增长。为了维持性能,它必须频繁扩容,这会导致大量的空间浪费
-
拉链法:从容应对高负载。即使负载因子达到 1.0 甚至更高,由于每个桶独立,它的性能下降是线性的。这意味着拉链法在内存利用率上更具弹性
2. 删除操作
-
开放定址法:删除是一个噩梦。你不能直接把一个位置设为 NULL,否则会掐断后面的探测路径(导致 find 失败)。因此,必须使用伪删除标记。这不仅增加了逻辑复杂度,还可能导致查找路径越来越长
-
拉链法:删除就是简单的单链表节点删除。逻辑清晰,且不会对其他元素的查找产生副作用
3. 应对哈希碰撞的稳定性
-
开放定址法:当哈希冲突发生时,数据会侵占原本未被占用的桶(引发二次冲突),从而产生连锁反应
-
拉链法:冲突被限制在局部。如果某个 Key 的哈希分布极差,它只会让那一个桶变长,而不会污染整个数组。在现代工程中,如果链表过长(如超过 8 个),拉链法还可以通过红黑树化(如 Java 8+)来保证最坏情况下的 O(log N) 效率,而开放定址法很难做到这一点
4. 存储对象的灵活性
-
开放定址法:哈希表通常要求直接存储数据对象。如果对象很大(比如存一个巨大的结构体),频繁的挪动和探测会带来极大的开销
-
拉链法:数组里存的是指针,实际数据在堆上。无论对象多大,桶数组本身始终保持轻量
总结
本次封装的核心在于通过 KeyOfValue 仿函数 实现了底层 HashTable 与上层 set/map 的解耦。这种一套引擎,两套外壳的设计,不仅利用模板策略完成了 O(1) 平均时间复杂度的极致性能,更通过 pair<const K, V> 从编译器层面锁死了 Key 的修改权限
与基于红黑树实现的普通 map / set 相比,它们的区别主要体现在:
| 特性 | map / set | unordered_map / unordered_set |
|---|---|---|
| 底层结构 | 红黑树(平衡搜索树) | 哈希表(开链法) |
| 查找效率 | O(log N)(稳定) | O(1)(平均),最坏 O(N) |
| 元素顺序 | 有序(按 Key 排序) | 无序 |
| 迭代器 | 双向迭代器(可 --) |
前向迭代器(仅 ++) |
| 适用场景 | 需要范围查找或有序遍历 | 追求极致的单点查找速度 |
总体而言,普通 map 的优势在于稳定性和有序性,而 unordered 版本则胜在速度。通过这次封装实践,你不仅掌握了哈希桶的底层实现原理,更深入理解了 STL 如何运用泛型编程技术在不同逻辑容器间实现高效的代码复用
