好久没写博客了,然而真的没什么想写的,只好瞎水一篇了。
众所周知,如果一个std::vector
的capacity()
被改变了,所有迭代器都会被非法化。今天在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行注释掉就可以通过编译了,因为此时x
在shrink_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位赋值”如此简单的任务,具体方法太过丑陋、冗长而没有可读性,就不展开了。