【 C++ 系统第三阶段:STL 容器、迭代器、Lambda 与算法】
C++ 系统第三阶段:STL 容器、迭代器、Lambda 与算法
前言
在掌握对象生命周期、RAII、智能指针、移动语义和异常安全之后,C++ 学习会逐渐从“如何正确管理一个对象”转向“如何管理大量对象”。
第三阶段的核心问题是:
当程序中存在一组数据时,应该使用什么容器保存?如何查找、筛选、统计、排序和删除?容器发生变化后,迭代器、指针和引用是否仍然有效?
这一阶段主要学习以下内容:
- 序列式容器:
vector、list、deque - 容器适配器:
stack、queue、priority_queue - 关联式容器:
map、set - 哈希容器:
unordered_map、unordered_set - 迭代器与迭代器失效
- Lambda 表达式
- 常用 STL 算法
- 容器与算法的综合使用
LogManager日志管理项目
本文先系统讲解知识点,最后通过一个单线程内存日志管理器将这些内容串联起来。
一、STL 的整体结构
STL 是 Standard Template Library,即标准模板库。
它主要包含:
容器
迭代器
算法
函数对象
容器适配器
可以简单理解为:
容器:负责保存数据
迭代器:负责访问数据
算法:负责处理数据
Lambda:负责描述处理条件
例如:
std::vector<int> values{3, 1, 2};
std::sort(values.begin(), values.end());
其中:
std::vector保存数据;begin()和end()提供迭代器区间;std::sort对区间排序。
STL 的一个核心思想是:
算法通常不直接依赖具体容器,而是操作迭代器区间。
二、序列式容器
序列式容器按照元素的位置组织数据。
常见的序列式容器包括:
std::vector
std::list
std::deque
2.1 std::vector
vector 可以理解为能够自动扩容的动态数组。
它的主要特点是:
- 元素通常存储在连续内存中;
- 支持随机访问;
- 尾部插入通常效率较高;
- 中间插入和删除需要移动后续元素;
- 容量不足时会重新分配内存;
- 非常适合遍历和排序。
定义:
std::vector<int> values;
添加:
values.push_back(10);
values.push_back(20);
访问:
std::cout << values[0];
std::cout << values.at(1);
operator[] 不检查越界,at() 会检查越界并在错误时抛出异常。
2.2 size() 与 capacity()
size() 表示当前真实存在的元素数量。
values.size();
capacity() 表示当前已经分配的空间最多可以容纳多少个元素。
values.capacity();
例如:
std::vector<int> values;
values.reserve(10);
此时通常是:
size = 0
capacity >= 10
因为 reserve() 只预留内存,不会创建元素。
2.3 reserve() 与 resize()
reserve()
values.reserve(10);
只改变容量,不改变元素数量。适合提前知道大概会插入多少元素的场景,可以减少扩容次数。
resize()
values.resize(10);
会让容器中真正存在 10 个元素。
因此:
reserve 改变 capacity
resize 改变 size
2.4 push_back() 与 emplace_back()
push_back() 把一个已经存在的对象复制或移动进容器:
LogItem item;
logs.push_back(item);
logs.push_back(std::move(item));
emplace_back() 使用参数直接在容器尾部构造对象:
logs.emplace_back(/* 构造参数 */);
如果已经有一个完整对象,使用:
logs.push_back(std::move(item));
通常已经足够清晰。
2.5 vector 扩容过程
当:
size == capacity
并继续添加元素时,vector 通常会:
- 申请更大的连续内存;
- 把原来的元素复制或移动到新内存;
- 销毁旧内存中的元素;
- 释放旧内存;
- 插入新元素。
因为元素地址发生变化,所以扩容后:
原来的所有迭代器失效
原来的所有指针失效
原来的所有引用失效
2.6 vector 删除后的失效规则
假设原数据为:
A B C D
删除 B 后:
A C D
C 和 D 会向前移动。
因此:
删除位置以及删除位置后面的迭代器、指针和引用失效。
删除位置之前的通常仍然有效。
2.7 为什么日志管理适合使用 vector
日志管理通常具有以下操作特点:
- 经常从尾部添加;
- 经常遍历所有日志;
- 经常按条件筛选;
- 需要排序;
- 需要与 STL 算法配合。
因此:
std::vector<LogItem> m_logs;
是一个合理的第一版设计。
需要注意,按 ID 查找仍然是 O(n),这不是 vector 的优势。
选择 vector 的主要原因是:
尾插方便
遍历效率高
连续内存
缓存局部性好
支持 std::sort
适合练习和使用 STL 算法
2.8 std::list
list 是双向链表。
特点:
- 节点内存通常不连续;
- 不支持随机访问;
- 已知位置后插入和删除方便;
- 插入其他节点时,大部分已有迭代器仍有效;
- 缓存局部性通常不如
vector; - 每个节点还需要额外保存前后指针。
不能简单地认为:
删除多就一定使用 list
因为找到删除位置本身也可能需要线性遍历。
2.9 std::deque
deque 是双端队列。
特点:
- 头部和尾部插入效率较高;
- 支持随机访问;
- 内存通常不是完全连续的;
- 不提供
reserve()和capacity(); - 迭代器失效规则比
vector更复杂。
适合:
- 双端队列;
- 滑动窗口;
- 频繁从头尾插入删除的场景。
三、容器适配器
常见容器适配器包括:
std::stack
std::queue
std::priority_queue
3.1 std::stack
stack 是后进先出:
Last In First Out
常用接口:
push
pop
top
empty
size
3.2 std::queue
queue 是先进先出:
First In First Out
常用接口:
push
pop
front
back
empty
size
3.3 std::priority_queue
priority_queue 会让优先级最高的元素位于顶部。
默认是大顶堆:
std::priority_queue<int> values;
最大的元素优先取出。也可以通过自定义比较器改变优先级规则。
四、关联式容器
关联式容器按照键组织元素,而不是按照位置组织元素。
常见容器包括:
std::map
std::set
std::multimap
std::multiset
4.1 二叉搜索树与平衡树
普通二叉搜索树满足:
左子树的元素小于根节点
右子树的元素大于根节点
如果插入数据本身已经有序,普通二叉搜索树可能退化成链表,查找效率会从接近 O(log n) 退化到 O(n)。
因此,map 和 set 通常基于平衡搜索树实现,例如红黑树。
这一阶段需要理解:
- 为什么需要控制树高;
- 为什么平衡树的查找、插入、删除通常是
O(log n); - 为什么不要求立即完整手写红黑树。
4.2 std::map
map 保存键值对:
std::map<std::string, int> scores;
插入:
scores["Tom"] = 90;
查找:
auto it = scores.find("Tom");
特点:
- 键唯一;
- 元素按照键有序;
- 查找、插入、删除通常是
O(log n); - 支持范围查询。
operator[] 的副作用
使用:
scores["Jack"]
如果 "Jack" 不存在,map 会自动插入一个新元素。
纯查询时更适合使用:
find
at
contains
其中 contains 是 C++20 接口。
4.3 std::set
set 只保存键:
std::set<int> values;
特点:
- 元素唯一;
- 元素有序;
- 元素本身就是键;
- 元素不能被随意修改,否则可能破坏树的顺序。
4.4 multimap 与 multiset
它们允许重复键。
范围查询常用:
lower_bound
upper_bound
equal_range
五、哈希容器
常见哈希容器包括:
std::unordered_map
std::unordered_set
unordered_map 通常使用哈希表实现。
特点:
- 元素无序;
- 平均查找、插入、删除接近
O(1); - 最坏情况可能退化到
O(n); - 需要哈希函数;
- 可能发生哈希冲突。
5.1 哈希冲突
不同的键可能计算出相同的桶位置:
key1 → bucket 3
key2 → bucket 3
这就是哈希冲突。
常见处理方式之一是链地址法。
5.2 负载因子与 rehash
负载因子可以理解为:
元素数量 ÷ 桶数量
负载因子过高时,冲突会增加。
容器可能重新申请更多桶并重新计算元素位置,这个过程称为 rehash。rehash 后,原来的迭代器通常会失效。
5.3 map 与 unordered_map 的选择
需要保持键有序或范围查询时,优先考虑 map。
只关心快速按键查找、不关心顺序时,优先考虑 unordered_map。
数据量较小且主要顺序遍历时,vector 也可能更合适。
容器选择不能只看理论复杂度,还需要考虑:
- 数据量;
- 内存占用;
- 遍历性能;
- 是否要求有序;
- 是否要求范围查询;
- 哈希函数成本;
- 缓存局部性。
六、迭代器
迭代器是连接容器与算法的桥梁,可以理解为一种类似指针的对象。
auto it = values.begin();
std::cout << *it;
++it;
6.1 begin() 与 end()
begin() 指向第一个元素。
end() 指向最后一个元素后面的尾后位置。
注意:
end()不是最后一个元素,不能解引用。
6.2 左闭右开区间
STL 算法通常操作:
[first, last)
含义是包含 first,不包含 last。
6.3 begin() 与 cbegin()
cbegin() 明确返回只读迭代器。
只读查询函数可以使用:
m_logs.cbegin()
m_logs.cend()
6.4 迭代器类别
常见迭代器类别包括:
输入迭代器
输出迭代器
前向迭代器
双向迭代器
随机访问迭代器
vector 提供随机访问迭代器,list 只提供双向迭代器。
不能对 list 迭代器直接执行:
it + 3
需要使用:
std::advance
std::next
std::prev
6.5 范围 for
推荐:
for (const auto& item : result)
{
std::cout << item.id;
}
使用 const auto& 可以避免复制元素,并保证只读。
七、Lambda 表达式
Lambda 是匿名函数。
基本形式:
[capture](parameters)
{
body
}
例如:
[id](const LogItem& item)
{
return item.id == id;
}
7.1 捕获方式
[] // 不捕获
[value] // 值捕获
[&value] // 引用捕获
[=] // 默认值捕获
[&] // 默认引用捕获
[this] // 捕获当前对象
7.2 值捕获与引用捕获
值捕获保存一份副本;引用捕获操作原变量。
引用捕获必须特别注意生命周期,避免 Lambda 中保存悬空引用。
7.3 mutable
值捕获变量默认不能在 Lambda 内修改。
[value]() mutable
{
++value;
}
这里只修改 Lambda 内部保存的副本。
7.4 泛型 Lambda
C++14 支持:
[](const auto& left, const auto& right)
{
return left < right;
}
八、常用 STL 算法
STL 算法通常定义在:
#include <algorithm>
8.1 find_if
auto it = std::find_if(
logs.cbegin(),
logs.cend(),
[id](const LogItem& item)
{
return item.id == id;
}
);
找到时返回指向第一个匹配元素的迭代器;没找到时返回结束迭代器。
8.2 count_if
return std::count_if(
logs.cbegin(),
logs.cend(),
[level](const LogItem& item)
{
return item.level == level;
}
);
只返回数量,不复制日志。
8.3 any_of
return std::any_of(
logs.cbegin(),
logs.cend(),
[](const LogItem& item)
{
return item.level == LogLevel::Error;
}
);
找到第一条匹配日志后即可停止。
8.4 copy_if
std::copy_if(
m_logs.cbegin(),
m_logs.cend(),
std::back_inserter(result),
[level](const LogItem& item)
{
return item.level == level;
}
);
为什么需要 back_inserter
reserve() 只增加容量,不增加元素数量。
空 vector 中没有已经构造好的元素可供覆盖,所以需要 back_inserter 通过 push_back() 追加结果。
8.5 sort
std::sort(
logs.begin(),
logs.end(),
[](const LogItem& left, const LogItem& right)
{
return left.timestamp < right.timestamp;
}
);
为什么不能使用 <=
比较器必须满足严格弱序,一个元素不能被判断为小于自己。
因此相等时必须返回 false,应该使用 <,不能使用 <=。
8.6 remove_if
remove_if 只会把保留元素移动到前面,并返回新的逻辑末尾,不会缩小容器。
auto newEnd = std::remove_if(
values.begin(),
values.end(),
condition
);
values.erase(newEnd, values.end());
这称为 erase-remove 惯用法。
8.7 其他常用算法
std::all_of
std::none_of
std::for_each
std::transform
std::unique
std::min_element
std::max_element
std::accumulate
其中 accumulate 定义在:
#include <numeric>
九、LogManager 综合项目
LogManager 是一个单线程、内存中的日志管理器。
它负责:
添加日志
按 ID 查找
按 ID 删除
按级别筛选
按级别统计
判断是否存在错误
按时间排序
按级别和时间排序
删除空日志
第一版暂时不处理:
- 多线程;
- 自动写文件;
- 日志滚动;
- 网络上传;
- 数据库;
- 单例模式。
9.1 日志数据
enum class LogLevel
{
Debug,
Info,
Warning,
Error
};
struct LogItem
{
int id = 0;
LogLevel level = LogLevel::Info;
std::string message;
std::int64_t timestamp = 0;
};
9.2 类不变量
LogManager 需要保证:
所有日志 id 必须大于 0
所有日志 id 必须唯一
timestamp 不能小于 0
message 暂时允许为空
timestamp 允许重复
m_logs 不保证始终有序
所有修改必须通过公开接口完成
操作失败时原有状态保持不变
9.3 为什么 m_logs 必须为 private
private:
std::vector<LogItem> m_logs;
这样可以防止外部绕过 AddItem() 的检查,直接插入非法数据。
十、核心功能实现
10.1 按 ID 查找
const LogItem* LogManager::FindById(int id) const
{
auto it = std::find_if(
m_logs.cbegin(),
m_logs.cend(),
[id](const LogItem& item)
{
return item.id == id;
}
);
if (it == m_logs.cend())
{
return nullptr;
}
return &(*it);
}
返回的是 vector 内部元素地址。扩容、删除和排序后,该指针可能失效,因此不应长期保存。
10.2 添加日志
bool LogManager::AddItem(LogItem item)
{
if (item.id <= 0 || item.timestamp < 0)
{
return false;
}
if (FindById(item.id) != nullptr)
{
return false;
}
m_logs.push_back(std::move(item));
return true;
}
所有检查都在修改容器之前完成,失败时不会改变原状态。
10.3 查询数量和空状态
std::size_t LogManager::ItemSize() const noexcept
{
return m_logs.size();
}
bool LogManager::IsEmpty() const noexcept
{
return m_logs.empty();
}
10.4 按 ID 删除
bool LogManager::EraseItem(int id)
{
auto it = std::find_if(
m_logs.cbegin(),
m_logs.cend(),
[id](const LogItem& item)
{
return item.id == id;
}
);
if (it == m_logs.cend())
{
return false;
}
m_logs.erase(it);
return true;
}
10.5 按级别筛选
std::vector<LogItem>
LogManager::FindByLevel(LogLevel level) const
{
std::vector<LogItem> result;
result.reserve(m_logs.size());
std::copy_if(
m_logs.cbegin(),
m_logs.cend(),
std::back_inserter(result),
[level](const LogItem& item)
{
return item.level == level;
}
);
return result;
}
10.6 按级别统计
std::size_t
LogManager::CountByLevel(LogLevel level) const
{
return std::count_if(
m_logs.cbegin(),
m_logs.cend(),
[level](const LogItem& item)
{
return item.level == level;
}
);
}
不应通过 FindByLevel(level).size() 统计,否则会产生不必要的对象复制。
十一、剩余核心功能
11.1 判断是否存在错误日志
bool LogManager::HasError() const
{
return std::any_of(
m_logs.cbegin(),
m_logs.cend(),
[](const LogItem& item)
{
return item.level == LogLevel::Error;
}
);
}
11.2 按时间排序
void LogManager::SortByTime()
{
std::sort(
m_logs.begin(),
m_logs.end(),
[](const LogItem& left, const LogItem& right)
{
return left.timestamp < right.timestamp;
}
);
}
11.3 按级别和时间排序
int LogManager::GetLevelPriority(LogLevel level) noexcept
{
switch (level)
{
case LogLevel::Error:
return 4;
case LogLevel::Warning:
return 3;
case LogLevel::Info:
return 2;
case LogLevel::Debug:
return 1;
}
return 0;
}
void LogManager::SortByLevelAndTime()
{
std::sort(
m_logs.begin(),
m_logs.end(),
[](const LogItem& left, const LogItem& right)
{
const int leftPriority =
GetLevelPriority(left.level);
const int rightPriority =
GetLevelPriority(right.level);
if (leftPriority != rightPriority)
{
return leftPriority > rightPriority;
}
return left.timestamp < right.timestamp;
}
);
}
11.4 删除空消息
std::size_t LogManager::RemoveEmptyMessages()
{
const std::size_t oldSize = m_logs.size();
auto newEnd = std::remove_if(
m_logs.begin(),
m_logs.end(),
[](const LogItem& item)
{
return item.message.empty();
}
);
m_logs.erase(newEnd, m_logs.end());
return oldSize - m_logs.size();
}
十二、推荐的完整接口
class LogManager
{
public:
const LogItem* FindById(int id) const;
bool AddItem(LogItem item);
bool EraseItem(int id);
std::size_t ItemSize() const noexcept;
bool IsEmpty() const noexcept;
std::vector<LogItem>
FindByLevel(LogLevel level) const;
std::size_t
CountByLevel(LogLevel level) const;
bool HasError() const;
void SortByTime();
void SortByLevelAndTime();
std::size_t RemoveEmptyMessages();
private:
static int
GetLevelPriority(LogLevel level) noexcept;
private:
std::vector<LogItem> m_logs;
};
十三、测试要求
测试数据应覆盖:
多个不同 ID
多个不同日志级别
不同时间戳
重复 ID
非法 ID
负时间戳
空 message
推荐测试:
- 初始状态为空;
- 合法日志添加成功;
- 重复 ID 添加失败;
- 非法 ID 添加失败;
- 负时间戳添加失败;
- 查询存在和不存在的 ID;
- 删除存在和不存在的 ID;
- 按级别筛选;
- 按级别统计;
- 判断是否存在错误日志;
- 按时间排序;
- 按级别和时间排序;
- 删除空消息;
- 失败操作不会改变日志数量。
十四、第三阶段必须掌握的问题
为什么使用 vector 保存日志
因为日志主要进行尾部添加、顺序遍历、筛选和排序,vector 连续内存、遍历效率高,并支持 std::sort。
find_if 返回什么
返回指向第一个满足条件元素的迭代器;没有找到时返回结束迭代器。
为什么找不到时等于 end()
因为算法操作 [first, last) 区间,遍历到尾后位置仍未找到时,就返回 last。
为什么 FindById 返回的指针可能失效
因为它指向 vector 内部元素,扩容、删除和排序都可能改变元素地址或位置。
为什么 copy_if 配合 back_inserter
因为结果容器一开始没有元素,back_inserter 会通过 push_back() 把匹配结果追加到容器尾部。
为什么 reserve() 不改变 size()
因为 reserve() 只申请容量,不构造元素。size() 表示真实存在的元素数量。
为什么 remove_if 不能真正删除元素
因为算法只整理迭代器区间,不改变容器大小。它返回新的逻辑末尾,真正删除还需要调用 erase()。
为什么排序比较器不能使用 <=
因为比较器必须满足严格弱序,相等元素比较时必须返回 false。
Lambda 值捕获与引用捕获有什么区别
值捕获保存副本,引用捕获操作原变量。引用捕获必须注意生命周期。
vector 删除和扩容后哪些迭代器失效
扩容后所有迭代器、指针和引用失效;删除后,删除位置及其后面的迭代器、指针和引用失效。
十五、阶段总结
第三阶段的重点不是背诵接口,而是形成解决数据管理问题的方法:
分析数据特征
↓
选择合适容器
↓
使用迭代器描述区间
↓
使用 Lambda 描述条件
↓
使用 STL 算法处理数据
↓
理解容器修改后的失效规则
LogManager 将第三阶段知识串联起来:
| 功能 | 使用的知识 |
|---|---|
| 保存日志 | vector |
| 添加日志 | push_back、移动语义 |
| 按 ID 查找 | find_if |
| 删除指定日志 | find_if、erase |
| 按级别筛选 | copy_if、back_inserter |
| 按级别统计 | count_if |
| 判断错误日志 | any_of |
| 按时间排序 | sort |
| 多条件排序 | 比较器、严格弱序 |
| 删除空消息 | remove_if + erase |
| 保护内部数据 | private、类不变量 |
| 只读查询 | const 正确性 |
完成这一阶段后,可以进入第四阶段:
继承
多态
虚函数
虚析构
抽象接口
对象切片
虚函数表
RTTI
届时可以继续改造日志项目,将输出部分抽象为:
ILogSink
ConsoleLogSink
FileLogSink
MemoryLogSink
让 LogManager 只负责管理日志,不同输出组件负责将日志写入不同目标。
更多推荐

所有评论(0)