動手寫一個Vector
本文是對《最好的C++教程》的動手寫資料結構部分的一個整理,主要包含91p動手寫Array陣列和92p動手寫Vector陣列的內容。
自己動手來寫這些資料結構是學習C++的絕佳方法,並且可以更加深刻的理解標準庫中Vector和Array的實現和用法。
Array陣列主要包含的知識點有:模板,constexpr,const成員函式
Vector陣列主要包含的知識點有:動態擴容,placement new,move semantics,emplace_back
原作者視訊連結:https://youtu.be/TzB5ZeKQIHM ,https://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在其型別為int
和std::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
,然後直接在給定的地址空間進行物件的構造。
實際上這一節介紹的EmplaceBack
和Placement 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
運算子實際上會做兩件事情
- 分配記憶體
- 呼叫建構函式
而這裡相當於記憶體分配已經提前做好了,我們只需要在相應的位置呼叫建構函式即可。
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下除錯看看結果
正確記憶體管理實現
我們使用的辦法就是將new
和delete
的兩階段分開,其中分配和回收的過程則呼叫::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
方法等,有能力的小夥伴可以嘗試實現更多。後續我也會繼續完善。