C++基础:Stanford CS106L学习笔记 6 迭代器

目录

      • [6.1 迭代器基础](#6.1 迭代器基础)
      • [6.2 迭代器类别](#6.2 迭代器类别)
        • [6.2.1 Output](#6.2.1 Output)
        • [6.2.2 Input](#6.2.2 Input)
        • [6.2.3 Forward](#6.2.3 Forward)
        • [6.2.4 Bidirectional](#6.2.4 Bidirectional)
        • [6.2.5 Random Access](#6.2.5 Random Access)
        • [6.2.6 Contiguous](#6.2.6 Contiguous)
      • [6.3 迭代器失效](#6.3 迭代器失效)
      • [6.4 迭代器变体](#6.4 迭代器变体)
        • [6.4.1 const迭代器](#6.4.1 const迭代器)
        • [6.4.2 反向迭代器](#6.4.2 反向迭代器)

迭代器为遍历容器数据结构提供了统一的接口。

指针指向任何对象,迭代器指向容器元素。

尽管不同容器背后的实现方式(利用的数据结构)不同,但我们仍然可以做一些特定操作,不必关心背后的实现,比如:

cpp 复制代码
std::vector<int> container { 1, 2, 3 };

// The for loop below will still work if we had defined container as:
//
//    - std::deque<int> container;
//    - std::map<std::string, int> container;
//    - std::set<int> container;
//    - std::unordered_map<std::string, int> container;
 
for (const auto& elem : container) {
  // Do something with `elem`
}

这种语法是如何工作的呢?在底层,编译器会对上述代码进行如下的语法转换:

cpp 复制代码
std::vector<int> container { 1, 2, 3 };

auto begin = std::begin(container);
auto end = std::end(container);

for (auto it = begin; begin != end; ++it) {
  const auto& elem = *it;
  // Do something with `elem`
}

什么是std::begin(container)?什么是it?我们将会看到,这些都是​迭代器​,它们是一种数据类型,与指针具有相同的语义,能够对数据结构进行通用迭代,而不受该数据结构实现方式的影响。

6.1 迭代器基础

容器和迭代器协同工作以支持迭代。

6.1.1 容器接口

粗略地说,迭代器的作用类似于指向容器中某个元素的指针。容器的迭代器定义了一个元素序列 ------ 每个迭代器都知道如何找到序列中的下一个迭代器。每个 C++ 容器都有内置方法,这些方法会返回指向容器开头和结尾的迭代器。这些方法保证以常数时间运行。

容器方法 描述
std::begin(c)或c.begin() 获取指向容器第一个元素的迭代器
std::end(c)或c.end() 获取指向容器的超尾 元素(末尾后的第一个元素)的迭代器,实际上不会指向任何元素​
cpp 复制代码
std::set<int> s { 1, 2, 3, 4, 5 };
auto begin = s.begin();
auto end = s.end();

空容器中,c.begin()c.end()指向同一个不存在的元素

cpp 复制代码
std::set<int> s;
auto begin = s.begin();
auto end = s.end();
6.1.2 迭代器接口

迭代器都提供了一套简单的操作,以允许遍历容器。与容器方法一样,这些操作也需要在常数时间内运行。

迭代器方法 描述
auto it = c.begin() ​复制构造:​​operator=会创建一个现有迭代器的副本,该副本指向同一个元素。
++it OR it++ ​递增:​ operator++将迭代器向前移动到下一个元素。
it == c.end() ​比较:​ operator==用于判断两个迭代器是否指向同一个元素。
*it ​间接引用: operator返回对底层元素的引用。该引用能否被读取或写入,取决于it是输入迭代器还是输出迭代器。

注意不要将迭代器递增到超出末尾的位置(例如++c.end()),也不要尝试解引用尾后迭代器(*c.end())。这两种操作在 C++ 中都是未定义行为!

迭代器与指针

结合容器接口和迭代器接口,弄明白以下代码在做什么:

cpp 复制代码
std::vector<int> container { 1, 2, 3 };

auto begin = std::begin(container);
auto end = std::end(container);

for (auto it = begin; begin != end; ++it) {
  const auto& elem = *it;
  // Do something with `elem`
}

如果你已经阅读并熟悉第5节,你可能会注意到迭代器的语义与指针的语义非常相似。和指针一样,迭代器可以被解引用、递增、比较等等。

从某种意义上说,​迭代器是指针的一种泛化,指向的内存不一定是连续的 ​。这使得更复杂的数据类型(如unordered_map)看起来仿佛它们的元素是在内存中连续存储的。

++itit++
cpp 复制代码
/** The prefix form, e.g. ++it */
Iterator& operator++() {
  /*
   * Code that moves this iterator to the next element
   */
  return *this;
}

/** The postfix form, e.g. it++
 *
 * Note the `int` in the signature exists only to differentiate
 * from the above method. 
 */
Iterator operator++(int) {
  Iterator prev = *this;    // Save a copy the iterator's current state
  ++*this;                  // Move the iterator forward using the prefix form above
  return prev;              // Return the **old** state!
}

前缀形式(++it)会就地更新迭代器,并返回对同一个迭代器的引用。后缀形式(it++)同样会更新迭代器,但​返回的是迭代器递增之前的旧值的副本 ​。因此,it++可能会比++it稍慢一些,因为会创建一个额外的副本。

++it是左值,it++是右值

左值和右值见第2节2.3

要理解"++i、--i是左值,i++、i--是右值",核心在于​操作后是否返回"原变量本身",即可寻址、能被赋值​,关键区别如下:

  1. ​++i(前置自增)与 --i(前置自减)​ 执行逻辑是"先修改变量值,再返回变量本身"。比如 ++i 会先把 i 的值加1,然后直接返回 i 这个变量(不是临时值)------ 变量有明确内存地址,能被后续操作赋值(比如 (++i) = 10 是合法的),因此符合"左值(具名、可寻址)"的定义。
  2. ​i++(后置自增)与 i--(后置自减)​ 执行逻辑是"先保留变量当前值到临时变量,再修改原变量值,最后返回临时变量"。比如 i++ 会先存一份 i 的原始值到临时空间,再给 i 加1,最终返回的是那个"临时值"------ 临时值没有名字、用完就销毁,无法被寻址或赋值(比如 (i++) = 10 会编译报错),因此属于"右值(临时、不可寻址)"。
6.1.3 迭代器类型

到目前为止,我们已经使用了auto 来让编译器推断类型,例如c.begin() 的类型。那么迭代器的实际类型是什么呢?这将取决于容器,但一般来说,给定容器类型C,其迭代器类型将是C::iterator。例如,std::string::iteratorstd::unordered_map<std:string, double>::iterator 都是迭代器类型。

在内部,编译器会为每种数据结构实现这些迭代器类型,并在容器类内部通过using定义为类型别名。

cpp 复制代码
// vector
template <typename T>
class std::vector {
public:
  using iterator = T*;
};

// unordered_map
template <typename K, typename V>
class std::unordered_map {
public:
  using iterator = _unordered_map_iterator<K, V>;
};

template <typename K, typename V>
struct _unordered_map_iterator {
  // Advance to the next element in the map
  void operator++();

  // Get access to the element associated with the iterator
  std::pair<const K, V>& operator*();
};

6.2 迭代器类别

并非所有迭代器都支持相同的操作集。由于其容器的内部定义方式不同,有些迭代器比其他迭代器更 "强大"。这主要是因为 C++ 标准中规定,所有迭代器方法的运行时复杂度都必须为 O (1)。

例如,使用向量迭代器向前跳 n 个元素是很容易的,因为向量的内存是按顺序排列的,只需 O (1) 时间;但对于元素以分布式方式排列的std::unordered_map来说,要做到这一点就不那么简单了。这就产生了多种不同的​迭代器类别​:每个容器的迭代器根据其支持的操作,会归入以下类别中的一种。

6.2.1 Output

如果一个迭代器支持通过operator=覆盖其指向的元素,那么它就是一个​输出迭代器​。比如:

*it = elem;

覆盖元素: operator*返回一个引用,该引用的值可以被覆盖,从而改变迭代器所指向元素的值。

如果修改容器的元素需要对容器进行重构,那么某些容器迭代器将不属于输出迭代器。例如,更改 std::map 中某个元素的键可能会改变该元素在其二叉搜索树中的位置,因此 std::map<K, V>::iterator不属于输出迭代器。

6.2.2 Input

迭代器如果支持读取所指向的元素,则是一个​输入迭代器​,例如:

auto elem = *it;

读取元素: operator*返回一个引用,该引用表示迭代器所指向的元素。

几乎所有迭代器都是输入迭代器------ 事实上,以下所有迭代器类别都是输入迭代器的特化版本。

6.2.3 Forward

到目前为止,我们实际上还没有明确说明对容器的迭代器进行多次遍历是否有效。例如,对于在begin迭代器和end迭代器之间的元素范围,多次遍历这些元素是否有效?

cpp 复制代码
for (auto it = begin; it != end; ++it) {
  std::cout << *it << " ";
}

std::cout << "\n\n";

/* Are we allowed to run this for loop again with the same iterators? */
for (auto it = begin; it != end; ++it) {
  std::cout << *it << " ";
}

前向迭代器保证多次遍历是有效的。

it1​==it2 ==> ++it1==++it2

前向迭代器必须满足​多趟保证 ​。也就是说,给定指向同一元素的迭代器aba == b),必须满足++a == ++b。用通俗的话讲,将两个迭代器都向前移动(无论顺序如何),它们都应指向同一个元素。

每个 STL 容器的迭代器都是前向迭代器 ------ 直观地说,这是合理的:简单地遍历容器的元素不会以阻止再次遍历的方式改变它们。

然而,在 STL 容器之外,情况通常并非如此:考虑std::istream_iterator,它允许我们迭代istream的元素,如下例所示:

cpp 复制代码
std::istringstream str("0.1 0.2 0.3 0.4");

auto begin = std::istream_iterator<double>(str);
auto end = std::istream_iterator<double>();

for (auto it = begin; begin != end; ++it) {
  std::cout << *it << " ";
}

因此,std::istream_iterator 不是前向迭代器。

哪种数据结构可能不需要多遍迭代器?

流!!!

6.2.4 Bidirectional

双向迭代器是一种正向迭代器,它既可以向前移动,也可以向后移动,例如

--it;OR it--;

注意不要在begin迭代器之前进行递减操作!编写--c.begin()属于未定义行为,就像++c.end()一样。

递减: operator-- 将迭代器向后移动到前一个元素。

如果能够找到前一个元素,容器的迭代器就是双向的。如果容器是顺序的(比如 vector)或者其元素是经过排序的(比如std::map),就可能出现这种情况。有些容器很难从迭代器向后移动(或者选择不提供这种功能),比如std::unordered_map

6.2.5 Random Access

随机访问迭代器是一种双向迭代器,它支持一次向前或向后跳跃多个元素。

auto it = c.begin() + n;

负值会使迭代器向后移动。这些迭代器在语法上与指针非常相似。例如,我们可以使用operator[]将递增 / 解引用操作结合起来:

cpp 复制代码
std::vector<int> v { 1, 2, 3 };
auto it = v.begin();
auto elem = it[2];      // Same as *(it + 2)

注意不要越界或解引用​end迭代器!

支持的操作 描述
it += n 随机访问 : 如果 n 为正数,operator+=会将it向前移动n步 ------ 这与调用it++n 次的效果相同,只是该操作是在常数时间内完成的。当 n 为负数时,operator+=会将it向后移动。
it -= n 随机访问 : 与it += -n相同
it + n 随机访问 :创建一个新的迭代器,向前n
it - n 随机访问 :创建一个新的迭代器n步向后跳跃
it1 < it2 it1 <= it2 it1 > it2 it1 >= it2 有序比较 :检查it1在序列中分别位于it2之前还是之后
复制代码
                                                                                                                                    |
6.2.6 Contiguous

连续迭代器 是随机访问迭代器的一个子集,它进一步规定其元素在内存中是连续存储的。例如,std::deque是随机访问的,但不是连续的,见第4节4.3.2。

连续迭代器和随机访问迭代器之间没有太大区别。然而,获取这些迭代器所指向元素的地址(&*it)会发现它们在内存中是连续存储的。

6.3 迭代器失效

如果我们修改迭代器的底层容器,迭代器会发生什么情况?迭代器与指针非常相似,它们指向其元素在内存中的固定位置,再加上一些用于推导下一个元素位置的记录数据。因此,重构容器的操作可能会使先前获取的迭代器失效。如果我们不够谨慎,这可能会导致未定义的行为。

以下表格总结了在本教材讨论的容器中,哪些操作会使迭代器失效,哪些操作不会使迭代器失效:

方法 迭代器有效吗? 前提条件/注意事项
std::vector
push_back insert capacity()​​已改变​。如果向量必须重新分配其内部缓冲区,那么元素将被复制到新的缓冲区中,这会使所有现有的迭代器失效。
push_back insert **被修改元素之后的迭代器。**这些迭代器会被向前推移,因此它们将不再指向相同的元素。
push_back insert 所有其他情况
pop_back erase 修改元素后的迭代器​。这些迭代器会向后移动,因此它们将不再指向相同的元素。
pop_back erase 所有其他情况
std::deque
push_front push_back insert 所有迭代器失效
pop_front pop_back 指向首尾元素的迭代器
pop_front pop_back 所有其他情况
std::map,std::set
insert operator[]
erase 除了指向已删除元素的迭代器之外
std::unordered_map, ​std::unordered_set
insert operator[] 插入导致了​重哈希 ​,在频繁使用迭代器场景中,std::unordered_map/set类型不稳定,更容易因冲哈希失效;而std::map/set因其基于红黑树,只影响元素本身,稳定性更强。
insert operator[]
erase 除了指向已删除元素的迭代器之外

6.4 迭代器变体

在实际使用 C++ 时,你偶尔会发现上述迭代器概念的一些变体。这些迭代器 "种类" 使我们能够更恰当地处理const容器,也能以相反顺序使用双向迭代器。

6.4.1 const迭代器

对于一个const容器,我们不希望允许通过其迭代器修改该容器的元素。这就是const正确性的理念 ------ 被标记为 const 的对象不应该允许通过其接口的任何部分进行修改。

const迭代器 允许容器类型遵循这一原则。元素类型为T的容器类型C实际上会有两种迭代器类型:

  • C::iterator,它指向类型为 T 的元素(例如,std::string::iterator 指向 char
  • C::const_iterator,它指向类型为 const T(例如 std::string::const_iterator指向 const char

这意味着你不能修改const_iterator所指向的元素。因此,每个const_iterator必然不是输出迭代器,因为你无法写入底层元素。但是,你仍然可以有非输出容器,它们的iteratorconst_iterator类型之间存在有意义的区别!

例:想想 std::map::iterator,它指向一个代表映射中键值对的 std::pair<const K, V>。这个迭代器不是输出迭代器,因为修改整个键值对可能会改变键,而这会改变该条目在映射中的存储位置(关于原因,参见有关第4节关联容器的章节)------ 这也正是键是 const K 的原因。

cpp 复制代码
std::map<std::string, size_t> m { { "Fabio", 10 }, { "Jacob", 4 } };

auto it = m.begin();    // Iterator to { "Fabio", 10 }
it->second = 106;       // Changes element to { "Fabio", 106 }

现在考虑如果it是一个const_iterator会发生什么:

cpp 复制代码
const std::map<std::string, size_t> m { { "Fabio", 10 }, { "Jacob", 4 } };

auto it = m.begin();    // std::map<std::string, size_t>::const_iterator
// it->second = 106;    // This line doesn't compile!

在这个更新后的示例中,它是一个const_iterator,因为m 被标记为const(常量正确性)。由于it 是一个const_iterator,整个元素都是const 的,我们无法像之前那样将"Fabio" 的值更新为106。

实际上,const_iterator可用于任何预期使用同一容器的 iterator​ 的地方,只要不需要进行修改即可。 例如,不修改其范围的迭代器算法(将在下一章讨论)(例如,std::count_if 用于统计两个迭代器之间的元素数量),在const_iterator 上的工作效果与在常规iterator 上的工作效果一样好。然而,会修改其范围的算法(例如,std::sort 会在两个迭代器之间原地对元素进行排序)无法用于 const_iterator 进行编译。

给定一个const容器c,调用c.begin()c.end()会通过这些方法提供的const重载版本自动返回const_iterator。不过,如果你需要一个用于非const容器的const迭代器,可以通过每个标准库容器上定义的以下便捷方法来获取:

容器方法 描述
std::cbegin(c) c.cbegin() 获取指向容器第一个元素的const_iterator
std::cend(c) c.cend() 获取指向容器的超尾 元素的const_iterator
6.4.2 反向迭代器

双向迭代器为我们提供了一种简便的方式来创建容器中元素的逆序排列。如果我们想要反向遍历容器,或者希望以相反的顺序应用迭代器算法(例如,std::find(c.begin(), c.end(), v) 从左侧开始搜索值 v------ 要是我们想从右侧开始搜索该怎么办呢?),这就非常有用。而这正是 反向迭代器 的作用。

所有其迭代器为双向迭代器的容器,都提供了一个反向迭代器接口,用于反向遍历序列。这些方法与其对应的普通方法类似:

容器方法 描述
std::rbegin(c) c.rbegin() 获取指向容器反转序列中第一个元素的迭代器。解引用此迭代器会得到容器常规(未反转)序列中的最后一个元素。
std::rend(c) c.rend() 获取指向容器反向序列末尾后元素的迭代器。解引用此迭代器是无效的 ------ 从概念上讲,它指向容器常规(非反向)序列中第一个元素之前的一个不存在的元素。
std::crbegin(c) c.crbegin() 返回const迭代器版本的rbegin()
std::crend(c) c.crend() 返回const迭代器版本的rend()

反向迭代器只能存在于双向迭代器中:在后台,反向迭代器存储一个常规迭代器,并且对反向迭代器执行向前移动(operator++)会使所存储的迭代器递减(operator--)。反向迭代器是作为双向迭代器周围的模板化包装器(std::reverse_iterator)来实现的,因此容器无需实现任何额外功能即可达到此效果。实际上,反向迭代器存储的是一个指向其解引用值的下一个元素的迭代器,如下方图表所示。

cpp 复制代码
std::vector<int> v { 1, 2, 3, 4, 5 };

auto rbegin = v.rbegin();
auto rend = v.rend();

从概念上讲,我们认为反向迭代器指向一个反向序列中的元素,其语义与普通迭代器相同。

反向迭代器实际上存储了一个迭代器,该迭代器比它们概念上指向的位置​超前一位​(即在普通序列中)。

这种实现细节背后的动机是,真正的 "起始前" 迭代器(即rend所代表的迭代器)在 C++ 中会是未定义行为 ------ 回想一下,--v.begin()在 C++ 中是无效的。

我们可以通过调用反向迭代器的成员函数base()来获取它所存储的实际迭代器,例如:

cpp 复制代码
std::vector<int> v { 1, 2, 3, 4, 5 };

auto rb = v.rbegin();
auto re = v.rend();

auto rb_base = rb.base();
auto re_base = re.base();

注意,v.rbegin()实际上将v.end()存储为其base迭代器,而v.rend()存储v.begin()

反向迭代器通常继承其底层迭代器的类别(例如,随机访问迭代器在反向时仍是随机访问迭代器)。唯一的例外是连续迭代器 ------ 由于元素是反向排序的,当在反向序列中向前移动时,地址是递减而非递增的,因此它们不再严格连续。

相关推荐
瑶光守护者11 小时前
【学习笔记】5G RedCap:智能回落5G NR驻留的接入策略
笔记·学习·5g
你想知道什么?11 小时前
Python基础篇(上) 学习笔记
笔记·python·学习
小小晓.11 小时前
Pinely Round 4 (Div. 1 + Div. 2)
c++·算法
SHOJYS11 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法
weixin_4093831211 小时前
简单四方向a*学习记录4 能初步实现从角色到目的地寻路
学习·a星
steins_甲乙11 小时前
C++并发编程(3)——资源竞争下的安全栈
开发语言·c++·安全
xian_wwq11 小时前
【学习笔记】可信数据空间的工程实现
笔记·学习
煤球王子11 小时前
学而时习之:C++中的异常处理2
c++
浩瀚地学12 小时前
【Arcpy】入门学习笔记(五)-矢量数据
经验分享·笔记·python·arcgis·arcpy
仰泳的熊猫12 小时前
1084 Broken Keyboard
数据结构·c++·算法·pat考试