iterator迭代器
迭代器其实还是很安全的,这是在某些情况下,稍微不注意就会发生一些逻辑上的错误,从而导致实际的结果与猜测的结果发生产生分歧。
我就用vector和list模板中的迭代器来简单分析一下
什么是迭代器
这就是迭代器,他没有我们想的那么高大上,他只是给指针类型的变量起了一个统一的别名罢了。
//冲定义迭代器的类型
typedef T* iterator;
typedef const T* const_iterator;
平常我们遍历一个数组的时候,是这样写的
for(int i = 0; i < len; i++)
cout<<arr[i]<<' ';
cout<<endl;
但是C++有了迭代器之后,我们的程序遍历是可以这样写的
vector<int>::iterator it = arr.begin();
while(it != arr.end())
{
cout<<*it<<' ';
it++;
}
迭代器的本质其实就是指针,他的类型就是原本数据的指针类型。有了迭代器,我们就可以通过指针的移动,在访问的时候直接对内存进行解引用,可以很大程度避免了越界访问的风险。
迭代器其实还可以反向遍历,尽管他是反向遍历,但是他的逻辑却符合我们的思维
vector<int>::reverse_iterator j = arr.rbegin();
while( j != arr.rend())
{
cout << *j << " ";
j++;
}
- 这里的
rbegin()
其实就是数组的末尾位置,rend()
是数据开始位置的前一个位置,他把原本的左闭右开
区间变成了左开右闭
的区间。(rend(),rbegin()]
使用auto来方便迭代器书写
可能认为迭代器的类型书写的话有点长,在编译器没有提示的时候写很容易写错。在C++中还有一个auto的类型,他可以根据我们的已有的数据类型来推断当前变量的数据类型,需要注意的是,他的右边必须是一个已经明确的数据类型,不能用auto来对变量进行声明。
for (auto& eoch : arr)
{
cout << eoch << ' ';
}
cout << endl;
我们使用调试来看一看auto的内部是怎么实现的
发现在我们模拟实现的vector
模板内部,auto走到了begin()函数的内部,是不是可以猜测auto和begin迭代器有关呢?当我们注释掉begin()函数之后,再次调用auto进行打印
发现报的错误就是没有找到合适的begin()和end()函数,所以我们可以大胆推断,auto关键字调用的内部,就是找这个类型的begin()和end()接口,然后通过迭代器进行遍历。
使用auto可以减少我们很多变量类型的书写过程。
迭代器会“失效”吗
其实迭代器是不会失效的,失效的只是我们书写代码的逻辑,以及一些细节方面没有考虑到位。
来看一个会使迭代器“失效”的函数接口,大家应该都不陌生
//中间插入元素
void insert(iterator pos, const T& x)
{
assert(pos <= _finish);//插入位置必须合法
if (_finish == _end_of_storage)//是否需要扩容
reserve(capacity() * 2);
//把pos位置后的数据向后挪动一位
iterator end = _finish - 1;
while (end >= pos)
{
*(end + 1) = *end;
end--;
}
*pos = x;
_finish++;
}
这是一段我们经常使用的插入数据的一种写法,区别就在于我们一般没有使用迭代器,但是这个代码加上了迭代器。
测试之后,发现好像跟我们预想的结果不一样,-3
不见了,后面还打印了一个随机的数字,但是我们使用stl中标准的vector容器时却不会出现问题,那么一定是我们的程序的问题了。经过调试我们看看-3
处到底发生了什么
和其他插入不同的是,这一步多了一个扩容的过程,在扩容的内部发生了什么呢?
之前学C语言中的realloc函数的时候,了解到扩容的时候其实不一定是在原数组后面直接增加一段空间的。还有可能在堆中重新开辟指定大小的空间,然后把原本的数据都拷贝过去,函数的返回值是成功开辟一段空间后数组的首地址,这个首地址存在一定的可能性跟扩容前的地址不同。
其实我们并没有成功插入我们想要插入的数据,然后我们的_finish
指针还向后移动了一位,就会出现打印的时候,结尾的随机数字。
这该怎么改呢,在汇编中有一个名词,叫做段基址 + 偏移量
,虽然扩容后空间的地址都变了,但是我们想要插入的位置和起始位置相比的偏移量是不会改变的,可以通过偏移来在扩容后改变pos插入位置的地址。
//中间插入元素
void insert(iterator pos, const T& x)
{
assert(pos <= _finish);//插入位置必须合法
if (_finish == _end_of_storage)//是否需要扩容
{
size_t offset = pos - _start;//计算偏移量
reserve(capacity() * 2);
pos = _start + offset;//更新新的插入位置
}
//把pos位置后的数据向后挪动一位
iterator end = _finish - 1;
while (end >= pos)
{
*(end + 1) = *end;
end--;
}
*pos = x;
_finish++;
}
这样我们打印的结果就显得正常话了。
erase 导致的迭代器失效
这是一段删除列表中所有的偶数的程序
void text2()
{
list<int> head;
for (int i = 0; i < 10; i++)
head.push_back(i);
//删除所有偶数
list<int>::iterator it = head.begin();
while (it != head.end())
{
int num = *it;
if (num % 2 == 0)
head.erase(it);
it++;
}
//打印列表
show(head);
}
在运行的过程中发生了访问的异常
经过调试我们发现
在list的erase
操作中,他的返回值是下一个元素的位置,但是我们没有接收这个返回值,就导致了在删除操作之后,当前的it迭代器
就已经失效了,这个时候的迭代器相当于一个指针。
//i++
Self operator++(T)
{
Self tmp(*this);
_node = _node->_next;
return tmp;
}
此时it++
操作就想当与一个指针的++
操作,最后指向的元素并不是我们想要的下一个数字1的迭代器,而是一个随机的不合法的位置。
当我们再次调用*it
进行解引用的时候,就会出现越界访问的异常。
- list容器中的erase模板
//earse
iterator erase(iterator pos)
{
//没有元素的时候不能删除
assert(pos != end());
//迭代器的成员是node的一个结构体
node* del = pos._node;
node* ret = del->_next;
node* pre = del->_pre;
pre->_next = ret;
ret->_pre = pre;
delete del;
//调用*运算符重载,返回解引用
return iterator(ret);
}
正确的删除方式
void text2()
{
list<int> head;
for (int i = 0; i < 10; i++)
head.push_back(i);
//删除所有偶数
list<int>::iterator it = head.begin();
while (it != head.end())
{
int num = *it;
if (num % 2 == 0)
it = head.erase(it);
else
it++;
}
//打印列表
show(head);
}
- 另一种写法
void text2()
{
list<int> head;
for (int i = 0; i < 10; i++)
head.push_back(i);
//删除所有偶数
list<int>::iterator it = head.begin();
while (it != head.end())
{
int num = *it;
if (num % 2 == 0)
head.erase(it++);
it++;
}
//打印列表
show(head);
}
这样写的话,迭代器是会失效的。但是我们使用的是后置++,他的返回值就是下一个有效元素的一个对象,是一个合法的,我们的迭代器固然失效了,但是又被一个合法的数据给变成有效的了。
分析
由于在写程序的时候,一些考虑不到的地方就会造成我们输出的结果和预期的发生不一样的变化,这个时候调试是一个好东西。
在使用迭代器的时候,我们一定要对存在扩容的函数进行细致的排查,因为迭代器是一个指针,扩容改变空间地址的同时,指针所指向的位置是不会改变的。