动手写一个Vector

本文是对《最好的C++教程》的动手写数据结构部分的一个整理,主要包含91p动手写Array数组92p动手写Vector数组的内容。

自己动手来写这些数据结构是学习C++的绝佳方法,并且可以更加深刻的理解标准库中Vector和Array的实现和用法。

Array数组主要包含的知识点有:模板,constexpr,const成员函数

Vector数组主要包含的知识点有:动态扩容,placement new,move semantics,emplace_back

原作者视频链接:https://youtu.be/TzB5ZeKQIHMhttps://youtu.be/ryRf4Jh_YC0

文中代码github链接:https://github.com/zhangyi1357/Little-stuff

Array数组

在大多数情况下,当我们需要一个数组时,我们都会优先使用vector,因为vector可以动态扩容,效率也足够高,非常好用。

但是你需要array数组的情况在于很多时候只需要一个静态大小的数组,而这种情况下vector的堆内存分配相较于array数组直接在栈上分配内存的效率就比较低了。

实际上一个Array数组的实现非常简单,如果你对模板比较熟悉的话,基本上就是给一个数组写一个模板然后给用户几个接口。

Array数组API

首先我们来看看其最终的API,这里我们直接以其一个使用的示例来看我们需要完成哪些功能

  • 最基础的创建一个指定类型和大小的array
  • 能用[]运算符来索引,可以读取也可以写入
  • Size方法返回其大小,其中Size需要在编译器确定
  • 支持Data方法返回其数据地址,可以利用memset批量设置其值
int main() {
constexpr int size = 5;
Array<int, size> data; static_assert(data.Size() < 10, "Size is too large"); data[0] = 2;
data[1] = 3;
data[2] = 5;
for (size_t i = 0; i < data.Size(); ++i)
std::cout << data[i] << std::endl;
std::cout << "-----------------" << std::endl; memset(data.Data(), 0, data.Size() * sizeof(int));
for (size_t i = 0; i < data.Size(); ++i)
std::cout << data[i] << std::endl;
std::cout << "-----------------" << std::endl; Array<std::string, size> data2;
data2[0] = "Cherno";
data2[1] = "C++";
for (size_t i = 0; i < data2.Size(); ++i)
std::cout << data2[i] << std::endl; return 0;
}

其输出为

2
3
5
0
336165216
-----------------
0
0
0
0
0
-----------------
Cherno
C++

这里有一个小点需要注意,我们可以看到未经初始化的Array在其类型为intstd::string时有不同的表现,int类型其值是未定义的,所以可能输出任意值,例如上面的336165216,而std::string类型会自动初始化为一个空串。

Array数组实现

根据以上API,可以给出如下简洁代码实现

template <typename T, size_t S>
class Array {
public:
constexpr int Size() const { return S; } T& operator[](size_t index) { return m_Data[index]; }
const T& operator[](size_t index) const { return m_Data[index]; } T* Data() { return m_Data; }
const T* Data() const { return m_Data; }
private:
T m_Data[S];
};

const成员函数

注意到对[]运算符的重载和Data方法都给出了两个版本,一个const一个非const版本。

const版本的函数性质和返回值都是const,这主要是为了兼容const Array<T, S>的用法,因为一个const Array<T, S>类型的对象是不能调用非const成员函数的,而显然我们也不希望这样一个类型的返回值是非const的,因为我们不想通过该成员函数来改变其值。

constexpr

注意到前面的main函数中有如下一条语句

    static_assert(data.Size() < 10, "Size is too large");

这条语句用于编译期检查,那么我们的Size方法一定也要能在编译器确定其值,这一点是完全可以做到的,因为我们要求的模板参数S需要在编译期就能确定其值,所以我们只需要在Size方法的返回值前面加上一个constexpr表示该值可以在编译期求取即可。

Vector数组

Vector数组相较于Array的最大特点就在于动态扩容,我们不用指定其初始容量,而在使用过程中可以不断地以O(1)的时间复杂度向其尾部插入元素或读取任意位置的元素。

后文我们将先阐述动态扩容策略,并在此策略上完成基础版本的实现,然后在此基础上逐步优化性能添加功能。

动态扩容策略

首先我们需要在O(1)的时间复杂度内读取任意位置的元素,所以肯定需要连续存储的内存空间,不考虑使用链表等数据结构。

其次需要O(1)的时间复杂度在尾部进行插入,Array数组其实可以满足这点,但是其容量有限,那么很直观的一个思路就是先分配一个有限容量的数组,如果满了还需要插入就重新分配一个更大的数组。

而动态扩容的trick就在此处,每次重新分配之后我们都需要将数组完整地挪到新的内存地址去,这一过程是非常耗时的,对于一个长度为n的数组来说其时间复杂度为O(n)。

我们解决的办法是每次分配数组的时候直接多分配一些空间,这样很多次插入操作才会有一个扩容操作,于是扩容的高消耗就被均摊到了每次的插入操作上,达到总体的O(1)时间复杂度。

那么具体多分配多少空间呢,我们要保证一次扩容操作被分摊到O(n)次插入操作上才行,所以扩大的容量必须要是O(n)这个数量级的。

实际中不同的编译器的处理方式不尽相同,MSVC中以1.5倍扩容,GCC中以2倍扩容。本文采取2倍扩容的方式。

基础版本

基础版本API

基础版本只需要实现以下的简单API即可,拆解开来我们需要完成

  • 动态扩容
  • PushBack方法
  • 重载[]运算符
  • Size方法
template<typename T>
void PrintVector(const Vector<T>& vector) {
for (size_t i = 0; i < vector.Size(); ++i)
std::cout << vector[i] << std::endl; std::cout << "---------------------------" << std::endl;
} int main() {
Vector<std::string> vector;
vector.PushBack("Cherno");
vector.PushBack("C++");
vector.PushBack("Vector");
PrintVector(vector); return 0;
}

基础版本实现

该实现较为简单,直接给出,各部分都有详细注释。注意我们的初始化策略是分配分配两个元素的空间。

template <typename T>
class Vector {
public:
Vector() { ReAlloc(2); }
~Vector() { delete[] m_Data; } void PushBack(const T& value) {
// check the space
if (m_Size >= m_Capacity)
ReAlloc(m_Size + m_Size); // push the value back and update the size
m_Data[m_Size++] = value;
} T& operator[](size_t index) { return m_Data[index]; }
const T& operator[](size_t index) const { return m_Data[index]; } size_t Size() const { return m_Size; } private:
void ReAlloc(size_t newCapacity) {
// allocate space for new block
T* newBlock = new T[newCapacity]; // ensure no overflow
if (newCapacity < m_Size)
m_Size = newCapacity; // move all the elements to the new block
for (int i = 0; i < m_Size; ++i)
newBlock[i] = m_Data[i]; // delete the old space and update old members
delete[] m_Data;
m_Data = newBlock;
m_Capacity = newCapacity;
} private:
T* m_Data = nullptr; size_t m_Size = 0;
size_t m_Capacity = 0;
};

move版本

以上的基础版本可以实现基本的功能,但是其效率却太低,存在许多复制。我们可以自己写一个class测试一下。

move版本API

class Vector3 {
public:
Vector3() {}
Vector3(float scalar)
: x(scalar), y(scalar), z(scalar) {}
Vector3(float x, float y, float z)
: x(x), y(y), z(z) {} Vector3(const Vector3& other)
: x(other.x), y(other.y), z(other.z) {
std::cout << "Copy" << std::endl;
}
Vector3(const Vector3&& other)
: x(other.x), y(other.y), z(other.z) {
std::cout << "Move" << std::endl;
}
~Vector3() {
std::cout << "Destroy" << std::endl;
} Vector3& operator=(const Vector3& other) {
std::cout << "Copy" << std::endl;
x = other.x;
y = other.y;
z = other.z;
return *this;
}
Vector3& operator=(Vector3&& other) {
std::cout << "Move" << std::endl;
x = other.x;
y = other.y;
z = other.z;
return *this;
}
friend std::ostream& operator<<(std::ostream&, const Vector3&);
private:
float x = 0.0f, y = 0.0f, z = 0.0f;
}; std::ostream& operator<<(std::ostream& os, const Vector3& vec) {
os << vec.x << ", " << vec.y << ", " << vec.z;
return os;
} int main() {
Vector<Vector3> vec;
vec.PushBack(Vector3());
vec.PushBack(Vector3(1.0f));
vec.PushBack(Vector3(1.0f, 2.0f, 3.0f));
PrintVector(vec); return 0;
}

对于基础版本的API其输出为

Copy
Destroy
Copy
Destroy
Copy
Copy
Destroy
Destroy
Copy
Destroy
0, 0, 0
1, 1, 1
1, 2, 3
---------------------------

中间连着两个Copy和两个Destroy是扩容过程。除此之外的都是PushBack时产生的。

实际上我们并不需要这么多复制,在PushBack的时候可以将原来的内容直接移动到新的位置,扩容过程也是一样。这就要用到C++11的移动语义的特性了。

move版本实现

消除以上的Copy其实很简单,只需要重载一个接受右值的PushBack并在其中进行move即可,另外要注意扩容过程也需要改成move的。

// new PushBack Method
void PushBack(T&& value) {
// check the space
if (m_Size >= m_Capacity)
ReAlloc(m_Size + m_Size); // push the value back and update the size
m_Data[m_Size++] = std::move(value);
} // in ReAlloc
for (int i = 0; i < m_Size; ++i)
newBlock[i] = std::move(m_Data[i]);

可以看到以下结果

Move
Destroy
Move
Destroy
Move
Move
Destroy
Destroy
Move
Destroy
0, 0, 0
1, 1, 1
1, 2, 3
---------------------------

可以看到现在全都是Move,没有Copy,效率提高!

EmplaceBack & Placement new

好了,现在我们有很高效的PushBack实现,但是我们发现每一次PushBack仍然在外面构造好一个变量然后移动到Vector里面。

那么有没有这样一种可能,直接把构造需要的参数给到Vector,然后直接在给定的地址空间进行对象的构造。

实际上这一节介绍的EmplaceBackPlacement New就可以做到这一点。

原地构造 API

可以看到这里给EmplaceBack的直接是构造Vector3所需的参数而不是Vector3。

int main() {
Vector<Vector3> vec;
vec.EmplaceBack();
vec.EmplaceBack(1.0f);
vec.EmplaceBack(1.0f, 2.0f, 3.0f);
PrintVector(vec); return 0;
}

原地构造实现

首先是EmplaceBack的实现,实现依赖于模板参数展开,这里不做详细讨论,仅给出其实现。

注意到实现中的new运算符,不同于一般的new运算符,这里给出了一个参数作为需要new的位置的地址,这样就可以直接在原地构造而不需要移来移去。

为了更好地理解placement new,有必要讲一下new运算符的机制,new运算符实际上会做两件事情

  1. 分配内存
  2. 调用构造函数

而这里相当于内存分配已经提前做好了,我们只需要在相应的位置调用构造函数即可。

template<typename... Args>
T& EmplaceBack(Args&&... args) {
// check the space
if (m_Size >= m_Capacity)
ReAlloc(m_Size + m_Size); // Placement new
new (&m_Data[m_Size]) T(std::forward<Args>(args)...);
return m_Data[m_Size++];
}

测试结果为

Move
Move
Destroy
Destroy
0, 0, 0
1, 1, 1
1, 2, 3
---------------------------

Amazing! 我们只在扩容的时候进行了两次Move,所有的对象都是在原地直接进行构造的。

关于new和delete的疑问

前面说了new运算符会干两件事,分配内存和调用构造函数,那么在ReAlloc中我们就使用了new,同时做了分配内存和调用构造函数两件事,后面又将原来的值挪到新分配的地方,那构造函数的调用不就浪费了?

是的!实际上这个问题同样会反映在delete运算符上,对于new来说只是效率降低了,但对delete来说可能会造成严重的bug。

不过不要着急后面会解决这个问题。

PopBack和析构函数

前面的过程中为了输出简单省略了析构函数,实际上析构函数不可或缺,否则会有内存泄漏。

同时我们增加PopBack的功能。而这二者组合起来会造成一个非常严重的问题。

PopBack和析构函数 API

int main() {
Vector<Vector3> vec;
vec.EmplaceBack();
vec.EmplaceBack(1.0f);
vec.EmplaceBack(1.0f, 2.0f, 3.0f);
PrintVector(vec);
vec.PopBack();
vec.PopBack();
PrintVector(vec); return 0;
}

PopBack和析构函数实现

其实现非常简单

    void PopBack() {
if (m_Size > 0) {
--m_Size;
m_Data[m_Size].~T();
}
} ~Vector() { delete[] m_Data; }

输出也正常:

Move
Move
Destroy
Destroy
0, 0, 0
1, 1, 1
1, 2, 3
---------------------------
Destroy
Destroy
0, 0, 0
---------------------------
Destroy
Destroy
Destroy
Destroy

但是暗藏玄机的是,如果我们的Vector3类中有指针指向某一片内存空间的话,那么PopBack中会调用一次Vector3的析构函数,然后析构函数中的delete还会对该地址空间调用一次析构函数,那么该内存空间将被delete两次!

接下来我们着手解决该问题。

::operator new/delete

我们解决的办法即本小节标题::operator new/delete。首先给出测试的API。

析构API

class Vector3 {
public:
Vector3() {
m_MemoryBlock = new int[5];
}
Vector3(float scalar)
: x(scalar), y(scalar), z(scalar) {
m_MemoryBlock = new int[5];
}
Vector3(float x, float y, float z)
: x(x), y(y), z(z) {
m_MemoryBlock = new int[5];
} Vector3(const Vector3& other) = delete; Vector3(Vector3&& other)
: x(other.x), y(other.y), z(other.z) {
std::cout << "Move" << std::endl;
m_MemoryBlock = other.m_MemoryBlock;
other.m_MemoryBlock = nullptr;
}
~Vector3() {
std::cout << "Destroy" << std::endl;
delete[] m_MemoryBlock;
} Vector3& operator=(const Vector3& other) {
std::cout << "Copy" << std::endl;
x = other.x;
y = other.y;
z = other.z;
return *this;
}
Vector3& operator=(Vector3&& other) {
std::cout << "Move" << std::endl;
x = other.x;
y = other.y;
z = other.z;
return *this;
}
friend std::ostream& operator<<(std::ostream&, const Vector3&);
private:
float x = 0.0f, y = 0.0f, z = 0.0f;
int* m_MemoryBlock = nullptr; }; std::ostream& operator<<(std::ostream& os, const Vector3& vec) {
os << vec.x << ", " << vec.y << ", " << vec.z;
return os;
} int main() {
{
Vector<Vector3> vec;
vec.EmplaceBack();
vec.EmplaceBack(1.0f);
vec.EmplaceBack(1.0f, 2.0f, 3.0f);
PrintVector(vec);
vec.PopBack();
vec.PopBack();
PrintVector(vec);
}
std::cout << "hello" << std::endl;
return 0;
}

对于此此前程序给出的输出为

Move
Move
Destroy
Destroy
0, 0, 0
1, 1, 1
1, 2, 3
---------------------------
Destroy
Destroy
0, 0, 0
---------------------------
Destroy
Destroy

可以看到并没有输出hello,应该是程序异常退出了,给程序打个断点在gdb下调试看看结果

正确内存管理实现

我们使用的办法就是将newdelete的两阶段分开,其中分配和回收的过程则调用::operator new::operator delete

具体实现如下:

    ~Vector() {
Clear();
::operator delete(m_Data, m_Capacity * sizeof(T));
} void Clear() {
for (int i = 0; i < m_Size; ++i)
m_Data[i].~T(); m_Size = 0;
} void ReAlloc(size_t newCapacity) {
// allocate space for new block
T* newBlock = (T*)::operator new(newCapacity * sizeof(T)); // ensure no overflow
if (newCapacity < m_Size)
m_Size = newCapacity; // move all the elements to the new block
for (int i = 0; i < m_Size; ++i)
new(&newBlock[i]) T(std::move(m_Data[i])); // delete the old space and update old members
Clear();
::operator delete(m_Data, m_Capacity * sizeof(T));
m_Data = newBlock;
m_Capacity = newCapacity;
}

可以看到主要就是将析构函数的调用挪到了Clear函数里,只析构有元素的位置,然后删除和分配空间用::operater new/delete

注意::operator delete的该重载函数直到C++14才得到支持,所以以上代码需要编译命令-std=c++14或更高。

其输出结果为

Move
Move
Destroy
Destroy
1, 2, 3
---------------------------
Destroy
---------------------------
hello

没有问题!NICE!

总结

以上的Vector模板类已经实现了动态扩容和高效的空间管理,但是仍有许多尚未完成的部分,例如迭代器,erase方法等,有能力的小伙伴可以尝试实现更多。后续我也会继续完善。

参考资料

Cherno视频教程91P(Array)bilibili

Cherno视频教程92P(Vector) bilibili

C++ STL vector扩容原理分析 - Jcpeng_std - 博客园 (cnblogs.com)

自己动手写Vector【Cherno C++教程】的更多相关文章

  1. C++移动语义 详细讲解【Cherno C++教程】

    移动语义 本文是对<最好的C++教程>的整理,主要是移动语义部分,包含视频85p左值和右值.89p移动语义与90p stdmove和移动赋值操作符. 移动语义是C++11的新feature ...

  2. 自己动手写插件底层篇—基于jquery移动插件实现

    序言 本章作为自己动手写插件的第一篇文章,会尽可能的详细描述一些实现的方式和预备知识的讲解,随着知识点积累的一点点深入,可能到了后期讲解也会有所跳跃.所以,希望知识点不是很扎实的读者或者是初学者,不要 ...

  3. 自己动手写PHP MVC框架

    自己动手写PHP MVC框架 来自:yuansir-web.com / yuansir@live.cn 代码下载: https://github.com/yuansir/tiny-php-framew ...

  4. [JVM] - 一份<自己动手写Java虚拟机>的测试版

    go语言下载 配置GOROOT(一般是自动的),配置GOPATH(如果想自己改的话) 参照<自己动手写Java虚拟机> > 第一章 指令集和解释器 生成了ch01.exe文件 这里还 ...

  5. WhyGL:一套学习OpenGL的框架,及翻写Nehe的OpenGL教程

    最近在重学OpenGL,之所以说重学是因为上次接触OpenGL还是在学校里,工作之后就一直在搞D3D,一转眼已经毕业6年了.OpenGL这门手艺早就完全荒废了,现在只能是重学.学习程序最有效的办法是动 ...

  6. 【原创】自己动手写控件----XSmartNote控件

    一.前面的话 在上一篇博文自己动手写工具----XSmartNote [Beta 3.0]中,用到了若干个自定义控件,其中包含用于显示Note内容的简单的Label扩展控件,用于展示标签内容的labe ...

  7. 【原创】自己动手写工具----XSmartNote [Beta 3.0]

    一.前面的话 在动笔之前,一直很纠结到底要不要继续完成这个工具,因为上次给它码代码还是一年多之前的事情,参考自己动手写工具----XSmartNote [Beta 2.0],这篇博文里,很多园友提出了 ...

  8. 【原创】自己动手写工具----XSmartNote [Beta 2.0]

    一.前面的话 在上一篇自己动手写工具----XSmartNote中,我简单介绍了这个小玩意儿的大致界面和要实现的功能,看了一下园子里的评论,评价褒贬不一,有人说“现在那么多云笔记的工具”,“极简版ev ...

  9. 【原创】自己动手写工具----签到器[Beta 2.0]

    一.前面的话 上一篇中基本实现了简单的签到任务,但是不够灵活.在上一篇自己动手写工具----签到器的结尾中,我设想了几个新增功能来提高工具的灵活程度,下面把新增功能点列出来看看: (1)新增其他的进程 ...

随机推荐

  1. MAC OS 常用快捷键

    删除文件或文件夹 commond + delete 复制文件或文件夹 commond + c 粘贴文件或文件夹 commond + v

  2. 【转】性能测试报告模板 V1.0

    1. 测试项目概述与测试目的 1.1 项目概述  本部分主要是针对即将进行压力测试的对象(接口.模块.进程或系统)进行概要的说明,让人明白该测试对象的主要功能与作用及相关背景. 1.2 测试目标  简 ...

  3. Ansible之roles模块--lnmp分布式部署

    Ansible之roles模块--lnmp分布式部署 目录 Ansible之roles模块--lnmp分布式部署 1. role模块的作用 2. roles的目录结构 3. roles内个目录含义解释 ...

  4. 记录使用WKWebView进行OC与JS交互所踩过的坑

    目录: 1.页面cookie缓存 2.允许弹出JS的弹框 3.在webview页面加载的时候,添加加载进度条 4.禁止掉webview页面的长按复制粘贴功能 5.设置webview的userAgent ...

  5. Spring IOC-基于XML配置的容器

    Spring IOC-基于XML配置的容器 我们先分析一下AbstractXmlApplicationContext这个容器的加载过程. AbstractXmlApplicationContext的老 ...

  6. 动静分离+url地址重定向+HTTPS协议

    动静分离+url地址重定向+HTTPS协议

  7. [技术干货-算子使用] mindspore.scipy 入门使用指导

    1. MindSpore框架的SciPy模块 SciPy 是基于NumPy实现的科学计算库,主要用于数学.物理学.生物学等科学以及工程学领域.诸如高阶迭代,线性代数求解等都会需要用到SicPy.Sci ...

  8. Spring系列18:Resource接口及内置实现

    本文内容 Resource接口的定义 Resource接口的内置实现 ResourceLoader接口 ResourceLoaderAware 接口 Resource接口的定义 Java 的标准 ja ...

  9. nginx负载均衡初体验

    本例采取简单的轮询策略进行nginx的负载均衡处理. 在反向代理(参考:https://www.cnblogs.com/ilovebath/p/14771571.html)的基础上增加负载均衡处理的n ...

  10. yum配置及使用命令

    linux yum 命令 yum( Yellow dog Updater, Modified)是一个在Fedora和RedHat以及SUSE中的Shell前端软件包管理器. 基於RPM包管理,能够从指 ...