目录
1. insert的实现
2. 迭代器失效
2.1 迭代器失效的两种情况
指向已释放的内存(物理失效)
元素移动导致迭代器指向错误(逻辑失效)
2.2 修改代码
3. erase的实现
编辑修改代码
4. resize的实现
5. 构造函数
5.1 默认构造函数
5.2 使用迭代器范围构造
5.3 使用n个val构造
6. 拷贝构造函数
7. 赋值运算符重载
8. 关于vector对象的浅拷贝问题
1. insert的实现
reserve(n)函数的核心逻辑是:当容器当前容量不足以容纳新元素时,会重新申请一块更大的内存空间,将原来元素复制到新空间中,然后释放旧的内存空间。
在下面这段代码中进行插入操作时,扩容后pos仍然指向旧的内存位置,而旧的内存已经被释放,循环条件end>=pos地址比较毫无意义,对*pos赋值时会导致程序崩溃。
//错误示例
void insert(iterator pos, const T& x)
{assert(pos >= _start);assert(pos <= _finish);//扩容 若触发扩容,旧内存被释放,pos失效if (_finish ==_end_of_storage){ reserve(capacity() == 0 ? 4 : capacity() * 2); }iterator end = _finish - 1;//向后挪动数据while (end >= pos){*(end + 1) = *end;--end;}*pos = x;++_finish;
}
正确做法:先记录pos相对于起始位置的偏移量,扩容后再根据偏移量来更新pos。
//right!
void insert(iterator pos, const T& x)
{ assert(pos >= _start);assert(pos <= _finish); //扩容 if (_finish ==_end_of_storage){//记录pos相对于起始地址的偏移量size_t len = pos - _start;//扩容(重新申请内存,旧内存释放)reserve(capacity() == 0 ? 4 : capacity() * 2);//扩容后,更新pos到新内存的对应位置pos = _start + len;}iterator end = _finish - 1;//挪动数据while (end >= pos){*(end + 1) = *end;--end;}*pos = x;++_finish;
}
2. 迭代器失效
2.1 迭代器失效的两种情况
指向已释放的内存(物理失效)
//插入元素
void insert(iterator pos, const T& x)
{assert(pos >= _start);assert(pos <= _finish);//扩容if (_finish == _end_of_storage){//记录pos相对于起始地址的偏移量size_t len = pos - _start;//扩容(重新申请内存,旧内存释放)reserve(capacity() == 0 ? 4 : capacity() * 2);//扩容后,更新pos到新内存的对应位置pos = _start + len;}iterator end = _finish - 1;//挪动数据while (end >= pos){*(end + 1) = *end;--end;}*pos = x;++_finish;
}
……
//测试调用void test_vector2(){vector<int> v;v.push_back(1);v.push_back(2);v.push_back(3);v.push_back(4);//再插入元素则需要扩容print_vector(v);int x;cin >> x;auto p = find(v.begin(), v.end(), x);if (p != v.end()){v.insert(p, 300); //传入p的拷贝给insert的形参pos 形参的改变不影响实参 //(*p) *= 10; //错误!进行扩容时insert内部更新迭代器pos,但不影响实参p,p仍指向旧位置,所以外部p已失效,访问可能会出错。} print_vector(v);}
元素移动导致迭代器指向错误(逻辑失效)
自定义insert函数中,即使不扩容,迭代器也会失效。
原因:插入时,pos及之后的元素会向后挪动一位,假设迭代器p指向位置 i ,插入后原位置i的元素被移动到 i+1,新元素被插入到 i 位置,p仍指向物理地址 i ,但该地址的元素已经不是原来的了,p指向的“语义”已经改变,属于逻辑失效。
在VS环境下,标准库vector的迭代器失效检查非常严格,即使不发生扩容,insert后使用原迭代器也会触发调试错误。
总结:迭代器的 “失效” 不仅指 “指向已释放内存”(物理失效),还包括 “容器结构改变导致指向逻辑错误”(逻辑失效)。这也是为什么标准库文档强调:对容器执行修改操作(
insert
/erase
等)后,所有可能受影响的迭代器都应视为失效,不应再使用。
2.2 修改代码
插入操作(无论是否扩容)都会导致原迭代器可能失效:
- 若发生扩容:旧内存被释放,原迭代器指向无效地址(物理失效)。
- 若未扩容:元素后移导致原迭代器指向的 “语义位置” 改变(逻辑失效,比如原迭代器本应指向元素 A,插入后指向的是新元素)。
让 insert 返回更新后的 pos 迭代器,这也是STL标准库中 insert 的原型,STL中vector::insert 的原型正是:iterator insert(iteartor pos ,const T& value);
iterator insert(iterator pos, const T& x)
{assert(pos >= _start);assert(pos <= _finish);//扩容if (_finish == _end_of_storage){//记录pos相对于起始地址的偏移量size_t len = pos - _start;//扩容(重新申请内存,旧内存释放)reserve(capacity() == 0 ? 4 : capacity() * 2);//扩容后,更新pos到新内存的对应位置pos = _start + len;}iterator end = _finish - 1;//挪动数据while (end >= pos){*(end + 1) = *end;--end;}*pos = x;++_finish;return pos; //返回更新后的pos迭代器! ! ! !
}……
//安全使用
void test_vector2(){vector<int> v;v.push_back(1);v.push_back(2);v.push_back(3);v.push_back(4);print_vector(v);int x;cin >> x;auto p = find(v.begin(), v.end(), x);if (p != v.end()){p = v.insert(p, 99);//用insert的返回值更新p,获取新的有效迭代器p现在指向新插入的99(*p) *= 10; //安全操作:此时p有效,修改后新元素变为990} print_vector(v);}
3. erase的实现
//错误示例
//删除偶数元素
void erase(iterator pos)
{assert(pos >= _start);assert(pos < _finish);iterator it = pos + 1;while (it != end()){*(it-1) = *it;++it;}--_finish;
}//测试调用 删除所有的偶数
auto it = v.begin();
while (it != v.end())
{if (*it % 2 == 0){v.erase(it); //删除后it已失效}++it; //错误:无论是否删除都对it自增
}print_vector(v);
对于上面erase的实现大有问题:
- 如果v=[1,2,4,5]含有连续的偶数,按当前逻辑执行,最终会导致v=[1,4,5],偶数4被漏删,逻辑错误。
- 如果v=[1,2,3,4],那么当it超出有效范围[_start,_finish]后,*it属于未定义行为,程序可能读取到任意值,也可能直接崩溃,如果碰巧读取到一个偶数,那么就会发生断言错误。
修改代码
对于上面的逻辑错误,我们只需要添加else分支,将++it封装到else里面即可解决问题。
//删除元素 void erase(iterator pos) {assert(pos >= _start);assert(pos < _finish);iterator it = pos + 1;while (it != end()){*(it-1) = *it;++it;}--_finish; }//……//删除所有的偶数 auto it = v.begin(); while (it != v.end()) {if (*it % 2 == 0){v.erase(it); }else{++it;} }
这种写法在数组式容器中能正常工作,是因为当删除it指向的元素后,it并没有失效(仍指向原来的内存地址),但该地址的值已经变成了“原下一个元素”。此时不执行++it,下一轮循环会直接检查这个值,相当于间接实现了“迭代器后移”的效果。
但这种写法有两个隐藏的风险:
1.但是在erase函数里面有扩容逻辑时 ,it指向已释放的内存,可能导致程序崩溃。
2. 依赖容器底层实现,只有当容器是连续内存存储时,逻辑才成立。如果换成链表式容器,链表的底层是离散的节点,每个节点存“数据”和“指向下一节点的指针”,节点间通过指针连接,内存不连续。删除节点后 it 会指向“已释放的内存”,下一次循环解引用*it时可能直接崩溃,或读取到垃圾数据。
如果想让代码更健壮、更符合常规设计,建议让erase函数返回“下一个有效迭代器”,并使用标准写法:it=v.erase(it),这样无论扩不扩容,无论容器底层是数组还是链表,逻辑都能保持一致且安全。
正确代码:
//删除元素 iterator erase(iterator pos) {assert(pos >= _start);assert(pos < _finish);iterator it = pos + 1;while (it != end()){*(it-1) = *it;++it;}--_finish;return pos; //返回下一个有效迭代器 }//……//删除所有的偶数 auto it = v.begin(); while (it != v.end()) {if (*it % 2 == 0){it = v.erase(it); //用返回值更新 it(指向删除后的下一个元素)}else{++it;} }
这一设计符合 STL 规范,能确保迭代器操作的安全性,逻辑与 STL 标准库的vector::erase完全一致。
4. resize的实现
resize的作用是调整容器的大小,当容器需要扩展大小时,用
val
来初始化所有新增的元素;如果不传递这个参数,就使用元素类型T
的默认构造值(比如内置类型是 0,自定义类型调用默认构造)。
函数原型:void resize( size_t n, const T& val= T() ) ,这样写的意思就是调用T类型的默认构造函数生成匿名对象(临时对象),引用绑定到临时对象上,T是int时,T()就是0;T是自定义类时,调用其默认构造函数创建对象。使用常量引用,传递参数时可以避免拷贝,同时保证了val不会被修改。
也可以这样写:void resize( size_t n, T val = T() ) ,这里 = T()表示调用T类型的默认构造函数生成的临时对象,再调用拷贝构造函数构造val,编译器可能会优化为直接构造val。
void resize(size_t n, const T& val= T()) //第二个参数也可以写作 T val = T()
{if (n < size()){_finish = _start + n;}else{reserve(n);while (_finish < _start + n){*_finish = val;++_finish;}}
}
5. 构造函数
5.1 默认构造函数
//构造函数vector() //自定义空构造:_start / _finish / _end_of_storage 默认初始化(垃圾值)
{}//或 vector() = default; C++11 引入,编译器生成的默认构造函数同样让三个指针默认初始化(垃圾值)
对于第一种写法,用户自定义的空构造函数,编译器不再生成默认构造。函数体为空,且没有成员初始化列表,此时成员变量会执行默认初始化。
默认初始化规则:
- 若成员是内置类型(如int、指针):默认初始化是“不初始化”,值为随机垃圾值。
- 若成员是类类型(如string、自定义类):默认初始化是“调用该类的默认构造函数”。
对于第二种写法,是c++11及以后的标准中合法且推荐的写法,用于显式要求编译器生成“默认的构造函数”,编译器生成的默认构造函数同样会对成员变量进行默认初始化,两种写法效果类似,但=default 表意更清晰,是更推荐的写法。
5.2 使用迭代器范围构造
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{while (first != last)//使用!= 是考虑容器是链表的情况{push_back(*first);++first;}
}
它的作用是通过一段迭代器范围[first,last)来初始化vector对象,这个构造函数的特点在于,它本身也是一个函数模版。为什么需要把它设计成为一个函数模版呢?
当使用一段迭代器范围来初始化vector时,这段范围可能来自一个数组、链表,为了兼容这些不同类型的迭代器,这个构造函数必须自身成为一个函数模版。如果固定使用类内部的iterator,就无法使用其它容器初始化vector。
如果构造函数写成固定使用类内部的iterator会导致下面问题:
template <classT>class vector { public: typedef T* iterator; typedef const T* const_iterator; // 仅接受自身迭代器的构造函数(局限性很大)vector(iterator first, iterator last) {while (first != last) {push_back(*first);++first;}}// ... };
//无法用数组初始化vector int arr[] = {1,2,3,4}; vector<int> v(arr, arr+4); // 编译失败!arr是int*,不是vector<int>::iterator//也无法用list的迭代器初始化vector std::list<int> lt = {1,2,3}; vector<int> v(lt.begin(), lt.end()); // 编译失败!list的迭代器不是vector的iterator
它的实际用法:
// 1. 从数组初始化vector
int arr[] = {1, 2, 3, 4, 5};
vector<int> v1(arr, arr + 5);// 2. 从另一个容器(list)的迭代器区间初始化vector
std::list<double> lst = {1.1, 2.2, 3.3};
vector<double> v2(lst.begin(), lst.end()); // 3. 从同类型vector的迭代器区间初始化vector
vector<string> v3 = {"a", "b", "c"};
vector<string> v4(v3.begin() + 1, v3.end());
这个函数是vector类模版的模版构造函数,属于带参的构造函数的一种,这样设计实现了“从任意序列范围初始化vector的功能”,极大提升了容器的通用性,这也是C++标准库std::vector的标准构造函数之一。
5.3 使用n个val构造
vector(size_t n,const T& val=T())
{reserve(n);for (size_t i = 0; i < n; i++){push_back(val);}
}
但是,这样写会出现下面的情况:
//模版构造函数
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{while (first != last)//使用!= 是考虑容器是链表的情况{push_back(*first);++first;}
}vector(size_t n,const T& val=T())
{reserve(n);for (size_t i = 0; i < n; i++){push_back(val);}
}//……vector<string> v6(10, "11111111");
print_container(v6);vector<int> v7(10);
print_container(v7);vector<int> v8(10, 1); //这里报错!
print_container(v8);
为什么v6、v7都能正常运行,而v8却出错了呢?
C++函数重载决议规定,当有多个构造函数可以匹配时,编译器会选择最匹配的那个。问题出在类型匹配上,v7的初始化只提供了一个参数10,不会匹配到模版构造函数,核心原因是参数数量不匹配,此构造函数的第一个参数是size_t类型,需要将int转换为size_t类型。
v8的初始化提供了两个参数,(10是int,1是int)
对于模版构造函数,可以直接推导出 InputIterator = int,,此时两个参数类型完全匹配,不需要任何转换。对于第二个构造函数,第一个参数需要走隐式类型转换将int类型的10转换为siez_t类型。
所以编译器认为模板构造函数是更匹配的,从而选择了它。但是在模版构造函数中使用push_back(*first); first是int类型,解引用int是不合法的,所以会报错。
解决方法:让第二个构造函数的参数匹配度更高,将size_t 改成 int,构造函数对(int , int)类型的调用实现 “完全匹配”。
当非模板函数和模板函数都能提供 “同样好的匹配” 时,非模板函数的优先级更高,此时v8会调用第二个构造函数。
vector(int n, const T& val = T()) {reserve(n);for (size_t i = 0; i < n; i++){push_back(val);} }
6. 拷贝构造函数
//拷贝构造函数
vector(const vector<T>& v)
{reserve(v.size());for (auto& e : v){push_back(e);}
}
当通过拷贝构造函数创建新对象时,新对象的三个指针作为内置类型会先执行默认初始化,值为随机值,后续通过 reserve(v.size()); 一次性分配足够内存,并将这三个指针显式赋值,从而完成它们的 “有效初始化”,通过后续操作(push_back)来规范它们的值。
7. 赋值运算符重载
传统写法:
//赋值 v1 = v3
vector<T>& operator=(const vector<T>& v)
{if (this != &v) //避免自己给自己赋值{clear();//清理数据,但保留空间reserve(v.size());//一次扩容到位,避免频繁扩容for (auto& e : v){push_back(e);}}return *this;
}
现代写法:
//交换函数
void swap(vector<T> v)
{std::swap(_start, v._start);std::swap(_finish, v._finish);std::swap(_end_of_storage, v._end_of_storage);
}//赋值 拷贝并交换 v1 = v3
vector<T>& operator=(vector<T> v) //这里是值传递 也可以这样写:vector& operator=(vector v)
{swap(v);return *this;
}
当函数参数是值传递时,会触发拷贝构造函数,将v3拷贝一份到参数v中(v是v3的副本),swap(v)交换的是v1和v3的副本,交换后当前对象*this有了v3的资源,完成了赋值,参数v拥有了当前对象原来的旧资源,函数结束时,v会被销毁(触发析构函数),避免了内存泄漏。这种写法天然处理了自赋值,不会出现资源被提前释放的问题。
在这个模板类的内部,编译器已经知道当前处于的vector<T>的作用域中,所以在模板类的内部成员函数定义中,可以直接用类名vector 代替完整的vector<T>,这是 C++ 的语法规则允许的简写,核心原因是模板类的作用域内会自动关联模板参数。
8. 关于vector<string>对象的浅拷贝问题
vector<string> v;
v.push_back("1111111111111111111");
v.push_back("1111111111111111111");
v.push_back("1111111111111111111");
v.push_back("1111111111111111111");
print_vector(v);v.push_back("1111111111111111111"); //扩容
print_vector(v);
运行结果:
为什么会出现乱码呢?
核心原因是:memcpy是浅拷贝,而std::string是带动态内存管理的类,浅拷贝会导致多个string对象共享同一块堆内存,析构时重复释放或非法访问已释放内存。
std::string的内部结构(简化理解):
class string {
private:char* _data; // 指向堆上的字符缓冲区size_t _size;size_t _capacity;// ... 析构函数会释放 _data 指向的堆内存
};
void reserve(size_t n) { if (n > capacity()){size_t old_size = size(); T* tmp = new T[n];memcpy(tmp, _start, size() * sizeof(T));delete[] _start;_start = tmp;_finish = tmp + old_size;_end_of_storage = tmp + n;} }
memcpy(tmp, _start, size() * sizeof(T)); ,当使用memcpy拷贝string对象时,只是按字节赋值string对象的成员变量,没有复制_data指向的堆上的字符内容。这会导致旧内存中的string对象和新内存的string对象,_data指针指向同一块堆内存。旧内存执行delete[] _start; 时,底层调用n次string的析构函数完成n个对象中资源的清理,再调用operator delete[ ]函数释放空间,新内存中的string对象的_data指针变成野指针(指向已释放的内存)。所以,后续访问这些新的string对象(如打印、再次析构)时,就会出现未定义行为(乱码、崩溃等)。
解决思路:修改reserve函数,把memcpy换成循环赋值:
void reserve(size_t n) { if (n > capacity()){size_t old_size = size(); T* tmp = new T[n];//memcpy(tmp, _start, old_size * sizeof(T)); 只适合内置类型,对于带动态内存管理的类(如string)memcpy执行浅拷贝会出错 for (size_t i = 0; i < old_size; ++i){tmp[i] = _start[i]; //tmp[i]和_start[i]都是string对象,对它们的赋值会触发string类的operator= 重载函数 ,相当于a.operator=(b) string的赋值运算符会深拷贝堆上的字符}delete[] _start;_start = tmp;_finish = tmp + old_size;_end_of_storage = tmp + n;} }
总结一下,不能用memcpy来拷贝自定义类型(尤其是带资源管理的类型,如string、vector自己等),必须用深拷贝(调用拷贝构造或赋值运算符),否则会导致多个对象共享同一份资源,引发资源管理错误(重复释放、野指针访问等),从而出现乱码等问题。
push_back("11111111")参数传递过程
void push_back(const T& x)
push_back的参数是const string& x,字符串字面量(类型为const char[N],会自动“衰减”为const char*)。因为std::string类提供了非explicit的构造函数string(const char*) 允许隐式类型转换,因此编译器会自动用这个构造函数把const char*隐式转换成string临时对象,然后将临时对象绑定到const string&参数上(const引用会延长临时对象生命周期,直到函数调用结束)。