C++中對象的延遲構造

来源:https://www.cnblogs.com/apocelipes/p/18415570
-Advertisement-
Play Games

本文並不討論“延遲初始化”或者是“懶載入的單例”那樣的東西,本文要討論的是分配某一類型所需的空間後不對類型進行構造(即對象的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++標準語法。

回到正題,這麼寫有兩個壞處:

  1. 類型Elem必須能被預設初始化,否則就得在構造函數里把array里的每一個元素都初始化
  2. 我們申請了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。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 題目描述 給你二叉樹的根節點 root 和一個表示目標和的整數 targetSum 。判斷該樹中是否存在 根節點到葉子節點 的路徑,這條路徑上所有節點值相加等於目標和 targetSum 。如果存在,返回 true ;否則,返回 false 。 葉子節點 是指沒有子節點的節點。 解題思路 我們這題採 ...
  • 在前後端分離模式下,Spring MVC 的作用主要集中在處理後端的業務邏輯和 API 介面,而不再直接管理視圖部分。也就是說,Spring MVC 的重點是如何處理客戶端的請求並返回數據(通常以 JSON 或 XML 格式),而視圖渲染交給前端框架(如 Vue.js、React 等)來完成。 下麵 ...
  • 目錄變數程式的本質:二進位文件1.變數:記憶體上的某個位置開闢的空間2.變數的初始化:3.為什麼要有變數4.局部變數與全局變數5.變數的大小由類型決定6.任何一個變數,記憶體賦值都是從低地址開始往高地址1.1 關鍵字auto1.2 關鍵字register什麼樣的變數可以採用register? 變數 程式 ...
  • 題目描述 給定一個二叉樹的 根節點 root,請找出該二叉樹的 最底層 最左邊 節點的值。 假設二叉樹中至少有一個節點。 解題思路 這道題用層次遍歷的方式來說是比較簡單的,用遞歸的話如果我們看別人的精簡代碼很難看出其中隱藏的細節,這裡遞歸遍歷其實我們用到了回溯的思想,我們直接採用前序遍歷的方式(其實 ...
  • DataEase —— 開源的數據可視化分析工具,支持豐富的數據源連接,能夠通過拖拉拽方式快速製作圖表,並可以方便的與他人分享。 ...
  • 情況說明 在SpringBoot中集成了RocketMQ,實踐過程中,通過RocketMQ DashBoard觀察,生產者可以正常將進行消息提交;通過日誌及DashBoard觀察,消費者成功在RocketMQ中進行了註冊和訂閱且觀察到了消費者啟動的日誌行。問題是消費者依舊不會自動消費生產者提交的消息 ...
  • 題目描述 給定二叉樹的根節點 root ,返回所有左葉子之和。 解題思路 這裡我才用的是前序遍歷,我們在遍歷的時候因為是要手機左葉子節點,所以我們就不能等到遍歷當前節點的時候再去做判斷,應該遍歷到一個節點的時候就對其下一個節點的左右子樹進行判斷,這樣才能確保我們得到的是我們的左葉子節點 代碼實例 c ...
  • 目錄關鍵字unsigned和signed數據在電腦中的存儲原碼 與 補碼的轉化與硬體關係原,反,補的原理:整型存儲的本質變數存取的過程類型目前的作用十進位與二進位快速轉換大小端位元組序判斷當前機器的位元組序"負零"(-128)的理解截斷建議在無符號類型的數值後帶上u, 關鍵字unsigned和sign ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...