Effective STL
Scott Meyers 经典「Effective 系列」第三弹,专门针对 C++ 标准模板库(STL)的 50 条实用经验。全书 200 页,分 7 大章节,覆盖容器、迭代器、算法、函数对象及 STL 编程实践。每条条款都像一条「专家建议」——不解释理论,直接告诉你什么该做、什么不该做、为什么。
与其他 Meyer 著作的关系:
- 《Effective C++》 — C++ 语言本身的 55 条建议
- 《More Effective C++》 — 35 条新增 C++ 编程经验
- 本书 — 专注 STL(容器 + 算法 + 迭代器 + 函数对象)
全书结构
| 章节 | 主题 | 条款数 |
|---|---|---|
| 1 | Containers(容器) | Item 1–12 |
| 2 | vector and string | Item 13–18 |
| 3 | Associative Containers(关联容器) | Item 19–25 |
| 4 | Iterators(迭代器) | Item 26–29 |
| 5 | Algorithms(算法) | Item 30–37 |
| 6 | Functors(函数对象) | Item 38–42 |
| 7 | Programming with the STL(STL 编程实践) | Item 43–50 |
第一章:Containers(容器)— Item 1–12
Item 1: 慎重选择容器类型
STL 容器种类远超想象:
- 标准序列容器:vector、string、deque、list
- 标准关联容器:set、multiset、map、multimap
- 非标准序列容器:slist(单链表)、rope(重型 string)
- 非标准哈希容器:hash_set、hash_map 等(见 Item 25)
- 非 STL 容器:数组、bitset、valarray、stack、queue、priority_queue
选择容器要考虑的因素远不止「插入删除频率」:内存布局、迭代器失效、异常安全、排序约束、内存分配策略等。vector 是默认首选,但不是万能的。
Item 2: 当心「容器无关代码」的幻觉
STL 提倡泛型,但试图写出「对所有容器都适用」的代码往往是徒劳的。不同容器的能力差异巨大:
- 序列容器支持 push_front/push_back,关联容器不支持
- 关联容器有 count/find/lower_bound 等成员函数
- vector/deque/string 是连续内存,list 不是
- 迭代器类别也不同(随机访问 vs 双向 vs 前向)
最佳实践:用 typedef 封装容器类型,方便日后切换,而不是写模板代码强行兼容所有容器。
Item 3: 确保容器中对象的拷贝操作廉价且正确
容器里存的是对象的副本,不是原对象。插入时拷贝、取出时也拷贝。如果拷贝代价大,会严重影响性能。
- 大对象考虑存指针(但要管理内存,见 Item 7)
- 考虑用智能指针(见 Item 8 的警告)
- 拷贝构造函数必须正确(深拷贝 vs 浅拷贝)
Item 4: 用 empty() 而非 size() == 0 判断是否为空
两者逻辑等价,但 empty() 是常数时间 O(1),而某些容器(如 list)的 size() 可能是线性时间 O(n)(取决于实现是否缓存了大小)。empty() 永远是高效的。
Item 5: 优先使用区间成员函数,而非单元素操作
能用区间版本就不用循环+单元素插入:
assign(begin, end)优于 循环赋值insert(pos, begin, end)优于 循环 insert- 区间构造函数优于默认构造后逐个插入
原因:
- 更简洁、更易读
- 更高效(单次内存分配 vs 多次)
- 避免多次元素移动
Item 6: 警惕 C++ 最令人头疼的语法解析(most vexing parse)
经典陷阱:
ifstream dataFile("ints.dat");
list<int> data(istream_iterator<int>(dataFile), istream_iterator<int>());这不会创建 list,而是声明了一个返回 list
解决方法:给迭代器参数加上额外的括号,或者用命名的迭代器变量。
这是 C++ 的经典语法歧义:凡是能被解析为函数声明的,就会被解析为函数声明。
Item 7: 容器存 new 出来的指针时,记得在容器销毁前 delete 指针
容器销毁时只会销毁指针本身,不会 delete 指针指向的对象 → 内存泄漏。
解决方案:
- 手动循环 delete(易错,异常不安全)
- 改用智能指针容器(如 shared_ptr,见 Item 8 对 auto_ptr 的警告)
- 改用 Handle 类或引用计数对象
Item 8: 永远不要创建 auto_ptr 容器
auto_ptr 的拷贝语义是所有权转移(拷贝后原指针变为 null),这与 STL 容器对元素的要求(必须能正常拷贝)根本冲突。
标准明确禁止 auto_ptr 容器,编译器应该报错。但某些实现可能不报错,结果是运行时灾难。
替代方案:shared_ptr、引用计数智能指针。
Item 9: 仔细选择删除元素的方式
删除操作因容器类型而异,没有统一写法:
- 连续内存容器(vector/deque/string):erase-remove 惯用法
c.erase(remove(c.begin(), c.end(), 1963), c.end()); - list:直接用
c.remove(value)更高效 - 关联容器(set/map 等):用
c.erase(value)成员函数
按条件删除时同理:
- 连续容器:erase + remove_if
- list:remove_if 成员
- 关联容器:手动循环 erase,但要注意迭代器失效!
Item 10: 了解分配器的约定和限制
allocator 是 STL 中最容易被误解的组件之一。关键点:
- 分配器是按值传递的,所以不能有状态(或状态必须被正确拷贝)
- 同类型的分配器必须相等(a1 == a2 必须成立),否则无法互换内存
- 自定义分配器的指针/引用/大小类型等都有严格的 typedef 要求
- C++98 的分配器模型限制很多,不支持多态分配器等高级用法
大多数时候你不需要自定义分配器。如果需要,先确保你理解这些约束。
Item 11: 理解自定义分配器的合理用途
什么时候值得写自定义分配器?
- 内存池/共享内存:特殊内存来源(如共享内存段、内存映射文件)
- 对齐要求:某些硬件需要特定对齐的内存
- 性能优化:默认分配器有锁开销,单线程环境可用无锁分配器
- 内存调试:追踪分配/释放、检测泄漏
但大多数性能瓶颈不在分配器上。先 profiling,再决定是否优化。
Item 12: 对 STL 容器的线程安全性有现实的预期
标准 C++ 对线程只字不提,STL 容器的线程安全性完全取决于实现。
现实情况:
- 多读安全(多个线程同时读同一个容器通常 OK)
- 多写不安全(必须自己加锁)
- 不同容器的多线程安全程度不同
最佳实践:自己加锁,不要依赖实现细节。用 RAII 锁(如 lock_guard)确保异常安全。
第二章:vector 和 string — Item 13–18
Item 13: 优先用 vector 和 string 替代动态分配的数组
用 new[] 分配数组意味着你要承担:
- 记得 delete[](泄漏风险)
- 用对 delete 形式(new 对应 delete,new[] 对应 delete[])
- 无法自动扩容
vector 和 string 自动管理内存,支持动态增长,还有完整的 STL 接口。几乎没有理由再用 new[]。
Item 14: 使用 reserve 避免不必要的重新分配
vector/string 扩容时会:
- 分配更大的内存(通常 1.5x 或 2x)
- 拷贝所有元素到新位置
- 销毁旧元素,释放旧内存
- 所有迭代器/指针/引用失效
如果你知道大致容量,提前 reserve可以避免多次重新分配,同时避免迭代器失效。
注意:reserve 只改 capacity(容量),不改 size(大小)。resize 改 size。
Item 15: 留意 string 实现的多样性
string 的实现方式比你想象的多得多:
- COW(Copy-On-Write):写时复制,共享底层数据。多线程下有性能问题
- SSO(Small String Optimization):小字符串存在栈上,不用堆分配
- 引用计数 + 长度 + 容量
- 不同的内存布局
这些差异会影响:性能、线程安全、迭代器失效时机等。不要假设所有实现行为一致。
Item 16: 知道如何将 vector 和 string 数据传给 legacy C API
很多 C API 接受数组指针。vector 和 string 可以无缝衔接:
- vector:
&v[0]或v.data()(C++11起),等价于 T* 指针 - string:
s.c_str()返回 const char*,适用于只读 C 字符串
对于 vector,不要用
&v.front()以外的方式(如v.begin()返回迭代器,不是指针)。 对于 string,不要试图通过&s[0]修改字符串(C++98 不保证连续存储,C++11 之后可以)。
Item 17: 用「swap 技巧」释放多余容量
vector/string 只会增长,不会自动缩小。即使你 erase 了很多元素,capacity 也不会变。
swap 技巧:
vector<Contestant>(contestants).swap(contestants);
// 创建一个临时 vector(拷贝了当前元素,但 capacity 刚好够用)
// 然后 swap,原 vector 的多余容量被临时对象带走并销毁string 同理:
string(s).swap(s);Item 18: 避免使用 vector
vector
- 它不是真正的 STL 容器(不满足容器的所有要求)
- 它不真正存储 bool(用位压缩存储,1 位一个元素)
返回的「引用」实际上是一个代理对象(proxy),行为不像真正的 bool&。这会导致各种诡异的 bug。
替代方案:
deque<bool>(真正的 bool 容器)bitset(固定大小的位集合)- 用
char代替bool
第三章:Associative Containers(关联容器)— Item 19–25
Item 19: 理解相等(equality)和等价(equivalence)的区别
这是 STL 中最容易混淆的概念之一:
- 相等(equality):基于
operator==,a == b为 true - 等价(equivalence):基于比较函数(通常
less<T>),!comp(a,b) && !comp(b,a)为 true
关联容器用等价判断元素是否相同,不是相等。这意味着两个元素可能等价但不相等(当比较函数不基于 == 时)。
Item 20: 为指针的关联容器指定比较类型
如果你直接创建 set<string*>,默认比较器 less<string*> 比较的是指针地址,不是字符串内容!这导致元素按内存地址排序,而不是按字典序。
解决方法:自定义比较器(函数对象),比较指针指向的内容:
struct StringPtrLess {
bool operator()(const string* a, const string* b) const {
return *a < *b;
}
};
set<string*, StringPtrLess> ssp;Item 21: 永远让比较函数对相等的值返回 false
如果比较函数在两个元素相等时返回 true,会破坏关联容器的不变式。
经典例子:set<int, less_equal<int>> s; s.insert(10); s.insert(10); — 两个 10 都可能被插入,因为 !(10<=10) && !(10<=10) 是 false,容器认为它们不等价。
比较函数必须定义严格弱序(strict weak ordering)。对相等元素返回 true 会导致未定义行为。
Item 22: 避免就地修改 set/multiset 的键
set/multiset 的元素是有序的,如果你修改了一个元素的键值,可能破坏排序顺序 → 后续操作行为未定义。
map/multimap 的 key 是 const 的,编译器会阻止你修改。但 set 的元素不是 const 的(因为元素本身就是键),所以要靠程序员自律。
安全做法:要修改就先 erase 再 insert(虽然有性能代价)。
Item 23: 考虑用排序后的 vector 替代关联容器
对于「查找多、插入删除少」的场景,排序后的 vector + binary_search 可能比 set/map 更快:
- vector 内存连续,缓存友好(locality of reference)
- set 是树结构,每个节点都是独立分配,缓存不友好
- 二分查找的时间复杂度相同(O(log n)),但常数因子更小
插入时先 push_back 再 sort,或者用 lower_bound 找到插入位置。
Item 24: 在效率重要时,仔细选择 map::operator[] 和 map::insert
两者行为不同:
- operator[]:如果键不存在,先默认构造一个 value,再赋值给它。可能创建你不想要的默认对象。
- insert:只在键不存在时插入。更高效,也更安全。
经验法则:
- 更新操作(键一定存在):用 operator[],语法简洁
- 插入操作(键可能不存在,且关心效率):用 insert
Item 25: 熟悉非标准的哈希容器
标准 C++98 没有哈希表,但几乎每个 STL 实现都提供了非标准版本:
- SGI 风格:hash_set、hash_map、hash_multiset、hash_multimap
- 基于哈希表,平均 O(1) 查找,比树结构的关联容器快
C++11 已引入 unordered_set/unordered_map 等标准哈希容器。 如果你需要哈希表且可移植性要求不高,这些非标准容器是很好的选择。
第四章:Iterators(迭代器)— Item 26–29
Item 26: 优先用 iterator 而非 const_iterator / reverse_iterator
虽然四种迭代器都存在,但很多成员函数只接受 iterator:
- insert(pos, value) 的 pos 必须是 iterator
- erase(pos) 的 pos 在某些容器中也必须是 iterator
如果你有 const_iterator 但需要 iterator,转换起来很麻烦(见 Item 27)。所以除非确实需要 const,否则用 iterator 更方便。
Item 27: 用 distance 和 advance 将 const_iterator 转为 iterator
C++98 没有直接的转换方式。技巧:
typedef deque<int> IntDeque;
typedef IntDeque::iterator Iter;
typedef IntDeque::const_iterator ConstIter;
ConstIter ci;
Iter i(c.begin());
advance(i, distance<ConstIter>(i, ci));注意:distance 对随机访问迭代器是 O(1),对其他是 O(n),可能很慢。
C++11 提供了更直接的转换方式。
Item 28: 了解 reverse_iterator 的 base() 迭代器
reverse_iterator 的 base() 返回对应的「普通迭代器」,但指向的位置不同:
- reverse_iterator 指向的元素,其 base() 指向下一个元素
- 类似指针和数组的关系(rbegin 对应 end,rend 对应 begin 前一个位置)
在插入/删除操作时要特别注意这个偏移量,搞清楚你实际操作的是哪个位置。
Item 29: 考虑用 istreambuf_iterator 做逐字符输入
istream_iterator
- 它会跳过空白字符(默认情况下)
- 它调用 operator>>,有额外开销
istreambuf_iterator
- 不跳过空白
- 更快(直接操作缓冲区)
- 适合逐字符处理文本文件
第五章:Algorithms(算法)— Item 30–37
Item 30: 确保目标区间足够大
STL 算法不会自动扩大目标容器的容量。如果你写:
vector<int> results;
transform(v.begin(), v.end(), results.begin(), f);这是未定义行为 — results 是空的,往 begin() 位置写会越界。
解决方法:
- 提前
resize或reserve + back_inserter - 用插入迭代器:
back_inserter(results)、front_inserter、inserter
Item 31: 了解你的排序选项
STL 不止 sort 一个排序算法:
- sort:完全排序,O(n log n),随机访问迭代器
- stable_sort:稳定排序(保持相等元素的相对顺序)
- partial_sort:只排前 N 个元素,其余不管
- nth_element:只找到第 N 个元素的位置,比 partial_sort 更快
- partition:按条件分成两组,不排序
- stable_partition:稳定版本的 partition
- list::sort:list 专用,用归并排序
选对算法可以节省大量时间。你不需要全排序时,就别用 sort。
Item 32: 真正想删除元素时,在 remove 类算法后调用 erase
remove 不会真正删除元素! 它只是把要「删除」的元素移到区间末尾,返回新的逻辑末尾迭代器。容器的大小不变。
erase-remove 惯用法:
v.erase(remove(v.begin(), v.end(), val), v.end());这是 STL 最经典的惯用法之一,必须掌握。
Item 33: 对指针容器使用 remove 类算法要小心
如果容器里存的是指针,remove 只是重新排列指针,被「移除」的指针仍然指向原来的对象 → 内存泄漏(如果你以为它们被删了)。
在 erase 之前,要么先 delete 那些要移除的对象,要么用智能指针容器。
Item 34: 注意哪些算法要求有序区间
有些算法只在有序区间上工作正确:
- binary_search / lower_bound / upper_bound / equal_range — 二分查找系列
- set_union / set_intersection / set_difference / set_symmetric_difference — 集合运算
- merge — 合并两个有序区间
- inplace_merge — 原地合并
如果你传给它们无序区间,结果是未定义的。这些算法的优势是 O(log n) 或 O(n),但前提是输入有序。
Item 35: 用 mismatch 或 lexicographical_compare 实现简单的大小写不敏感字符串比较
大小写不敏感字符串比较是常见需求。STL 方式:
- mismatch:逐字符比较,找到第一个不同的位置
- lexicographical_compare:字典序比较,返回 bool
两者都可以传自定义比较函数(如大小写无关的比较)。
更复杂的场景(如 Unicode、locale 敏感)需要专门的库。
Item 36: 理解 copy_if 的正确实现
有趣的事实:STL 有 11 个名字带「copy」的算法,但没有 copy_if(C++98 时代)。你需要自己实现:
template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator copy_if(InputIterator begin, InputIterator end,
OutputIterator destBegin, Predicate p) {
while (begin != end) {
if (p(*begin)) *destBegin++ = *begin;
++begin;
}
return destBegin;
}C++11 已加入 std::copy_if。
Item 37: 用 accumulate 或 for_each 做区间汇总
有时候你需要把整个区间「压缩」成一个值:
- accumulate:求和、求积、拼接字符串等简单汇总
- 默认是加法,也可传自定义二元操作
- for_each:更通用的区间遍历,可以做任何事
- for_each 返回函数对象,可以携带结果
两者的区别:accumulate 用于计算汇总值,for_each 用于产生副作用。
第六章:Functors(函数对象)— Item 38–42
Item 38: 按值传递设计函数对象类
STL 算法总是按值传递函数对象(就像 C 按值传递函数指针一样)。这意味着:
- 你的函数对象应该拷贝代价低
- 不应该有太多状态(或状态必须能正确拷贝)
- 如果状态很大,用 pimpl 手法(把状态放到堆上,用引用计数共享)
经典陷阱:函数对象的状态在拷贝后不同步 → 用 ref/cref 包装(C++11)或者用指针实现。
Item 39: 让谓词成为纯函数
纯函数 = 返回值只依赖参数,没有副作用,也不修改任何状态。
为什么谓词必须是纯函数?因为 STL 算法可能会多次拷贝和调用谓词对象,如果谓词有内部状态且会变化,不同拷贝的状态不一致 → 行为不可预测。
如果你真的需要有状态的谓词,用 Item 38 的 pimpl 技巧,但要非常小心。
Item 40: 让函数对象类可适配
STL 函数适配器(bind1st、bind2nd、not1、not2 等)要求函数对象提供特定的 typedef:
argument_type、result_type(一元函数)first_argument_type、second_argument_type、result_type(二元函数)
最简单的方式:继承自 unary_function 或 binary_function 基类,它们提供了这些 typedef。
C++11 用 std::function 和 lambda 替代了这些适配器。
Item 41: 理解 ptr_fun、mem_fun、mem_fun_ref 的用途
这三个函数把普通函数/成员函数包装成可适配的函数对象:
- ptr_fun:包装普通函数指针
- mem_fun:包装成员函数指针,通过指针调用
- mem_fun_ref:包装成员函数指针,通过引用调用
它们的存在是为了让函数指针也能用 bind1st/bind2nd 等适配器。
C++11 的 std::bind 和 lambda 让这些变得过时。
Item 42: 确保 less 等价于 operator<
默认情况下,set<T> 用 less<T> 作为比较器,而 less<T> 默认调用 operator<。
如果你为某个类型特化了 less<T> 但不改 operator<(或反过来),就会造成混乱:有些容器用 less,有些代码用 operator<,行为不一致。
经验:要么都用默认(operator<),要么两者一起改,保持一致。
第七章:Programming with the STL(STL 编程实践)— Item 43–50
Item 43: 优先用算法调用,而非手写循环
三个理由:
- 效率:算法作者比你更了解底层实现,优化更好
- 正确性:手写循环容易出边界错误、迭代器失效等 bug
- 可维护性:算法名表达意图(find、for_each、transform),循环没有语义
但也不要为了用算法而用算法。如果循环写起来更简单清晰,就用循环。见 Item 47。
Item 44: 同名时,优先用成员函数而非算法
有些容器有和算法同名的成员函数:
- 关联容器:count、find、lower_bound、upper_bound、equal_range
- list:remove、remove_if、unique、sort、merge、reverse
优先用成员函数,两个原因:
- 更快:成员函数知道容器的内部结构(如树结构直接遍历)
- 更正确:成员函数用等价关系(基于比较器),算法用相等(基于 ==),关联容器中两者可能不一致
Item 45: 区分 count、find、binary_search、lower_bound、upper_bound、equal_range
查找算法选择指南:
| 你想知道什么 | 无序区间 | 有序区间 |
|---|---|---|
| 值是否存在? | find | binary_search |
| 第一个等于 x 的元素? | find | lower_bound |
| 有多少个等于 x 的元素? | count / count_if | equal_range + distance |
| 第一个大于 x 的元素? | — | upper_bound |
| 所有等于 x 的范围? | find + 遍历 | equal_range |
有序区间的查找都是 O(log n),无序的是 O(n)。差别巨大。
Item 46: 考虑用函数对象而非函数作为算法参数
反直觉但正确:函数对象通常比函数指针更快。
原因:函数对象是模板参数,编译器可以内联;函数指针是运行时参数,无法内联。
Stepanov 的基准测试显示:sort 用 less<int>()(函数对象)比用 strcmp(函数指针)快得多,因为前者被完全内联了。
这也解释了为什么 C++ 的 sort 通常比 C 的 qsort 快。
Item 47: 避免写出「只写不读」的代码
一行代码塞入 5 个适配器 + 绑定器 + 反向迭代器,虽然很酷,但别人(包括三个月后的你)根本看不懂。
例子:
v.erase(
remove_if(
find_if(v.rbegin(), v.rend(), bind2nd(greater_equal<int>(), y)).base(),
v.end(),
bind2nd(less<int>(), x)
),
v.end()
);这行代码在做什么?(答案:删除最后一个 >= y 的元素之前所有 < x 的元素)
经验法则:如果你需要思考超过 10 秒才能理解一行代码,就把它拆成多行,加注释,或者用命名的变量/函数。
Item 48: 永远 include 正确的头文件
不同 STL 实现的头文件依赖不同。在一个平台上编译通过,换个平台可能缺头文件。
基本规则:
- vector →
<vector> - string →
<string> - map →
<map> - set →
<set> - 算法 →
<algorithm> - 迭代器 →
<iterator> - 函数对象/适配器 →
<functional>
不要依赖间接包含,显式 include 你用到的每个组件。
Item 49: 学会解读 STL 相关的编译器错误
STL 的编译器错误信息又长又吓人,动辄几百行。技巧:
- 找关键信息:跳过模板实例化堆栈,找真正的错误描述
- 替换复杂类型名:用 typedef 简化代码,错误信息也会更清晰
- 用静态检查工具:如 STL 特定的 lint 工具
- 从小例子开始:逐步增加复杂度,定位哪一步出错
常见错误:传给算法的迭代器类型不匹配、函数对象缺少必要的 typedef、operator[] 用在 const map 上等。
Item 50: 熟悉 STL 相关的网站
推荐资源(2001 年视角):
- SGI STL 文档:最完整的 STL 参考
- STLport:可移植的 STL 实现
- Boost:高质量 C++ 库集合,很多被收入 C++11/14/17 标准
- C++ FAQ:通用 C++ 问答
2025 年的更新:cppreference.com 是最权威的在线参考。Boost 仍然重要。
核心洞见总结
1. STL 的设计哲学
STL 不是一个库,而是一个约定。只要你遵循迭代器/容器/算法的约定,你自己写的代码也能无缝融入 STL 生态。
2. 最重要的几条经验
如果只能记住 10 条:
- 慎重选容器(Item 1)— vector 不是万能的
- empty() 优于 size() == 0(Item 4)
- erase-remove 惯用法(Item 9, 32)— 删除元素的标准写法
- reserve 提前分配(Item 14)— 避免多次扩容
- swap 技巧释放内存(Item 17)
- vector
是坑 (Item 18)— 别用 - 关联容器用等价,不是相等(Item 19)
- 目标区间要够大,用 back_inserter(Item 30)
- 优先算法,其次手写循环(Item 43)
- 同名时成员函数优于算法(Item 44)
3. C++ 版本演进视角
书中很多内容是针对 C++98 的。在现代 C++(C++11 及以后)中,以下已改变或有更好的替代:
- auto_ptr → unique_ptr / shared_ptr
- 哈希容器 → unordered_set / unordered_map(标准)
- bind1st/bind2nd/ptr_fun/mem_fun → std::bind / lambda
- iterator/const_iterator 转换 → 更灵活
- vector
→ 仍然是坑 😅
但这本书的核心思想(选择合适的容器、理解算法的前提条件、注意迭代器失效、避免常见陷阱)完全适用。
关联笔记
- 《A Tour of C++》-第二版-Bjarne-Stroustrup — C++ 全景导览,包含 STL 简介
- 《Modern C++ Design》-现代C++设计-Andrei-Alexandrescu — 泛型编程的高级技巧
- 《The C++ Programming Language》-第四版-Bjarne-Stroustrup — C++ 圣经,STL 部分非常详尽
- 《From Mathematics to Generic Programming》-从数学到泛型编程-Stepanov-Rose — STL 发明者的思想源头
- 《高效程序的奥秘》-Hacker-s-Delight-Henry-S-Warren — 位操作和底层优化技巧