Effective STL
《Effective STL》 条款01至10的要点可以总结如下:
条款01:慎重选择容器类型
- 任意位置插入元素:选择序列容器(
vector
,string
,deque
,list
)。 - 不关心元素排序:使用哈希容器,只需要快速查找,但不关心顺序。
- 选择标准容器:标准容器有更好的支持和兼容性,尽量使用标准容器。排除哈希容器、
slist
(单链表)和rope
(“rope”型的string
)。 - 随机访问迭代器:使用
vector
,deque
,string
,rope
;避免使用list
和哈希容器。 - 避免元素移动:频繁插入和删除元素时使用list。避免使用连续内存的容器。
- 兼容C:需要与C代码兼容时,选择
vector
。 - 查找速度关键:查找速度优先时,使用
unordered_set
。、排序的vector
、标准关联容器(按速度顺序)。 - 介意引用计数:避免
string
和rope
。 - 回滚能力:选择基于节点的容器,如
list
;或使用连续内存容器以获得事务语义。 - 迭代器有效性:基于节点的容器不会使迭代器、指针、引用失效。
string
上的swap
:会使迭代器、指针、引用失效。- 随机访问迭代器且无删除操作:指向数据的引用和指针不会失效,如
deque
。
假设我们需要在容器任意位置插入元素并且要求随机访问。
1 |
|
条款02:不要试图编写独立于容器类型的代码
不要编写对序列容器和关联容器都适用的代码。因为它们的接口和行为不同,尝试编写通用代码可能会导致错误。
1 |
|
条款03:确保容器中的对象拷贝正确而高效
容器存储的是对象的拷贝,确保拷贝构造函数和赋值操作符的正确性和高效性。如果存储基类指针,应使用智能指针来避免内存泄漏。
1 |
|
条款04:调用empty而不是检查size()是否为0
empty()
对所有标准容器都是常数时间操作,而size()
对于list
是线性时间。
1 |
|
条款05:区间成员函数优先于单元素成员函数
使用区间成员函数(如insert
, erase
)效率高、易于理解,能更好地表达意图。区间创建、删除、赋值、插入可以用到区间成员函数。
1 |
|
条款06:当心C++编译器的烦人分析机制
在C++中,编译器有时会以非预期的方式解释代码,这通常是因为C++的语法允许函数声明和对象声明在某些情况下看起来非常相似。这种“讨厌的分析”(Annoying Parse)是C++编程中的一个常见问题,特别是当涉及模板、函数指针或复杂表达式时。
例子
假设我们有一个名为Foo
的类,它有一个返回std::unique_ptr<int>
的静态成员函数create
。现在,我们想要声明一个std::unique_ptr<int>
类型的变量ptr
,并使用Foo::create
函数来初始化它。
错误的写法(可能导致“最糟糕解析”):
1 | std::unique_ptr<int> ptr = Foo::create; // 这可能被解释为函数指针的声明 |
在上面的代码中,ptr
可能被错误地解释为指向Foo::create
函数的指针,而不是调用该函数并接收返回的std::unique_ptr<int>
对象。
正确的写法(避免“最糟糕解析”):
- 使用括号来调用函数:
1 | std::unique_ptr<int> ptr = Foo::create(); // 显式调用函数 |
在这里,通过添加括号()
,我们明确地告诉编译器我们要调用Foo::create
函数,并将返回的对象赋值给ptr
。
- 使用
auto
关键字(在C++11及更高版本中推荐):
1 | auto ptr = Foo::create(); // 使用auto推导类型 |
在这个例子中,auto
关键字允许编译器自动推导ptr
的类型为std::unique_ptr<int>
。由于Foo::create()
的返回值类型明确,因此这里不会发生“最糟糕解析”问题。
在现代C++中,虽然编译器通常能够正确地解析大多数表达式,但在某些情况下,特别是当涉及到函数指针、模板或复杂的表达式时,仍然需要小心避免“最糟糕解析”问题。通过使用括号来明确函数调用、显式声明变量类型或使用auto
关键字来推导类型,我们可以提高代码的可读性和正确性,并减少误解析的可能性。
条款07:容器包含指针
在容器对象调用析构函数之前,确保指针已被删除。使用智能指针来管理动态分配对象。
1 |
|
条款08:切勿创建包含auto_ptr的容器
auto_ptr
的拷贝或复制会使源对象失去资源所有权,不适合STL容器要求的拷贝可赋值属性。
std::auto_ptr
在C++11及之后的标准中被弃用,因为它不支持数组、与STL容器不兼容(由于所有权转移问题)且不符合移动语义。
条款09:auto_ptr的所有权转移
复制auto_ptr
会转移其资源所有权,原对象被置为NULL
。
条款10:慎重选择删除元素的方法
-
删除特定值的对象:
- 对于
vector
,string
,deque
,使用erase-remove
。 - 对于
list
,使用list::remove
。 - 对于关联容器,使用其
erase
成员函数。
1
2
3
4
5
6
7
8
int main() {
std::vector<int> vec = {1, 2, 3, 2, 4};
vec.erase(std::remove(vec.begin(), vec.end(), 2), vec.end()); // 删除所有值为2的元素
return 0;
} - 对于
-
在循环内部操作:
- 标准序列容器:写循环遍历元素,使用
erase
返回值更新迭代器。 - 关联容器:写循环遍历元素,传给
erase
时对迭代器进行后缀递增。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == 3) {
it = vec.erase(it); // 删除元素并更新迭代器
} else {
++it;
}
}
for (int n : vec) {
std::cout << n << " ";
}
return 0;
} - 标准序列容器:写循环遍历元素,使用
-
删除满足判别式的对象:
- 对于
vector
,string
,deque
,使用erase-remove_if
。 - 对于
list
,使用list::remove_if
。 - 对于关联容器,使用
remove_copy_if
和swap
或编写循环遍历元素。
- 对于
让我们详细讲解《Effective STL》中提到的条款11到25,并提供例子来帮助更好地理解和应用这些条款。
条款11:了解分配子的约定与概念
分配子(allocator)是STL内存管理的重要工具。分配子负责分配和释放内存,并提供指针和引用类型定义。
-
分配子提供类型定义:
allocator<T>::pointer
和allocator<T>::reference
。- 例子:
1
2
3
4
5
6template <typename T>
struct MyAllocator {
using pointer = T*;
using reference = T&;
// 其他实现...
}; -
库实现者可以忽略类型定义,直接使用指针和引用。
- 例子:
1
2
3std::vector<int> vec;
int* ptr = &vec[0]; // 假设 allocator<int>::pointer 是 int*
int& ref = vec[0]; // 假设 allocator<int>::reference 是 int& -
STL实现者可以假定同类型的所有分配子是等价的。
- 例子:
1
2
3// 分配子类型等价,可以互换使用
std::allocator<int> alloc1;
std::allocator<int> alloc2 = alloc1; -
大多数标准容器从未单独使用过对应的分配子。例如,
list
需要为listNode
分配内存。- 例子:
1
2
3
4
5
6
7
8
9
10
11template <typename T>
struct ListNode {
T data;
ListNode* next;
};
template <typename T, typename Alloc = std::allocator<T>>
class MyList {
using NodeAlloc = typename Alloc::template rebind<ListNode<T>>::other;
// 其他实现...
};
条款12:编写自定义分配子需要什么
编写自定义分配子时需要注意以下几点:
-
定义模板类型:
allocator::pointer
与allocator::reference
。- 例子:
1
2
3
4
5
6template <typename T>
struct MyAllocator {
using pointer = T*;
using reference = T&;
// 其他实现...
}; -
分配子不应该有非静态对象。
- 例子:
1
2
3
4
5template <typename T>
struct MyAllocator {
static int counter; // 非静态对象会导致问题
// 其他实现...
}; -
提供
rebind
模板。- 例子:
1
2
3
4
5
6
7
8template <typename T>
struct MyAllocator {
template <typename U>
struct rebind {
using other = MyAllocator<U>;
};
// 其他实现...
};
条款13:理解分配子的用法
分配子的用法包括将容器内容放在共享内存或不同的堆中。
- 分配子可用于在共享内存、特定堆或其他内存区域中分配 STL 容器的内容。
- 通过提供自定义分配子,可以控制 STL 容器的内存管理策略。
条款14:切勿对STL容器的线程安全性有不切实际的依赖
STL对多线程的支持有限,在修改容器或调用算法时需要自己加锁。
- RAII:
- 例子:
1
2
3
4
5
6std::mutex mtx;
void thread_safe_function() {
std::lock_guard<std::mutex> lock(mtx);
// 修改STL容器的代码
}
条款15:当你在动态分配数组的时候,请使用 vector 和 string
使用 std::vector
和 std::string
代替裸指针和动态分配的数组,因为他们更易于使用和管理,避免内存泄漏和越界错误。
-
使用
vector
:1
std::vector<int> vec(10); // 动态分配一个包含10个元素的数组
条款16:使用 reserve 来避免不必要的新分配
STL容器会自动增长以便容纳下其中的数据,只要没有超出它们的限制。reserve
可以减少不必要的内存重新分配,提升性能。
vector 与 string 的增长实现过程
在 vector
和 string
中,当需要增加容量时,通常的实现方式是分配一块大小为旧内存两倍的新内存,将所有元素复制到新内存中,析构旧内存的对象并释放旧内存。这种增长方式虽然简单,但频繁的内存分配和拷贝操作会带来性能上的开销。
使用 reserve
reserve
函数可以预先分配足够的内存,避免频繁的内存重新分配,从而提升性能,避免指针、迭代器、引用失效带来的开销。因此,建议在容器刚刚构造出来时就使用 reserve
。
示例:
1 | std::vector<int> vec; |
容器的4个易混函数
只有 vector
与 string
提供所有的这4个函数:
size()
: 返回容器中当前元素的个数。capacity()
: 返回容器当前分配的内存可以容纳的元素个数。resize(Container::size_type n)
: 调整容器大小到n
个元素。如果n
小于当前元素个数,多余的元素将被析构。如果n
大于当前元素个数,将添加默认构造的新元素到容器末尾。如果n
大于当前容量,将重新分配内存。reserve(Container::size_type n)
: 预留至少n
个元素的内存容量。如果n
小于当前容量,不会改变容量;如果n
大于当前容量,将导致重新分配内存。
示例代码
1 |
|
在使用 STL 容器时,通过 reserve
预留足够的内存,可以避免频繁的内存重新分配,提高程序的性能。同时理解 size()
、capacity()
、resize()
和 reserve()
之间的区别和作用,可以更好地管理和优化容器的使用。
条款17:string 实现的多样性
string
的实现可能会有所不同,主要包括引用计数、动态分配次数等。这些实现细节对性能和内存管理有直接影响。
-
引用计数:
1
2std::string str1 = "Hello";
std::string str2 = str1; // 可能使用引用计数
条款18:了解如何把 vector 和 string 数据传给旧的API
-
传递
vector
数据:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设这是一个旧的C风格API
void old_c_api(int* arr, size_t size) {
for (size_t i = 0; i < size; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
// 确保 vector 非空,然后传递数据指针和大小
if (!vec.empty()) {
old_c_api(&vec[0], vec.size());
}
return 0;
}
条款19:使用“swap技巧”删去多余的容量
swap
可以释放不再需要的内存。
-
使用
swap
释放容量:1
2std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int>().swap(vec); // 释放多余的容量
条款20:swap 的内部机制
swap 会交换容器的内部指针,从而高效地交换两个容器的内容,而不涉及元素的逐个复制操作。使用 swap 交换容器时,不会使迭代器失效,但对于 string 容器例外。
-
使用
swap
:1
2
3std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2 = {4, 5, 6};
vec1.swap(vec2); // 交换内部指针
条款21:避免使用 vector<bool>
,用 deque<bool>
或 bitset
代替
vector<bool>
不是标准容器,不容纳 bool
,使用其他替代品。
-
使用
deque<bool>
:1
std::deque<bool> boolDeque = {true, false, true};
-
使用
bitset
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 定义权限的枚举类型
enum Permissions {
READ = 0, // 读权限
WRITE, // 写权限,默认情况下它的值是1,因为它是READ之后的下一个枚举值
DELETE, // 删除权限,默认情况下它的值是2
MODIFY, // 修改权限,默认情况下它的值是3
NUM_PERMISSIONS = 4 // 显式指定bitset的大小为4,以匹配枚举的元素数量
};
// 自定义函数,用于输出bitset的易读形式
void printPermissions(const std::bitset<NUM_PERMISSIONS>& permissions) {
std::cout << "READ: " << (permissions.test(READ) ? "Yes" : "No")
<< ", WRITE: " << (permissions.test(WRITE) ? "Yes" : "No")
<< ", DELETE: " << (permissions.test(DELETE) ? "Yes" : "No")
<< ", MODIFY: " << (permissions.test(MODIFY) ? "Yes" : "No") << std::endl;
}
int main() {
// 创建一个用户权限的bitset,默认所有权限都是false
std::bitset<NUM_PERMISSIONS> userPermissions;
// 假设我们要设置用户有读和写权限
userPermissions.set(READ); // 设置读权限
userPermissions.set(WRITE); // 设置写权限
// 使用自定义函数打印用户的所有权限状态
printPermissions(userPermissions);
// 如果需要撤销某个权限,比如删除权限
userPermissions.reset(DELETE);
// 再次打印权限状态
printPermissions(userPermissions);
return 0;
}
条款22:理解等价与相等
等价基于排序,不能用相等运算符直接比较。
- 等价:等价性允许我们在保持排序的同时,存储和检索具有相同排序关键字但其他属性不同的元素。
- 相等:两个元素
x
和y
相等,如果根据operator==
运算符,x == y
的结果为true
。这通常意味着这两个元素在所有属性上都完全相同。在标准容器中,相等性的检查主要用于精确匹配,如std::set
和std::map
中的find
操作。
条款23:使用散列容器
在C++标准库中,自C++11起,引入了基于散列的容器,这些容器提供了平均时间复杂度为O(1)的查找、插入和删除操作,极大地提高了效率。
-
非标准散列容器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main() {
std::unordered_map<int, std::string> hashmap; // 创建一个散列表
// 插入键值对
hashmap[1] = "one";
hashmap[2] = "two";
hashmap[3] = "three";
// 查找并打印值
auto it = hashmap.find(2);
if (it != hashmap.end()) {
std::cout << "Value for key 2: " << it->second << std::endl;
}
// 删除一个键值对
hashmap.erase(3);
return 0;
}
条款24:为包含指针的关联容器指定比较类型,而不是比较函数
使用比较类型模板,而不是比较函数。
-
比较类型模板:
1
2
3
4
5
6
7struct ComparePointers {
bool operator()(const int* lhs, const int* rhs) const {
return *lhs < *rhs;
}
};
std::set<int*, ComparePointers> pointerSet;
条款25:切勿直接修改 set 或 multiset 中的键
在std::set
或std::multiset
中,键是不可变的。这是因为这些容器依赖于键来维护其内部的排序和唯一性。直接修改键会导致容器的排序和唯一性约束失效,从而引发未定义行为。
-
修改
set
中的元素:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
std::set<int> s = {1, 2, 3};
// 查找元素
auto it = s.find(2);
if (it != s.end()) {
// 删除元素
s.erase(it);
// 插入修改后的元素
s.insert(4);
}
return 0;
}
条款 26:考虑用排序的vector替代关联容器
在程序中,有时用排序的vector
容器可以比使用关联容器(如std::map
或std::set
)更加高效,尤其是在数据的设置阶段和查找阶段分离的情况下。当数据结构的查找操作不与删除和添加操作混在一起时,可以考虑使用vector
。这种方法的好处包括更低的内存消耗和更快的运行速度。
具体做法
当你使用vector
来模仿map<const K, V>
时,存储在vector
中的应是pair<K, V>
而不是pair<const K, V>
。这需要自己写三个自定义比较函数(用于排序和查找的比较函数)。
示例代码如下:
1 |
|
条款 27:更新一个已有的映射表元素
如果要更新一个已有的映射表元素,应该选择operator[]
,而如果是添加元素,那么最好选择insert
。operator[]
操作符可以直接访问和修改元素,而insert
则适用于添加新元素。
条款 28:使用迭代器
在选择迭代器时,应该优先使用iterator
而不是const_iterator
,除非需要明确地保护数据不被修改。
条款 29:将const_iterator转换为iterator
使用distance
和advance
函数可以将容器的const_iterator
转换为iterator
:
1 |
|
条款 30:理解reverse_iterator的base()成员函数
- 对于插入操作,
ri
和ri.base()
是等价的。 - 对于删除操作,
ri
和ri.base()
不是等价的,需谨慎使用。例如:
1 | std::vector<int> v = {1, 2, 3, 4, 5}; |
条款 31:使用istreambuf_iterator进行逐个字符的输入
如果需要逐个字符的输入,可以使用istreambuf_iterator
:
1 |
|
条款 32:确保算法所需的目标空间足够大
使用算法时,要确保目标区间足够大或能够在运行过程中自动增大。可以使用插入型迭代器如back_inserter
、front_inserter
和ostream_iterator
。
条款 33:了解各种与排序相关的选择
在选择排序算法时,应根据具体的需求和功能来决定使用合适的算法。以下是针对不同需求的建议:
1. 完全排序:如果需要对一个容器(如vector、string、deque或数组)中的所有元素进行完全排序,可以使用 sort 或 stable_sort。其中 stable_sort 保留相等元素的相对顺序。
2. 部分排序:如果只需要对容器中的前n个最小(或最大)的元素进行排序,可以使用 partial_sort。
3. 查找特定位置的元素:如果需要找到容器中第n个位置的元素,或者找到等价性最前面的n个元素,而不需要对整个容器进行排序,则可以使用 nth_element。
4. 按特定条件分区:如果需要将容器中的元素按照是否满足某个特定条件区分开,可以使用 partition 或 stable_partition。这些算法可以将满足条件和不满足条件的元素分割到两个区间内。
5. 对于list的特殊处理:对于数据存储在list中的情况,可以直接使用内置的 sort 和 stable_sort 算法,而无需使用 partition 或 nth_element。
6. 排序算法选择的原则:在选择排序算法时,应该基于具体的功能需求而不是性能考虑。每种算法都有其适用的场景和优劣点,因此应根据问题的需求选择最合适的算法。
条款 34:使用remove
后应调用erase
remove
并不会删除容器中的元素,而是将要删除的元素移到末尾,并返回新末尾的迭代器。因此,需要配合erase
使用:
1 | std::vector<int> v = {1, 2, 3, 4, 5}; |
条款 35:对包含指针的容器使用remove时要小心
由于remove
操作会导致内存泄漏,应该在使用remove-erase
前手动删除指针或使用智能指针。
条款 36:了解算法要求排序区间的参数
例如,copy_if
可以用于复制符合条件的元素:
1 | template<typename InputIterator, typename OutputIterator, typename Predicate> |
条款 37:使用accumulate或for_each进行区间统计
在STL中,accumulate 和 for_each 是用于对容器中元素进行统计或处理的两个重要算法。它们能够帮助简化代码、提高可读性,并且减少编程错误的可能性。
• accumulate 适合用于需要对容器元素进行聚合操作(如求和、求积)的场景。
• for_each 适合用于需要对容器中每个元素执行特定操作的场景,可以是打印、更新等操作。
1 |
|
条款 38:遵循按值传递的原则来设计函数子类
在STL中,函数对象(或称为函数子类)的设计需要遵循按值传递的原则,这样能够与STL中的算法习惯保持一致。以下是如何设计函数子类以符合这一原则的示例代码:
设计原则解析
-
分离数据和虚函数:
- 函数子类
Bs
中只包含数据成员Weight w; int x;
和虚函数virtual void operator()(const T &val) const;
- 数据成员 Weight w; int x; 被分离出来,不包含在
B<T>
类中。
- 函数子类
-
继承和友元类:
Bs<T>
继承自std::unary_function<T, void
>,表明Bs<T>
是一个一元函数对象。B<T>
类友元声明friend class B<T>;
,允许B<T>
类访问Bs<T>
类的私有成员。
-
操作符重载:
Bs<T>
类重载了operator()
,使得Bs<T>
类的实例可以像函数一样调用。B<T>
类也重载了 operator(),在其内部调用了Bs<T>
类实例的 operator(),实现了函数调用的转发。
条款 41:确保判别式是“纯函数”
判别式函数对象应该是纯函数,所谓“纯函数”,指的是函数的输出只依赖于其输入参数,不依赖于任何外部状态,并且函数执行不产生任何可观察的副作用。因为它可能会被多次拷贝和使用。
条款 42:使你的函数子类可配接
为了使函数对象与STL的标准函数配接器(如not1、not2、bind1st、bind2nd)兼容,函数对象应继承std::unary_function或std::binary_function。这些基类定义了函数对象的参数类型和返回类型,从而使配接器能够正确推导类型信息。
1 |
|
条款 43:理解ptr_fun
、mem_fun
和mem_fun_ref
这些函数适配器用于将成员函数和普通函数转换为函数对象,以便它们可以与STL算法一起使用。
-
std::ptr_fun:将普通函数转换为函数对象。
-
std::mem_fun:将成员函数指针转换为函数对象(适用于通过对象指针调用成员函数)。
-
std::mem_fun_ref:将成员函数指针转换为函数对象(适用于通过对象引用调用成员函数)。
1 |
|
条款 44:确保less<T>
与operator<
的语义相同
通常less<T>
通过operator<
实现。如果需要不同的比较,建议自定义一个比较类而不是特化less
。
1 |
|
条款 45:算法的调用优先于手写的循环
使用STL算法通常比手写循环更高效、更易读、且不易出错。例如,使用 std::for_each
而不是手写循环。
1 |
|
条款 46:容器的成员函数优先于同名函数
容器的成员函数往往速度更快且与容器结合更紧密,应优先使用。例如,使用vector
的push_back
而不是insert
。
1 |
|
条款 47:正确区分关键字
例如,count
、find
、binary_search
、lower_bound
、upper_bound
和equal_range
的使用需正确区分。
条款 48:使用函数对象作为STL算法的参数
STL算法可以接受函数对象作为参数,以便自定义算法的行为。函数对象可以是重载了operator()的类或结构体。
1 |
|
条款 49:避免产生“直写行”的代码
避免复杂、难以维护的“一行代码”,应注重代码可读性。
1 | // 差的例子 |
条款 50:包含正确的头文件
使用STL组件时,确保包含正确的头文件。
- STL容器
- 大多数STL容器都在与其同名的头文件中声明。
- 例如:
#include <vector>
,#include <set>
。
- STL算法
- 大多数STL算法声明在
<algorithm>
头文件中。
- 大多数STL算法声明在
- 特殊迭代器
- 特殊迭代器声明在
<iterator>
头文件中。
- 特殊迭代器声明在
- 函数对象和函数配接器
- 函数对象(如
std::less
)和函数配接器(如std::not1
,std::bind2nd
)声明在<functional>
头文件中。 - 例如:
#include <functional>
。
- 函数对象(如
条款 51:学会分析STL相关的编译器诊断信息
编译器生成的错误信息有助于诊断和修复代码中的问题。熟悉这些信息有助于更快地找到并解决问题。
条款 52:熟悉STL相关的web站点
-
SGI STL:原始STL的实现和文档。
-
STLport:一个STL的开源实现,提供更多功能和优化。
-
Boost:一个广泛使用的C++库集合,其中包含许多STL的扩展和增强。