第七章 · 标准库容器、算法与 ranges

实战主题 :容器怎么选、算法怎么用、ranges 管道怎么写、STL 的复杂度契约如何指导选择。衔接 STL 讲义------机制已懂,这一章练的是判断

本章定位 :第六七章是「标准库把泛型编程做成成品」的两连章。第六章解决「接口约束」这一头;本章解决另一头------现成的容器与算法怎么选、怎么组合 。Ranges 把「算法」从单个函数升级成「可组合的管道」,是 C++20 之后组织数据变换的新语法。

前置 :第一~六章。第四章的移动语义与第六章的模板是理解 ranges 视图与 emplace 机制的地基;第三章的 RAII 是理解「容器存值 vs 存指针」与 emplace 资源陷阱的前置。

编译约定g++ -std=c++23 -Wall -Wextra(测量用例另加 -O2 注明),所有代码均已实测(g++ 15.2, Ubuntu)。


0. 这一章回答什么问题

到这一章为止,你已经会写强类型接口、会管资源、会控制拷贝和移动、也会用 concepts 约束模板。不过说实话,这些本事都还停在「造轮子」的阶段------真正让你省力的是标准库:容器替你管内存和布局,算法替你写逻辑,ranges 替你组织数据流。所以这一章,我们就把下面这几件事一件件讲清楚:

  1. 容器怎么选? 标准库里十几二十个容器,每个都有自己的复杂度契约。其实多数时候你根本不用选------默认就是 std::vector;真到了要选的时候,凭什么选?
  2. 该用算法还是自己写循环? STL 里有上百个算法。什么时候你该提醒自己一句------「一上来就手写循环,多半是没把 STL 算法认全」?
  3. ranges 管道到底是个啥?filter | transform 这样一段段接起来,它跟「先循环算完、再把中间结果存起来」本质上差在哪(就是惰性)?又怎么把视图变回真正的容器?
  4. emplace 一定比 push 快吗? Item 42 说它「理论上更快、实践里未必」------这句话其实是在教你养成哪种判断习惯?

Grimm 自己那本书,在标准库这一章开头就很实在地承认:这部分写得偏薄,判断得靠读者自己补(一手原文,Grimm Ch16):

Despite the standard library's crucial importance, this section is not exhaustive. Many rules are missing, the mentioned rules are often quite concise, other rules are already the topic of other parts of the C++ Core Guidelines. Consequently, I complement those rules with additional information when necessary.

翻成大白话就是:Core Guidelines 对 STL 只给了几条大方向的规则(SL.con.1/SL.con.2/SL.1),具体到「这个容器什么时候该换掉、emplace 还是 insert」,得靠你自己拿主意。这也正是本章和 STL 讲义的分工------讲义把机制讲透(容器内部怎么实现、算法怎么跑),本章专门练判断(什么时候用哪个、为什么)。

三本书的分工:

来源 提供的层 这一章回答的问题
Grimm《C++ Core Guidelines Explained》 规范 SL.con.1/SL.con.2(默认 vector)、SL.1(标准库优先)、Ch16 自我定位、「标准库上百个算法」与 Sean Parent 名言
Bancila《Modern C++ Programming Cookbook》 解法 Ch5「Using vector as a default container」「Selecting the right standard containers」、Ch12 ranges 适配器与 std::ranges::to
Meyers《Effective Modern C++》 边界 Item 42(emplace vs insert:理论 vs 实践、测量优先、资源管理陷阱)

整章的主线就一句话:标准库干的事,是把「你自己动手写的一切」变成「声明你要什么」------默认 vector 加上算法、ranges 管道这三件套打底,剩下拿不准的差异,交给复杂度契约和实测去裁决。


1. 容器怎么选:默认 vector,其余按判据换(SL.con.2 + Bancila Ch5)

1.1 vector 是默认值(Grimm SL.con.2 一手原文)

Core Guidelines 用一条规则就把选择的成本压到了最低------多数时候你压根不用选

SL.con.2 Prefer using STL vector by default unless you have a reason to use a different container.

Grimm 展开得很直白:

If you want to add elements to your container or remove elements from your container at run time, use a std::vector; if not, use a std::array.

他接着给了 std::array/std::vector 共有的三个优势(一手原文,Grimm Ch16)------你注意看,这三条全是内存布局带来的:

  1. The fastest general-purpose access (random access, including being CPU vectorization friendly)
  2. The fastest default access pattern (begin-to-end or end-to-begin is CPU cache prefetcher friendly)
  3. The lowest space overhead (contiguous layout has zero per-element overhead, which is CPU cache friendly)

连续内存(contiguous)就是 vector 的全部秘密:随机访问 O(1)、顺序访问能命中 cache line 预取、零 per-element 开销。你在工程里碰到的绝大多数「怎么比我手写的链表还快」,答案都落在这三条上,而不是「vector 用了什么黑魔法」。

1.2 什么时候它不是默认:SL.con.1 与复杂度契约

SL.con.1 Prefer using STL array or vector instead of a C-array

大小在编译期就知道 → 用 std::array(前面 Grimm 展开 SL.con.2 时已经说过了)。你可以把 std::array 理解成「把一段定长的缓冲摆在栈上」,而 std::vector 是把元素放到堆上。除此之外,凡是「C 数组能做到的」,STL 容器不但做得一样好,还额外带着边界信息------这就是该把 C 数组换掉的理由。

那什么时候才该换掉 vector?Bancila 给了一份决策清单,一条一条都是判据(见 §1.3),而每条判据背后站着的都是复杂度契约:vector 在尾部 push/pop 是摊销 O(1)、但中间插删是 O(n);list/deque/forward_list 则是在别的场景里更便宜。 每种容器的内部机制,STL 讲义已经讲透了,这里要练的判断,说白了就是把手上的「复杂度契约」翻译成一句「我的访问和插删模式到底是什么」。

1.3 决策清单(Bancila Ch5 一手原文)

Bancila 在「Selecting the right standard containers」的开篇是这么说的(一手原文):

Selecting the right container for a given task is not always straightforward. This recipe will provide guidelines to help you decide which one to use for what purpose.

逐条判据(一手原文节选):

  • Use std::vector as the default container, when no other specific requirements exist.
  • Use std::array when the length of a sequence is fixed and known at compile time.
  • Use std::deque if you frequently need to add or remove elements at the beginning and the end of a sequence.
  • Use std::list if you frequently need to add or remove elements in the middle of the sequence (that's anywhere else other than the beginning and end) and bidirectional traversing of the sequence is required.
  • Use std::forward_list if you frequently need to add or remove elements anywhere in the sequence but you only need to traverse the sequence in one direction.
  • Use std::unordered_map if you need to store key-value pairs and the order of the elements is not important but keys must be unique.
  • Use std::map if you need to store key-value pairs with unique keys but the order of the elements is given by their keys.
  • Use std::set/std::unordered_set(...)multiset/multimap(...)根据「元素唯一与否 + 是否按序」二轴组合。

把这些抽成一张判据表(本质就三个问题:要不要有序?键怎么组织?插删和访问的热点落在哪?):

你的需求 容器
没有特别需求 std::vector(默认)
编译期定长 std::array
头尾都要频繁增删 std::deque
中间频繁增删 + 双向遍历 std::list
单向遍历就够的链表 std::forward_list
键值对、键唯一、不要求按序 std::unordered_map
键值对、键唯一、按键排序 std::map
唯一值、不要求顺序 std::unordered_set
唯一值、要排序 std::set
允许重复的 set/map multiset/multimap(或 unordered 版本)
LIFO / FIFO / 按优先级出队 stack / queue / priority_queue(适配器)

你留意一下最后那组的名字,名字本身就在说判据:unordered_ 前缀的 = 放弃顺序、换来 O(1);不带前缀的(红黑树)= 保住有序、代价是 O(log n)。这些选项之间真正的分水岭,就是这两条复杂度契约,而不是「我用哪个比较顺手」。

1.4 容器里存值,还是存指针?(衔接第三章 R.20 附近)

有一条贯穿 Core Guidelines 资源章的原则,能自动回答「容器里到底存值还是存指针」:默认存值 (值语义,第四章)。那什么时候才不得不存指针?------当你要放的是运行时多态 的异构对象(比如 vector<unique_ptr<Shape>>),或者这个对象既不可拷贝也不可移动。这种时候 Rules 说,用智能指针来表达所有权:

R.20 Use unique_ptr or shared_ptr to represent ownership

R.21 Prefer unique_ptr over shared_ptr unless you need to share ownership

落到写法上就是:容器里存 unique_ptr<Base>,而不是裸指针 vector<Base*> 。裸指针容器的问题在于,「这些对象究竟归谁」没有任何地方记着------第三章 RAII 那一整套教训,到容器这一层会原封不动地再来一遍。如果对象很贵、拷贝代价高、但你又确实需要值语义,那就优先「把它移动进容器」(push_back(std::move(x)) / emplace_back(...)),别一遇到贵就退回去用指针。

1.5 小结:选容器的动作顺序

  1. 先问自己:能用默认吗? 能就上 vector,别再往下想了(这就是 SL.con.2 的意图)。
  2. 编译期就定长?→ std::array(顺手把所有 C 数组也一起换掉,SL.con.1)。
  3. 出现了特别的模式(头尾/中间增删、按键查、唯一性、顺序要求)?→ 去查 §1.3 那张判据表。
  4. 被迫要存指针?→ 用 unique_ptr/shared_ptr,别用裸指针(R.20/R.21)。

2. 用算法,不手写循环(SL.1 / ES.1)

2.1 ES.1 一手原文

「优先用标准库」这条,是 Core Guidelines 里唯一一个同时在表达式章(ES)和标准库章(SL)重复出现的总纲级规则(Grimm 就在 ES.1 的正文里现场演示)------先看规则本身:

ES.1 Prefer the standard library to other libraries and to "handcrafted code"

Grimm 举的第一个例子,就是「给一个 vector 求和」:

There is no reason to write a raw loop to sum up a vector of doubles:

cpp 复制代码
int max = v.size();
double sum = 0.0;
for (int i = 0; i < max; ++i) sum += v[i];

Instead, use the std::accumulate algorithm ... This clearly communicates your intent and makes the code more readable.

cpp 复制代码
auto sum = std::accumulate(std::begin(v), std::end(v), 0.0);

再看第二个任务「求积」------你发现没有,循环压根不用换,只把传进算法的实参换掉就行(可以传 lambda,甚至直接用标准库自带的函数对象):

cpp 复制代码
auto pro = std::accumulate(std::begin(v), std::end(v), 1.0,
                           [](double fir, double sec){ return fir * sec; });
auto pro2 = std::accumulate(std::begin(v), std::end(v), 1.0, std::multiplies<>());

「把循环体抽象成实参」,正是算法比裸循环高明的地方:你的意图从「怎么走(i 从 0 到 max)」变成了「做什么(累加还是累乘)」,读代码的人不用再一行行去推。

2.2 Sean Parent 名言与「上百个算法」

Grimm 在讲这条规则时,引用了一段流传特别广的话(一手原文,Grimm Ch8):

If you want to improve the code quality in your organization, replace all your coding guidelines with one goal: Prefer an algorithm to a raw loop.

紧接着,是那个更扎心的观察:

Or to say it more directly: If you write a raw loop, you probably don't know the algorithms of the STL well enough. The STL has more than 100 algorithms.

把这句话变成你的日常反射:每回动手写循环之前,先问一句自己------这件事 STL 有没有现成的算法能干?find/count/accumulate/transform/copy/remove/sort/lower_bound......上百个算法里几乎总能命中一个。)真的一个都命中不了,再让 ranges 的组合能力兜底(§4)。

说得再具体点:你手头要是正写着一个三十来行、又是判断又是累加的 for 循环,先别急着往下敲------很可能一句 accumulate,或者一条 filter | transform 管道就够了。你花在循环边界、初始值、越界上的那点心思,标准库算法都已经替你处理过、测过了。

实测对照(c7_1_algorithms.cpp,raw loop 与 accumulate 三连,结果一致):

cpp 复制代码
// ES.1:同一件事,raw loop 与 std::accumulate
std::vector<double> v{1.5, 2.5, 3.0};
int max = static_cast<int>(v.size());
double sum0 = 0.0;
for (int i = 0; i < max; ++i) sum0 += v[i];     // raw loop
auto sum1 = std::accumulate(v.begin(), v.end(), 0.0);
auto sum2 = std::accumulate(v.begin(), v.end(), 1.0,
                            [](double a, double b) { return a * b; });
text 复制代码
raw loop  sum = 7
accumulate sum = 7  product = 11.25

3. 按值删除的三代写法:erase-remove → std::erase → ranges(衔接 STL 单元04)

按值删除算是 STL 里最经典、也最反直觉的一处用法:vector 居然没有 remove(值) 这样的成员函数 ------因为「删除」意味着把挖出的洞填上、让后面的元素往前挪,这一步必须配合 erase 才干得完。所以老写法得拿两个算法拼起来:

  1. std::remove 把目标值全部挪到逻辑尾部(元素并未销毁,只是被覆盖/移动),返回新逻辑尾迭代器;
  2. 再用成员 erase 把「新逻辑尾 → 原 end」这一段的元素真正析构、缩短容器。

这就是所谓的 erase-remove 惯用法 。到了 C++20,标准库把它封成了一个自由函数 std::erase(c, value) / std::erase_if(c, pred)(vector/deque/list/string 这些都提供了),一步到位、不容易写错;ranges 则换个思路,让你用「把要留下的挑出来」这种正向思维,去表达同一个删除。

实测四种写法(c7_1_algorithms.cpp,删 vector{1,2,3,2,4,2,5} 里的 2,结果一致):

cpp 复制代码
// ① 旧时代经典:两个算法拼出一个语义
auto to_remove = std::remove(nums.begin(), nums.end(), 2);
nums.erase(to_remove, nums.end());

// ② C++20 自由函数:一步到位
std::vector<int> nums2{1, 2, 3, 2, 4, 2, 5};
std::erase(nums2, 2);

// ③ 谓词删除(不删固定值,删"满足条件")
std::vector<int> nums3{1, 2, 3, 2, 4, 2, 5};
std::erase_if(nums3, [](int x) { return x == 2; });
text 复制代码
erase-remove: 1 3 4 5
std::erase:   1 3 4 5
erase_if:      1 3 4 5

三种写法语义完全等价、输出也一模一样。判断上你就记三句话:

  • erase-remove :得先懂「remove 只是把元素搬走、erase 才真正析构」这个机制,你才想得起来把 erase 拼上去------最容易漏的一步,也最该被取代;
  • std::erase/erase_if:这是「标准库替你把机制收好了」的成品,优先用它;
  • 要按条件删 就用 erase_if;「挑出留下的那个子集」则交给 ranges 管道(§4)。

4. ranges 管道:算法从函数到组合(Bancila Ch12)

4.1 视图(view)的本质:惰性、不拥有、引用底层

C++20 的 ranges 把「算法」往上提了一格:std::views::filter 并不马上筛完、返回一个新容器,而是返回一个视图 ------一个自己不拥有数据、只是「看」着底层序列的描述。Bancila 一句话就把它点破了:

Ranges are lazy, which means they are evaluated, and they produce results only when we iterate over them.

惰性意味着两件大事,都挺关键:

  1. 零中间容器filter | transform 全程不产生中间 vector,只在最终迭代时才逐个元素流过管道;
  2. 视图引用的是源头:底层容器一变,视图下一次迭代看到的就是新数据(见 §4.2 实测里「push 之后视图立刻看到新元素」)。

代价也是从这儿来的:视图有悬挂风险 ------容器一旦被销毁或重分配(迭代器失效),这个视图就不能再用了。所以养成个习惯:视图就是一次性的描述 ,随用随建,别长期存着一个指向会变化的容器的视图。这里顺手补个场景:如果你要把它传给别的函数、甚至存成成员变量,那就得先用 to 物化成一个真正的容器;只有在当前这一小段里遍历一次,才让它是视图。

对两个最常用的适配器,Bancila 给了精确的定义(一手原文,Cookbook Ch12):

ranges::filter_view / views::filter represents a view of the underlying sequence but without the elements that do not satisfy a specified predicate

ranges::transform_view / views::transform represents a view of the underlying sequence after applying a specified function to each element of the range

4.2 实测:排序 + filter | transform 管道 + 惰性 + 物化(c7_containers.cpp

教材集 §5.7 的最小练习完整代码:

cpp 复制代码
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    // 默认选 vector(SL.con.2 / Bancila Ch5)
    std::vector<int> data{5, 3, 1, 4, 2};

    // 排序交给标准库算法,不手写循环(ES.1)
    std::ranges::sort(data);
    std::cout << "sorted:       ";
    for (int x : data) std::cout << x << ' ';
    std::cout << '\n';

    // ranges 管道:筛选偶数再平方(Bancila Ch12 range adaptors)
    auto result = data
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; });
    std::cout << "filter|square:";
    for (int x : result) std::cout << x << ' ';
    std::cout << '\n';

    // 管道是惰性视图:不迭代就不计算。往源头再塞一个偶数,视图立刻能看到
    data.push_back(6);                       // 6^2 = 36
    std::cout << "after push 6: ";
    for (int x : result) std::cout << x << ' ';
    std::cout << "  (视图仍引用 data,惰性求值)\n";

    // C++23:std::ranges::to 把视图物化成容器(Bancila Ch12)
    auto squared = result | std::ranges::to<std::vector<int>>();
    std::cout << "ranges::to:   ";
    for (int x : squared) std::cout << x << ' ';
    std::cout << "  (真正的 vector)\n";
    return 0;
}

实测输出(g++ -std=c++23 -Wall -Wextra):

text 复制代码
sorted:       1 2 3 4 5
filter|square:4 16
after push 6: 4 16 36   (视图仍引用 data,惰性求值)
ranges::to:   4 16 36   (真正的 vector)

这三行输出,各自演示了一个点:

  1. ranges::sort(data) :C++20 的带范围算法,连 begin()/end() 都不用写;
  2. filter|square :管道串起两个适配器,data 本身没变;
  3. after push 6 冒出了 36 :这一行就证明了 result惰性视图 ------要是它在 push_back(6) 之前就已经物化完了,就不可能冒出这个 36。它只是「看」着 data,你每迭代一次它就重算一遍(顺带也说明视图不是快照,别把它存着跨过容器的失效期)。

4.3 物化视图:std::ranges::to(C++23)

管道的类型又复杂又长,所以平时一般就用 auto 接着;但当你需要把它落成真正的容器、或者传给一个只认容器的接口时,C++23 的 std::ranges::to 一步就能物化(一手原文,Cookbook Ch12):

C++23 provides a range conversion function, called std::ranges::to, which makes this an easy task. It also enables conversion between different containers.

上面的 squared 就是 result | std::ranges::to<std::vector<int>>()------把那个惰性视图真正「算完、装进 vector」。它也支持 to<std::vector<std::string>>() 这样的任意目标容器,连 map 都行。判断上记住一条:只有要复用的结果才值得 to 物化,一次遍历用完的中间数据,永远保持视图就好


5. emplace 还是 insert?(Meyers Item 42:理论 vs 实践)

5.1 机制:插入函数会造临时对象(Item 42 一手原文)

Meyers 用一个挺反直觉的现象开场:vs.push_back("xyzzy") 这一行,容器里装的是 std::string,可你传进去的实参其实是字符串字面量 const char[6]。编译器为了填平这个类型上的差异,会先造出一个临时的 std::string,再插进去(一手原文,Item 42):

compilers see a mismatch between the type of the argument (const char[6]) and the type of the parameter taken by push_back (a reference to a std::string). They address the mismatch by generating code to create a temporary std::string object from the string literal ... they treat the call as if it had been written like this:

cpp 复制代码
vs.push_back(std::string("xyzzy"));   // create temp. std::string and pass it

运行时它做三件事(Item 42 列出的机制):

  1. A temporary std::string object is created from the string literal "xyzzy". ... Construction of temp is the first std::string construction.
  2. temp is passed to the rvalue overload for push_back, where ... A copy of x is then constructed in the memory for the std::vector. This construction---the second one---is what actually creates a new object inside the std::vector.
  3. Immediately after push_back returns, temp is destroyed.

合起来是两次构造 + 一次析构 ,可你真正需要的,其实只有「在容器那块内存里构造一次」。emplace_back 则是把实参直接交给容器内的构造函数(完美转发,见第六章),一次构造就搞定:

emplace_back does exactly what we desire: it uses whatever arguments are passed to it to construct a std::string directly inside the std::vector. No temporaries are involved.

5.2 实测:用计数把机制摆到眼前(c7_2_emplace.cpp

我们写一个带静态计数器的类型,分别数一数「push_back(一个构造好的对象)」和「emplace_back(直接传构造实参)」各自触发了多少次构造、移动、析构:

cpp 复制代码
struct Gadget {
    std::string tag;
    static long fromArgs, moved, gone;
    explicit Gadget(char c, int n) : tag(std::size_t(n), c) { ++fromArgs; }
    Gadget(Gadget&& o) noexcept : tag(std::move(o.tag)) { ++moved; }
    ~Gadget() { ++gone; }
};
// ...
std::vector<Gadget> a;  a.reserve(1);           // 预留容量,排除扩容移动的干扰
a.push_back(Gadget('a', 40));                   // 调用处构造临时,再移入容器
std::vector<Gadget> b;  b.reserve(1);
b.emplace_back('b', 40);                        // 用实参就地构造,无临时

实测输出:

text 复制代码
push_back(Gadget(...)) : fromArgs=1(临时) + moved=1(移入),临时随后析构
                         此时 moved=1, gone=1(那个临时已析构)
emplace_back('b',40) : fromArgs=1(就地) + moved=0,无临时
                        此时 moved=0, gone=0

你看,做的是同一件事:push_back 那条路先构造了临时对象、再移动进容器、最后又把临时析构掉(所以 gone=1);而 emplace_back 那条路,从头到尾只有一次就地构造,零移动、零析构。这就是 Item 42 说的「两次构造 + 一次析构 vs 一次构造」,这个差异本身。

这里也顺手把开头那个问题说透------插进容器的,到底是一份拷贝、还是你原来那个对象? 在 push_back 这种写法里,你手写的那个 Gadget 其实是临时对象,容器里装的是「移动过去」的一份新对象;而 emplace_back 是拿实参在容器内存里直接长出一个,压根没有中间那个临时。

5.3 理论 vs 实践:为什么不一律 emplace(Item 42 一手原文)

既然 emplace 能做到 insert 能做的一切、理论上还不比它慢,那为什么不干脆全都用 emplace?Meyers 把话说得很透:

Emplacement functions can thus do everything insertion functions can. They sometimes do it more efficiently, and, at least in theory, they should never do it less efficiently. So why not use them all the time?

Because, as the saying goes, in theory, there's no difference between theory and practice, but in practice, there is. With current implementations of the Standard Library, there are situations where, as expected, emplacement outperforms insertion, but, sadly, there are also situations where the insertion functions run faster.

到底谁快,取决于实参类型、容器、插入位置、异常安全,还有(关联容器那边的)去重场景,很难一句话讲死。结论就一句(一手原文):

The usual performance-tuning advice thus applies: to determine whether emplacement or insertion runs faster, benchmark them both.

于是「理论 vs 实践」这句名言,最终落到一个很具体的习惯上:性能这种事,一律以测量为准,别拿故事当依据 。下面是实测的呼应(c7_2_emplace.cpp-O2,200 万次,字面量塞进 vector<string>):

text 复制代码
长串(>SSO):  push_back=  100.8 ms   emplace_back=  98.3 ms   差 2.5 ms
短串(SSO内): push_back=   34.6 ms   emplace_back=  30.9 ms   差 3.7 ms

读这份数据得诚实:emplace 确实更快,但远没有「快一倍」那种故事感 ------因为真正的成本大头(每次构造/析构字符串都要做的堆分配)两种写法谁也逃不掉,emplace 省下的,仅仅是一个临时的构造、移动加析构。长串差 2.5ms/200万 ≈ 每元素 1.25ns,相对总时长也就 2.5% 左右。更要紧的是短串(SSO 内)几乎打平 ------临时对象在栈缓冲里差不多是免费的,Item 42 三条启发式里「实参类型与容器元素类型不同」的那条优势,到这儿就消失了。所以结论不是「emplace 没用」,而是:该用 emplace 的地方就大方用它(构造型插入、实参不是容器元素类型),但别指望它是性能银弹,真在乎就自己 benchmark

5.4 三条启发式与资源管理陷阱(Item 42 结尾)

Meyers 给了三条判据,说的是「什么时候 emplace 几乎必然更快」(一手原文,摘要),值得你当检查清单背下来:

  • The value being added is constructed into the container, not assigned. ...
  • The argument type(s) being passed differ from the type held by the container. ...
  • The container is unlikely to reject the new value as a duplicate. ...
  • 条件一(构造 vs 赋值)提醒:插到已有元素位置emplace(begin,...))多是移动赋值,还是得造临时当移动源,优势蒸发;
  • 条件三(关联容器)尤其反直觉:set/map 查重时会先造一个完整节点再比对,重复值会让那次构造白费

比速度更要紧的,是 Item 42 结尾那个资源陷阱 ------在一个装着资源管理对象的容器里用 emplace,会撕开一个异常安全的窗口。举个场景:list<shared_ptr<Widget>>,你要塞进去一个带自定义删除器的 shared_ptr(这种情况用不了 make_shared,只能 new Widget)。写成 push_back(shared_ptr<Widget>(new Widget, killWidget)) 时,那个临时 shared_ptr 就是一道资源守卫 :哪怕 push 途中节点分配抛了异常,临时对象一析构也会替你调 killWidget,不会泄漏。但你要是图省事改成:

cpp 复制代码
ptrs.emplace_back(new Widget, killWidget);

The raw pointer resulting from "new Widget" is perfect-forwarded to the point inside emplace_back where a list node is to be allocated. That allocation fails, and an out-of-memory exception is thrown. ... the raw pointer that was the only way to get at the Widget on the heap is lost. That Widget (and any resources it owns) is leaked.

原因是 shared_ptr 的构造被完美转发推迟到了容器内存里头,等节点分配失败时,new 出来的那个裸指针还没来得及 交到 shared_ptr 手上------没人负责析构它,就泄漏了。Meyers 的结论是:

When working with containers of resource-managing objects, you must take care to ensure that if you choose an emplacement function over its insertion counterpart, you're not paying for improved code efficiency with diminished exception safety .

Frankly, you shouldn't be passing expressions like "new Widget" to emplace_back or push_back or most any other function, anyway, because, as Item 21 explains, this leads to the possibility of exception safety problems...

这一整段和第三章完全闭环了:别让裸 new 出现在任何函数实参里 ,改用 make_shared/make_unique,在一个独立的语句里把资源交到守卫对象手上(R.22/R.23)。从工程判断上说:在资源管理对象的容器里,你想用 emplace 省掉一个临时?省下来的那点,远比不上泄漏的风险,别省


6. 工作流总装:容器-算法-管道决策清单

把整章串成一套动作顺序,写代码前,先对着它把手里的数据变换过一遍:

第一步 · 选容器

  1. 默认 std::vector;编译期定长用 std::array(SL.con.2 / SL.con.1)。
  2. 只有出现具体模式(头尾/中间插删、按键查、唯一/有序)才查判据表换容器(§1.3)。
  3. 容器元素 = 值;被迫多态才 unique_ptr,绝不裸指针容器(R.20/R.21)。

第二步 · 选"怎么算"

  1. 先搜 STL 算法,不写 raw loop(ES.1 / Sean Parent:>100 个算法)。

  2. 算法能表达,直接算法;要"筛选/投影/分段",上 ranges 管道(§4)。

第三步 · 决定物化与副作用

  1. 管道结果只遍历一次 → 保持视图(惰性,零中间容器);要复用/传出去 → std::ranges::to 物化(§4.3)。

  2. 千万别长期持有引用易变容器的视图。

第四步 · 增删与构造

  1. 插入新元素、实参 ≠ 容器元素类型、非去重场景 → emplace(Item 42 三条判据)。

  2. 容器里是资源管理对象、要传裸指针 → 回到 push_back + make_* ,不碰 emplace(Item 42 资源陷阱)。

  3. 拿不准性能 → 两条都写,benchmark 见真章(Item 42:measure)。


7. 综合练习

7.1 最小练习(教材集 §5.7)

完整代码和实测输出已经在 §4.2 展示过(c7_containers.cpp)。请你合上文件、独立默写一遍:vector 默认容器 → ranges::sortfilter | transform 管道 → 惰性说明 → ranges::to 物化。写完还得能解释清楚:输出里「after push 6 冒出的那个 36」为什么是惰性的证据。

7.2 进阶:把「删除」写成正向筛选(编码题 4 的落地)

题目是:自己造一个「按条件删或留」的场景,用 ranges 管道实现,再跟 erase-remove 对照一下。§3 已经给过「删掉 2」的对照;下面这个是保留子集 语义的正向写法(c7_3_exercise.cpp):

cpp 复制代码
// 单词表:长度>=5 的词转大写,收集成新 vector
std::vector<std::string> words{"cpp", "ranges", "concept", "raii", "noexcept", "stl"};
auto long_upper = words
    | std::views::filter([](const std::string& w) { return w.size() >= 5; })
    | std::views::transform([](std::string w) {
          std::ranges::transform(w, w.begin(),
                [](unsigned char c){ return static_cast<char>(std::toupper(c)); });
          return w;
      })
    | std::ranges::to<std::vector<std::string>>();

实测输出:

text 复制代码
长度>=5 且转大写: RANGES CONCEPT NOEXCEPT
手写循环对照结果一致: yes

(注意 std::ranges::transform 在这儿出现了两回:外面管道里是当适配器 用的,内部对单个字符串则是当就地算法用的------同一个名字、两个角色,别搞混。)再跟手写的双循环版一比:管道把「遍历、条件、投影、收集」这四件事压成了一行声明,而且中途不产生任何临时容器。


8. 本章验收自测

先口答前 4 题,再独立完成编码题。编码题统一 g++ -std=c++23 -Wall -Wextra 编译运行通过。答案在文末折叠区。

口答题

  1. 容器选择的核心判据是什么?为什么默认 vector(Bancila「rule of thumb」+ Grimm SL.con.2 的三条连续内存优势)?
  2. SL.1/ES.1 为什么主张优先标准库而非手写?「If you write a raw loop, you probably don't know the algorithms well enough」该怎么理解?
  3. Item 42 说 emplace「理论上更快实践未必」,它教的是哪种判断习惯?三条启发式各指什么(构造 vs 赋值 / 实参类型 / 去重)?
  4. ranges 视图为什么是惰性的?这种惰性带来什么收益(零中间容器)与什么风险(视图悬垂)?

编码题

写一个「保留满足条件的子集」的程序(两种写法对照):

  1. 用 ranges 管道(filter + 一个投影 + ranges::to)实现;
  2. 用老写法(erase-remove 或手写循环)实现同一个语义;
  3. 对比:删 2 的固定值场景用 std::erase,删「满足条件」的谓词场景用 std::erase_if
  4. 加一问:为什么 remove 之后必须再 erase?(mechanism 层面回答,衔接 STL 单元04。)

参考答案

点击展开参考答案

口答 1:核心判据 = 需求的复杂度契约(增删/访问热点 + 是否按键 + 是否需有序 + 元素唯一性)。默认 vector 的原因 = 连续内存(contiguous layout):①随机访问最快、可向量化;②begin-to-end 顺序访问命中 CPU cache 预取;③零 per-element 空间开销------Bancila 的「rule of thumb」与 Grimm SL.con.2("unless you have a reason")把举证责任交给"不用 vector 的一方":没有具体理由就停在默认,别为想象的需求换容器。

口答 2 :标准库算法把意图 写进调用本身(accumulate/sort/find 指名道姓),raw loop 把意图藏进循环体让读者逐行推断;且算法有标准测试、正确的边界处理与复杂度保证。所以 Sean Parent 才说把全部代码规范换成一条------优先算法于循环;Grimm 补一句更狠的:手写循环多半是因为不知道 STL 有对应算法(上百个)。

口答 3 :教的是测量优先、故事靠边 的判断习惯------性能主张要有实测背书("benchmark them both"),因为实践里实现细节会把理论的差吃掉。三条启发式:①值是构造 进容器而非赋值进已有位置(后者得造临时当移动源,优势蒸发);②实参类型不同于 容器元素类型(相同则无临时可省);③容器大概率不拒绝重复(关联容器查重要先造节点,重复则那次构造白费)。三者皆真 emplace 才几乎必然更快。

口答 4 :视图只是"看着"底层序列的惰性描述,迭代才计算 (Bancila:produce results only when we iterate over them)。收益 = 管道全程零中间容器、内存与时间都省;风险 = 视图不拥有数据,底层容器销毁或迭代器失效(重分配)后视图成悬垂引用------所以视图随取随建,别长期保存指向易变容器的视图。

编码题 :见 §7.2 c7_3_exercise.cpp(管道版 + 手写对照版,输出一致)。删除对照见 §3 三种写法。固定值删除用 std::erase、谓词删除用 std::erase_if。为什么 remove 后还要 erasestd::remove算法 ,只把要删的元素搬/移到逻辑尾部、返回新逻辑尾迭代器,容器长度未变、元素未析构erase容器成员,才真正把尾部那截析构并缩短 size。vector 的按值删除必须"搬移(算法)+截断(成员)"两步拼------这正是 erase-remove 惯用法存在的机制原因。


9. 本章小结

第七章要做的事,就是把「机制已经懂的 STL」升级成「会判断的 STL」。你可以带走这四件事:

  1. 容器默认 vector(SL.con.2) ,三条连续内存优势(随机访问/顺序预取/零开销)说明原因;编译期定长用 std::array(SL.con.1)。换容器的判据是复杂度契约,不是手感。
  2. 算法优先于手写循环(ES.1/SL.1):把意图写进算法名,把循环体抽象成实参;不认识算法的 raw loop 要警惕。
  3. ranges 管道把算法变组合(Bancila Ch12) :视图惰性、不拥有、零中间容器,filter | transform 声明数据流;要落容器用 std::ranges::to 物化,注意视图悬垂风险。
  4. emplace 有真实机制优势、却非银弹(Item 42) :理论 vs 实践靠 benchmark 裁决;三条启发式(构造 vs 赋值、实参类型、去重)缩小该用 emplace 的范围;资源管理对象容器里的裸指针插入,回退 push_back + make_* 保异常安全

下一章我们进入「并发与异步」。容器和算法解决的是单线程内 的数据怎么组织;下一章把问题换成多线程同时跑 时的正确性------以及 Meyers 写在 C++14 的那些并发建议,到 C++20 还需要打哪些补丁(比如 jthread)。

验证记录汇总 (本章全部代码实测于 g++ 15.2, Ubuntu, -std=c++23 -Wall -Wextra;测量用例另加 -O2):

  • c7_containers.cpp → sort 后 1 2 3 4 5;管道 4 16;push 6 后视图看到 36(惰性证据);ranges::to 物化 4 16 36
  • c7_1_algorithms.cpp → raw loop 与 accumulate 同为 7;product 11.25;erase-remove / std::erase / erase_if / filter|to 四种删除与筛选同果
  • c7_2_emplace.cpp → 计数:push_back 路径 fromArgs=1+moved=1+gone=1(临时已析构);emplace_back 路径 moved=0,gone=0;-O2 测量长串 emplace 快 2.5ms/200万、SSO 短串接近打平
  • c7_3_exercise.cpp → 管道版 RANGES CONCEPT NOEXCEPT,与手写循环对照 yes

素材来源

  • 《C++ Core Guidelines Explained》Rainer Grimm(Addison-Wesley 2022)------ SL.1/ES.1(标准库优先、accumulate 示例、Sean Parent 名言、"STL has more than 100 algorithms")、SL.con.1/SL.con.2(默认 vector/array 与三条连续内存优势)、Ch16 开头自我定位("not exhaustive")、R.20/R.21 附近(容器存值 vs 智能指针表达所有权)(一手)
  • 《Modern C++ Programming Cookbook》3rd ed. Marius Bancila(Packt 2024)------ Ch5「Using vector as a default container」(rule of thumb)、Ch5「Selecting the right standard containers」(逐条判据清单)、Ch12「Exploring the standard range adaptors」(filter/transform 定义、命名空间别名)、Ch12「Converting a range to a container」(ranges 惰性、std::ranges::to C++23)(一手)
  • 《Effective Modern C++》Scott Meyers(O'Reilly 2015)------ Item 42(emplace vs insert 机制、"in theory...in practice, there is"、benchmark 优先、三条启发式、shared_ptr 自定义删除器场景的资源泄漏陷阱)(一手)
相关推荐
钓鱼的肝1 小时前
csp-j-s总结(4)
c++·经验分享·笔记·算法
峥嵘life1 小时前
2026华为AI码道 CodeArts 使用分享:Windows端 + 服务器CLI 实战总结
android·大数据·开发语言·python
Logic1011 小时前
C语言/数据结构位运算题解:异或XOR找出独特数字的索引位置——成对数字在两侧
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
橘子汽水1681 小时前
Leetcode 763,45 划分字母区间 跳跃游戏II
数据结构·算法·leetcode
x秀x1 小时前
sam3新手使用教程
开发语言·python
Java后端的Ai之路1 小时前
大模型LLM评估完全指南
开发语言·人工智能·python·llm·评估
别动我齐刘海1 小时前
ROS2 Jazzy + C++ 实战路线——基础学习1
c++·vscode·python·ubuntu·机器学习·自动驾驶·github
kiracrimson1 小时前
建堆的两种算法:O(N) 和 O(N log N) 差在哪里
数据结构
Brilliantwxx1 小时前
【STM32】 从HAL库源码深度解析I2C(源码解析+面试题)
开发语言·stm32·单片机·嵌入式硬件·架构