分配器 (C++)

在C++編程中，分配器（allocator）是C++標準庫的重要組成部分。C++的庫中定義了多種被統稱為「容器」的數據結構（如鍊表、集合等），這些容器的共同特徵之一，就是其大小可以在程序的運行時改變；為了實現這一點，進行動態記憶體分配就顯得尤為必要，在此分配器就用於處理容器對記憶體的分配與釋放請求。換句話說，分配器用於封裝標準模板庫（STL）容器在記憶體管理上的低層細節。默認情況下，C++標準庫使用其自帶的通用分配器，但根據具體需要，程式設計師也可自行定製分配器以替代之。

分配器最早由作為C++標準模板庫（Standard Template Library，簡稱STL）的一部分發明，其初衷是創造一種能「使庫更加靈活，並能獨立於底層數據模型的方法」，並允許程式設計師在庫中利用自定義的指針和引用 (C++)；但在將標準模板庫納入C++標準時，C++標準委員會意識到對數據模型的完全抽象化處理會帶來不可接受的性能損耗，為作折中，標準中對分配器的限制變得更加嚴格，而有鑑於此，與斯特潘諾夫原先的設想相比，現有標準所描述的分配器可定製程度已大大受限。

雖然分配器的定製有所限制，但在許多情況下，仍需要用到自定義的分配器，而這一般是為封裝對不同類型記憶體空間（如共享內存與已回收記憶體）的訪問方式，或在使用記憶體池進行記憶體分配時提高性能而為。除此以外，從記憶體占用和運行時間的角度看，在頻繁進行少量記憶體分配的程序中，若引入為之專門定製的分配器，也會獲益良多。

## 背景

亞歷山大·斯特潘諾夫與李夢（Meng Lee）在1994年將標準模板庫草案提交給C++標準委員會。提交伊始，草案就得到了委員會的初步支持，但委員會成員也對此提出了一些意見，尤其是要求斯特潘諾夫定製庫內的容器，使之與底層存儲模型相獨立。作為對要求的回應，斯特潘諾夫發明了分配器，而正因此，標準模板庫的所有容器接口也被迫重寫，以與分配器相兼容。在修改標準模板庫以將之引入C++標準庫的過程中，許多標準委員會成員（如Andrew Koenig (程式設計師)與比雅尼·史特勞斯特魯普）也與斯特潘諾夫協同工作。他們亦發現自定義分配器甚至有應用於長生命周期（持續存儲）的標準模板庫容器的潛力，斯特潘諾夫對此的評論則是「重要而有趣的見解」。

從移植性的角度說，所有因在地址、指針等概念上有所限定而只兼容特定機器的組件都應該封裝進一個輕量且易於理解的工具之中。但分配器並非標準模板庫的本質所在，且也非分解基本數據結構與演算法的關鍵。（節錄）

——標準模板庫的設計者亞歷山大·斯特潘諾夫

在原有的提案里的分配器設定中，斯特潘諾夫雜糅了一些語言特性（如可將模板參數也定義為模板），但由於當時的編譯器皆無法處理之，所以最終並未被標準委員會所接納，斯特潘諾夫則如此描述當時的情形：「比雅尼·史特勞斯特魯普與安迪·克尼格需要花大量時間來檢查我們是否正確使用了這些未實現的特性。」在分配器應用後，之前庫中直接使用的指針與引用 (C++)也可以分配器所定義的類型替代，斯特潘諾夫亦曾如此描述分配器：「標準模板庫有個不錯的特性便是：唯一要提及機器相關類型的地方（……）（只需）被封裝成（僅）約16行內的代碼。」除此以外，斯特潘諾夫原本還打算在分配器中完全封裝存儲模型，但標準委員會意識到這一做法會造成無法接受的效能損失，因而為補償之，分配器的使用需求也做了一定擴充。

分配器的應用中比較特別的一點是，容器的實現過程中可能會假定分配器對指針與相關整型的類型定義與默認分配器所提供的等價，因而給定分配器類型的所有實例在比較時常會得出「相等」的結果C++03C++03，而這一效果實際上恰與設計分配器的初衷背道而馳，並使帶狀態分配器的可用性大大受限，斯特潘諾夫後來對此評論道：「（分配器）理論上說是不差的主意（......）但不幸的是在實踐中無法發揮其功效。「他洞察到若要令分配器更加實用，就有必要針對核心語言的引用 (C++)部分進行修改。

## 使用需求

任意滿足分配器使用需求的C++類別都可作分配器使用。具體來說，當一個類別（在此設為類別`A`）有為一個特定類型（在此設為類型`T`）的對象分配記憶體的能力時，該類別就必須提供以下類型的定義：

- `A::pointer` 指針
- `A::const_pointer` 常量指針
- `A::reference` 引用
- `A::const_reference` 常量引用
- `A::value_type` 值類型
- `A::size_type` 所用記憶體大小的類型，表示類別A所定義的分配模型中的單個對象最大尺寸的無符號整型
- `A::difference_type` 指針差值的類型，為帶符號整型，用於表示分配模型內的兩個指針的差異值C++03。

如此才能以通用的方式聲明對象與對該類對象的引用`T`。allocator提供這些指針或引用的類型定義的初衷，是隱蔽指針或引用的物理實現細節；因為在16位編程時代，遠指針（far pointer）是與普通指針非常不同的，allocator可以定義一些結構來表示這些指針或引用，而容器類使用者不需要了解其是如何實現的。

雖然按照標準，在庫的實現過程中允許假定分配器（類別）A的`A::pointer`（指針）與`A::const_pointer`（常量指針）即是對`T*`與`T const*`的簡單的類型定義，但一般更鼓勵支持通用分配器C++03。

另外，設有對於為某一對象類型`T`所設定的分配器`A`，則A必須包含四項成員函數，分別為分配函數、解除分配函數、最大個數函數和地址函數：

- `A::pointer A::allocate(size_type n, A<void>::const_pointer hint = 0)`。分配函數用以進行記憶體分配。其中調用參數n即為需要分配的對象個數，另一調用參數`hint`（須為指向已為`A`所分配的某一對象的指針）則為可選參數，可用於在分配過程中指定新陣列所在的記憶體地址，以提高引用局部性，但在實際的分配過程中程序也可以根據情況自動忽略掉該參數。該函數調用時會返回指向分配所得的新陣列的第一個元素的指針，而這一陣列的大小足以容納n個`T`類別元素。在此需要注意的是，調用時只為此陣列分配了記憶體，而並未實際構造對象。
- `void A::deallocate(A::pointer p, A::size_type n)`。解除分配函數。其中p為需要解除分配的對象指針（以`A::allocate`函數所返回的指針做參數），n為對象個數，而調用該函數時即是將以p起始的n個元素解除分配，但同時並不會析構之。C++標準明確要求在調用deallocate之前，該地址空間上的對象已經被析構。
- `A::max_size()`，最大個數函數。返回`A::allocate`一次調用所能成功分配的元素的最大個數，其返回值等價於`A::size_type(-1) / sizeof(T)`的結果。
- `A::pointer A::address ( reference x )`，地址函數。調用時返回一個指向`x`的指針。

除此以外，由於對象的構造/析構過程與分配/解除分配過程分別進行，因而分配器還需要成員函數`A::construct`（構造函數）與`A::destroy`（析構函數）以對對象進行構造與析構，且兩者應等價於如下函數C++03：

```cpp
template <typename T>
void A::construct(A::pointer p, A::const_reference t) { new ((void*) p) T(t); }

template <typename T>
void A::destroy(A::pointer p){ ((T*)p)->~T(); }
```

以上代碼中使用了placement `new`語法，且直接調用了析構函數。

分配器應是可複製構造的，任舉一例，為`T`類別對象而設的分配器可由另一為`U`類別所設的分配器構造。若某分配器分配了一段存儲空間，則這段存儲空間只能由與該分配器等價的分配器解除分配C++03。分配器還需要提供一個模板類別成員類`template &lt;typename U&gt; struct A::rebind { typedef A&lt;U&gt; other; };`，以模板 (C++)參數化的方式，借之來針對不同的數據類型獲取不同的分配器。例如，若給定某一為整型（`int`）而設的分配器`IntAllocator`，則可執行`IntAllocator::rebind<long>::other`以獲取對應長整型（`long`）的相關分配器。實際上，stl::list實際要分配的是包含了雙向鍊表指針的node，而不是實際分配int類型，這是引入了rebind的初衷。

與分配器相關聯的operator ==，僅當一個allocator分配的記憶體可以被另一個allocator釋放時，上述相等比較算符返回真。operator !=的返回結果與之相反。

## 自定義分配器

定義自定義分配器的主要原因之一是提升性能。利用專用的自定義分配器可以提高程序的效能，又或提高記憶體使用效率，亦或兩者兼而有之。默認分配器使用`new`操作符分配存儲空間C++03，而這常利用C語言堆分配函數（malloc）實現。由於堆分配函數常針對偶發的記憶體大量分配作優化，因此在為需要一次分配大量記憶體的容器（如向量、雙端隊列）分配記憶體時，默認分配器一般效率良好。但是，對於關聯容器與雙向鍊表這類別需要頻繁分配少量記憶體的容器來說，若採用默認分配器分配記憶體，則通常效率很低。除此之外，基於malloc的默認分配器還存在許多問題，諸如較差的引用局部性，以及可能造成碎片化 (電腦科學)。

簡言之，此段（......）（如同）是這一標準針對分配器的一場《我有一個夢想》的演講。在夢想成真之前，關心可移植性的程式設計師將把自己局限於（使用）無狀態的自定義分配器上。

——斯科特 梅耶斯，《Effective STL》

有鑑於此，在這一情況下，人們常使用基於記憶體池的分配器來解決頻繁少量分配問題。與默認的「按需分配」方式不同，在使用基於記憶體池的分配器時，程序會預先為之分配大塊記憶體（即「記憶體池」），而後在需要分配記憶體時，自定義分配器只需向請求方返回一個指向池內內存的指針即可；而在對象析構時，並不需實際解除分配記憶體，而是延遲到記憶體池的生命周期完結時才真正解除分配。

在「自定義分配器」這一話題上，已有諸多C++專家與相關作者參與探討，例如斯科特·梅耶斯的作品《Effective STL》與安德烈·亞歷山德雷斯庫的《Modern C++ Design》都有提及。梅耶斯洞察到，若要求針對某一類型T的分配器的所有實例都相等，則可移植的分配器的實例必須不包含狀態。雖然C++標準鼓勵庫的實現者支持帶狀態的分配器C++03，但梅耶斯稱，相關段落是「（看似）美妙的觀點」，但也幾乎是空話，並稱分配器的限制「過於嚴苛」。例如，STL的list允許splice方法，即一個list對象A的節點可以被直接移入另一個list對象B中，這就要求A的分配器申請到的記憶體，可被B的分配器釋放掉，從而推導出A與B的分配器實例必須相等。梅耶斯的結論是，分配器最好定義為使用靜態方法的類型。例如，根據C++標準，分配器必須提供一個實現了rebind方法的other類模板。

另外，在《C++程序設計語言》中，比雅尼·史特勞斯特魯普則認為「『嚴格限制分配器，以免各對象資訊不同』，這點顯然問題不大」（大意），並指出大部分分配器並不需要狀態，甚至沒有狀態情形下性能反倒更佳。他提出了三個自定義分配器的用例：記憶體池型的分配器、共享內存型分配器與垃圾回收型分配器，並展示了一個分配器的實現，此間利用了一個內部記憶體池，以快速分配/解除分配少量記憶體。但他也提到，如此優化可能已經在他所提供的樣例分配器中實現。

自定義分配器的另一用途是調試記憶體相關錯誤。若要做到這一點，可以編寫一個分配器，令之在分配時分配額外的記憶體，並藉此存放調試資訊。這類分配器不僅可以保證記憶體由同類別分配器分配/解除分配記憶體，還可在一定程度上保護程序免受緩存溢出之害。

### 使用方法

當初始化標準容器時，若需使用自定分配器，則可將其寫入模板參數，以代替默認的`std::allocator<T>`，如下所示C++03：

```cpp
namespace std {
  template <class T, class Allocator = allocator<T> > class vector;
// ...
```

正如其他所有C++類別模板般，在初始化同一標準庫容器時，若使用了不同的分配器，則所生成容器的類型亦不同。譬如，若函數需一整型向量陣列`std::vector<int>`作為參數，則其只能接受由默認分配器生成的整型向量陣列。

### C++11

通過加入「作用域」分配器，C++11標準進一步強化了分配器接口，從而保證帶有嵌套式記憶體分配特點的容器（如字串向量陣列等）所分配到的記憶體皆來自容器自身的分配器。

另外，C++11標準刪除了「給定類型的分配器在比較時總是相等」的模稜兩可的要求，使帶狀態分配器不僅實用性得到提升，而且可管理進程外的共享內存。現今分配器的作用多為讓程式設計師可以控制容器的記憶體分配，而非適應基底硬體的地址模型。事實上，C++11標準刪去了分配器「自適應地址模型」的功能，結果抹消了其設計初衷。

## 外部連結

- 分配器（STL） , CodeGuru
- C++標準分配器：介紹與實現 , CodeProject
- 基於malloc的分配器實現樣例 , Dr. Dobb's Journal