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
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...