65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C++ 模板实现具有自定义分配器的动态内存对象

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2023 年 1 月 22 日

MIT

12分钟阅读

viewsIcon

16067

downloadIcon

152

如何使用 C++ 模板实现具有自定义分配器的动态内存对象

引言

如果您进行任何涉及 Windows API 的平台编程,您将花费大量时间进行指针运算和处理动态分配的内存块。

在我之前的文章中,我已經發布了幾種簡化此類操作的技術,例如用於處理可變大小結構或按需增長的 IO 緩衝區的類。這些技術都使用了一個表示堆內存塊的類。

然而,這也限制了它們的使用範圍僅限於堆內存。有時候,您希望將內存分配在堆以外的地方,例如進程空間之間共享的內存,甚至可以通過映射到 PCI 板的內存實現不同計算機之間的共享。當然,還有一些情況下您需要不同行為的分配器。在所有這些情況下,您都不希望僅僅因為想更改底層物理實現就重新實現所有內容。

在本文中,我將探討如何使用自定義分配器與通用內存類結合,以便在編譯時決定類將如何管理其動態內存,這將通過 C++ 模板編程來實現。

我想強調的是,C++ 擁有巨大的功能和靈活性,有許多完全不同的方法可以實現類似的功能,以涵蓋所有可以想像到的場景。在本文中,我將探討一種對我處理的平台編程有意義的方法。

設計目標 / 語義

本文的目標是概述一個表示內存範圍的類的實現,並遵循以下設計約束:

  1. 指針值和內存大小對派生類可用,但內存對象的內部狀態不可用。
  2. 內存實現在一個抽象基類上,該基類可以作為一個與實現無關的接口來訪問內存。
  3. 內存的分配器是一個模板參數,因此它是對象類型的一部分。嘗試混合具有不同分配器的派生類將導致編譯器錯誤,而不是運行時錯誤。
  4. 抽象基類也是一個模板參數。這使得具有不同行為保證的內存對象在被錯誤使用時能夠在編譯時生成錯誤,而不是在運行時。

創建分配器對象

如果我們要管理內存,就需要一個內存分配器來為我們完成這項工作。由於我們希望能夠在編譯時切換這些分配器,因此它們需要符合接口約定。C++ 沒有 .NET 的接口概念,但我們可以有一個抽象基類,它相當於相同的東西。

嚴格來說,分配器不需要抽象基類,因為如果缺少正確的方法簽名,編譯器會直接報錯。儘管如此,有一個抽象基類也很方便,可以更清晰地說明內存分配器的要求。

    class IAllocator
    {
    public:
        virtual void Allocate(void* &ptr, size_t &size, size_t reqSize) = 0;
        virtual void DeAllocate(void* &ptr, size_t &size) = 0;
        virtual void Resize(void* &ptr, size_t& size, size_t reqSize) = 0;
    };

我的內存分配器的三個基本功能是分配一塊內存、釋放內存以及重新分配內存大小。請注意,由於指針和大小總是成對出現,我將它們都通過引用傳遞。實際值保存在其他地方,在派生類中。分配器的唯一職責是管理內存並確保方法調用後 ptrsize 的值是正確的。

一種可能的實現是管理標準進程堆內存的分配器。

    class CHeapAllocator : public IAllocator
    {
    public:
        virtual void Allocate(void* &ptr, size_t& size, size_t reqSize);
        virtual void DeAllocate(void* & ptr, size_t& size);
        virtual void Resize(void* &ptr, size_t& size, size_t reqSize);
    };

CHeapAllocator 類只不過是 abstract 類的實現。所有方法都有實現,但我將它們保留為虛函數,以便將來可以方便地讓其他堆分配器類按需覆蓋它們。

    void CHeapAllocator::Allocate(void* &ptr, size_t& size, size_t reqSize)
    {
        DeAllocate(ptr, size);

        if (reqSize== 0)
            return;

        ptr = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, reqSize);
        if (!ptr) {
            throw std::bad_alloc();
        }

        size = reqSize;
    }

    void CHeapAllocator::DeAllocate(void* &ptr, size_t& size)
    {
        if (ptr)
            HeapFree(GetProcessHeap(), 0, ptr);

        ptr = NULL;
        size = 0;
    }

    void CHeapAllocator::Resize(void* &ptr, size_t& size, size_t reqSize)
    {
        if (reqSize== 0) {
            DeAllocate(ptr, size);
            return;
        }

        if (!ptr) {
            Allocate(ptr, size, reqSize);
        }
        else {
            void* newPtr = 
                  HeapReAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, ptr, reqSize);
            if (!newPtr) {
                throw std::bad_alloc();
            }
            ptr = newPtr;
            size = reqSize;
        }
    }

關於實現沒有太多可說的。這是使用內建堆函數的標準方法。內存大小的重新分配是通過 HeapReAlloc 函數完成的,該函數有一個很好的特性,可以保留已存在的內存內容。如果重新分配失敗,指針不會被覆蓋,因為原始指針沒有被釋放,而且如果我們將 ptr 設置為 NULL,那麼原來分配的內存就會洩漏。

即使某種內存技術不允許重新分配大小,它也可以通過分配器獲得指針和大小的控制權來實現重新分配。因此,它可能能夠通過分配新內存並替換指針來實現重新分配。這也是 HeapReAlloc 本身的工作方式:如果現有內存塊旁邊有足夠的空間,堆管理器會更新內部簿記。否則,它會分配新內存並複製內容。

內存接口

核心上,一塊內存由一個指針和一個大小參數表示。管理其生命週期的手段在許多情況下與該內存的使用無關。為了代碼的獨立性,預見代碼塊不需要了解任何其他內容的場景是一個好主意。為此,我們管理的每一塊內存都派生自一個通用的內存接口。

    class IMemory 
    {
    public:
        virtual size_t Size() const = 0;
        virtual void* Ptr() const = 0;
        virtual bool IsValid() const = 0;
        virtual operator void* () const = 0;
    };

在我介紹中描述的一些用例中,我們需要通過 public 方法調用來調整內存大小。一種選擇是提供一個虛函數 Resize,如果未被覆蓋實現,它將拋出異常。我故意不這樣做,因為如果應用程序嘗試調整不支持重新分配大小的內存對象的大小,只會在運行時顯示。相反,我創建了另一個繼承自 IMemory 的基類,並添加了一個 Resize 方法。

    class IResizeableMemory : public IMemory 
    {
    public:
        virtual void Resize(size_t newSize) = 0;
    };

任何動態內存類都將繼承自其中一個。因此,嘗試在需要可調整大小的內存的地方使用不可調整大小的內存將導致編譯器錯誤。

同樣,我們可以爭辯說 IMemory 只保證它代表一塊內存,但它不保證內存範圍本身在創建後是固定的。如果必須確保關於內存區域不可變性的保證,我們可以這樣定義接口:

    class IImmutableMemory : public IMemory 
    {
    };

它沒有額外的函數,但可以用作模板參數進行特化以更改實現。

內存基類

在前一節中,我描述了內存接口。這些接口在內存基類中實現。

類型定義

基類的類型定義如下:

    template <typename AllocatorType, typename BaseType>
    class CMemoryBase : public BaseType
    {
    private:
        void* m_ptr = NULL;
        size_t m_size = 0;
        AllocatorType m_Allocator;

        //.....
    };

該類接受兩個模板類型參數。AllocatorType 用於創建分配器對象來處理所有內存。這種內存處理是通過 protectedAllocateDeAllocateResize 方法完成的。請注意,該類確實有一個 Resize 方法。它是一個 protected 方法,而不是 public 方法,因此只能在類內部訪問。對象可以自行調整大小,除非它是不可變的。它只是不允許其他代碼這樣做。

由於 CMemoryBase 繼承自提供的基類,我們可以在編譯時定義接口約定。如果我們有一個期望 IMemory 派生類的代碼,而我們提供了一個 IImmutableMemory,那將會起作用,因為 IImmutableMemory 繼承自 IMemory。反之,如果在期望 IImmutableMemory 的地方提供 IResizeableMemory,那將會失敗。

這有一個很大的優勢,那就是編譯器可以強制執行預期的行為。

内存管理

關於內存管理本身,沒有太多可以說的。CMemoryBase 類有三個使用分配器的方法。

    protected:
        void Allocate(size_t size) {
            m_Allocator.Allocate(m_ptr, m_size, size);
        }

        void DeAllocate() {
            m_Allocator.DeAllocate(m_ptr, m_size);
        }

        void Resize(size_t newSize) {
            m_Allocator.Resize(m_ptr, m_size, newSize);
        }

這些方法目前是 protected 的。它們的可訪問性將被繼承自 CMemoryBase 的類修改,以強制執行基類所暗示的約定。

構造函數和賦值

構造函數和析構函數也同樣簡單。

    public:
        CMemoryBase() : BaseType() {}
        CMemoryBase(size_t size) {
            Allocate(size);
        }
        virtual ~CMemoryBase() {
            DeAllocate();
        }

內存在構造函數和析構函數中分配。析構函數被設為虛函數,以確保任何已分配的內存都將得到釋放,從而防止內存洩漏。

我們的類支持移動賦值和移動構造。對我們來說,這意味著簡單地交換 m_ptrm_size。儘管將默認值保留原樣很誘人,但我們需要定義自己的實現,因為在 C++ 中,移動語義並不保證值被交換。它們也可以被複製。這將是災難性的,因為可能會有兩個對象具有相同的 m_ptr 值,這將導致雙重刪除。

        CMemoryBase(CMemoryBase&& other) noexcept {
            std::swap(m_ptr, other.m_ptr);
            std::swap(m_size, other.m_size);
        }

        CMemoryBase& operator = (CMemoryBase&& other) noexcept {
            if (this == &other)
                return *this;

            std::swap(m_ptr, other.m_ptr);
            std::swap(m_size, other.m_size);

            return *this;
        }

同樣,我們還指定了一個自定義拷貝構造函數和自定義拷貝賦值,因為僅僅拷貝 m_ptrm_size 將產生相同的災難性結果。對我們來說,我們將 CMemoryBase 實例的「拷貝」定義為創建內存範圍及其內容的副本。

        CMemoryBase(CMemoryBase& other) {
            this->Allocate(other.m_size);
            memcpy(m_ptr, other.m_ptr, m_size);
        }

        CMemoryBase& operator = (CMemoryBase& other) {
            if (this == &other)
                return *this;
            Allocate(other.m_size);
            memcpy(m_ptr, other.m_ptr, m_size);
            return *this;
        }

順便提一下:關於移動賦值中的自賦值檢查是否必要,仍然存在爭議。答案是:可能,從邏輯上講,不需要。但這是錯誤的問題。真正重要的是:如果有人以某種方式創造了一種情況,導致這種情況發生,將很難診斷。您想成為那個在星期五晚上深入調用堆棧來找出一個統計學上的巧合的人嗎?答案是:絕對不想。

實現基礎類型

BaseType 用於在編譯時決定哪個將是基類:IMemoryIResizeableMemory。這裡唯一的保證是至少使用 IMemory 作為基類。該接口的實現是微不足道的。

    public:
        size_t Size() {
            return m_size;
        };

        void* Ptr() {
            return m_ptr;
        }

        bool IsValid() {
            return m_ptr != NULL;
        }

        operator void* () {
            return m_ptr;

此時沒有 publicResize 方法。

內存實現類

通用的內存類如下所示:

    template <typename AllocatorType, typename BaseType = IMemory>
    class CMemory : public CMemoryBase< AllocatorType, BaseType>
    {
    public:
        typedef CMemoryBase< AllocatorType, BaseType> base;

        CMemory() : base() {}
        CMemory(size_t size) : base(size) {}
    };

它派生自基類,沒有添加任何內容。您可能會問:為什麼不直接跳過基類,而在 CMemory 中實現所有內容?這是一個有效的問題。答案是,我們將為派生自 IMemory 的各種基類提供部分特化。

部分特化要求您為模板參數的子集提供完整的實現。這意味著,如果我們將所有邏輯都放在 CMemory 中,那麼我們需要在特化中提供另一個完整的實現。通過將所有邏輯放在 CMemoryBase 中,我們只有一個實際的實現,並且我們使用 CMemory 來進行各種特化。

要創建一個可調整大小的內存實現,我們可以簡單地這樣做:

    template <typename AllocatorType>
    class CMemory<AllocatorType, IResizeableMemory> :
        public CMemoryBase< AllocatorType, IResizeableMemory>
    {
    public:
        typedef CMemoryBase< AllocatorType, IResizeableMemory> base;
        CMemory() : base() {}
        CMemory(size_t size) :base(size) {}

        using base::Resize;
    };

除了默認實現之外,它沒有其他功能,只是將受保護的 Resize 方法變成公共可訪問。同樣的技巧用於確保 IImutableMemory 的實現確實是不可變的。它將分配器方法從 protected 視圖中移除,並將它們設為 private,以便無法使用。

    template <typename AllocatorType>
    class CMemory<AllocatorType, IImmutableMemory> :
        public CMemoryBase< AllocatorType, IImmutableMemory>
    {
    public:
        typedef CMemoryBase< AllocatorType, IImmutableMemory> base;

    private:
        using base::Resize;
        using base::Allocate;
        using base::DeAllocate;
    
    public:
        CMemory() : base() {}
        CMemory(size_t size) : base(size) {}

        CMemory& operator = (CMemory& other) = delete;
        CMemory& operator = (CMemory&& other) = delete;
    };

我們還採取額外步驟刪除拷貝和移動賦值運算符。這對於我們想要實現的目標非常合理。我們需要確保 CMemory 對象所代表的內存對象(當然不是其內容)是不可變的。如果允許賦值,對象的狀態將會改變,這被 IImmutableMemory 接口禁止。

默認實現

因為在大多數情況下,人們希望使用普通的進程堆,所以我提供了這些作為默認值:

    typedef CMemory<CHeapAllocator, IResizeableMemory> CResizableHeapMemory;
    typedef CMemory<CHeapAllocator, IMemory> CHeapMemory;
    typedef CMemory<CHeapAllocator, IImmutableMemory> CImmutableHeapMemory;

Using the Code

使用內存類非常簡單。我提供了使用堆內存的示例。示例分為兩個部分。

内存管理

首先,我們演示內存管理以及拷貝/移動構造和賦值。

CHeapMemory makeMem(int val) {
    CHeapMemory mem(sizeof(int));
    memcpy(mem, &val, sizeof(val));
    return mem;
}

int main()
{
    CHeapMemory mem1(sizeof(int));
    int val = 42;
    memcpy(mem1, &val, sizeof(val));

    std::cout << "mem1 is " << mem1.Size() << " bytes in size and has int content " 
        << *(static_cast<int*>(mem1.Ptr())) << endl; 

    CHeapMemory mem2 = mem1;            //copy constructor
    std::cout << "mem2 is " << mem2.Size() << " bytes in size and has int content "
        << *(static_cast<int*>(mem2.Ptr())) << endl;  

    CHeapMemory mem3 = makeMem(83);       //move constructor
    std::cout << "mem3 is " << mem3.Size() << " bytes in size and has int content "
        << *(static_cast<int*>(mem3.Ptr())) << endl;

    mem2 = mem3;                        //copy assignment
    std::cout << "mem2 is " << mem2.Size() << " bytes in size and has int content "
        << *(static_cast<int*>(mem2.Ptr())) << endl;

    mem2 = makeMem(123);                //move assignment
    std::cout << "mem2 is " << mem2.Size() << " bytes in size and has int content "
        << *(static_cast<int*>(mem2.Ptr())) << endl;

    return 0;
}

如您在涉及 mem1 的拷貝操作中看到的,對象被隱式轉換為 void*,因為該轉換存在。當我們使用 static_cast<int*>(mem1.Ptr()) 時,我們需要使用 Ptr 方法,因為 static_cast 接受任何類型,這意味著使用主要類型,它無法直接轉換為 int*

涉及 mem2mem3 的示例用於演示移動和拷貝構造函數和賦值的行為。

类型安全

正如在引言中提到的,設計目標之一是通過在編譯模板時操縱基類來確保預期行為與提供的參數相匹配的編譯時保證。

這很容易證明,只需嘗試做錯事即可:

void useMem(IMemory& mem) {

}

void useIMem(IImmutableMemory& mem) {

}

void useRMem(IResizeableMemory& mem) {

}

int main() {

    CImmutableHeapMemory immem;
    CResizableHeapMemory remem;
    CHeapMemory          hmem;
    
    useMem(hmem);       //OK, IMemory is base
    useMem(remem);      //OK, derives from IMemory
    useMem(immem);      //OK, derives from IMemory

    //useRMem(hmem);    //C2664, Incorrect type
    useRMem(remem);     //OK, IResizeableMemory is base
    //useRMem(immem);   //C2664, Incorrect type

    //useIMem(hmem);    //C2664, Incorrect type
    //useIMem(remem);   //C2664, Incorrect type
    useIMem(immem);     //OK, IImmutableMemory is base

    return 0;
}

不過最後還有一點。有一句俗話說:「使用 C 很容易誤傷自己。C++ 使這變得更難,但當你這樣做時,它會讓你粉身碎骨。」這句話在這裡也適用。你可以做一些類似這樣的事情,它會編譯並運行。

useIMem(*static_cast<IImmutableMemory*>(static_cast<void*>(&remem)));   //foot.shoot()

不用說,如果你開始這樣做,所有保證都將失效,除了必然災難的保證。C++ 編譯器不會保護你免於故意這樣做。

关注点

寫這篇文章比我預期的要困難。當我開始寫這篇文章時,我有一個非常基本的非模板的可調整大小的內存對象,我在我的代碼中經常使用它。寫這篇文章的想法是在我遇到一個情況,當時內存對象不應該是可調整大小的。

解決這個問題最簡單的方法是通過將 resize 方法設為 private 來隱藏它。然而,我認為將行為約定作為一個基類會很巧妙。然後我認為預見使用不同分配器的可能性也是一個好主意,事情(和時間)就失控了。我學到了很多關於模板的知識,這些知識是我以前不知道的。在最終確定這種方法之前,我嘗試了幾種不同的方法。當事情開始優雅地結合在一起時,我知道我走在正確的道路上。

我仍然有一些想法,讓這個類更有用,增加額外的功能,或者嘗試不同的分配器。例如,使用這段代碼,可以很容易地通過使用一個以一定概率隨機失敗的分配器來測試應用程序的健壯性。這些是我可能會在未來的文章中探討的事情。目前,本文涵蓋了對引言中提到的要求的全面實現,因此這是一個不錯的停下來的地方。

感謝您閱讀至此。歡迎評論和投票。代碼已獲得 MIT 許可證,請隨意使用。

历史

  • 2023 年 1 月 22 日:初版
© . All rights reserved.