Effective STL

Channing Hsu

《Effective STL》 条款01至10的要点可以总结如下:

条款01:慎重选择容器类型

  1. 任意位置插入元素:选择序列容器(vector, string, deque, list)。
  2. 不关心元素排序:使用哈希容器,只需要快速查找,但不关心顺序。
  3. 选择标准容器:标准容器有更好的支持和兼容性,尽量使用标准容器。排除哈希容器、slist(单链表)和rope(“rope”型的string)。
  4. 随机访问迭代器:使用vector, deque, string, rope;避免使用list和哈希容器。
  5. 避免元素移动:频繁插入和删除元素时使用list。避免使用连续内存的容器。
  6. 兼容C:需要与C代码兼容时,选择vector
  7. 查找速度关键:查找速度优先时,使用unordered_set。、排序的vector、标准关联容器(按速度顺序)。
  8. 介意引用计数:避免stringrope
  9. 回滚能力:选择基于节点的容器,如list;或使用连续内存容器以获得事务语义。
  10. 迭代器有效性:基于节点的容器不会使迭代器、指针、引用失效。
  11. string上的swap:会使迭代器、指针、引用失效。
  12. 随机访问迭代器且无删除操作:指向数据的引用和指针不会失效,如deque

假设我们需要在容器任意位置插入元素并且要求随机访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <vector>
#include <deque>

int main() {
// 使用vector
std::vector<int> vec;
vec.push_back(1);
vec.insert(vec.begin(), 0); // 在开头插入元素

// 使用deque
std::deque<int> deq;
deq.push_back(1);
deq.push_front(0); // 在开头插入元素

return 0;
}

条款02:不要试图编写独立于容器类型的代码

不要编写对序列容器和关联容器都适用的代码。因为它们的接口和行为不同,尝试编写通用代码可能会导致错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <vector>
#include <set>

template <typename Container>
void add_elements(Container& c) {
// 这样写可能会出错,因为关联容器没有insert的这个重载
c.insert(c.end(), 10);
}

int main() {
std::vector<int> vec;
std::set<int> s;
add_elements(vec);
add_elements(s); // 会出错
return 0;
}

条款03:确保容器中的对象拷贝正确而高效

容器存储的是对象的拷贝,确保拷贝构造函数和赋值操作符的正确性和高效性。如果存储基类指针,应使用智能指针来避免内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <vector>
#include <memory>

class Base {
public:
virtual ~Base() {}
};

class Derived : public Base {};

int main() {
std::vector<std::shared_ptr<Base>> v;
// 使用智能指针存储派生类对象。
v.push_back(std::make_shared<Derived>());
return 0;
}

条款04:调用empty而不是检查size()是否为0

empty()对所有标准容器都是常数时间操作,而size()对于list是线性时间。

1
2
3
4
5
6
7
8
9
#include <list>

int main() {
std::list<int> lst;
if (lst.empty()) {
// 空的
}
return 0;
}

条款05:区间成员函数优先于单元素成员函数

使用区间成员函数(如insert, erase)效率高、易于理解,能更好地表达意图。区间创建、删除、赋值、插入可以用到区间成员函数。

1
2
3
4
5
6
7
8
9
#include <vector>
#include <list>

int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
std::list<int> lst;
lst.insert(lst.end(), vec.begin(), vec.end()); // 区间插入
return 0;
}

条款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. 使用括号来调用函数
1
std::unique_ptr<int> ptr = Foo::create(); // 显式调用函数

在这里,通过添加括号(),我们明确地告诉编译器我们要调用Foo::create函数,并将返回的对象赋值给ptr

  1. 使用auto关键字(在C++11及更高版本中推荐):
1
auto ptr = Foo::create(); // 使用auto推导类型

在这个例子中,auto关键字允许编译器自动推导ptr的类型为std::unique_ptr<int>。由于Foo::create()的返回值类型明确,因此这里不会发生“最糟糕解析”问题。

在现代C++中,虽然编译器通常能够正确地解析大多数表达式,但在某些情况下,特别是当涉及到函数指针、模板或复杂的表达式时,仍然需要小心避免“最糟糕解析”问题。通过使用括号来明确函数调用、显式声明变量类型或使用auto关键字来推导类型,我们可以提高代码的可读性和正确性,并减少误解析的可能性。

条款07:容器包含指针

在容器对象调用析构函数之前,确保指针已被删除。使用智能指针来管理动态分配对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <vector>
#include <memory>

class Weight {};

int main() {
typedef std::shared_ptr<Weight> SPW;
std::vector<SPW> vwp;
for (int i = 0; i < 10; ++i) {
vwp.push_back(SPW(new Weight));
}
return 0;
}

条款08:切勿创建包含auto_ptr的容器

auto_ptr的拷贝或复制会使源对象失去资源所有权,不适合STL容器要求的拷贝可赋值属性。

std::auto_ptr在C++11及之后的标准中被弃用,因为它不支持数组、与STL容器不兼容(由于所有权转移问题)且不符合移动语义。

条款09:auto_ptr的所有权转移

复制auto_ptr会转移其资源所有权,原对象被置为NULL

条款10:慎重选择删除元素的方法

  1. 删除特定值的对象

    • 对于vector, string, deque,使用erase-remove
    • 对于list,使用list::remove
    • 对于关联容器,使用其erase成员函数。
    1
    2
    3
    4
    5
    6
    7
    8
    #include <vector>
    #include <algorithm>

    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;
    }
  2. 在循环内部操作

    • 标准序列容器:写循环遍历元素,使用erase返回值更新迭代器。
    • 关联容器:写循环遍历元素,传给erase时对迭代器进行后缀递增。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <vector>
    #include <iostream>

    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;
    }
  3. 删除满足判别式的对象

    • 对于vector, string, deque,使用erase-remove_if
    • 对于list,使用list::remove_if
    • 对于关联容器,使用remove_copy_ifswap或编写循环遍历元素。

让我们详细讲解《Effective STL》中提到的条款11到25,并提供例子来帮助更好地理解和应用这些条款。

条款11:了解分配子的约定与概念

分配子(allocator)是STL内存管理的重要工具。分配子负责分配和释放内存,并提供指针和引用类型定义。

  1. 分配子提供类型定义allocator<T>::pointerallocator<T>::reference

    • 例子:
    1
    2
    3
    4
    5
    6
    template <typename T>
    struct MyAllocator {
    using pointer = T*;
    using reference = T&;
    // 其他实现...
    };
  2. 库实现者可以忽略类型定义,直接使用指针和引用

    • 例子:
    1
    2
    3
    std::vector<int> vec;
    int* ptr = &vec[0]; // 假设 allocator<int>::pointer 是 int*
    int& ref = vec[0]; // 假设 allocator<int>::reference 是 int&
  3. STL实现者可以假定同类型的所有分配子是等价的

    • 例子:
    1
    2
    3
    // 分配子类型等价,可以互换使用
    std::allocator<int> alloc1;
    std::allocator<int> alloc2 = alloc1;
  4. 大多数标准容器从未单独使用过对应的分配子。例如,list 需要为 listNode 分配内存。

    • 例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template <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:编写自定义分配子需要什么

编写自定义分配子时需要注意以下几点:

  1. 定义模板类型allocator::pointerallocator::reference

    • 例子:
    1
    2
    3
    4
    5
    6
    template <typename T>
    struct MyAllocator {
    using pointer = T*;
    using reference = T&;
    // 其他实现...
    };
  2. 分配子不应该有非静态对象

    • 例子:
    1
    2
    3
    4
    5
    template <typename T>
    struct MyAllocator {
    static int counter; // 非静态对象会导致问题
    // 其他实现...
    };
  3. 提供 rebind 模板

    • 例子:
    1
    2
    3
    4
    5
    6
    7
    8
    template <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
    6
    std::mutex mtx;

    void thread_safe_function() {
    std::lock_guard<std::mutex> lock(mtx);
    // 修改STL容器的代码
    }

条款15:当你在动态分配数组的时候,请使用 vector 和 string

使用 std::vectorstd::string 代替裸指针和动态分配的数组,因为他们更易于使用和管理,避免内存泄漏和越界错误。

  • 使用 vector

    1
    std::vector<int> vec(10); // 动态分配一个包含10个元素的数组

条款16:使用 reserve 来避免不必要的新分配

STL容器会自动增长以便容纳下其中的数据,只要没有超出它们的限制。reserve 可以减少不必要的内存重新分配,提升性能。

vector 与 string 的增长实现过程

vectorstring 中,当需要增加容量时,通常的实现方式是分配一块大小为旧内存两倍的新内存,将所有元素复制到新内存中,析构旧内存的对象并释放旧内存。这种增长方式虽然简单,但频繁的内存分配和拷贝操作会带来性能上的开销。

使用 reserve

reserve 函数可以预先分配足够的内存,避免频繁的内存重新分配,从而提升性能,避免指针、迭代器、引用失效带来的开销。因此,建议在容器刚刚构造出来时就使用 reserve

示例:

1
2
std::vector<int> vec;
vec.reserve(100); // 预留空间,避免多次分配

容器的4个易混函数

只有 vectorstring 提供所有的这4个函数:

  1. size(): 返回容器中当前元素的个数。
  2. capacity(): 返回容器当前分配的内存可以容纳的元素个数。
  3. resize(Container::size_type n): 调整容器大小到 n 个元素。如果 n 小于当前元素个数,多余的元素将被析构。如果 n 大于当前元素个数,将添加默认构造的新元素到容器末尾。如果 n 大于当前容量,将重新分配内存。
  4. reserve(Container::size_type n): 预留至少 n 个元素的内存容量。如果 n 小于当前容量,不会改变容量;如果 n 大于当前容量,将导致重新分配内存。

示例代码

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
#include <iostream>
#include <vector>

int main() {
std::vector<int> vec;
vec.reserve(100); // 预留空间,避免多次分配

std::cout << "Initial size: " << vec.size() << std::endl;
std::cout << "Initial capacity: " << vec.capacity() << std::endl;

for (int i = 0; i < 50; ++i) {
vec.push_back(i);
}

std::cout << "Size after adding 50 elements: " << vec.size() << std::endl;
std::cout << "Capacity after adding 50 elements: " << vec.capacity() << std::endl;

vec.resize(30); // 调整大小到30个元素
std::cout << "Size after resize to 30: " << vec.size() << std::endl;
std::cout << "Capacity after resize to 30: " << vec.capacity() << std::endl;

vec.reserve(200); // 再次预留空间
std::cout << "Size after reserving 200: " << vec.size() << std::endl;
std::cout << "Capacity after reserving 200: " << vec.capacity() << std::endl;

return 0;
}

在使用 STL 容器时,通过 reserve 预留足够的内存,可以避免频繁的内存重新分配,提高程序的性能。同时理解 size()capacity()resize()reserve() 之间的区别和作用,可以更好地管理和优化容器的使用。

条款17:string 实现的多样性

string 的实现可能会有所不同,主要包括引用计数、动态分配次数等。这些实现细节对性能和内存管理有直接影响。

  • 引用计数

    1
    2
    std::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
    #include <vector>
    #include <iostream>

    // 假设这是一个旧的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
    2
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::vector<int>().swap(vec); // 释放多余的容量

条款20:swap 的内部机制

swap 会交换容器的内部指针,从而高效地交换两个容器的内容,而不涉及元素的逐个复制操作。使用 swap 交换容器时,不会使迭代器失效,但对于 string 容器例外。

  • 使用 swap

    1
    2
    3
    std::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
    #include <iostream>
    #include <bitset>

    // 定义权限的枚举类型
    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:理解等价与相等

等价基于排序,不能用相等运算符直接比较。

  • 等价:等价性允许我们在保持排序的同时,存储和检索具有相同排序关键字但其他属性不同的元素。
  • 相等:两个元素xy相等,如果根据operator==运算符,x == y的结果为true。这通常意味着这两个元素在所有属性上都完全相同。在标准容器中,相等性的检查主要用于精确匹配,如std::setstd::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
    #include <iostream>
    #include <unordered_map>

    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
    7
    struct ComparePointers {
    bool operator()(const int* lhs, const int* rhs) const {
    return *lhs < *rhs;
    }
    };

    std::set<int*, ComparePointers> pointerSet;

条款25:切勿直接修改 set 或 multiset 中的键

std::setstd::multiset中,键是不可变的。这是因为这些容器依赖于键来维护其内部的排序和唯一性。直接修改键会导致容器的排序和唯一性约束失效,从而引发未定义行为。

  • 修改 set 中的元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>
    #include <set>

    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::mapstd::set)更加高效,尤其是在数据的设置阶段和查找阶段分离的情况下。当数据结构的查找操作不与删除和添加操作混在一起时,可以考虑使用vector。这种方法的好处包括更低的内存消耗和更快的运行速度。

具体做法

当你使用vector来模仿map<const K, V>时,存储在vector中的应是pair<K, V>而不是pair<const K, V>。这需要自己写三个自定义比较函数(用于排序和查找的比较函数)。

示例代码如下:

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
#include <vector>
#include <string>
#include <algorithm>
#include <iostream>

// 自定义比较函数
struct DataCompare {
bool operator()(const std::pair<std::string, int> &lhs, const std::pair<std::string, int> &rhs) const {
return lhs.first < rhs.first; // 按键排序
}
};

// 主函数
int main() {
std::vector<std::pair<std::string, int>> dataVector;

// 插入数据
dataVector.push_back({"apple", 1});
dataVector.push_back({"banana", 2});
dataVector.push_back({"cherry", 3});

// 排序数据
std::sort(dataVector.begin(), dataVector.end(), DataCompare());

// 查找数据
auto it = std::lower_bound(dataVector.begin(), dataVector.end(), std::make_pair("banana", 0), DataCompare());
if (it != dataVector.end() && it->first == "banana") {
std::cout << "Found: " << it->first << ", " << it->second << std::endl;
} else {
std::cout << "Not found." << std::endl;
}

return 0;
}

条款 27:更新一个已有的映射表元素

如果要更新一个已有的映射表元素,应该选择operator[],而如果是添加元素,那么最好选择insertoperator[]操作符可以直接访问和修改元素,而insert则适用于添加新元素。

条款 28:使用迭代器

在选择迭代器时,应该优先使用iterator而不是const_iterator,除非需要明确地保护数据不被修改。

条款 29:将const_iterator转换为iterator

使用distanceadvance函数可以将容器的const_iterator转换为iterator

1
2
3
4
5
6
7
8
9
10
11
#include <deque>
#include <iterator>

typedef std::deque<int> IntDeque;
typedef IntDeque::iterator Iter;
typedef IntDeque::const_iterator ConstIter;

IntDeque d;
ConstIter ci = d.cbegin();
Iter i(d.begin());
std::advance(i, std::distance<ConstIter>(i, ci));

条款 30:理解reverse_iterator的base()成员函数

  1. 对于插入操作,riri.base()是等价的。
  2. 对于删除操作,riri.base()不是等价的,需谨慎使用。例如:
1
2
3
std::vector<int> v = {1, 2, 3, 4, 5};
auto ri = std::find(v.rbegin(), v.rend(), 3);
v.erase((++ri).base());

条款 31:使用istreambuf_iterator进行逐个字符的输入

如果需要逐个字符的输入,可以使用istreambuf_iterator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <fstream>
#include <iterator>
#include <string>
int main() {
std::ifstream inputFile("example.txt");
// 检查文件是否成功打开
if (!inputFile.is_open()) {
std::cerr << "Failed to open file." << std::endl;
return 1;
}
// 使用 istreambuf_iterator 读取文件中的所有字符到一个 string
std::string fileData(
std::istreambuf_iterator<char>(inputFile),
std::istreambuf_iterator<char>()
);
// 关闭文件
inputFile.close();
// 输出读取的内容
std::cout << "File content: " << fileData << std::endl;
return 0;
}

条款 32:确保算法所需的目标空间足够大

使用算法时,要确保目标区间足够大或能够在运行过程中自动增大。可以使用插入型迭代器如back_inserterfront_inserterostream_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
2
std::vector<int> v = {1, 2, 3, 4, 5};
v.erase(std::remove(v.begin(), v.end(), 3), v.end());

条款 35:对包含指针的容器使用remove时要小心

由于remove操作会导致内存泄漏,应该在使用remove-erase前手动删除指针或使用智能指针。

条款 36:了解算法要求排序区间的参数

例如,copy_if可以用于复制符合条件的元素:

1
2
3
4
5
6
7
8
9
10
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;
}

条款 37:使用accumulate或for_each进行区间统计

在STL中,accumulate 和 for_each 是用于对容器中元素进行统计或处理的两个重要算法。它们能够帮助简化代码、提高可读性,并且减少编程错误的可能性。

​ • accumulate 适合用于需要对容器元素进行聚合操作(如求和、求积)的场景。

​ • for_each 适合用于需要对容器中每个元素执行特定操作的场景,可以是打印、更新等操作。

1
2
3
4
5
6
7
8
#include <numeric>
#include <vector>

std::vector<int> v = {1, 2, 3, 4, 5};
// 求和
int sum = std::accumulate(v.begin(), v.end(), 0);
// 对每个元素求平方并输出
std::for_each(v.begin(), v.end(), print_square);

条款 38:遵循按值传递的原则来设计函数子类

在STL中,函数对象(或称为函数子类)的设计需要遵循按值传递的原则,这样能够与STL中的算法习惯保持一致。以下是如何设计函数子类以符合这一原则的示例代码:

设计原则解析

  1. 分离数据和虚函数

    • 函数子类 Bs 中只包含数据成员 Weight w; int x; 和虚函数 virtual void operator()(const T &val) const;
    • 数据成员 Weight w; int x; 被分离出来,不包含在 B<T> 类中。
  2. 继承和友元类

    • Bs<T> 继承自 std::unary_function<T, void>,表明 Bs<T> 是一个一元函数对象。
    • B<T> 类友元声明 friend class B<T>;,允许 B<T> 类访问 Bs<T> 类的私有成员。
  3. 操作符重载

    • 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
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
#include <iostream>
#include <vector>
#include <algorithm>
#include <functional> // for std::unary_function, std::not1

// 一个简单的一元函数对象,判断一个整数是否为偶数
class IsEven : public std::unary_function<int, bool> {
public:
bool operator()(int x) const {
return x % 2 == 0;
}
};

int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6};

// 使用not1和IsEven来筛选出奇数
std::vector<int> odds;
std::copy_if(v.begin(), v.end(), std::back_inserter(odds), std::not1(IsEven()));

for (int n : odds) {
std::cout << n << " ";
}
std::cout << std::endl;

return 0;
}

条款 43:理解ptr_funmem_funmem_fun_ref

这些函数适配器用于将成员函数和普通函数转换为函数对象,以便它们可以与STL算法一起使用。

  • std::ptr_fun:将普通函数转换为函数对象。

  • std::mem_fun:将成员函数指针转换为函数对象(适用于通过对象指针调用成员函数)。

  • std::mem_fun_ref:将成员函数指针转换为函数对象(适用于通过对象引用调用成员函数)。

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
40
41
42
43
44
45
#include <iostream>
#include <vector>
#include <algorithm>
#include <functional> // for std::ptr_fun, std::mem_fun, std::mem_fun_ref

// 普通函数
bool is_odd(int x) {
return x % 2 != 0;
}

// 类,包含一个成员函数
class Number {
public:
explicit Number(int v) : value(v) {}
bool is_positive() const {
return value > 0;
}
private:
int value;
};

int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6};

// 使用ptr_fun将普通函数转换为函数对象
std::vector<int> odds;
std::copy_if(v.begin(), v.end(), std::back_inserter(odds), std::ptr_fun(is_odd));

for (int n : odds) {
std::cout << n << " ";
}
std::cout << std::endl;

// 使用mem_fun将成员函数转换为函数对象
std::vector<Number> numbers = {Number(1), Number(-2), Number(3)};
std::vector<Number> positives;
std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(positives), std::mem_fun_ref(&Number::is_positive));

for (const Number &n : positives) {
std::cout << n.is_positive() << " ";
}
std::cout << std::endl;

return 0;
}

条款 44:确保less<T>operator<的语义相同

通常less<T>通过operator<实现。如果需要不同的比较,建议自定义一个比较类而不是特化less

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <set>

// 在这个示例中,ReverseCompare 自定义了比较逻辑,并在 std::set 中使用,从而实现了逆序排序。
class ReverseCompare {
public:
bool operator()(int a, int b) const {
return a > b; // 逆序
}
};

int main() {
std::set<int, ReverseCompare> s = {1, 2, 3, 4, 5};

for (int n : s) {
std::cout << n << " ";
}
std::cout << std::endl;

return 0;
}

条款 45:算法的调用优先于手写的循环

使用STL算法通常比手写循环更高效、更易读、且不易出错。例如,使用 std::for_each 而不是手写循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <vector>
#include <algorithm>

// 简单的打印函数对象
struct Print {
void operator()(int x) const {
std::cout << x << " ";
}
};

int main() {
std::vector<int> v = {1, 2, 3, 4, 5};

// 使用for_each
std::for_each(v.begin(), v.end(), Print());

std::cout << std::endl;

return 0;
}

条款 46:容器的成员函数优先于同名函数

容器的成员函数往往速度更快且与容器结合更紧密,应优先使用。例如,使用vectorpush_back而不是insert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <vector>
#include <list>

int main() {
std::vector<int> v;

// 使用vector的成员函数push_back
v.push_back(1);
v.push_back(2);

// 使用list的成员函数push_back
std::list<int> l;
l.push_back(1);
l.push_back(2);

return 0;
}

条款 47:正确区分关键字

例如,countfindbinary_searchlower_boundupper_boundequal_range的使用需正确区分。

条款 48:使用函数对象作为STL算法的参数

STL算法可以接受函数对象作为参数,以便自定义算法的行为。函数对象可以是重载了operator()的类或结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <vector>
#include <algorithm>

struct Print {
void operator()(int x) const {
std::cout << x << " ";
}
};

int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
std::for_each(v.begin(), v.end(), Print());
std::cout << std::endl;
return 0;
}

条款 49:避免产生“直写行”的代码

避免复杂、难以维护的“一行代码”,应注重代码可读性。

1
2
3
4
5
6
7
// 差的例子
int result = (a + b) * (c - d) / e;

// 好的例子
int sum = a + b;
int diff = c - d;
int result = sum * diff / e;

条款 50:包含正确的头文件

使用STL组件时,确保包含正确的头文件。

  1. STL容器
    • 大多数STL容器都在与其同名的头文件中声明。
    • 例如:#include <vector>#include <set>
  2. STL算法
    • 大多数STL算法声明在 <algorithm> 头文件中。
  3. 特殊迭代器
    • 特殊迭代器声明在 <iterator> 头文件中。
  4. 函数对象和函数配接器
    • 函数对象(如 std::less)和函数配接器(如 std::not1std::bind2nd)声明在 <functional> 头文件中。
    • 例如:#include <functional>

条款 51:学会分析STL相关的编译器诊断信息

编译器生成的错误信息有助于诊断和修复代码中的问题。熟悉这些信息有助于更快地找到并解决问题。

条款 52:熟悉STL相关的web站点

  • SGI STL:原始STL的实现和文档。

  • STLport:一个STL的开源实现,提供更多功能和优化。

  • Boost:一个广泛使用的C++库集合,其中包含许多STL的扩展和增强。

评论
目录
Effective STL