Skip to content

OOP 朋辈辅学 Lec6 迭代器与智能指针

1 头文件

1. 1 不同的头文件

C/C++ 编程中常见三类头文件:

  • C++ 标准库头文件:由 C++ 标准定义,通常随编译器分发,大多不带 .h 后缀,如 <iostream>,内容定义在 std 命名空间中
  • C 标准库头文件:最初为 C 语言设计,C++ 兼容了它们
  • 系统级头文件:由操作系统提供,暴露系统 API

stdio.hcstdio 的区别

  • 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 置空;注意它不会销毁对象,需要手动 delete
  • reset():销毁当前管理的对象,并可选地指向一个新资源

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);  // 错误:产生两个控制块

p1p2 各自以为自己拥有这块内存,最后可能发生两次 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 结束时,局部变量 ab 会析构,但:

  • 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 结束后,引用计数可以正常归零,AB 都会被释放。

由于 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。所有权越清晰,程序越容易维护。