JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

constexpr、配置器、span 與 string_view

三個現代工具,把工作從執行期的時鐘推到型別系統上:constexpr 把計算搬進編譯器,自訂配置器讓你重新掌管堆積,而 span/string_view 讓你傳遞一扇「看向資料的窗」,既不複製也不擁有。

為何這篇收束整個階梯

本階梯前幾篇給了你 C++ 的「值系統」:用於確定性清理的 RAII、用於廉價交接資源的移動語意、用於所有權的智慧指標,以及用於「編譯後化為烏有」的抽象的樣板。這最後一篇收集了三個服侍同一位主人的工具——零開銷原則,也就是「抽象在執行期不該比它所取代的手寫程式碼貴」的承諾。每個工具把不同種類的工作移出執行期的時鐘:constexpr 把計算搬進編譯器,自訂配置器把堆積的決定權交回你手上,而 span/string_view 讓傳遞資料不再靠複製。

請注意這條貫穿的線索:在 C 裡,做這些工作的工具箱是前置處理器巨集(編譯期工作)、malloc()/free()(唯一的那個堆積),以及一個裸 `char *` 加上分開傳遞的長度(借來的窗)。C++ 給了它們每一個一個有型別、會檢查、且編譯器看得懂也能最佳化的替代品。這就是整個階梯濃縮成一句話:把 C 用裸指標與無型別技巧做的事,交給型別系統去承載意義——而且不增加執行期成本。

constexpr:讓你的程式碼在編譯器內部執行

一個 constexpr 函式就是一個普通函式,只是編譯器被允許在編譯期評估它——只要那時它的輸入是已知的。把一個函式標上 `constexpr`,函式本體就得遵守幾條規則(不可做輸入輸出、不可呼叫非 constexpr 的函式等等),但作為回報:如果你在一個需要常數的脈絡裡、用編譯期已知的引數呼叫它,編譯器就會在編譯時執行這個函式,並把答案直接烤進程式裡。同一個函式在輸入只有執行期才知道時,照樣能正常在執行期運作。這就是 constexpr,它是 C 為了編譯期算術所用的那些巨集把戲的、有型別檢查、可除錯的後繼者。

把它弄具體。寫一個 `constexpr unsigned table_mask(unsigned n)`,本體跑一個迴圈 `while (m < n) m <<= 1` 然後回傳 `m - 1`——一個有迴圈、有位移的真函式。現在寫 `constexpr unsigned MASK = table_mask(100);`。因為 `MASK` 本身就是一個由常數輸入初始化的常數,編譯器會在編譯期間跑完那個迴圈,算出 127,於是產生出來的程式就只含著字面值 `0x7F`——沒有迴圈、沒有位移、沒有執行期算術。你甚至能用 `static_assert(MASK == 0x7F)` 守護它,並用它去設定陣列大小 `char buf[MASK + 1]`。同一個 `table_mask` 若拿一個從檔案讀進來的值去呼叫,就只會在執行期正常地跑。

請精準理解 `constexpr` 承諾與不承諾什麼。它說的是這個函式可以在編譯期評估,而不是一定要——用一個執行期變數去呼叫它,它就只是像任何函式一樣在執行期跑。更嚴格的表親是 consteval:一個 consteval 函式是即時函式(immediate function),必須永遠在編譯期評估,所以用執行期的值去呼叫它是一個硬性錯誤。對「兩個世界都該能用」的程式碼用普通的 constexpr;只有在某個答案在執行期真的毫無意義時,才去拿 consteval。也別忘了這個誠實的限制:編譯器是在一台受限的抽象機器上直譯你的程式碼,所以沉重的 constexpr 計算會讓編譯變慢——你是在拿建置時間換執行時間,這通常是筆好交易,但從來不是免費的。

自訂配置器:把堆積拿回來

在 C 裡只有一個堆積、一個入口:malloc()free(),一個通用的配置器,必須伺候每一種大小與生命週期模式。對多數程式碼這沒問題。但在熱路徑上——比方說你每一幀配置又釋放一萬個短命節點——malloc() 的通用性就成了開銷:它要搜尋一個空閒串列、更新碎片記帳,還可能上一道鎖。一個自訂配置器讓你把那唯一的策略換成一個為你的存取模式調校過的策略,而 C++ 的容器天生就接受它。最簡單也最有用的就是 arena(競技場,又叫 bump 配置器):一開始就向 malloc() 拿一大塊,保留一個指向「下一個空閒位元組」的單一指標,然後靠「回傳這個指標、再把它依所求大小往前推進」來「配置」。完全沒有逐物件的釋放——你靠「把指標重設回開頭」一次回收全部。配置只是一次指標加法;釋放整個 arena 是一次儲存。這筆交易很誠實:你放棄了提早釋放單一物件,換來幾乎免費的配置。

struct Arena {                    // a bump / arena allocator
    char  *base;                  // start of the big block from malloc
    size_t cap;                   // total bytes, e.g. 0x100000 (1 MiB)
    size_t used = 0;              // bytes handed out so far

    void *alloc(size_t n, size_t align) {
        size_t p = (used + (align - 1)) & ~(align - 1);  // round up to alignment
        if (p + n > cap) return nullptr;                 // honest: arena can fill up
        used = p + n;
        return base + p;          // bump the cursor, hand back the slot
    }
    void reset() { used = 0; }    // free EVERYTHING at once, no per-object free
};
配置就是一次對齊向上取整加一次指標推進;reset() 一次回收整個 arena。

以前要把手寫配置器一個一個接進每個容器很痛苦,所以 C++17 加了 std::pmr(多型記憶體資源)。一個 `std::pmr::memory_resource` 是一個帶有虛擬 `do_allocate`/`do_deallocate` 的介面;像 `std::pmr::vector` 這樣的容器持有一個指向它的指標,並把每次配置都導向它。你可以把你的 arena 包成一個記憶體資源,一次交給一整棵容器子樹。有個成本要誠實說:配置呼叫如今是一次虛擬呼叫(一次間接跳躍),不會被內聯——但這是一道「守著巨大收益」的間接,因為底層資源是個 arena。配置器介面正是標準函式庫把堆積交到你手上的方式。

span 與 string_view:一扇窗,而非一份複本

想想 C 如何把陣列傳給函式:它無法傳陣列本身,所以傳一個指向第一個元素的指標加一個分開的長度,`f(int *data, size_t n)`。這兩者以毫不相干的引數一起旅行,而沒有任何東西阻止呼叫端傳錯 `n` 而讀到越界。一個 `std::span<T>` 不過就是那兩個值——一個指標與一個長度——綁成一個帶有安全介面的小物件。它是一個非擁有檢視:它什麼都不擁有、什麼都不配置、什麼都不釋放;它只是看著一段別人擁有的、連續的 T。

因為一個 span 不過是指標加大小——通常 16 個位元組,用兩個暫存器傳遞——它把零開銷原則化為具體:跟 C 的雙引數慣用法一樣便宜,卻型別安全,還隨身帶著 `.size()`,所以「會做範圍檢查的迴圈」與「邊界斷言」都是免費附贈。同樣的想法套到文字上就是 std::string_view:一個指標與一個長度,檢視一段字元,無論是誰擁有它們。一個 string_view 可以指名 `std::string` 的一個切片、一個字串字面值,或一塊記憶體映射檔案的片段,一個位元組都不複製,也不在意擁有者是誰。這正是 C++ 對 Rust 的切片與 &str 的回答——一扇借來的、看向序列的窗。

這些檢視既美妙又確實危險,而一篇溫和的指南必須把話說明白。檢視不會延長它所指之物的生命週期——它是一次借用,跟裸指標一模一樣,而那個本該抓到錯誤的借用檢查器住在你腦袋裡,不在編譯器裡。經典陷阱:一個函式回傳一個指向暫時 `std::string` 內部的 `string_view`,而那個字串在完整運算式結束時就死了,留下懸空的檢視,任何讀取都是未定義行為。規則就是第三篇那一條:擁有者擁有、檢視借用,而檢視絕不可活得比它的擁有者久。在這份紀律下使用,span 與 string_view 抹去整類的複製;草率使用,它們就把 C 惡名昭彰的那種懸空指標臭蟲原封不動地請了回來。

把它們合起來:系統 C++ 的樣貌

退一步把這三個工具並排看,因為它們彼此押韻。constexpr 把計算移出執行期的時鐘、搬進編譯器。自訂配置器把堆積的決定移出通用路徑、搬進一個你選定的策略。span 與 string_view 把資料傳遞移出複製、搬到一扇借來的窗上。每一次的收穫都一樣:程式在執行期做更少的工,而這份節省是用「編譯器看得懂、能檢查」的型別表達的,不是用「你希望有人會讀」的註解。

  1. 看到一個算出來的常數、一個魔術數字、或一個做算術的 #define?把它做成一個帶 static_assert 的 constexpr 函式——成本移到編譯器,答案變成字面值。
  2. 剖析器在一條有明確生命週期模式的熱路徑上指向 malloc?把一個 arena(或 pool)包成 pmr 記憶體資源交給那些容器——但要跑解構式,並在前後都量測。
  3. 在傳一對「陣列與長度」、或一個子字串?改傳一個 span<T> 或一個 string_view,而不是指標加大小、或一份新複本——但在檢視存在期間都要讓擁有者保持存活。

整個階梯如今就在你手上了。C++ 配得上系統語言這個名號,不是因為它比 C 更低階——它並沒有——而是因為它讓你把意圖寫出來(這個擁有、這個借用、這個是常數、這個是我要的堆積),寫在一個會把那些意圖編譯成「你本會手寫的同一份機器碼」的型別系統裡。RAII、值與移動語意、智慧指標、樣板與概念,以及這收尾的三個工具,全都是同一個想法換上不同的衣服:在編譯期為抽象付帳,這樣你在執行期就不必再付。把這句話隨身帶著,這門語言其餘的部分讀起來就像同一主題的變奏。