OOP 朋辈辅学 Lec6 迭代器与智能指针¶
1 头文件¶
1. 1 不同的头文件¶
C/C++ 编程中常见三类头文件:
- C++ 标准库头文件:由 C++ 标准定义,通常随编译器分发,大多不带
.h后缀,如<iostream>,内容定义在std命名空间中 - C 标准库头文件:最初为 C 语言设计,C++ 兼容了它们
- 系统级头文件:由操作系统提供,暴露系统 API
stdio.h 和 cstdio 的区别
stdio.h继承自 C 语言,通常把名字放在全局命名空间cstdio是 C++ 风格头文件,通常把名字放在std命名空间
系统级头文件
- Windows 系统常见
<windows.h>,遵循 Win32 API - Linux、macOS、BSD 等 Unix-like 系统通常遵循 POSIX 标准
1. 2 避免重复包含¶
大型项目中,头文件可能被多次嵌套包含。如果不做保护,编译器可能因为重复定义而报错。
方案 1:include guard
#ifndef _INCLUDE_FILE_NAME_H_
#define _INCLUDE_FILE_NAME_H_
// 定义...
#endif
方案 2:#pragma once
#pragma once
// 定义...
两种方案
- include guard 是标准做法,兼容性最好
#pragma once写法更简洁,现代编译器普遍支持
1. 3 项目组织¶
C++ 工程实践中通常遵循声明与实现分离。
.h/.hpp文件:存放类、函数、模板等声明.cpp文件:存放具体逻辑实现
项目结构
project/
├── main.cpp
├── math_utils.h // 声明
└── math_utils.cpp // 实现
模板的特殊性
普通函数和类通常可以把声明放头文件、实现放 .cpp。但模板需要在实例化时让编译器看到完整定义,因此模板实现经常直接写在头文件里。
2 迭代器¶
2. 1 设计初衷¶
迭代器(Iterator)是一种抽象访问方式。通过迭代器,可以在不了解容器内部实现的情况下遍历容器,就像在处理数组一样。
STL 中,迭代器连接了容器和算法:
- 容器提供迭代器接口
- 算法只依赖迭代器
- 算法不直接依赖具体容器类型
算法通过迭代器作用于容器
sort(vec.begin(), vec.end());
find(lst.begin(), lst.end(), 10);
同一套算法可以服务于不同容器,这就是 STL 中“容器”和“算法”能够解耦的关键。
2. 2 为什么不都用下标¶
并不是所有容器都适合使用下标访问。
不同容器的访问方式
vector底层连续存储,可以高效使用vec[i]list底层是链表,访问第i个元素需要从头走过去set/map内部通常是树结构,没有“第几个元素”的自然含义
迭代器提供统一遍历方式,让代码不必关心容器内部到底是数组、链表还是树。
2. 3 基本操作¶
多数迭代器的语法和指针很像。
常见迭代器操作
*iter:解引用,获取当前元素++iter/iter++:移动到下一个位置==/!=:判断两个迭代器是否指向同一位置iter->member:访问当前元素的成员
不是所有迭代器都支持随机跳转
vector 的迭代器可以写 it + 3,但 list 的迭代器不行,因为链表无法直接跳到后面第 3 个结点。
2. 4 获取迭代器¶
容器通常提供 begin() 和 end() 获取遍历范围。
begin():返回指向第一个元素的迭代器end():返回指向最后一个元素之后位置的迭代器
end() 不指向最后一个元素
end() 是一个“哨兵”位置。当 it == container.end() 时,表示已经遍历完所有有效元素。
显式写出迭代器类型
vector<int> vec = {1, 3, 5, 7, 9};
for (vector<int>::iterator it = vec.begin();
it != vec.end(); ++it) {
cout << *it << " ";
}
为什么是 vector<int>::iterator
vector 是模板,只有指定了元素类型后才形成具体类。因此不存在单独的 vector::iterator,只能写 vector<int>::iterator。
2. 5 const_iterator¶
如果只需要读取元素,不希望通过迭代器修改元素,应使用 const_iterator。
只读遍历
vector<int> vec = {1, 3, 5};
for (vector<int>::const_iterator it = vec.cbegin();
it != vec.cend(); ++it) {
cout << *it << " ";
// *it = 10; // 错误:不能通过 const_iterator 修改
}
cbegin() 和 cend()
cbegin() / cend() 返回只读迭代器,适合表达“这里只读,不修改容器元素”的意图。
2. 6 range-for 的本质¶
C++11 之后常用的范围 for 循环,本质上也是基于迭代器。
范围 for
vector<int> vec = {1, 3, 5};
for (int& x : vec) {
x *= 2;
}
可以近似理解为:
等价理解
for (auto it = vec.begin(); it != vec.end(); ++it) {
int& x = *it;
x *= 2;
}
2. 7 迭代器与算法¶
不是所有算法都能作用于所有容器。算法需要的迭代器能力,必须由容器提供。
list 不能直接用 std::sort
list<int> lst = {4, 1, 3, 2};
// sort(lst.begin(), lst.end()); // 错误
lst.sort(); // 正确
std::sort 需要随机访问迭代器,而 list 只提供双向迭代器,因此 list 提供了自己的成员函数 sort()。
2. 8 迭代器失效¶
迭代器并不总是永久有效。容器结构发生变化时,旧迭代器可能失效。
vector 扩容导致迭代器失效
vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin() + 2; // it 指向 3
vec.push_back(6); // 可能触发重新分配
// *it = 10; // 未定义行为:it 可能已经失效
vector 扩容后,底层数组地址可能改变,原来的迭代器就会指向错误地址。
删除时使用 erase 返回的新迭代器
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;
}
}
常见失效规则
vector扩容会导致所有迭代器失效vector::erase会导致被删位置及其之后的迭代器失效list删除一个结点通常只会让指向该结点的迭代器失效map/set插入元素通常不会让已有迭代器失效
不同容器规则并不完全相同,写代码时应查对应容器的失效规则。
2. 9 迭代器的实现¶
迭代器本质上是一种行为模拟。它可以通过类模板封装原始指针,并重载 ++、*、->、== 等运算符,让对象用起来像指针。
简化后的 vector 迭代器
template <typename T>
class MyVectorIterator {
private:
T* m_ptr;
public:
MyVectorIterator(T* ptr): m_ptr(ptr) {}
T& operator*()
{
return *m_ptr;
}
MyVectorIterator& operator++()
{
m_ptr++;
return *this;
}
bool operator!=(const MyVectorIterator& other)
{
return m_ptr != other.m_ptr;
}
};
3 智能指针¶
3. 1 诞生背景¶
C/C++ 需要程序员直接面对内存管理,因此内存泄漏一直是重要问题。后来的许多语言提供了自动内存管理,有的甚至移除了指针。
手动管理和自动管理
- 自动管理下限高,程序员不容易忘记释放资源
- 手动管理上限高,经验丰富的程序员可以更精细地控制性能和生命周期
智能指针(smart pointer)就是 C++ 中用来降低内存泄漏风险的重要工具。
C++11 之后,标准库主要提供三种智能指针,定义在 <memory> 中:
unique_ptr:独占所有权shared_ptr:共享所有权weak_ptr:弱引用,不拥有对象
课件从已经废弃的 auto_ptr 开始,是为了理解智能指针的发展历程。
3. 2 RAII 思想¶
智能指针依赖 RAII(Resource Acquisition Is Initialization,资源获取即初始化)思想。
RAII 的核心
把资源绑定到对象生命周期上。
- 构造对象时获取资源
- 对象离开作用域时自动析构
- 析构函数中释放资源
这样即使函数中途 return 或抛出异常,只要栈对象能正常析构,资源就有机会被释放。
手动管理容易遗漏释放
void f()
{
int* p = new int(10);
if (error()) {
return; // 忘记 delete,内存泄漏
}
delete p;
}
智能指针把 delete 放进析构函数中,让释放动作随对象生命周期自动发生。
3. 3 auto_ptr¶
auto_ptr 是 C++98 标准库提供的早期智能指针,也采用了 RAII 思想。它包装原始指针,并在 auto_ptr 对象生命周期结束时自动 delete 指向的对象。
auto_ptr 的基本用法
{
auto_ptr<int> sp(new int(8));
} // sp 析构,释放堆上的 int
auto_ptr 真正的问题在于“拷贝”。
auto_ptr 的破坏性拷贝
auto_ptr<int> sp1(new int(8));
auto_ptr<int> sp2(sp1);
if (sp1.get() == NULL) {
cout << "sp1 is empty" << endl;
}
把一个 auto_ptr 赋给另一个时,原指针会变成空,新指针接管资源。这种行为今天看其实是移动,但当时却被设计成拷贝,违反直觉。
auto_ptr 的问题
- 拷贝会偷偷转移所有权,容易引发 bug
- 与 STL 容器不兼容
- 内部使用
delete,不能管理动态数组 - 已经被废弃,不应在新代码中使用
3. 4 unique_ptr¶
unique_ptr 表示独占所有权。同一时间只能有一个 unique_ptr 指向某个对象,因此它只支持移动,不支持拷贝。
独占所有权
unique_ptr<int> ptr1(new int(10));
// unique_ptr<int> ptr2 = ptr1; // 错误:不能拷贝
unique_ptr<int> ptr2 = move(ptr1); // 正确:转移所有权
make_unique
C++14 引入了 make_unique。在 C++11 中,常见写法仍是用 new 直接初始化 unique_ptr。
unique_ptr 开销极小,性能上几乎和原始指针相同。它适合用于局部变量、类成员,或函数参数中明确只有一个拥有者的资源。
unique_ptr 常用成员函数
get():返回原始指针,但不转移所有权release():放弃所有权,返回原始指针,并将unique_ptr置空;注意它不会销毁对象,需要手动deletereset():销毁当前管理的对象,并可选地指向一个新资源
unique_ptr 经常用于表达函数之间的所有权转移。
把所有权交给函数
void take(unique_ptr<int> p)
{
cout << *p << endl;
} // p 析构,资源释放
int main()
{
auto p = make_unique<int>(10);
take(move(p)); // 所有权交给 take
}
调用 move(p) 之后,原来的 p 不再拥有对象,不能继续解引用它。
如果函数只是临时访问对象,不应该把 unique_ptr 传进去,而应传原始指针或引用。
只借用,不转移所有权
void print(const int* p)
{
if (p != nullptr) {
cout << *p << endl;
}
}
auto p = make_unique<int>(10);
print(p.get());
一句话原则
谁负责释放资源,谁才应该拥有智能指针。
3. 5 shared_ptr¶
shared_ptr 允许多个智能指针共同指向同一个对象。它内部维护引用计数。
- 新的
shared_ptr指向对象时,引用计数加 1 shared_ptr销毁或重置时,引用计数减 1- 引用计数降为 0 时,自动释放对象
共享所有权
void example()
{
shared_ptr<int> ptr1 = make_shared<int>(20);
{
shared_ptr<int> ptr2 = ptr1; // 引用计数变为 2
} // ptr2 析构,引用计数变为 1
} // ptr1 析构,引用计数变为 0
shared_ptr 的内部结构
shared_ptr 通常包含两个指针:
- 指向受管对象的指针
- 指向控制块的指针,控制块里保存引用计数等信息
C++11 中推荐使用 make_shared 初始化 shared_ptr,因为它可以一次性分配对象和控制块,效率更高,也更安全。
不要用同一个原始指针构造多个 shared_ptr
int* raw = new int(10);
shared_ptr<int> p1(raw);
shared_ptr<int> p2(raw); // 错误:产生两个控制块
p1 和 p2 各自以为自己拥有这块内存,最后可能发生两次 delete。
正确做法是从已有的 shared_ptr 拷贝。
正确共享
auto p1 = make_shared<int>(10);
auto p2 = p1;
不过,shared_ptr 存在循环引用问题。
循环引用
class B;
class A {
public:
shared_ptr<B> ptrB;
~A() { cout << "A 被销毁" << endl; }
};
class B {
public:
shared_ptr<A> ptrA;
~B() { cout << "B 被销毁" << endl; }
};
int main()
{
auto a = make_shared<A>();
auto b = make_shared<B>();
a->ptrB = b;
b->ptrA = a;
}
运行程序看不到析构输出,说明对象没有被销毁。
为什么引用计数无法归零
main 结束时,局部变量 a 和 b 会析构,但:
A对象还被B::ptrA指着B对象还被A::ptrB指着- 两个对象互相持有对方,使引用计数都停在 1
3. 6 weak_ptr¶
weak_ptr 用来解决 shared_ptr 的循环引用问题。它不拥有对象,也不会增加引用计数。
用 weak_ptr 打破循环
class B;
class A {
public:
shared_ptr<B> ptrB;
~A() { cout << "A 被销毁" << endl; }
};
class B {
public:
weak_ptr<A> ptrA; // 不增加 A 的引用计数
~B() { cout << "B 被销毁" << endl; }
};
int main()
{
auto a = make_shared<A>();
auto b = make_shared<B>();
a->ptrB = b; // A 拥有 B
b->ptrA = a; // B 只观察 A,不拥有 A
}
此时 main 结束后,引用计数可以正常归零,A 和 B 都会被释放。
由于 weak_ptr 不拥有对象,它指向的对象可能已经被释放。因此不能直接通过 * 或 -> 访问,需要先调用 lock() 尝试获得临时 shared_ptr。
weak_ptr::lock
weak_ptr<int> wp;
{
auto sp = make_shared<int>(42);
wp = sp;
if (auto tmp = wp.lock()) {
cout << *tmp << endl;
}
}
weak_ptr 的常见场景
- 缓存
- 观察者模式
- 父子结点互相引用
- 只观察对象但不延长对象生命周期
3. 7 选择建议¶
实际写代码时,应根据所有权关系选择智能指针。
选择规则
- 明确只有一个拥有者:优先使用
unique_ptr - 确实需要多个拥有者:使用
shared_ptr - 只想观察对象、不延长生命周期:使用
weak_ptr - 只临时访问对象:使用引用或原始指针
不要为了“安全”默认把所有指针都写成 shared_ptr。所有权越清晰,程序越容易维护。