自己動手寫Vector【Cherno C++教程】


動手寫一個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); }

    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下調試看看結果

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 C++視頻教程91P(Array)bilibili

Cherno C++視頻教程92P(Vector) bilibili

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

原文鏈接:https://www.cnblogs.com/zhangyi1357/p/16009968.html
轉載請注明出處!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM