C++中vector类的部分简单实现

前言

在上一篇《C++中string类的部分简单实现》里,我们从零实现了一个简易的 string 类,把 C++ 动态内存管理、深拷贝、拷贝构造、迭代器这些核心概念都过了一遍。

这一篇我们把视野从"具体类型"扩展到"泛型容器"------实现一个简易的 vector。

vector 和 string 有很多相似之处:

  • 底层都是一块连续的内存;
  • 都用成倍扩容来应对增长;
  • 都需要深拷贝来避免二次释放;
  • 迭代器都是裸指针。

但 vector 又比 string 多了一层模板------它管理的不是固定的 char,而是任意类型 T。这一层泛化带来了几个全新的问题:

  • 元素如何搬运 :string 的元素是 char,可以用 memcpy 直接搬;vector<T> 的元素是任意类型,必须调用 T 的拷贝构造或赋值;
  • 迭代器失效 :vector 的迭代器是 T*,指向具体的内存地址------一旦扩容或删除元素,原来的迭代器就可能失效;
  • 构造函数的陷阱 :vector(n, val) 和 vector(first, last) 两个构造函数在重载决议时会互相"抢活"。

这些是本文的重点。至于 string 篇已经讲透的内容(深拷贝、Copy-Swap、成倍扩容),我们会一笔带过 ,把篇幅留给 vector 独有的知识点。

1 整体规划

和 string 篇不同,vector 是一个模板类。模板有一个重要的规则:

模板的声明和定义必须放在同一个文件里。

因为模板本身不是代码,而是"生成代码的模板"------编译器在实例化 (比如 vector<int>、vector<string>)的时候,必须能看到完整的定义才能生成对应类型的代码。如果像普通类那样声明放 .h、定义放 .cpp,当另一个 .cpp 引用 vector<int> 时,它只能看到头文件里的声明,看不到 .cpp 里的实现,链接时就会报"未定义符号"。

所以 vector 篇的文件组织是:

文件名 作用
vector.h 类的声明 + 实现(全放一起)
Test.cpp 测试代码

命名空间仍然用 xc,避免和标准库的 std::vector 冲突。

2 搭建文件框架

2.1 vector.h ------ 类的基本框架

cpp 复制代码
#pragma once
#include<iostream>
#include<assert.h>
#include<utility>       // 为 std::swap
using namespace std;     // 仅教学方便,工程中不推荐

namespace xc
{
	template <class T>
	class vector
	{
	public:
		typedef T* iterator;
		typedef const T* const_iterator;
		// ... 各种成员函数

	private:
		iterator _start = nullptr;            // 指向第一个元素
		iterator _finish = nullptr;           // 指向最后一个元素的下一个位置
		iterator _end_of_storage = nullptr;   // 指向已分配空间的末尾的下一个位置
	};
}

几个关键点:

  1. template <class T>
    类前面加上这一行,T 就成了一个"占位符"------它可以被替换成 int、string、MyClass 等任意类型。整个类里出现的 T,都会在实例化时被替换。
  2. typedef T* iterator;
    和 string 篇里的 typedef char* iterator; 完全一样,只是 char 换成了泛化的 T。这是 vector 泛型化最直接的体现------同一份代码,可以为任意类型提供迭代器。
  3. 三指针模型
    string 用的是 char* _str + size_t _size + size_t _capacity;vector 用的是三个指针:
成员 含义 对应 string
_start 指向第一个元素 _str
_finish 指向最后一个元素的下一个位置 _str + _size
_end_of_storage 指向已分配空间的末尾的下一个位置 _str + _capacity

于是:

  • size() = _finish - _start
  • capacity() = _end_of_storage - _start

为什么用指针而不是 size_t? 因为 vector 的迭代器就是 T*,用指针运算 _finish - _start 计算大小天然自然;如果用 size_t,还得额外保存 _start,反而更麻烦。标准库就是这么设计的。

  1. = nullptr 类内初始化

    这是 C++11 的语法------成员变量可以在类里直接给默认值。这样 vector() = default; 就能自动得到"三个指针都是 nullptr"的默认构造,不用手写。

  2. 关于头文件里的 using namespace std;

    为了和 string 篇保持一致,我们保留了它,简化代码书写。但请注意:工程中不推荐在头文件里写 using namespace std;------头文件会被很多 .cpp 包含,一旦污染命名空间,可能在别人的代码里引发名字冲突。实际项目中应该写 std::。

2.2 关于 using namespace std;

using namespace std; 的语义是"从这一行开始,把 std 里的所有名字都纳入查找范围 "。这样一来,像 std::swap、std::max、std::string 这些名字就可以省略 std:: 前缀直接使用,代码会短一些。

但问题在于:std 里的名字非常多,一旦引入查找范围,就可能和自己的名字冲突。举个例子:

cpp 复制代码
// 头文件里写了 using namespace std;

namespace mylib
{
	class string { /* ... */ };   // 自己定义的 string
}

// 使用者代码
mylib::string s1;      // 我想用 mylib::string
string s2;             // ❌ 到底是 mylib::string 还是 std::string?编译器报二义性

头文件里的 using namespace std; 尤其危险 ,因为它会被所有包含这个头文件的 .cpp 文件继承 。别人只是 #include "mylib.h",就莫名其妙地被"污染"了命名空间------这不是他写的,却要承担后果。

所以在实际工程中,规范是:

头文件里绝对不写 using namespace std;(或任何 using namespace xxx;)。

.cpp 文件里可以用,但更推荐缩小范围,比如 using std::cout; 只引入具体名字,或在函数内部局部 using。

本文为了教学简化,保留了 using namespace std;。但你如果要在真实项目里复用这些代码,务必把它删掉,改成写全名 std::swap、std::vector 等。

2.3 Test.cpp

cpp 复制代码
#include"vector.h"

int main()
{
	// 后续在此编写测试代码
	return 0;
}

到这里,vector 的基本框架就搭好了。它和 string 最大的不同是多了 template<class T> 这一层,但底层的三指针模型、迭代器思想都是相通的。

3 从 string 到 vector

在正式实现之前,我们先把 string 和 vector 做一个系统对比。明确哪些内容是重复的,哪些是 vector 独有的------这样可以避免两篇文章读起来像"重复劳动"。

3.1 一张对比表

维度 string vector
元素类型 固定为 char 任意类型 T
是否为模板 否 是(template<class T>)
底层数据结构 连续内存(char*) 连续内存(T*)
成员设计 _str + _size + _capacity _start + _finish + _end_of_storage
迭代器 char* T*
扩容策略 成倍扩容 成倍扩容
元素搬运方式 memcpy(按字节) T::operator=(按元素)
迭代器失效 不常涉及 频繁涉及(扩容/删除时)
输入输出运算符 有 << / >> 无通用实现
比较运算符 六个都实现 意义不大,可不实现

结论 :核心思想(深拷贝、扩容、迭代器)是相通的,但元素搬运 和迭代器失效是 vector 独有的问题。

3.2 哪些内容不再重复讲?

以下内容在 string 篇已经讲透,本篇只列代码,不展开 。如果你对某一部分有疑问,请回看 string 篇对应章节。

内容 string 篇对应章节 本篇处理方式
深拷贝vs 浅拷贝 5 拷贝构造与赋值 一句话带过
拷贝构造的三种写法 5.1 / 5.2 直接给代码
Copy-Swap 惯用法 5.2 / 5.3 直接给代码
swap 成员函数 5.3 直接给代码
无符号下溢陷阱 8.1 insert 本篇写法不同(用指针),稍作说明
成倍扩容 7.2 push_back 直接给代码,重点放在元素搬运上
const 重载 6.2 operator\[\] 直接给代码

3.3 哪些内容是 vector 独有的?

这些是本篇的重点,会重点展开:

  1. 模板与泛型

    vector<T> 用 template<class T> 把"元素类型"抽象出来。同一个类可以管理 int、double、string、自定义类型------这是 C++ 泛型编程的核心能力。

  2. 元素搬运不能用 memcpy

    string 的元素是 char,一个字节,直接 memcpy 就行。但 vector<T> 的元素是任意类型------如果 T = string,用 memcpy 会让新旧两个 string 的 _str 指向同一块内存,在容器的层面重现浅拷贝问题 。

    正确做法是用赋值 (触发 T::operator=)或拷贝构造来搬运元素。

  3. 迭代器失效

    vector 的迭代器是 T*,指向具体地址。一旦扩容,_start 变了,原来的迭代器就指向了已释放的内存------继续使用会导致未定义行为。

    insert 和 erase 都会导致迭代器失效,这是 vector 使用中最容易踩的坑。

  4. 构造函数的重载陷阱

cpp 复制代码
vector(int n, T val = T());                        // 版本 A
vector(size_t n, T val = T());                     // 版本 A'
template<class InputIterator>
vector(InputIterator first, InputIterator last);   // 版本 B

当传入的两个实参既不是 int,也不是当前平台的 size_t 时,版本 B 会因为"模板的精确匹配"而被优先选中------把这两个整数当成"起始迭代器"和"结束迭代器",然后尝试解引用一个整数,程序崩溃。

这个陷阱依赖于平台 :比如 vector<size_t> v(6u, 3u) 在 32 位下正常(size_t == unsigned int,走 size_t版本),但在 64 位下会走版本 B(size_t == unsigned long long,unsigned int 与之不匹配)。

这是 vector 设计中最经典的"重载陷阱",本篇会专门讲。

  1. resize 与 reserve 的区别

string 篇没细讲 resize。vector 篇会讲清楚:

  • reserve(n) 只改 capacity,不改 size;
  • resize(n) 改 size,可能触发扩容(如果需要),也可能缩短(只改 _finish)。

3.4 一句话总结

vector 是 string 的"泛化版"------把 char 换成 T,把"字符串"换成"任意类型的数组"。核心思想相通,但多出来的是:模板、元素搬运、迭代器失效。

接下来的章节,按"对象生命周期 → 行为接口"的顺序展开,和 string 篇保持一致的节奏。

4 构造与析构

4.1 默认构造:= default

cpp 复制代码
vector() = default;   // 强制编译器生成默认构造(三个指针靠类内初始化置为 nullptr)

vector() 什么都不用写------因为三个成员在类里已经给了默认值 nullptr:

cpp 复制代码
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;

= default 的含义是"让编译器自动生成这个函数的默认实现 "。由于成员已经有类内初始化,生成的默认构造就是"三个指针都置为 nullptr"。

为什么写成 = default 而不是 vector() {}?

两者功能一样。写成 = default 有两点好处:

  1. 语义更清晰------告诉读者"我要的就是编译器默认行为";
  2. 性能更好 ------= default 的构造函数在满足条件时可以是 constexpr 或 trivial,而手写 {} 会阻止这些优化。

这和 string 篇里 string(const char* str = ""); 有默认参数类似------都是一种"省事的默认构造",但 vector 的默认构造比 string 更简单,因为不用分配内存。

4.2 析构函数:delete\[\] 会调用元素的析构

cpp 复制代码
~vector()
{
	delete[] _start;      // 为每个元素调用 T 的析构函数,然后释放整块内存
	_start = _finish = _end_of_storage = nullptr;
}

delete[] 有两个动作:

  • 对数组里的每个元素调用 T 的析构函数;
  • 释放整块内存。

这一点非常重要------尤其对 vector<string>:

cpp 复制代码
xc::vector<string> v;
v.push_back("hello");
v.push_back("world");
// v 析构时:
//   delete[] _start;
//   → 对 v[0] 调用 ~string(),释放 "hello" 的堆内存
//   → 对 v[1] 调用 ~string(),释放 "world" 的堆内存
//   → 释放 _start 指向的整块数组内存

如果 delete[] 只释放内存、不调用元素的析构函数,那么每个 string 内部的 _str 就永远不会被释放------大量内存泄漏 。C++ 的 delete[] 专门处理了这件事,我们不需要手动遍历元素调用析构。

关于置空

cpp 复制代码
_start = _finish = _end_of_storage = nullptr;

和 string 篇一样,析构函数里把指针置空是好习惯------防止后续代码误用悬空指针。虽然对象马上就要销毁,但代价极小,值得保留。

4.3 拷贝构造

cpp 复制代码
vector(const vector<T>& v)
{
	// 提前开好和 v 一样大的空间,避免 push_back 过程中反复扩容
	reserve(v.capacity());
	for (auto e : v)
	{
		push_back(e);   // 逐个元素拷贝构造
	}
}

和 string 篇呼应 :vector 的拷贝构造本质上也是深拷贝------为新对象申请独立内存,把源对象的元素一个一个搬过来。

具体做法:

  1. reserve(v.capacity()) ------ 提前开好和 v 一样大的空间,避免 push_back 过程中反复扩容;
  2. for (auto e : v) push_back(e); ------ 遍历 v,把每个元素拷贝到新对象里。

为什么用 push_back 而不是 memcpy?

因为 vector<T> 的元素是任意类型。用 memcpy 搬运会触发"深层次的浅拷贝问题"(见后面第 7 节)。而 push_back 内部用的是 T 的拷贝赋值/拷贝构造,对 string 这样的类型会自动做深拷贝,完全正确。

4.4 swap 与赋值运算符

cpp 复制代码
void swap(vector<T>& v)
{
	std::swap(_start, v._start);
	std::swap(_finish, v._finish);
	std::swap(_end_of_storage, v._end_of_storage);
}

vector<T>& operator=(vector<T> v)   // 传值参数:拷贝发生在传参阶段
{
	swap(v);        // 交换后 v 析构,释放 *this 的旧资源
	return *this;
}

这两个函数和 string 篇完全一样 ------swap 交换三个指针(O(1)),赋值用传值参数的现代写法(Copy-Swap 惯用法)。

关于"为什么传值"、"为什么 swap 后就安全了" ------这些在 string 篇的第 5 章已经详细讲过,这里不再重复。如果你对"传值参数的拷贝构造什么时候发生"、"为什么 swap 后 v 析构就能释放旧资源"有疑问,请回看 string 篇 5.2 节。

4.5 initializer_list 构造

cpp 复制代码
vector(initializer_list<T> il)
{
	reserve(il.size());
	for (auto e : il)
	{
		push_back(e);
	}
}

initializer_list<T> 是 C++11 引入的一种轻量容器,让我们可以这样写:

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

它的底层是什么? initializer_list<T> 本质上是"一块只读的连续内存 + 长度",由编译器在 {} 出现时自动构造。它不拥有元素的所有权------你可以把它理解为一个"指向临时数组的只读视图"。

所以 vector(initializer_list<T> il) 的构造过程是:

  1. reserve(il.size()) 一次性开好空间;

  2. 遍历 il,把每个元素 push_back 进来。

和拷贝构造的区别:

  • 拷贝构造 vector(const vector<T>& v) 从一个已存在的 vector 拷贝;
  • initializer_list 构造从 {} 括起来的字面量列表构造。

4.6 迭代器区间构造

cpp 复制代码
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{
	while (first != last)
	{
		push_back(*first);
		++first;
	}
}

功能 :用任意类型 的一段迭代器区间来构造 vector。

比如:

cpp 复制代码
xc::vector<int> v1 = {1, 2, 3, 4, 5};
xc::vector<int> v2(v1.begin() + 1, v1.end() - 1);   // 拷贝 v1 的中间一段

std::string s = "hello";
xc::vector<int> v3(s.begin(), s.end());             // 用 string 的迭代器构造

为什么用模板而不是具体类型?

因为"能用迭代器区间构造"这件事,对所有提供迭代器的容器都适用:

  • 从 vector 的区间构造;
  • 从 list 的区间构造;
  • 从 string 的区间构造;
  • 甚至从原生数组指针构造(数组名就是首地址)。

如果写死成 vector(const vector<T>& first, ...),就只能从同类 vector 构造;用模板参数 InputIterator,就能接收任意迭代器。

"InputIterator"是一个约定俗成的名字 ------它表示"输入迭代器",即至少要支持 *first(解引用)和 ++first(自增)操作。标准库自己也是用这个名字。

4.7 (n, val) 构造

cpp 复制代码
vector(int n, T val = T())
{
	resize(n, val);
}

功能 :构造一个有 n 个元素的 vector,每个元素初始化为 val。

cpp 复制代码
xc::vector<int> v1(10, 1);        // 10 个元素,都是 1
xc::vector<int> v2(10);           // 10 个元素,都是 0(默认值 T())

T val = T() 里的 T() 是默认构造的匿名对象 ------对 int 来说是 0,对 string 来说是空串,对自定义类型来说是默认构造的对象。

为什么写成 T val = T() 而不是 T val = 0?

因为 T 是任意类型,可能是 string、MyClass------0 无法赋给它们。用 T() 是"创建一个 T 类型的默认对象"的通用写法。

小结 :vector 的构造与析构大部分 和 string 相通,但多了三个新接口:

  • initializer_list 构造------支持 {} 语法;

  • 迭代器区间构造------支持从任意区间构造;

  • (n, val) 构造------批量填充。

同时,这一节也埋下了两个伏笔:

  1. 析构函数里 delete[] _start; 会为每个元素调用 T 的析构函数 ------对 vector<string> 至关重要;
  2. vector(int n, T val = T()) 和迭代器区间构造的重载冲突------第 10 节专门讲。

下一节我们会讲迭代器 ------从 char* 到 T* 的泛化,以及 const_iterator 的意义。这部分相对简短,重点是把"泛化"的直觉讲清楚。

6 迭代器

vector 的迭代器非常朴素------就是 T*:

cpp 复制代码
typedef T* iterator;
typedef const T* const_iterator;

和 string 篇的对比:

string 里我们用 typedef char* iterator;------同样是裸指针;

vector 里换成 typedef T* iterator;------形式一样,意义却大了一级。

因为 T 是模板参数,同一个 vector 类可以为任意类型提供迭代器。vector<int>::iterator 是 int*,vector<string>::iterator 是 string*,vector<vector<int>>::iterator 是 vector<int>*------一份代码,适配所有类型。这就是 C++ 泛型编程的魅力。

6.1 begin / end 的四个版本

cpp 复制代码
iterator begin()              { return _start; }    // 指向第一个元素
iterator end()                { return _finish; }   // 指向末尾的下一个位置
const_iterator begin() const  { return _start; }
const_iterator end() const    { return _finish; }

两个返回类型的意义:

  • 非 const 版本 :返回 T*,可以读写元素;
  • const 版本 :返回 const T*,只能读,不能写。
    这和 string 篇里的 operator[] 完全对称------const 重载是C++里非常常见的一种模式:
cpp 复制代码
// 非 const 对象 → 用非 const 版本 → 可以修改
vector<int> v1 = {1, 2, 3};
*v1.begin() = 100;      // OK

// const 对象 → 用 const 版本 → 只能读
const vector<int> v2 = {1, 2, 3};
*v2.begin() = 100;      // ❌ 编译错误

6.2 const_iterator 的意义

为什么要有 const_iterator?看这个场景:

cpp 复制代码
template<class Container>
void Print(const Container& con)
{
	for (auto e : con)
	{
		cout << e << ' ';
	}
	cout << endl;
}

它接收 const Container&------容器本身是 const。此时范围 for 会调用const 版本的 begin() 和 end():

cpp 复制代码
// 编译器展开成:
for (auto it = con.begin(); it != con.end(); ++it)
{
	auto e = *it;
	cout << e << ' ';
}
  • 如果没有 const 版本的 begin(),const Container& 就无法调用 begin()(非 const 成员函数不能作用于 const 对象)------ Print 直接编译失败;
  • 有了const版本 ,返回const T*(即const_iterator)---保证遍历过程中不会意外修改元素。

在 string 篇里,我们没有实现 const_iterator(当时作为"留待扩展")。vector 篇正好把它补全------这也是两篇文章的一个小呼应。

另一层意义:类型安全。

cpp 复制代码
template<class T>
void f(const vector<T>& v)
{
	for (auto& e : v)
	{
		e = T();     // ❌ 如果 v 是 const,这里编译报错
	}
}

当函数接收 const vector<T>& 时,迭代器类型自动变成 const_iterator,任何修改元素的尝试都会在编译期被拦住。这是 C++ 通过类型系统保证 const 正确性的经典应用。

6.3 范围 for 循环的原理

有了 begin() 和 end(),就可以用范围 for:

cpp 复制代码
vector<int> v = {1, 2, 3, 4, 5};
for (auto e : v)
{
	cout << e << ' ';
}

编译器会把它展开成:

cpp 复制代码
for (auto it = v.begin(); it != v.end(); ++it)
{
	auto e = *it;
	cout << e << ' ';
}

所以任何同时提供 begin() 和 end() 的类型,都能被范围 for 遍历------这一点在 string 篇里讲过,vector 里完全一致。

如果 v 是 const 对象 ,范围 for 会调用 begin() const 和 end() const,自动使用 const_iterator。这就是为什么我们必须实现 const 版本------否则 const 对象就无法用范围 for。

6.4 迭代器的本质:指针的抽象

到这一步,你应该能看清一个事实:

迭代器就是对指针的一层抽象。

  • string 的迭代器是 char*------指向一个 char;
  • vector<int> 的迭代器是 int*------指向一个 int;
  • vector<string> 的迭代器是 string*------指向一个 string;
  • list 的迭代器是"节点指针的封装"------不再是裸指针,但行为仍然是指向一个元素。

标准库的算法 (sort、find、reverse 等)正是通过迭代器这个"统一接口",才能同时作用于 string、vector、list、map 等所有容器:

cpp 复制代码
std::sort(v.begin(), v.end());         // 对 vector 排序
std::reverse(s.begin(), s.end());      // 对 string 反转

算法只关心"能不能 *it、能不能 ++it、能不能比大小" ,不关心底层容器是什么------这就是迭代器设计的核心价值。

string 篇我们已经接触过这个概念,vector 篇里它以更彻底的形式出现。

7 扩容:reserve 与元素搬运

vector 和 string 一样,都需要成倍扩容 来应对增长。但 vector 的扩容比 string 多一个关键问题:元素如何从旧空间搬到新空间。

string 的元素是 char,一个字节,直接 memcpy 就行。但 vector<T> 的元素是任意类型 T------如果 T 是 string,用 memcpy 会出大问题。

这一节围绕这个核心问题展开。

7.1 reserve------扩容与三指针更新

cpp 复制代码
void reserve(size_t n)
{
	if (n > capacity())
	{
		// 先记下当前的元素个数:
		// 后面 _start 会被重新赋值,_finish - _start 就不再是旧的大小了
		size_t old_size = size();
		T* tmp = new T[n];

		// 逐个把旧元素搬过来
		// 注意:这里不能用 memcpy------见正文"为什么不能用 memcpy"
		for (size_t i = 0; i < old_size; i++)
		{
			tmp[i] = _start[i];     // 对 T = string 会调用 operator=,完成深拷贝
		}

		delete[] _start;            // 释放旧空间(并为每个旧元素调用析构)
		_start = tmp;
		_finish = _start + old_size;
		_end_of_storage = _start + n;
	}
}

(1)为什么要先保存 old_size?

因为重新定位 _finish 的时候,不能再依赖 _finish - _start ------_start 已经被改成了新空间的首地址,而 _finish 还指着旧空间,两者相减得到的是垃圾值。

看下面这个错误写法:

cpp 复制代码
T* tmp = new T[n];
for (size_t i = 0; i < size(); i++)     // size() = _finish - _start
{
	tmp[i] = _start[i];
}
delete[] _start;
_start = tmp;                                   // _start 指向新空间
_finish = _start + (_finish - _start);          // ❌ _finish 还指向旧空间!

_finish - _start 这一步:_finish 是"旧空间里的某个地址",_start 是"新空间的首地址"------两个不相关地址相减,结果毫无意义。

正确做法 :在 _start 改变之前 ,先把元素个数保存到 old_size:

cpp 复制代码
size_t old_size = size();       // ← 在 _start 被覆盖前先算好
...
delete[] _start;
_start = tmp;
_finish = _start + old_size;    // ✅ 用旧大小,正确

(2)为什么需要 delete\[\] _start?

扩容的本质是"申请新空间 → 搬数据 → 释放旧空间"。delete[] _start 就是最后一步------释放旧空间。

注意 delete[] 会为旧空间里的每个元素调用析构函数 。这一点很重要:对 vector<string>,它会析构每个旧 string,释放各自的堆内存。

(3)三指针如何更新?

扩容完成后,需要重新定位三个指针:

cpp 复制代码
_start = tmp;                       // 起点:新空间首地址
_finish = _start + old_size;        // 终点:新空间首地址偏移 old_size
_end_of_storage = _start + n;       // 容量末:新空间首地址偏移 n
  • _finish = _start + old_size:因为搬过来了 old_size 个元素;
  • _end_of_storage = _start + n:因为新容量就是 n。

7.2 核心问题:为什么不能用 memcpy?

现在来看本节最重要的问题:搬元素为什么不能用 memcpy?

先看"错"的写法:

cpp 复制代码
// ❌ 错误示范
T* tmp = new T[n];
memcpy(tmp, _start, sizeof(T) * old_size);
delete[] _start;

问题分析 :memcpy 是按字节复制 ------它把 _start 指向的那段字节,原样拷贝 到 tmp。对于简单类型(int、double、指针),这没问题------复制字节就是复制值。

但 string 不简单 。它的内部有一个 char* _str 指针------memcpy 会把这个指针的值直接复制过去。

结果:

这就出了两个问题:

问题一:共享资源

新旧的 string 都指向同一块 _str 内存------修改任何一个,另一个也变。

问题二:二次释放

扩容完成后执行 delete[] _start------它会析构旧空间的 string,释放 _str 指向的堆内存。然后程序运行下去,新空间的 string 析构时,又会 delete[] _str------同一块内存被释放两次,程序崩溃。

这就是"深层次的浅拷贝问题":memcpy 做的是浅拷贝(复制字节),但 string 内部还有更深一层的资源(堆上的字符数组)------浅拷贝让两个对象共享了这块资源。

7.3 正确做法:用赋值触发 T::operator=

正确的做法是逐个元素赋值:

cpp 复制代码
for (size_t i = 0; i < old_size; i++)
{
	tmp[i] = _start[i];
}

tmp[i] = _start[i] 调用的是 T 的 operator=:

  • 对 int,就是普通的整数赋值(浅拷贝一个整数);
  • 对 string,是 string::operator=------它会重新申请一块独立的内存 ,把字符内容拷过去。这就是深拷贝;
  • 对 vector<int>,同理,内部会递归地做深拷贝。

这就是为什么 string 篇讲过的"深拷贝"在 vector 里依然重要 ------只不过这次它发生在容器的层面:

  • string 篇讲的是"类自身"的深拷贝(拷贝构造、赋值运算符);
  • vector 篇讲的是"容器在搬运元素"时的深拷贝(赋值触发 T::operator=)。

两者本质是同一个问题:只要涉及指针,就不能简单复制字节,要让对象自己决定如何复制。

7.4 关于 tmpi = _starti 的一个细节

你可能会问:tmp[i] = _start[i] 是"赋值",而 tmp 里的元素是未初始化的 (new T[n] 会调用 T 的默认构造)。给一个未初始化对象"赋值"有意义吗?

有意义的 ------因为 new T[n] 已经调用了默认构造 ,tmp[i] 已经是一个有效的 T 对象了。然后 tmp[i] = _start[i] 调用 T::operator=,用 _start[i] 覆盖它。

更高效的做法 是用拷贝构造(直接构造,不用先默认构造再赋值):

cpp 复制代码
// 伪代码
for (size_t i = 0; i < old_size; i++)
{
	new (&tmp[i]) T(_start[i]);   // 定位 new,用拷贝构造直接构造 tmp[i]
}

但这就涉及定位 new 的语法,超出本文范围。而且对于教学实现来说,tmp[i] = _start[i] 完全够用------现代编译器可能也会优化掉那次默认构造的开销。

标准库用的是另一套办法 :它把 new T[n] 拆成"分配原始内存"(operator new)+"用 std::uninitialized_copy 在原始内存上构造"两步。这样能保证"分配内存"和"构造对象"分离,效率更高、更灵活。但代价是代码复杂很多,不适合教学入门。

所以我们采用new T[n] + 赋值的方案------牺牲一点效率,换来代码的简洁清晰。

7.5 一个对比:string vs vector 的 reserve

维度 string::reserve vector::reserve
分配内存 new char[n + 1] new T[n]
搬运元素 memcpy(str, _str, _size + 1) tmp[i] = _start[i];(逐个赋值)
为什么 char 是简单类型,直接按字节拷贝 T 可能是 string 等复杂类型,必须调用 T::operator=
更新状态 _capacity = n; _start, _finish, _end_of_storage 三个指针全部更新

一句话对比:

  • string::reserve 关心的是"拷贝多少字节";
  • vector<T>::reserve 关心的是"如何让每个元素自己决定怎么复制"。

这就是泛化带来的本质区别。

小结 :reserve 是 vector 里最能体现泛型优势与陷阱的函数:

  1. 三指针更新 :_start 变了之后,_finish - _start 就不再是旧的大小,所以要先保存 old_size;
  2. 不能用 memcpy:T 可能是 string,memcpy 会导致"深层次的浅拷贝"------两个 string 共享堆内存、二次释放;
  3. 用赋值触发 T::operator=:对 string 自动深拷贝,正确安全;
  4. 教学版牺牲一点效率 :new T[n] 会先默认构造再赋值,标准库用定位 new 避免这个开销。

8 push_back、pop_back 与 resize

这一节讲三个"改变大小"的接口:

  • push_back / pop_back:在末尾追加/删除一个元素;
  • resize:一次性把 size 调整到指定值。

它们都依赖 reserve,所以放在 reserve 之后讲。

8.1 push_back------末尾追加

cpp 复制代码
void push_back(const T& x)
{
	if (_finish == _end_of_storage)
	{
		reserve(capacity() == 0 ? 4 : 2 * capacity());   // 成倍扩容
	}
	*_finish = x;      // 在末尾位置构造新元素
	++_finish;
}

三个要点:

  1. 判断条件是 _finish == _end_of_storage

_finish 指向"末尾的下一个位置",_end_of_storage 指向"容量的末尾"。两者相等,说明已经用完了当前所有容量,必须扩容。

这和 string 篇里的 if (_size >= _capacity) 是同一个判断------只是用指针表达更自然。

  1. 成倍扩容
cpp 复制代码
capacity() == 0 ? 4 : 2 * capacity()

和 string 完全一样:第一次分配 4 个元素的位置,之后每次翻倍。原因(string 篇 7.2 已经讲过)不再重复。

  1. *_finish = x; 是赋值,不是构造

注意这里:

cpp 复制代码
*_finish = x;

_finish 指向的是已经构造好的对象(new T[n] 时调用过 T 的默认构造),所以这里是赋值------调用 T::operator=。

  • 对 int:普通整数赋值;
  • 对 string:string::operator=,深拷贝字符串内容。

这和标准库的 push_back 略有不同 :标准库用定位 new 直接在"原始内存"上构造对象,避免先默认构造再赋值。但那涉及 operator new 和定位 new,本篇为了简化,统一用"new T[n] + 赋值"。

8.2 pop_back------末尾删除

cpp 复制代码
void pop_back()
{
	assert(!empty());  // 空 vector 不允许再删
	--_finish;
}

极其简单 ------只要把 _finish 往前挪一格。

8.3 empty / size / capacity

cpp 复制代码
bool empty() const
{
	return _start == _finish;
}
size_t size() const
{
	return _finish - _start;
}
size_t capacity() const
{
	return _end_of_storage - _start;
}

三个函数都是一行------用指针减法直接得到结果。这也体现了三指针模型的优势:不额外保存大小,需要时算一下就行。

8.4 resize------调整 size

cpp 复制代码
void resize(size_t n, T val = T())    // T() 是"默认构造的临时对象"
{
	if (n > size())
	{
		if (n > capacity())
		{
			reserve(n);                // 先扩容
		}
		while (_finish < _start + n)
		{
			*_finish = val;            // 多余的位置用 val 填充
			++_finish;
		}
	}
	else
	{
		_finish = _start + n;          // 缩短:只改 _finish,不动内存
	}
}

resize 的语义 :把 vector 的元素个数调整到 n。

三种情况:

情况一: n < size()(缩短)

cpp 复制代码
_finish = _start + n;

直接把 _finish 前移,逻辑上"丢掉"后面的元素。不释放内存 ------capacity 不变。所以 resize 缩小是 O(1) 的。

情况二: size() < n <= capacity()(填充,不扩容)

cpp 复制代码
while (_finish < _start + n)
{
	*_finish = val;
	++_finish;
}

用 val 依次填充 [_finish, _start + n) 范围内的位置。

情况三: n > capacity()(填充,要扩容)

cpp 复制代码
if (n > capacity())
{
	reserve(n);      // 先扩容
}
while (...) { ... }  // 再填充

调用示例:

cpp 复制代码
xc::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
// v = [1, 2, 3]

v.resize(10, 0);     // 扩容到 10 个元素,新增的是 0
// v = [1, 2, 3, 0, 0, 0, 0, 0, 0, 0]

v.resize(5, 1);      // 缩短到 5 个元素
// v = [1, 2, 3, 0, 0]

v.resize(2, 1);      // 再缩短到 2 个元素
// v = [1, 2]

8.5 resize 与 reserve 的区别

这是 string 篇没细讲、vector 篇需要补上的知识点。

接口 改变 size? 改变 capacity? 是否初始化元素?
reserve(n) 否 是(只扩,缩时不处理) 否
resize(n) 是 可能(只有 n > capacity() 时扩) 是(用 val 填充)

一句话理解:

  • reserve 只动容量,不动元素 ------它只是"预留空间",size 不变;
  • resize 动元素个数 ------多的填 val,少的丢弃。

典型用途:

  • reserve(n):提前一次性开好空间,避免后续多次 push_back 过程中反复扩容;
  • resize(n):把 vector 当作"定长数组"使用,一次性设置元素数量。

9 insert、erase 与迭代器失效

insert 和 erase 是 vector 里最容易踩坑 的两个接口,因为它们的实现涉及数据挪动,而数据挪动又会导致迭代器失效。

这是 vector 独有的问题------string 篇没遇到,因为 string 的迭代器我们用得少,而且 string 内部的坑主要是"无符号下溢"。

9.1 insert------在指定位置插入元素

cpp 复制代码
iterator insert(iterator pos, const T& x)
{
	assert(pos >= _start);
	assert(pos <= _finish);

	if (_finish == _end_of_storage)
	{
		// 扩容会更换 _start,导致 pos 失效
		// 先用下标把 pos 的位置记下来,扩容后再重新定位
		size_t len = pos - _start;
		reserve(capacity() == 0 ? 4 : 2 * capacity());
		pos = _start + len;
	}

	// 从后往前挪动数据,给 pos 腾出空位
	iterator end = _finish;      // 从"末尾的下一个位置"开始
	while (end > pos)
	{
		*end = *(end - 1);
		--end;
	}
	*pos = x;
	++_finish;
	return pos;                  // 返回插入位置的新迭代器
}

数据的方向和 string::insert 一致------从后往前,避免覆盖还没处理的数据,这里不再重复。

需要注意的一个小细节 :循环条件用 end > pos 而不是 end >= pos。

  • 用 >= 的话,end 会从 pos 继续减到 pos - 1------如果 pos == _start,就会越界到数组前面,是未定义行为;
  • 用 > 则让循环在 end == pos 时自然退出,不会越界。

这和 string 篇里"多预留一个位置"的技巧是同一个思想的两种表达------都是为了避免边界处的越界。

迭代器失效:扩容让 pos 变成野指针

这是本节最重要的坑。

insert 里如果触发扩容,reserve 会重新申请一块内存 ,然后 _start 指向新空间。此时原来的 pos 还指向旧空间 ------它变成了悬空指针(野指针):

复制代码
扩容前:  _start ──► [10][20][30][40] ...
                      ↑
                      pos

扩容后:  _start ──► [10][20][30][40][ ]...   ← 新空间
                      
          pos ──► (旧空间已被 delete[] 释放)  ← 悬空!

如果继续用 pos 挪数据,就会往已释放的内存里写------未定义行为,可能静默破坏数据,也可能直接崩溃。

正确做法 :在扩容之前 ,先把 pos 的相对位置 (下标)存下来;扩容完成后,用 _start + 下标 重新算出 pos:

cpp 复制代码
if (_finish == _end_of_storage)
{
	size_t len = pos - _start;                    // 存下标
	reserve(...);                                 // 扩容,_start 变了
	pos = _start + len;                           // 重新定位 pos
}

这就是 vector 里"迭代器失效"最经典的一种场景 ------不是迭代器本身坏了,而是它指向的地址在扩容后已经被释放。

9.2 erase------删除指定位置的元素

cpp 复制代码
iterator erase(iterator pos)
{
	assert(pos >= _start);
	assert(pos < _finish);

	// 把 pos 之后的数据整体前移一格
	iterator it = pos + 1;
	while (it < _finish)
	{
		*(it - 1) = *(it);
		++it;
	}
	--_finish;

	return pos;                  // 返回"被删除位置"的新迭代器(即原来 pos+1 处的元素)
}

挪数据的方向和 string::erase 一致 ------从前往后,把 pos 之后的数据整体前移一格,覆盖掉被删除的元素。这里不再重复。

返回值是 pos,不是 pos + 1。erase 返回的是"被删除位置的新迭代器":

  • 删除 pos 位置的元素后,原来 pos + 1 位置的元素会前移到 pos;

  • 所以"下一个有效元素"就是 pos 本身(它现在指向原来的 pos + 1)。

这在循环删除时非常重要:

cpp 复制代码
auto it = v.begin();
while (it != v.end())
{
	if (*it % 2 == 0)
	{
		it = v.erase(it);    // ✅ 用 erase 的返回值更新 it
	}
	else
	{
		++it;
	}
}

如果写 v.erase(it); 而不接收返回值,it 就失效了 ------不能再 ++it 或 *it。这就是第二种迭代器失效。

9.3 迭代器失效:两种典型场景

总结一下 vector 里最容易踩的两种迭代器失效:

场景一:扩容导致所有迭代器失效

insert 或 push_back 触发 reserve → _start 变了 → 之前拿到的所有迭代器(包括 begin()、 end()、以及任何指向 vector 的指针)全部失效。

场景二:erase 导致删除位置之后的迭代器失效

erase(pos) 删掉 pos 位置的元素后,pos 之后的元素整体前移 ------所以 pos 及其之后的迭代器全部失效(它们指向的地址虽然还有效,但指向的元素已经变了)。

两个使用建议:

  1. 别让迭代器的生命周期跨越可能扩容的操作------拿到一个迭代器后,如果中间做了 push_back、insert、reserve 等操作,这个迭代器就可能失效。比如:
cpp 复制代码
auto it = v.begin();
v.push_back(100);      // 可能扩容
*it = 200;             // ❌ it 可能已经失效
  1. erase 一定要接收返回值:
cpp 复制代码
it = v.erase(it);      // ✅ 正确
v.erase(it);           // ❌ it 失效,后续不能再使用

为什么 vector 这么容易失效? 因为它的元素是连续存储 的------扩容就要换一块内存,删除就要整体前移。而 list、map 这类容器的元素在内存里是分散的,所以它们的迭代器通常只在删除被删元素本身时才失效。

这也是 vector 和 list 的核心权衡:

  • vector:访问快(连续内存、随机访问 O(1)),但插入删除代价大(要挪数据);
  • list:插入删除快(改指针 O(1)),但访问慢(只能顺序遍历 O(N))。

9.4 和 string 篇的一个对比

对比项 string::insert vector::insert
返回值 string&(自身引用) iterator(新位置迭代器)
挪动方向 从后往前 从后往前
无符号下溢 用"多预留一个位置"规避 指针不会下溢,只需用 > 条件
迭代器失效 不常涉及 主要问题 (扩容更换 _start)
扩容后如何处理旧 pos 不涉及(string 没有"pos 迭代器"概念) 先存下标,扩容后重新算

核心差异 :string 的 insert 参数是 pos(下标),vector 的 insert 参数是 pos(迭代器)------下标在扩容后依然有效 ,但迭代器在扩容后会失效。

这也是为什么 vector 的实现里多了一步"存下标、扩容、重新定位"------它必须处理迭代器失效的问题。

小结 :insert 和 erase 的核心要点:

  1. 数据挪动方向 :insert 从后往前,erase 从前往后;
  2. 迭代器失效 :扩容或删除都会导致迭代器失效------这是 vector 独有的坑;
  3. insert 里必须"存下标 → 扩容 → 重新定位" ,避免用悬空的 pos;
  4. erase 必须接收返回值 ,it = v.erase(it); 是循环删除的正确姿势;
  5. 和 string 对比 :string 的 insert 用下标,vector 的 insert 用迭代器------这就是"迭代器失效"只在 vector 里成为大问题的原因。

10 构造函数重载陷阱

这是 vector 设计上的一个著名事故------两个看起来很正常的构造函数:

cpp 复制代码
vector(int n, T val = T());                        // 版本 A:n 个元素,每个都是 val
vector(size_t n, T val = T());                     // 版本 A':同上,只是参数类型不同
template<class InputIterator>
vector(InputIterator first, InputIterator last);   // 版本 B:从迭代器区间构造

单独看都没问题。但在某些情况下,用户明明想调用版本 A("n 个元素,每个都是 val"),编译器却选了版本 B ------把两个整数当成"起始迭代器"和"结束迭代器",然后尝试解引用一个整数------程序崩溃。

这不是理论上的可能性,而是真实发生过的 bug。下面我们看它到底怎么触发。

10.1 重载决议:什么条件下会"选错"?

要理解这个陷阱,先要知道 C++ 重载决议的两条规则:

规则一:精确匹配 > 类型转换匹配

如果两个候选函数都能匹配实参,但一个需要类型转换、另一个不需要,那么不需要转换的胜出。

规则二:非模板 > 模板

如果两个候选函数的匹配程度完全相同 ,那么非模板函数优先。

这两条规则组合起来,决定了 (n, val) 和迭代器区间这两个构造函数谁会被选中:

候选函数 匹配情况
vector(int n, T val) 实参类型必须能"精确匹配"或"转换匹配" int
vector(size_t n, T val) 实参类型必须能"精确匹配"或"转换匹配" size_t
vector(InputIterator, InputIterator)(模板) 实参类型可以被推导为 InputIterator,几乎总能"精确匹配"

关键点 :模板版本对任何 类型都是"精确匹配"------int、double、char*,甚至指针都能被推导成 InputIterator。所以只要 (n, val) 版本需要类型转换(即传入的类型和 int /size_t 不完全一样),模板就会以"精确匹配"胜出。

换句话说:

只要两个实参的类型既不是 int,也不是当前平台的 size_t,模板版本就会"抢走"调用。

这就是陷阱的触发条件。它依赖于平台 ------因为 size_t 在不同平台下类型不同,所以同一段代码可能 32 位正常、64 位崩溃。

10.2 为什么 32 位能跑,64 位崩溃?

我们的代码里额外加了一个 size_t 版本:

cpp 复制代码
vector(int n, T val = T());       // ①
vector(size_t n, T val = T());    // ②
template<class InputIterator>
vector(InputIterator first, InputIterator last);   // ③

Test.cpp 里演示了两种调用:

cpp 复制代码
xc::vector<int>    v6(6, 3);      // 两个 int
xc::vector<size_t> v7(6u, 3u);    // 两个 unsigned int

诡异的现象 :在 X86(32 位)上,程序能跑 ;在 X64(64 位)上, v7 崩溃 。原因在于 size_t 在两种平台下类型不同:

平台 size_t 实际类型 6u 与 size_t 的关系
X86 unsigned int(4 字节) 6u 和 size_t 类型完全相同
X64 unsigned long long(8 字节) 6u(unsigned int)和 size_t 类型不同

逐个分析:

v6(6, 3)------所有平台都正常

两个参数都是 int,直接精确匹配 ① vector(int, T)。模板 ③ 虽然也能匹配(InputIterator = int),但 ①是非模板函数 ------在两个都是精确匹配时,编译器优先选非模板 。所以 v6 在任何平台都走 ①。

v7(6u, 3u)------X86 正常,X64 崩溃

  • X86 :size_t == unsigned int,所以 6u 精确匹配 ② 的 size_t。② 和 ③ 都是精确匹配------非模板 ② 优先。走 ②,正常;
  • X64 :size_t == unsigned long long,6u 是 unsigned int------需要转换 。而 ③ 模板对 6u 是精确匹配(InputIterator = unsigned int)。精确匹配 > 类型转换,所以 ③ 胜出------走迭代器区间,崩溃。

换成表格:

平台 size_t 类型 v6(6, 3) v7(6u, 3u)
X86 unsigned int 走 ① (int, T) ✅ 走 ② (size_t, T) ✅
X64 unsigned long long 走 ① (int, T) ✅ 走 ③ 迭代器区间 ❌

这个现象说明一件重要的事:

加 size_t 重载并没有真正解决问题,只是让 32 位平台"碰巧"过关。

因为 32 位平台的 size_t 恰好等于 unsigned int,所以 6u 精确匹配了 ②;但 64 位平台下 size_t 变成了 8 字节,这个"巧合"就消失了。

再举两个例子(都是在 64 位平台下):

cpp 复制代码
xc::vector<int>  v8(6u, 3u);      // ⚠️ 走 ③ 迭代器区间------崩溃
xc::vector<long> v9(6L, 3L);      // ⚠️ 走 ③ 迭代器区间------崩溃
xc::vector<char> v10(6, 'a');     // ✅ 走 ① (int, T)------正常

规律是:

只要两个实参的类型不是 int 或当前平台的 size_t,模板版本 ③ 就会"抢走"调用。

这就是为什么标准库要约束版本 ③ 只在真正的迭代器传入时才生效------否则任何整数都会触发这个陷阱。

10.3 标准库是怎么彻底解决的?

标准库不靠"加 size_t 重载"来绕开,而是约束版本 B 只在 InputIterator 真的是迭代器时才生效 ------用 SFINAE 或 C++20 的 concept。

SFINAE 写法:

cpp 复制代码
template<class InputIterator,
         class = std::enable_if_t<is_iterator_v<InputIterator>>>
vector(InputIterator first, InputIterator last);

C++20 的 concept 写法:

cpp 复制代码
template<std::input_iterator InputIterator>
vector(InputIterator first, InputIterator last);

核心思想是一样的:给 InputIterator 加一个"必须是迭代器"的约束 。6 和 6u 都是整数,不是迭代器,所以版本 B 被踢出候选集 ------只剩下版本 A 参与重载------问题彻底解决。

C++20 的 concept 就是为了解决这类问题而生的------给模板参数加约束,把"看起来能匹配"变成"必须真的满足语义要求"。

我们的代码没有引入 SFINAE / concept------那样会引入 enable_if_t、iterator_traits 等偏底层的语法,超出"实现一个简易 vector"的范围。如果读者对这方面知识感兴趣可以自行查阅。

总结 :这个陷阱是 vector 设计上的历史遗留问题,标准库后来通过 SFINAE / concept 修复了;但在用户代码里同时写 (n, val) 和迭代器区间两种构造函数时,要小心这个坑。

10.5 一个启示

vector 的这个坑说明:

泛型和重载放在一起,很容易产生意外。

模板构造函数 vector(InputIterator, InputIterator) 本意是"从任意区间构造",但它泛化得太宽了------int、double、甚至指针都能被当作 InputIterator 推导出来。于是和 (n, val) 构造函数撞车。

这也解释了一个 C++ 演进的方向 ------从 C++98 的裸模板,到 C++11 的 SFINAE,再到 C++20 的 concept,本质上都是在解决同一个问题:如何给泛型代码加约束,让"看起来能匹配"变成"必须真的满足语义要求"。

12 写在最后

到这里,一个功能相对完整的 vector 类就实现完毕了。从三文件框架,到构造、析构、拷贝、扩容、插入、删除、迭代器失效------vector 的核心接口我们几乎都过了一遍。

回顾全程,最值得记住的是这几件事:

  1. 模板带来的泛化
    vector<T> 把"元素类型"抽象出来,同一份代码可以管理 int、string、vector<int> 等任意类型。迭代器从 char* 泛化到了 T*,算法从"针对某种容器"泛化到了"针对任意容器"。
  2. 元素搬运不能用 memcpy
    string 的元素是 char,可以直接 memcpy;vector<T> 的元素是任意类型------如果 T = string,memcpy 会让新旧两个 string 的 _str 指向同一块内存,在容器层面重现浅拷贝问题 。正确做法是用赋值(触发 T::operator=)逐个搬运。
  3. 迭代器失效
    vector 的迭代器是 T*,指向具体地址。扩容换了一块内存,所有迭代器都失效;erase 让后面的元素前移,被删位置之后的迭代器都失效。这是 vector 独有的坑------因为它的元素是连续存储的。
  4. 构造函数重载陷阱
    vector(int n, T val) 和 vector(InputIterator, InputIterator) 在传两个整数时会互相"抢活"。标准库用 SFINAE / concept 约束模板只接受真正的迭代器------这也是 C++ 从 C++98 到 C++20 一直在演进的方向:给泛型代码加约束。

结语

`vector` 是 C++ 里最常用的容器之一,也是**从"具体类型"跨越到"泛型编程"** 的第一步。

string 让你理解了动态数组 的封装;vector 让你理解了模板如何把封装泛化到任意类型 。这两步走下来,你已经摸到了 C++ 泛型编程的门槛------vector 之后,list、map、set 的实现思路都是相通的。

更重要的是,通过实现 string 和 vector,你学会的不只是这两个容器------深拷贝的思想可以应用到你写的每一个自定义类型,迭代器失效的规则可以帮你避开工程里的隐蔽 bug,重载陷阱的分析方法可以帮你在设计 API 时少踩坑。

希望这篇文章对你有帮助。

本文代码环境:Visual Studio 2022 / C++11 及以上

完整代码仓库:见评论区

如果本文对你有帮助,欢迎点赞、收藏、关注------你的支持是我持续更新的最大动力!如有疑问,欢迎在评论区留言讨论~

相关推荐
程序猿追7 小时前
kotlin-retry 接入鸿蒙后,怎样确认它真的会停止重试
开发语言·kotlin·harmonyos
我是人✓7 小时前
Java文件上传三方式对比:从传统到SpringBoot
java·开发语言
智能直播7 小时前
网络RTMP拉流不卡顿、声音不同步,一文讲透缓冲区、时间戳
开发语言·ffmpeg·php·音视频·视频编解码
墨云安全8 小时前
内网资产无法暴露到公网,怎么做安全检测?2026 年内网渗透测试的落地路径
开发语言·安全·网络安全·渗透测试·php
摇滚侠8 小时前
《计算机是如何工作的:人人都能懂的计算机软硬件工作原理》第 9 章 机器码与汇编语言 阅读笔记 9
java·开发语言·笔记
Cc.Y8 小时前
Java零基础入门:深入Java核心:文件IO与序列化全解
java·开发语言·python
xier_ran8 小时前
【infra之路】AI 编译器学习笔记(六):重拾 C++——lambda 与 MLIR 中的回调
c++·笔记·学习
jimy110 小时前
c++的 Type Traits (一)
开发语言·c++
别动我齐刘海10 小时前
Linux 系统调试学习2
linux·运维·c++·人工智能·学习·机器学习·机器人