本文並不討論“延遲初始化”或者是“懶載入的單例”那樣的東西,本文要討論的是分配某一類型所需的空間後不對類型進行構造(即對象的lifetime沒有開始),更通俗點說,就是跳過對象的構造函數執行。 使用場景 我們知道,不管是定義某個類型的對象還是用operator new申請記憶體,對象的構造函數都是會立 ...
本文並不討論“延遲初始化”或者是“懶載入的單例”那樣的東西,本文要討論的是分配某一類型所需的空間後不對類型進行構造(即對象的lifetime沒有開始),更通俗點說,就是跳過對象的構造函數執行。
使用場景
我們知道,不管是定義某個類型的對象還是用operator new
申請記憶體,對象的構造函數都是會立刻被執行的。這也是大部分時間我們所期望的行為。
但還有少數時間我們希望對象的構造不是立刻執行,而是能被延後。
懶載入就是上述場景之一,也許對象的構造開銷很大,因此我們希望確實需要它的時候才進行創建。
另一個場景則是在small_vector這樣的容器里。
small_vector會事先申請一塊棧空間,然後提供類似vector的api來讓用戶插入/刪除/更新元素。棧不像堆那樣可以方便地動態申請空間,所以通常需要棧空間的代碼會這樣寫:
template <typename Elem, std::size_t N>
class small_vec
{
std::array<Elem, N> data;
};
我知道還有類似alloc這樣的函數可以用,然而它性能欠佳而且可移植性差,你能找到的有關它的資料基本都會說不推薦用在生產環境里,VLA同理,VLA甚至不是的c++標準語法。
回到正題,這麼寫有兩個壞處:
- 類型Elem必須能被預設初始化,否則就得在構造函數里把array里的每一個元素都初始化
- 我們申請了10個Elem的空間,但最後只用了8個(對vector這樣的容器來說這是常見場景),但我們卻要構造Elem十次,顯然是浪費,更壞的是這些預設構造處理的對象是沒用的,後面push_back的時候就會被覆蓋掉,所以這十次構造都是不應該出現的。
c++講究一個不要為自己用不到的東西付出代價,因此在small_vec等基於棧空間的容器上延遲構造是個迫切的需求。
作為一門追求性能和表現力的語言,c++在實現這樣的需求上有不少方案可選,我們挑三種常見的介紹。
利用std::byte和placement new
第一種方法比較取巧。c++允許對象的記憶體數據和std::byte
之間進行互相轉換,所以第一種方案是用std::byte
的數組/容器替代原來的對象數組,這樣因為構造數組的時候只有std::byte
,不會對Elem進行構造,而std::byte
的構造是平凡的,也就是什麼都不做(但因為std::array的聚合初始化會被初始化為零值)。
這樣自然繞過了Elem的構造函數。我們來看看代碼:
template <typename Elem, std::size_t N>
class small_vec
{
static_assert(SIZE_T_MAX/N > sizeof(Elem)); // 防止size_t迴環導致申請的空間小於所需值
alignas(Elem) std::array<std::byte, sizeof(Elem)*N> data; // 除了要計算大小,對齊也需要正確設置,否則會出錯
std::size_t size = 0;
};
除了註釋那條之外,還要當心申請的空間超出系統設定的棧大小。
我說這個辦法比較取巧,是因為我們沒有直接構造Elem,而是拿std::byte
做了替代,雖然現在確實不會預設構造N個Elem對象了,但我們真正需要獲取/存儲Elem的時候代碼就會變得複雜。
首先是push_back,在這個函數里我們需要藉助“placement new”來在連續的std::byte
上構造對象:
void small_vec::push_back(const Elem &e)
{
// 檢查size是否超過data的上限,沒超過才能繼續添加新元素
new(&this->data[this->size*sizeof(Elem)]) Elem(e);
++this->size;
}
可以看到我們直接在對應的位置上構建了一個Elem對象,如果你能用c++20,那麼還要個可以簡化代碼的包裝函數std::construct_at
可用。
獲取的代碼看起來比較繁瑣,主要是因為需要類型轉換:
Elem& small_vec::at(std::size_t idx)
{
if (idx >= this->size) {
throw Error{};
}
return *reinterpret_cast<Elem*>(&this->data[idx*sizeof(Elem)]);
}
析構函數則需要我們主動去調用Elem的析構函數,因為array里存的是byte,它可不會幫我析構Elem對象:
~small_vec()
{
for (std::size_t idx = 0; idx < size; ++idx) {
Elem *e = reinterpret_cast<Elem*>(&this->data[idx*sizeof(Elem)]);
e->~Elem();
}
}
這個方案是最常見的,因為不止可以在棧上用。當然這個方案也很容易出錯,因為我們需要隨時計算對象所在的真正的索引,還得時刻關註對象是否應該被析構,心智負擔比較重。
使用union
c++里通常不推薦直接用union,要用也得是tagged union。
然而union在跳過構造/析構上是天生的好手:如果union的成員有非平凡預設構造/析構函數,那麼union自己的預設構造函數和析構函數會被刪除需要用戶自己重新定義,而且union保證除了構造函數和析構函數里明確寫出的,不會初始化或銷毀任何成員。
這意味union天生就能跳過自己成員的構造函數,而我們只用再寫一個什麼都不做的union的預設構造函數,就可以保證union的成員的構造函數不會被自動執行了。
看個例子:
class Data
{
public:
Data()
{
std::cout << "constructor\n";
}
~Data()
{
std::cout << "destructor\n";
}
};
union LazyData
{
LazyData() {}
~LazyData() {} // 可以試試刪了這兩行然後看看報錯加深理解
Data data;
};
int main()
{
LazyData d; // 什麼也不會輸出
}
輸出:
如果是struct LazyData
則會輸出“constructor”和“destructor”這兩行文字。所以我們能看到構造函數的執行確實被跳過了
union還有好處是可以自動計算類型需要的大小和對齊,現在我們的數組索引就是對象的索引,代碼簡單很多:
template <typename Elem, std::size_t N>
class small_vec
{
union ArrElem
{
ArrElem() {}
~ArrElem() {}
Elem value;
};
std::array<ArrElem, N> data; // 不用再手動計算大小和對齊,不容易出錯
std::size_t size = 0;
};
方案2也不會自動構造元素,所以添加元素依舊要依賴placement new,這裡我們使用前文提到的std::construct_at
簡化代碼:
void small_vec::push_back(const Elem &e)
{
// 檢查size是否超過data的上限,沒超過才能繼續添加新元素
std::construct_at(std::addressof(this->data[this->size++].value), e);
}
獲取元素也相對簡單,因為不需要再強制類型轉換了:
Elem& small_vec::at(std::size_t idx)
{
if (idx >= this->size) {
throw Error{};
}
return this->data[idx].value;
}
析構函數也是一樣,需要我們手動析構,這裡我就不寫了。另外千萬別在union的析構函數里析構它的任何成員,別忘了union的成員可以跳過構造函數的調用,這時你去它的調用析構函數是個未定義行為。
方案2比1來的簡單,但依舊有需要手動構造和析構的煩惱,如果你哪個地方忘記了就要出記憶體錯誤了。
使用std::optional
前兩個方案都依賴size來區分對象是否初始化,且需要手動管理對象的生命周期,這些都是潛在的風險,因為手動的總是不牢靠的。
std::optional
正好能用來解決這個問題,雖然它本來不是為此而生的。
std::optional
可以存某個類型的值或者表示沒有值的“空”,正好對於前兩個方案的對象是否被構造;而optional的預設構造函數只會構造一個處於“空”狀態的optional對象,這意味著Elem不會被構造。最重要的是對於存儲在其中的值,optional會自動管理它的生命周期,在該析構的時候就析構。
現在代碼可以改成這樣:
template <typename Elem, std::size_t N>
class small_vec
{
std::array<std::optional<Elem>, N> data; // 自動管理生命周期
std::size_t size = 0;
};
因為不用再手動析構,所以small_vec現在甚至連析構函數都可以不寫,交給預設生成的就行。
添加和獲取元素也變得很簡單,添加就是對optional賦值,獲取則是調用optional的成員函數:
void small_vec::push_back(const Elem &e)
{
// 檢查size是否超過data的上限,沒超過才能繼續添加新元素
this->data[size] = e;
}
Elem& small_vec::at(std::size_t idx)
{
if (idx >= this->size) {
throw Error{};
}
return *this->data[idx]; // 也可以用value(),但optional里是空的這裡會拋出std::bad_optional_access異常
}
但用optional不是沒有代價的:optional為了區分狀態是否為空需要一個額外的標誌位來記錄自己的狀態信息,它需要額外占用記憶體,但我們實際上可以通過size來判斷是否有值存在,索引小於size的optional肯定是有值的,所以這個額外的開銷顯得有些沒必要,而且optional內部的很多方法需要額外判斷當前狀態,效率也稍差一些。
判斷狀態帶來的額外開銷通常是無所謂的除非在性能熱點里,但額外的記憶體花費就比較棘手了,尤其是在棧這種空間資源有限的地方上。我們來看看具體的開銷:
union ArrElem
{
ArrElem() {}
~ArrElem() {}
long value;
};
int main()
{
ArrElem arr1[10];
std::optional<long> arr2[10];
std::cout << "sizeof long: " << sizeof(long) << '\n';
std::cout << "sizeof ArrElem arr1[10]: " << sizeof(arr1) << '\n';
std::cout << "sizeof std::optional<long> arr2[10]: " << sizeof(arr2) << '\n';
}
MSVC上long是4位元組的,所以輸出如下:
在Linux x64的GCC下long是8位元組的,輸出變成這樣:
也就是說用optional你就要浪費整整一倍的記憶體。
所以很多容器庫都是選擇方案2或者1,比如谷歌;方案3很少被用在這樣的庫中。
總結
為啥我沒推薦std::variant
呢,它不是union在現代c++里的首選替代品嗎?
原因是除了和optional一樣浪費記憶體外,它還強制要求第一個模板參數的類型必須能預設構造,否則必須用std::monostate
做填充,所以在延遲構造的場景里用它你既浪費了記憶體又讓代碼變得啰嗦,沒啥明顯的好處,並不推薦。
方案1其實也不推薦,因為像在刀尖上跳舞,武藝高強的自然用著不錯,但只要一個疏忽就萬劫不復了。
我的建議是如果只想要延遲構造對浪費記憶體不怎麼敏感,那麼就選擇std::optional
,否則就選方案2。