C++ 易误用的标准库函数
标准库函数的名字不总能准确表达语义:边界不代表命中,划分不等于排序,逻辑删除不改变容器大小,保存回调也不一定拥有它访问的数据。本文按六个系列整理这些区别,重点说明返回值、前置条件和常见误用。代码省略头文件与 main;ranges 接口及 std::erase 等需要 C++20,其他版本要求单独注明。
二分查找:边界不代表命中
三个接口的区别
在默认升序比较下:
| 函数 | 返回值 | value 不存在时 |
|---|---|---|
lower_bound | 首个 >= value 的位置 | 首个 > value 的位置,或 end() |
upper_bound | 首个 > value 的位置 | 与 lower_bound 重合 |
equal_range | 两者组成的半开区间 [lo, up) | 空区间,lo == up |
例如 {1, 2, 3, 3, 3, 5, 7} 查找 3,lower_bound 指向下标 2,upper_bound 指向下标 5;查找不存在的 4,两者都指向 5,而不是返回“未找到”。upper_bound 也不是最后一个等于目标值的位置。
前置条件:按比较关系划分
严格说,二分算法要求区间按相应比较关系划分,不一定要求整个区间有序:lower_bound 要求满足 comp(elem, value) 的元素在前;upper_bound 要求满足 !comp(value, elem) 的元素在前。equal_range 要求两种划分都成立,并满足 comp(elem, value) 成立时 comp(value, elem) 不成立。
实际使用通常先排序,并让排序与查找使用一致的比较关系。用 > 排序却用默认 < 查找,或对不满足划分条件的区间查找,都会违反前置条件,导致未定义行为;算法不会替你检查。
正确判断存在性
lower_bound 找的是插入边界,只检查 it != end() 不足以证明命中,还必须判等:
1 | |
自定义比较器下,“相等”应理解为比较器等价,即两边都不小于对方。lower_bound 已保证 !comp(*it, value),因此只需再检查 it != last && !comp(value, *it);这不一定等同于 operator==。
若需要全部等价元素,直接使用 equal_range:
1 | |
目标大于所有元素或区间为空时,边界是 end(),不可解引用;目标小于所有元素时,边界是 begin(),迭代器有效但并未命中。判等前先检查末尾,用 && 短路避免越界。
复杂度与容器选择
比较次数为 O(log n),但只有随机访问迭代器才能保证整体 O(log n);其他迭代器的推进次数可能为 O(n)。因此 set、map、multiset、multimap 应用成员 lower_bound / upper_bound / equal_range,保持对数复杂度。unordered_* 不提供这些成员接口,也不保证满足二分算法的划分条件。
up - lo 的计数只适用于随机访问迭代器;对树容器使用 std::distance(lo, up),计数仍要线性遍历命中区间。找到边界的复杂度也不包含后续删除的成本。
排名选择:nth_element 不会完成排序
第 k 小与前 k 个
nth_element 原地重排区间,使 nth 上的元素与完全排序后该位置的元素按比较器等价,并保证左侧元素不排在右侧元素之后;两侧内部都不保证有序。重复元素分别占据排名,同键对象不保证选中哪一个,也不保持原相对顺序。
算法定义在 <algorithm> 中,需要随机访问迭代器和可重排元素;普通非并行重载平均 O(n),不是最坏 O(n) 保证。比较器必须满足严格弱序,不能使用 <= 或 >=。
取第 k 小,位置是 k - 1;取第 k 大,改用 std::greater<>{}:
1 | |
若要前 k 个,半开区间的右边界则是 begin() + k。需要这个前缀有序时,再排序:
1 | |
nth == last 是合法调用,但不能解引用,也没有排序保证;空区间只能传 first == nth == last。上例 k = n 时,是随后的 sort 将全区间排序。边界值重复时,前缀只选出恰好 k 个,不会包含所有同值元素。
ranges:支持投影,但返回末尾
C++20 的 std::ranges::nth_element 可接受区间,也可接受 first, nth, last,并支持投影:先提取键,再比较。nth 仍是迭代器,不是整数下标。
1 | |
算法重排的是整个对象,不是只移动投影字段。传统版本返回 void,ranges 版本返回对应 last 的末尾迭代器,不是 nth;区间重载对非 borrowed 的临时区间返回 std::ranges::dangling。选中元素始终通过事先传入的 nth 读取。
偶数长度的中位数
选择下标 n / 2 后,奇数长度得到中位数,偶数长度得到中间较大的值。较小者不是 *(nth - 1):左半区无序,应该取其最大值。
1 | |
| 需求 | 算法 | 结果保证 |
|---|---|---|
| 第 k 小 / 第 k 大 | nth_element,位置取 k - 1 | 该位置排名正确 |
| 无序的前 k 个 | nth_element,边界取 k | 前缀已选出,但内部无序 |
| 有序的前 k 个 | partial_sort,边界取 k | 前缀有序,通常约 O(n log k) 比较(k > 1) |
| 整个区间有序 | sort | 全区间有序 |
不要把 nth_element 的结果直接交给要求有序或特定划分条件的二分查找。
删除:remove 搬移,erase 才缩小容器
逻辑末尾不是容器末尾
std::remove 把不等于指定值的元素按原相对顺序压缩到前部,remove_if 则移除谓词为 true 的元素。它们定义在 <algorithm> 中,返回新的逻辑末尾,不会改变容器大小;尾部对象仍存在,但值不可依赖,也不代表“被删除元素的集合”。
1 | |
这就是 erase-remove 惯用法。按条件删除时换成 remove_if 即可。它要求可写的前向迭代器与可移动赋值的元素,复杂度 O(n);对 vector 擦除压缩后的尾部无需再搬移保留元素,避免循环擦除中间元素的最坏 O(n²) 成本。缩小的是 size(),不是 capacity()。
C++20:容器接口与 ranges 接口
直接删除容器元素,优先使用定义在对应容器头文件中的 std::erase / erase_if;返回值是删除数,不是迭代器:
1 | |
std::erase(v, value) 按值删除全部匹配项,v.erase(pos) 按位置删除;关联容器按键删除仍用成员 erase(key),按条件删除可用 std::erase_if。
std::ranges::remove / remove_if 仍然只压缩,但返回的是待擦除的尾部子区间,不是保留区间;还支持投影:
1 | |
三个易错点
- 必须范围擦除。
v.erase(new_end)只删除一个元素;没有匹配项时new_end == end(),单位置擦除还违反前置条件。v.erase(new_end, v.end())可以安全处理空区间和全部匹配。 - 删除值不能被压缩过程改写。
remove(..., v[0])与std::erase(v, v[0])都可能在搬移时覆盖比较基准。先保存独立副本,例如const int target = v[0];谓词引用捕获区间元素作为基准时也有相同风险。 - 位置不等于对象身份。 压缩可能把另一个元素移动赋入原位置;随后的
vector::erase使删除起点及之后的迭代器、引用和旧end()失效,不要再使用旧的new_end。
固定大小数组也能压缩,但只能使用返回的逻辑区间,不能缩小物理长度。set / map 的键不可写,不适用 erase-remove;链表应使用成员 remove / remove_if,直接删除节点并更新大小。
去重:unique 只折叠相邻等价元素
相邻去重不是全局去重
std::unique 保留每个连续等价组的第一个元素,把结果压缩到前部;默认用相等比较,自定义版本使用等价谓词。它不会排序,也不会查找不相邻的重复值。返回与擦除规则和 remove 相同,复杂度 O(n)。
1 | |
全局去重且允许重排,可以先排序:
1 | |
排序不是 unique 的前置条件,而是把等价元素聚到一起的手段。若必须保留首次出现的顺序,应使用已见键集合,并按输入顺序收集首次出现的元素,不能先排序。
谓词必须表示等价关系
第三个参数表示“两个元素是否等价”,不是排序比较器。它必须满足自反性、对称性和传递性;<、<= 或不具传递性的浮点“距离小于 epsilon”都不符合要求。
按字段全局去重时,排序键与等价键要一致。若还要保留同键记录在原输入中的首条,可用 stable_sort;ranges 版本通过投影指定键,并返回待擦除的尾部子区间:
1 | |
稳定排序只保持组内原序,最终不同键仍按键排列。ranges 的概念约束也不能证明谓词具有等价关系;ranges::remove 和 ranges::unique 的区间重载对非 borrowed 临时区间都返回 dangling。
不改源区间的相邻去重用 unique_copy,输入与输出不能重叠;输出到空 vector 用 std::back_inserter,不能向 out.begin() 写入不存在的元素。链表成员 unique 则直接删除节点,不必再 erase。
调用包装:参数绑定与类型擦除是两件事
std::bind 和 std::function 都定义在 <functional> 中:前者改写参数绑定,返回具体类型的函数对象;后者统一调用签名,以类型擦除方式存储不同目标。要分别检查创建时持有什么、复制时复制什么、调用时如何传参。
std::bind:默认保存值,不是借用原变量
bind 按 decay 后的类型保存可调用对象和绑定参数:左值通常复制,右值可以移动,数组和函数退化为指针。借用原对象要显式使用 std::ref / cref,但它们不延长生命周期。
1 | |
成员函数同理:std::bind(&T::method, obj, ...) 保存对象副本;传 std::ref(obj) 或 &obj 才访问原对象,且必须保证其存活。复制指针只复制地址,不获得所指对象的所有权。
其他容易忽略的调用规则:
_1、_2指包装器调用时的第几个实参,可以重排目标参数。- 占位符实参按原值类别转发;普通绑定状态按左值传入,即使写成
std::move(wrapper)()也不会自动移交状态。因此能保存unique_ptr,不代表能把它按值交给目标函数。 - 嵌套
bind会先执行,再把结果交给外层目标,不是普通函数对象参数。 - 未使用的额外实参仍会求值;
callback(x, expensive_work())不会因为第二项被忽略就省掉它,也不保证实参从左到右求值。
通常优先用 lambda,捕获与参数关系更明确。只绑定前几个参数可用 C++20 的 bind_front:它按包装器的 cv 限定和值类别转发保存状态,因此右值包装器可以移交只移动资源:
1 | |
std::function:可复制包装器要求可复制目标
构造 function 时会保存目标对象;复制包装器时也会复制目标。值捕获状态通常各自独立,引用捕获仍指向同一对象,捕获 shared_ptr 则共享底层对象;复制目标不等于深拷贝全部数据。
1 | |
目标必须可复制,即使只打算移动 function 也一样。捕获 unique_ptr 的闭包不能直接存入 std::function,std::move 不会绕过此要求;用 std::ref(closure) 只能借用闭包,不是拥有式替代。
还需注意:
- 空调用抛异常。 默认构造的
function没有目标,调用抛std::bad_function_call;若接口允许空回调,可先用if (callback)检查。 - 可能分配,但没有可移植的 SBO 阈值。 不能只凭闭包大小判断是否动态分配,也不能断言每次调用一定间接调用或一定比 lambda 慢;性能取决于实现与优化。
- 引用返回不延长生命周期。
std::function<const int&()> f = [] { return 42; };在旧标准下可能通过构造检查,却返回悬空引用;C++23 的规则禁止这种构造,但库实现可能滞后。应返回实际存活对象的引用,显式引用返回类型也不能修复悬空对象。 const不代表目标只读。const std::function仍能调用 mutable lambda;并发修改目标或捕获对象时,依然需要同步。
| 需求 | 选择 | 主要约束 |
|---|---|---|
| 即时调用,不需要统一类型存储 | 模板参数 | 持有或借用由接口决定 |
| 固定、重排参数或显式捕获 | lambda | 引用捕获不延长生命周期 |
| 只绑定前几个参数 | C++20 bind_front | 按包装器值类别转发状态 |
| 使用占位符或嵌套绑定规则 | bind | 普通绑定状态按左值传入 |
| 统一签名存储,回调可复制 | function | 目标必须可复制;空调用抛异常 |
| 统一签名存储,回调只移动 | C++23 move_only_function | 需标准库支持;空调用违反前置条件 |
使用 move_only_function 前应检查 __cpp_lib_move_only_function >= 202110L,不能只看编译器是否接受 C++23。它能拥有捕获 unique_ptr 的闭包,也支持带 cv、引用限定和 noexcept 的调用签名;空调用不保证抛异常,C++23 下属于未定义行为。
异步执行:async 不是 fire-and-forget
明确启动策略
std::async 定义在 <future> 中,保存任务与参数,返回关联结果共享状态的 std::future<R>。结果可以是返回值,也可以是异常。
| 策略 | 执行方式 |
|---|---|
launch::async | 如同在新执行线程中调用,不依赖等待方触发 |
launch::deferred | 首次 get() / wait() 时,在调用该操作的线程中执行 |
async | deferred | 实现从允许的策略中选择 |
省略策略等价于允许 async | deferred,不是默认一定开线程。需要任务自行开始时,显式指定 launch::async,保存句柄,再取结果:
1 | |
两次启动具备并发机会,不保证执行时间一定重叠。显式 async 在无法启动线程时可直接抛 std::system_error,状态分配或参数构造也可能失败;这些与任务异常经 get() 重抛是不同的失败阶段。
future:有效、就绪与成功不同
| 操作 | 作用 | 是否消费状态 |
|---|---|---|
valid() | 检查是否关联共享状态,不表示完成 | 否 |
wait() | 等待就绪,不取得结果或重抛任务异常 | 否 |
wait_for() / wait_until() | 返回 ready、timeout 或 deferred | 否 |
get() | 等待,取得结果或重抛任务异常 | 是,之后无效 |
ready 也可能意味着异常已保存;等待完成不等于检查成功。future<void> 同样需要 get() 来观察异常。get() 即使重抛异常也消费状态,不能再次取值或等待;默认构造、被移走或 share() 后的原句柄也无效,不要依赖重复 get() 一定抛诊断异常。
定时等待不会执行尚未启动的 deferred 任务:
1 | |
一直轮询定时等待不会让 deferred 任务推进,销毁句柄也不会替你执行。timeout 不取消任务,也不是执行时长上限。
析构等待与借用边界
丢弃 async 返回值不会让任务脱离调用方。 对 launch::async 的共享状态,最后一个持有者释放状态时需满足关联线程完成的同步要求;未保存的临时 future 因此可能在语句结束时等待,使连续两次调用串行化:
1 | |
不是所有 future 都有这种等待:来自 promise / packaged_task 的状态没有 async 带来的析构等待,deferred 状态释放也不触发执行。转成 shared_future 不能绕过它,最后一个副本仍可能等待;覆盖或移动赋值释放旧状态时也可能等待。
析构等待不会重抛任务异常。多个任务要分别观察结果,不能把第一个 get() 抛出后的其他句柄析构,当成已处理其失败。
参数保存也不等于借用原变量:async 按 decay 后的类型保存状态,左值通常复制,右值可以移动,任务执行时保存值按右值参与调用。要访问原对象需显式使用 std::ref / cref:
1 | |
任务完成前,被借用对象必须存活,冲突访问必须同步。引用捕获、裸指针、string_view、span 同样不拥有底层数据;不要返回仍借用局部变量的任务句柄。
多个消费者读取同一结果可用 shared_future:重复 get() 不重算,一般结果返回 const R&,引用不能比共享状态活得更久。大量短任务、并发限制与队列调度应使用执行器或线程池;持续运行与协作停止需要受管理的线程。async 不提供取消,也没有可配置的标准线程池契约。






