从vector迭代器的非法化谈起

Posted by wyj on August 1, 2020

好久没写博客了,然而真的没什么想写的,只好瞎水一篇了。

众所周知,如果一个std::vectorcapacity()被改变了,所有迭代器都会被非法化。今天在LA群里有人谈到过这个问题,并且之前校内训练时也有人因为这个原因调试了很久。

这个“非法化”到底是什么意思呢?zh.cppreference.com中没有给出详细的解释。但是很容易猜测,原来的内存已经被释放了,访问已经被释放了的内存当然是未定义行为。

然而这个未定义行为真的很隐蔽,不仅完全不会段错误,甚至-fsanitize=undefined都完全检测不出来$\dots$参见下面的代码:

#include<bits/stdc++.h>
using namespace std;

int main(){
	vector v{1,2,3};
	auto&x=v[1];
	x=3;
	v.shrink_to_fit(); //强制v.capacity()=3
	v.resize(v.size()+1); //此时一定会内存重分配
	printf("%d\n",x);
	return 0;
}

编译参数是-std=c++17 -fsanitize=undefined。据我测试clang++g++编译后都可以正常运行,并且无论打开什么等级的优化都会输出0。把这里的x从引用改成指针或者迭代器显然不会对结果造成任何的影响。

那么这种错误就无法被轻易发现了吗?答案是否定的。可以打开检测内存问题的编译开关-fsanitize=address重新运行一遍,就会RE,并且显示了错误原因:在释放后使用了堆内存

ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000014 at pc 0x0000004c6fbe bp 0x7ffdac1ad9b0 sp 0x7ffdac1ad9a8

此时再使用gdb单步跟踪就可以轻松找出错误位置。

题外话:在NOI Linux中使用-fsanitize=address,配合-ftrapv(检测有符号整型溢出)即可部分模拟未定义行为检测的效果。

那么能否从源头上杜绝这个问题呢?C++怕是不太行,但是在Rust中就不一样了:

fn main(){
	let mut v=vec![1,2,3];
	let x=&mut v[1];
	*x=3;
	v.shrink_to_fit();
	v.resize(v.len()+1,0);
	println!("{}",*x); // CE!
}

感谢Rust伟大的borrow checker,编译期就会直接CE。由于v.shrink_to_fit()x会同时使用v的可变引用,就会导致访问冲突。如果把第7行注释掉就可以通过编译了,因为此时xshrink_to_fit()之前已经归还了v的引用,就不会有冲突。

然而这并不能掩饰Rust是一门专门来恶心人的语言的事实。稍微改造一下这段程序:

fn main(){
	let mut v=vec![1,2,3];
	let x=&mut v[1];
	*x=3;
	v[0]=3;
	println!("{}",*x); // CE!
}

你会发现前面那一套逻辑仍然完全是适用的,所以Rust编译器不会让这段“危险”的程序通过编译。为了哄编译器开心,必须要绕点弯路才能完成“同时给数组的第0位和第1位赋值”如此简单的任务,具体方法太过丑陋、冗长而没有可读性,就不展开了。