這一天,我用 Rust 重寫了已有 19 年歷史的 C+庫!
從版本 56 開始,Firefox 瀏覽器支持一種新的字元編碼轉換庫,叫做 encoding_rs。它是用 Rust 編寫的,代替了從 1999 年就開始使用的 C++ 編寫的字元編碼庫 uconv。最初,所有調用該字元編碼轉換庫的代碼都是 C++,所以儘管新的庫是用 Rust 編寫的,它也必須能被 C++ 代碼調用。實際上,在 C++ 調用者看來,這個庫跟現代的 C++ 庫沒什麼區別。下面是我實現這一點採用的開發方式。
相關閱讀:
關於 encoding_rs 本身:https://hsivonen.fi/encoding_rs/
演講視頻:https://media.ccc.de/v/rustfest18-5-a_rust_crate_that_also_quacks_like_a_modern_c_library
幻燈片:https://hsivonen.fi/rustfest2018/
怎樣寫現代 C++?
所謂「現代」C++的意思就是從 C++ 調用者來看,函數庫遵循 C++ 的核心指南(https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines),並具備以下新特性:
通過返回 std::unique_ptr / mozilla::UniquePtr 中在堆上分配的對象的指針進行堆內存分配管理。
調用者分配的緩衝區用 gsl::span / mozilla::Span 來表示,而不是用普通的指針和長度表示。
多個返回值使用 std::tuple / mozilla::Tuple 傳遞,而不是使用輸出參數。
非空的普通指針用 gsl::not_null / mozilla::NotNull 表示。
上面的 gsl:: 表示 Guidelines Support Library(https://github.com/microsoft/GSL),這個庫能提供核心指南要求、但尚未存在於 C++ 標準庫中的東西。
用 Rust 寫 C++ 庫?
「用 Rust」寫 C++ 庫的意思是指庫中的大部分是用 Rust 寫的,但提供給 C++ 調用者的介面至少在 C++ 調用者來看就像個真正的 C++ 庫一樣。
C++ 和 Rust 都與 C 有互操作性
C++ 的 ABI 非常複雜,而 Rust ABI 尚未完全確定。但是,C++ 和 Rust 都支持一些使用 C ABI 的函數。因此,要想讓 C++ 和 Rust 擁有互操作性,就需要通過某種方法,讓 C++ 把 Rust 代碼看成 C 代碼,Rust 把 C++ 代碼看成 C 代碼。
簡化的情形
這篇文章並不是 Rust 與 C++ 互聯的完整指南。encoding_rs 的介面非常簡單,缺乏兩種語言之間的互操作性上的常見問題。但是,encoding_rs 簡化 C++ 介面的例子可以作為一個指南,給那些希望在設計函數庫時了解跨語言互操作性的人們提供一些幫助。具體來說:
encoding_rs 從來不會調用 C++:跨語言調用是單向的。
encoding_rs 在調用返回後,不持有指向 C++ 對象的引用:因此 Rust 代碼不需要管理 C++ 內存。
encoding_rs 不需要用 Rust 或 C++ 語言提供繼承層次結構:因此兩種語言中都沒有 vtables。
encoding_rs 操作的數據類型非常簡單:只有基本類型的連續緩衝區(u8 / uint8_t 和 u16 / char16_t 的緩衝區)。
僅支持 panic=abort 配置(即 Rust 的崩潰會終止整個程序,無需回滾棧),而且這裡給出的代碼只有在該配置下才是正確的。這裡給出的代碼沒有去防止 Rust 崩潰跨越 FFI 邊界回滾,因此跨 FFI 邊界的崩潰是未定義的行為。
API 快速概覽
為了理解我們討論的 Rust API(https://docs.rs/encoding_rs/0.8.13/encoding_rs/),先來從高層次看看。整個函數庫有三個公開的結構體(struct):Encoding,Decoder 和 Encoder。從函數庫的使用者角度來看,這些結構能為各種具體的編碼提供統一的介面,所以可以像 traits、父類或介面一樣使用, 但嚴格來說它們實際上是結構體。Encoding 的實例是靜態分配的。Decoder 和 Encoder 封裝了流轉換的狀態,是在運行時動態分配的。
Encoding 實例的引用(即&"static Encoding)可以通過標籤獲得(從協議文本中提取的文本識別信息),或通過命名靜態變數(named static)獲得。然後 Encoding 可以作為 Decoder 的參數使用,後者是在棧上分配的。
在處理流時,Decoder 中有個方法可以將流從調用者分配的一個切片解碼到調用者分配的一個切片。解碼器不進行堆分配操作。
在處理流之外的情況時,調用者完全不需要處理 Decoder 和 Encoder 的任何東西。Encoding 會提供方法在一個緩衝區中處理整個邏輯輸入流。
處理過程
0. 對 FFI 友好的設計
有些設計來自於問題域本身的簡化因素。而有些只是選擇。
字元編碼庫可以合理地將編碼、解碼器和編碼器的概念表示成 traits(類似於 C++ 中沒有欄位的抽象父類),但是,encoding_rs 對這些概念採用了結構體(struct),以便在分發的時候能 match 成一個 enum,而不必依賴於 vtable(https://en.wikipedia.org/wiki/Virtual_method_table)。
這樣做的主要動機並不是消除 vtable 本身,而是故意讓層次結構不能擴展。其背後反映的哲學是,添加字元編碼不應該是程序員應當關心的事情。相反,程序應當使用 UTF-8 作為數據交換,而且程序不應當支持古老的編碼,除非需要兼容已有的內容。這種不可擴展的層次結構能帶來強類型安全。如果你從 encoding_rs 得到一個 Encoding 實例,那麼你可以信任它絕不會給出任何編碼標準中沒有給出的特性。也就是說,你可以相信它絕不會表現出 UTF-7 或 EBCDIC 的行為。
此外,通過分發 enum,一個編碼的解碼器可以在內部根據 BOM 嗅探的結果變成另一個編碼的解碼器。
有人可能會說,Rust 提供編碼轉換器的方式是將它變成迭代適配器,接受位元組迭代器的輸入,然後輸出 Unicode 的標量值,或者相反。然而迭代器不僅在跨越 FFI 邊界時更複雜,還使得加速 ASCII 處理等技巧更難以實現。而直接接受一個切片進行讀取和寫入操作,不僅使得提供 C API 更容易(用 C 的術語來說,Rust 切片解構成對齊的非空指針和一個長度值),而且可以通過觀察多個代碼單元能放入單個寄存器(ALU 寄存器或 SIMD 寄存器)的情況,實現一次處理多個代碼單元,從而實現 ASCII 處理加速。
如果 Rust 的原生 API 只處理基本類型、切片和(非 trait 對象的)結構體,那麼與支持高級 Rust 特性的 API 相比,這個 API 更容易映射到 C API。(在 Rust 中,發生類型擦除時會產生一個 trait 對象。也就是說,你得到的是一個 trait 類型的引用,它並沒有給出該引用指向的那個結構體的類型信息。)
1. 建立 C API
當涉及到的類型足夠簡單時,C 和 Rust之間的主要鴻溝,一是 C 語言缺乏方法、缺乏多返回值功能,二是不能以值形式傳送 C 結構體之外的類型。
方法用函數包裹起來,該函數的第一個參數是指向該方法所屬結構體的指針。
切片參數轉換為兩個參數:指向切片開頭的指針,以及切片的長度。
函數的返回值中,第一個基本類型的返回值作為返回值返回,其他返回值作為輸出參數。當輸出參數與同類型的輸入參數相關時,使用 in/out 參數是合理的。
如果 Rust 方法以值的形式返回一個結構體,那麼封裝函數將打包該結構體,並返回指向它的指針,因此 Rust 不必考慮該結構體。此外還要添加一個函數,用於釋放該指針指向的結構體。這樣,Rust 方法只需將指針打包,或者拆包。從 C 指針的角度來看,結構體是不透明的。
作為特殊情況,獲取編碼名稱的方法在 Rust 中返回 &"static str,它被包裹在一個函數中,接收一個指向可寫入的緩衝區的指針,緩衝區的長度至少應當為最長的編碼名稱的長度。
enum 用來表示輸入緩衝區的枯竭、輸出緩衝區佔滿,或錯誤以及詳細情況,這些 enum 在 C API 中轉變成 uint32_t,並加上相應的常量來表示「輸入空」或「輸出滿」以及一系列解釋其他錯誤的規則。這種方式不是最理想的,但在這種情況下很好用。
越界檢查時的長度計算改成飽和運算(saturating)。也就是說,調用者需要將 SIZE_MAX 當作越界的信號。
2.在 C++ 中根據 C API 重建 API
即使是慣用的 C API(https://github.com/hsivonen/encoding_c/blob/master/include/encoding_rs.h)也不能當做現代 C++ API 使用。幸運的是,類似於多重返回值、切片等 Rust 概念可以在 C++ 中表示,只需將 C API 返回的指針解釋成指向 C++ 對象的指針,就能展示出 C++ 的優雅。
大部分例子來自一個使用了 C++17 標準庫類型的 API(https://github.com/hsivonen/encoding_c/blob/master/include/encoding_rs_cpp.h)。在 Gecko 中,我們一般會避免使用 C++ 標準庫,而使用一個 encoding_rs 的特別版本的 C++ API,該版本使用了 Gecko 特有的類型(https://searchfox.org/mozilla-central/source/intl/Encoding.h)。這裡我假設標準庫類型的例子更容易被更多讀者接受。
方法的優雅
對於每個 C 語言中不透明的構造體指針,C++ 中都會定義一個類,C 的頭文件也會修改,使得從 C++ 編譯器的角度來看,指針類型變成指向 C++ 類實例的指針。這些放在一起就相當於一個 reinterpret_cast 過的指針,而不需要實際寫出 reinterpret_cast。
由於指針並不真正指向它們看似指向的類的實例,而是指向 Rust 結構體的實例,因此應該事先做好預防措施。這些類中沒有定義任何欄位。默認的無參數構造函數和複製構造方法被刪除,默認的 operator= 也被刪除。此外,這些類還不能包含虛方法。(最後一點是個重要的限制條件,稍後會討論。)
對於 Encoding 來說,所有實例都是靜態的,因此析構函數也被刪掉了。如果是動態分配的 Decoder 和 Encoder,還要添加一個空的析構函數和一個 static void operator delete。(後面會給一個例子。)這樣能讓這個偽 C++ 類的析構過程導向 C API 中相應類型的釋放函數。
這些基礎工作將指針變得看上去像是 C++ 類實例的指針。有了這些,就能在這些指針上實現方法調用了。(介紹完下一個概念後也會給出實例。)
返回動態分配的對象
前面說過,Rust API 以值方式返回 Encoder 或 Decoder,這樣調用者可以將返回值放在棧上。這種情況被 FFI 的包裹代替,因此 C API 只需通過指針暴露堆上分配的對象。而且,這些指針也被重新解釋為可 delete 的 C++ 對象指針。
不過還需要確保這些 delete 會在正確的時機被調用。在現代 C++ 中,如果對象在同一時刻只能有一個合法的所有者,那麼對象指針會被包裹在 std::unique_ptr 或 mozilla::UniquePtr 中。老的 uconv 轉換器支持引用計數,但在 Gecko 代碼中所有實際的應用中,每個轉換器都只有一個所有者。由於編碼器和解碼器的使用方式使得同一時刻只有一個合法的所有者,因此 encoding_rs 的兩個 C++ 包裹就使用了 std::unique_ptr 和 mozilla::UniquePtr。
我們來看看 Encoding 中那個返回 Decoder 的工廠方法。在 Rust 中,這個方法接收 self 的引用,通過值返回 Decoder。
在 FFI 層,第一個參數是顯式的指針類型,對應於 Rust 的 &self 和 C++ 的 this(具體來說,是 const 版本的 this)。我們在堆上分配內存(Box::new())然後將 Decoder 放進分配好的內存中。然後忘記內存分配(Box::into_row),這樣可以將指針返回給 C,而不會在作用域結束時釋放。為了能夠釋放內存,我們引入了一個新的函數,將 Box 放回,然後將它賦給一個變數,以便立即離開作用域,從而釋放堆上分配的內存。
在 C 文件頭中看起來像這樣:
ENCODING_RS_DECODER 是一個宏,用於在 C 頭文件在 C++ 環境中使用(而不是作為純 C API 使用)時將其替換成正確的 C++ 類型。
在 C++ 一側,我們使用 std::unique_ptr,相當於 Rust 的 Box。實際上它們也非常相似:
我們把從 C API 獲得的指針包裹在 std::unique_ptr 中:
當 std::unique_ptr 離開作用域時,刪除操作會通過 FFI 導向回 Rust,這是因為定義是下面這樣的:
如何工作?
在 Rust 中,非 trait 的方法只不過是語法糖:
對非 trait 類型的引用方法調用只不過是普通的函數調用,但第一個參數是指向 self 的引用。在 C++ 一側,非虛方法的調用原理相同:非虛 C++ 方法調用只不過是函數調用,但第一個函數是 this 指針。
在 FFI/C 層,我們可以將同樣的指針顯式地作為第一個參數傳遞。
在調用 ptr->Foo() 時,其中的 ptr 是 T* 類型,而如果方法定義為 void Foo()(它在 Rust 中映射到 &mut self),那麼 this 是 T* 類型,如果方法定義為 void Foo() const(在 Rust 中映射到 &self),則 this 是 const T* 類型,所以這樣也能正確處理 const。
這裡「非 trait 類型」和「非虛」是非常重要的。要想讓上面的代碼正確工作,那麼無論那一側都不能有 vtable。這就是說,Rust 不能有 trait,C++ 也不能有繼承。在 Rust 中,trait 對象(指向任何實現了 trait 的結構體的 trait 類型的引用)實現為兩個指針:一個指向結構體實例,另一個指向對應於數據的具體類型的 vtable。我們需要能夠把 self 的引用作為單一指針跨越 FFI 傳遞,所以在跨越 FFI 時無法攜帶 vtable 指針。為了讓 C++ 對象指針兼容 C 的普通指針,C++ 將 vtable 指針放在了對象自身上。由於我們的指針指向的並不是真正帶有 vtable 的 C++ 對象,而是 Rust 對象,所以必須保證 C++ 代碼不會在指針目標上尋找 vtable 指針。
其結果是,Rust 中的結構對應的 C++ 中的類不能從 C++ 框架中的通用基類繼承。在 Gecko 的情況中,C++ 類不能繼承 nsISupports。例如,在 Qt 的語境下,對應的 C++ 類不能從 QObject 繼承。
非空指針
Rust API 中有的方法會返回 &"static Encoding。Rust 的引用永遠不會為 null,因此最好是將這個信息傳遞給 C++ API。C++ 中對應於此的是 gsl::not_null和mozilla::NotNull。
由於 gsl::not_null 和 mozilla::NotNull 只不過是類型系統層面的寫法,它並不會改變底層指針的機器表示形式,因此對於有 Rust 保證的指針,跨越 FFI 之後可以認為它們絕不會為 null,所以我們想做的是,利用與之前將 FFI 返回的指針重新解釋為指向無欄位、無虛方法的 C++ 對象的指針同樣的技巧來騙過 C++ 編譯器,從而在頭文件中聲明那些 FFI 返回的絕不會為 null 的指針為類型 mozilla::NotNull。不幸的是,實際上這一點無法實現,因為在 C++ 中,涉及模板的類型不能在 extern "C" 函數的定義中使用,所以 C++ 代碼最後只能在從 C API 接收到指針、包裹在 gsl::not_null 或 mozilla::NotNull 時進行一系列的 null 檢查。
但是,也有一些定義是指向編碼對象常量的靜態指針(指向的目標是在 Rust 中定義的),而且恰巧 C++ 允許將這些定義為 gsl::not_null,所以我們這樣實現了。(感謝 Masatoshi Kimura 指出這一點的可行性。)
Rust 中靜態分配的 Encoding 實例的定義如下:
在 Rust 中,通用的規則(https://twitter.com/tshepang_dev/status/1051558270425591808)是 static 用來聲明不會改變的內存地址,const 用來聲明不會改變的值。因此,UTF_8_INIT 應當為 static,而 UTF_8 應當為 const:指向 static 實例的引用的值不會改變,但為這個引用靜態分配的內存地址則不一定。不幸的是, Rust 有一條規則說,const 的右側不能包含任何 static 的東西,因此這一條阻止了對 static 的引用,以確保 const 定義的右側可以被靜態檢查,確定它是否適合任何假想的 const 定義——甚至是那些在編譯時就試圖解引用(dereference)的定義。
但對於 FFI,我們需要為 UTF_8_INIT 分配一塊不會改變的內存,因為這種內存能在 C 的連接器中使用,可以讓我們為 C 提供命名的指針類型的東西。上面說的 UTF_8 的表示形式已經是我們需要的了,但為了讓 Rust 更優雅,我們希望 UTF_8 能參與到 Rust 的命名空間中。這意味著從 C 的角度來看,它的名字需要被改變(mangle)。我們浪費了一些空間來重新靜態分配指針來避免改變名稱,以供 C 使用:
這裡使用了指針類型,以明確 C 語言會將其當做指針(即使 Rust 引用類型擁有同樣的表現形式)。但是,Rust 編譯器拒絕編譯帶有全局可視性指針的程序。由於全局變數可以被任何線程訪問,多線程同時訪問指針指向的目標可能會引發問題。這種情況下,指針目標不會被修改,因此全局可視性是沒問題的。為了告訴編譯器這一點,我們需要為指針實現 Sync 這個 marker trait。但是,trait 不能在指針類型上實現。作為迂迴方案,我們為*const Encoding創建了一個新的類型。新的類型擁有與它包裹的類型同樣的表現形式,但我們可以在新類型上實現 trait。實現 Sync 是 unsafe 的,因為我們告訴了編譯器某些東西可以接受,這並不是編譯器自己發現的。
在 C++ 中我們可以這樣寫(宏擴展之後的內容):
指向編碼器和解碼器的指針也絕不會為 null,因為內存分配失敗會直接終止程序。但是 std::unique_ptr / mozilla::UniquePtr 和 gsl::nul / mozilla::NotNull 不能結合使用。
可選值
Rust 中常見的做法是用 Option 表示返回值可能有值也可能沒有值。現在的 C++ 提供了同樣的東西:std::optional。在 Gecko 中,我們使用的是 mozilla::Maybe。
Rust 的 Option 和 C++ 的 std::optional 實際上是一樣的:
但不幸的是,C++ 保留了安全性。從 std::optional 中提取出包裹值時最優雅的方法就是使用 operator*(),但這個也是沒有檢查的,因此也是不安全的。
多返回值
儘管 C++ 在語言層面缺少對於多返回值的支持,但多返回值可以從庫的層次實現。比如標準庫,相應的部分是 std::tuple,std::make_tuple 和 std::tie。在 Gecko 中,相應的庫是 mozilla::Tuple,mozilla::MakeTuple 和 mozilla::Tie。
切片
Rust 切片包裹了一個自己不擁有的指針,和指針指向內容的長度,表示數組中的一段連續內容。相應的 C 代碼為:
C++ 的標準庫中並沒有對應的東西(除了 std::string_view 可以用來表示只讀字元串切片之外),但 C++ 核心指南中已經有一部分叫做 span 的東西(https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#i13-do-not-pass-an-array-as-a-single-pointer):
GSL 依賴於 C++14,但在 encoding_rs 發布時,Gecko 由於 Android 的阻礙,不得不停留在 C++11 上(https://bugzilla.mozilla.org/show_bug.cgi?id=1325632#c25)。因此,GSL 不能原樣在 Gecko 中使用,我將 gsl::span 移植到了 C++11 上,變成 mozilla::Span(https://searchfox.org/mozilla-central/source/mfbt/Span.h#375)。移植的過程主要是去掉 constexpr 關鍵字,並用 mozilla:: 的類型和類型 trait 代替標準庫中的類型。在 Gecko 改成 C++14 後,部分 constexpr 關鍵字被恢復了。
不論如何, 我們有了自己的 mozilla::Span,現在可以添加 gsl::span 中缺少的、像 Rust 一樣的子 span 了。如果你需要子 span 下標 i 開始直到 j,但不包括 j,那麼 gsl::span 的實現方法是:
而 mozilla::Span 的實現方法是:
gsl::span 和 Rust 的切片有一個重要的區別:它們解構成指針和長度的方式不同。對於零長度的 gsl::span,指針可能會解構為 nullptr。而 Rust 切片中,指針必須不能為 null 且必須對齊,甚至零長度切片也是如此。乍一看起來似乎有點違反直覺:當長度為零時,指針永遠不會解引用,那麼它是否為 null 有什麼關係嗎?實際上,在優化 Option 之類的枚舉之中的 enum 差異時這一點非常重要。None 表示為全零比特,所以如果包裹在 Some() 中,那麼指針為 null、長度為零的切片就可能偶然被當做 None。通過要求指針不為 null 指針,Option 中的零長度切片就可以與 None 區分開來。通過要求指針必須對齊,當切片元素類型的對齊大於一時,就有可能進一步使用指針的低位比特。
在意識到我們不能將從 C++ 的 gsl::span::data() 中獲得的指針直接傳遞給 Rust 的 std::slice::from_raw_parts() 後,我們必須決定要在哪裡將 nullptr 替換成 reinterpret_cast(alignof(T))。如果使用 gsl::span 則有兩個候選的位置:提供 FFI 的 Rust 代碼中,或者在調用 FFI 的 C++ 代碼中。而如果使用 mozilla::Span,我們可以改變 span 的實現代碼,因此還有另外兩個候選的位置:mozilla::Span 的構造函數,和指針的 getter 函數。
在這些候選位置中,mozilla::Span 的構造函數似乎是編譯器最有可能優化掉某些檢查的地方。這就是為什麼我決定將檢查放在這裡的原因。這意味著如果使用 gsl::span,那麼檢查的代碼必須移動到FFI的調用中。所有從 gsl::span 中獲得的指針必須進行如下清洗:
此外,由於這段檢查並不存在於提供 FFI 的 diamante 中,C API 變得有點不尋常,因為它要求 C 的調用者即使在長度為零時也不要傳遞 NULL。但是,C API 在未定義行為方面已經有很多問題了,所以再加一個未定義行為似乎也不是什麼大事兒。
合併到一起
我們來看看上面這些特性結合後的例子。首先,Rust 中的這個方法接收一個切片,並返回一個可選的 tuple:
由於它是個靜態方法,因此不存在指向 self 的引用,在 FFI 函數中也沒有相應的指針。該切片解構成一個指針和一個長度。長度變成 in/out 參數,用來返回切片刀長度,以及 BOM 的長度。編碼變成返回值,編碼指針為 null 表示 Rust 中的 tuple 為 None。
C 頭文件中的簽名如下:
C++ 層在 C API 上重建對應於 Rust API 的部分:
這裡我們必須顯式使用 std::make_tuple,因為隱式構造函數在 std::tuple 嵌入到 std::optional 中時不能正確工作。
代數類型
之前,我們看到了 Rust 側的流 API 可以返回這個 enum:
現在 C++ 也有了類似 Rust 的 enum 的東西:std::variant。但在實踐中,std::variant 很難用,因此,從優雅的角度來看,Rust 的 enum 本應是個輕量級的東西,所以沒有道理使用 std::variant 代替。
首先,std::variant 中的變數是沒有命名的。它們通過位置或類型來識別。命名變數曾經作為 lvariant(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0095r1.html)提議過,但並沒有被接受。其次,即使允許重複的類型,使用它們也是不現實的。第三,並沒有語言層面上相當於 Rust 的 match(https://doc.rust-lang.org/book/second-edition/ch06-02-match.html)的東西。曾經提議過的inspect(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0095r1.html)相當於 match 的機制,但並未被接受。
在 FFI/C 層,上面 enum 的信息被打包到一個 u32 中。我們沒有試圖將它在 C++ 側擴展成更漂亮的東西,而是簡單地使用了與 C API 同樣的 uint32_t。如果調用者需要在異常情況下從中提取出兩個小的整數,那麼調用者可以自己用位操作從 uint32_t 中提取。
FFI 代碼如下:
使用零作為 INPUT_EMPTY 的魔術值是個微優化。在某些架構上,與零比較的代價要比與其他常量比較更低,而表示解碼時的異常情況和無法映射的情況的值不會與零重疊。
通知整數溢出
Decoder 和 Encoder 擁有一些方法用於查詢最壞情況下的緩衝區大小需求。 調用者提供輸入的代碼單元的數量,方法返回要保證相應的轉換方法不會返回OutputFull 所需的最小緩衝區大小(以代碼單元為單位)。
例如,將 UTF-16 編碼成 UTF-8,最壞情況下等於乘以三。至少在原理上,這種計算可以導致整數溢出。在 Rust 中,整數溢出被認為是安全的,因為即使由於整數溢出而分配了太少的緩衝區,實際上訪問緩衝區也會進行邊界檢查,所以整體的結果是安全的。但是,緩衝區訪問在 C 或 C++ 中通常是沒有邊界檢查的,所以 Rust 中的整數溢出可能會導致 C 或 C++ 中的內存不安全,如果溢出的計算結果被用來確定緩衝區分配和訪問時的大小的話。對於 encoding_rs 而言,即使是 C 或 C++ 負責分配緩衝區,寫入操作也是由 Rust 進行的, 所以也許是沒問題的。但為了確信起見,encoding_rs 提供的最壞情況的計算也進行了溢出檢查。
在 Rust 中,經過溢出檢查的結果會返回 Option。為保持 C API 中類型的簡單性,C API 會返回 size_t,並用 SIZE_MAX 通知溢出。因此,C API 實際上使用的是飽和算術(saturating arithmetic)。
在使用標準庫類型的 C++ API 中,返回類型是 std::optional。在 Gecko 中,我們使用了一個整數類型的包裹,提供溢出檢查和有效性標誌。在 Gecko 版本的 C++ API 中,返回值是 mozilla::CheckedInt,這樣處理溢出信號的方式與 Gecko 的其他部分一致。(邊註:我發現 C++ 標準庫依然沒有提供類似 mozilla::CheckedInt 的包裹以進行整數運算中的溢出檢查時感到非常震驚——這應該是標準就支持的避免未定義行為的方式。)
重建非流式 API
我們再來看看 Encoding 的非流式 API 中的方法:
返回類型 Option 中的類型是 Cow,這個類型的值或者是自己擁有的 String,或者是從別的地方借來的字元串切片(&"a str)。借來的字元串切片的生存時間"a 就是輸入切片(bytes: &"a [u8])的生存時間,因為在借的情況下,輸出實際上是從輸入借來的。
將這種返回值映射到 C 中面臨著問題。首先,C 不提供任何方式表示可能擁有也可能借的情況。其次,C 語言沒有標準類型來保存堆上分配的字元串,從而知道字元串的長度和容量,從而能在字元串被修改時重新分配其緩衝區。也許可以建立一種新的 C 類型,其緩衝區由 Rust 的 String 負責管理,但這種類型就沒辦法兼容 C++ 的字元串了。第三,借來的 C 字元串切片在 C 語言中將會表現成原始的指針和一個長度,一些文檔說這個指針僅在輸入指針有效的時候才有效。因此並沒有語言層面的機制來防止指針在釋放之後被使用。
解決方案並不是完全不在 C 層面提供非流式 API。下 Rust 側,非流式 API 只是個構建在流式 API 和一些驗證函數(ASCII 驗證、UTF-8 驗證、ISO-2022-JP ASCII 狀態驗證)上的便利 API。
儘管 C++ 的類型系統能夠表示與 Rust 的 Cow 相同的結構體,如std::variant,但這種C++的Cow是不安全的,因為它的生存期限"a無法被 C++ 強制。儘管 std::string_view(或gsl::span)可以(在大多素情況下)在 C++ 中作為參數使用,但作為返回值類型,它會導致在釋放後發生訪問。與 C 一樣,最好的情況就是有某個文檔能說明只要輸入的 gsl::span 有效,輸出的 std::string_view 就有效。
為了避免發生釋放後訪問,我們在依然使用 C++17 的 C++ API 的版本中,簡單地令 C++ 的 decode_without_bom_handling_and_without_replacement() 函數永遠複製並返回一個 std::optional。
但在 Gecko 的情況中,我們能夠在保證安全的情況下做得更好。Gecko 使用了 XPCOM 字元串(https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Guide/Internal_strings),它提供了多種存儲選項,特別是:從其他人那裡(不安全地)借來的 dependent string,自動用將短字元串列內嵌入至行內緩衝區的 auto string,以及指向堆上分配的引用計數器緩衝區的 shared string。
如果要解碼的緩衝區是個指向堆上分配的引用計數器緩衝區的 XPCOM 字元串,而且我們需要解碼至 UTF-8(而不是 UTF-16),而在這種情況下本應該從 Rust 那裡借(除非是刪除 BOM 的情況),現在我們可以另輸出字元串指向與輸入相同的堆上分配的引用計數器緩衝區(並增加引用計數)。這正是 mozilla::Encoding 的非流式 API 做法。
與 Rust 相比,除了輸入字元串必須使用引用計數存儲以便複製能正確工作之外,還有另外一個限制:如果 BOM 被移除,那麼輸入不能有 UTF-8 BOM。雖然 Rust 可以從輸入中借出不帶 BOM 的那一段切片,但對於 XPCOM 字元串,增加引用計數的方式只有在輸入和輸輸出的位元組內容完全一致的情況下才能正確工作。如果省略掉開頭的三個位元組,它們就不是完全一致了。
雖然使用 C++17 標準庫類型的 C++ API 中的非流式 API 是在 C++ 流式 API 的基礎上構建的,但為了更多的安全性,mozilla::Encoding 的非流式 API 並不是基於流式 C++ API 構建的,而是在 Rust 語言的流式 Rust API 的基礎上構建的(https://searchfox.org/mozilla-central/source/intl/encoding_glue/src/lib.rs)。在 Gecko 中,我們有 XPCOM 字元串的 Rust 綁定(https://searchfox.org/mozilla-central/source/servo/support/gecko/nsstring/src/lib.rs),所以可以從 Rust 中操作 XPCOM 字元串。
結語:我們真的需要用指針來引用 Decoder 和 Encoder 嗎?
由於 C++ 沒有安全的借用機制而導致必須在非流式 API 中進行複製之外,還有一點點令人失望的是,從 C++ 中實例化 Decoder 和 Encoder 需要進行堆分配操作,而 Rust 調用者是在棧上分配這些類型。我們能讓 C++ 的使用者也避免堆分配操作嗎?
答案是可以,但正確地實現這一點需要讓 C++ 的構建系統查詢 rustc 以構建常量,使得系統變得異常複雜。
我們不能跨越 FFI 直接用值的形式返回非 C 的結構體,但如果一個恰當地對齊的指針有足夠多的內存,我們可以將非 C 的結構體寫到由 FFI 的另一側提供的內存中。實際上,API 支持這個功能,作為之前在堆上實例化新的 Decoder 的操作的一種優化措施:
即使文檔說 encoding_new_decoder_into() 應當旨在之前從 API 獲得了 Decoder 的指針的情況下使用,對於 Decoder 的情況來說,使用 = 進行賦值操作應當不是問題,就算指針指向的內存地址沒有初始化,因為 Decoder 並沒有實現 Drop。用 C++ 的術語來說,Rust 中的 Decoder 沒有析構函數,所以只要該指針之前指向合法的 Decoder,那麼使用 = 進行賦值不會進行任何清理工作。
如果編寫一個 Rust 結構體並實現 Drop 使之析構成未初始化的內存,那就應該使用 std::ptr::write() 代替 =。std::ptr::write() 能「用給定的值覆蓋內存地址,而不會讀取或放棄舊的值」。也許,上面的情況也能作為使用 std::ptr::write() 的很好的例子,儘管嚴格來說並不那麼必要。
從 Rust 的 Box 中獲得的指針能保證正確地對齊,並且指向足夠大小的一片內存。如果 C++ 要分配棧內存供 Rust 代碼寫入,就要讓 C++ 代碼使用正確的大小和對齊。而從 Rust 向 C++ 傳遞這兩個值的過程,就是整個代碼變得不穩定的開始。
C++ 代碼需要自己從結構體發現正確的大小和對齊。這兩個值不能通過調用 FFI 函數獲得,因為 C++ 必須在編譯時就確定這兩個值。大小和對齊並不是常量,因此不能手動寫到頭文件中。首先,每當 Rust 結構體改變時這兩個值都會改變,因此直接寫下來有可能過會導致它們不能適應 Rust 結構體改變後的真實需求。其次,這兩個值在 32 位體系和 64 位體系上不一樣。第三,也是最糟糕的一點,一個 32 位體系上的對齊值可能與另一個 32 位體系的對齊值不一樣。具體來說,絕大多數目標體系上的 f64 的對齊值是 8,如 ARM、MIPS 和 PowerPC,而 x86 上的 f64 的對齊值是 4。如果 Rust 有 m68k 的移植(https://lists.llvm.org/pipermail/llvm-dev/2018-August/125325.html),那麼有可能會使 32 位平台上的對齊值產生更多不確定性(https://bugzilla.mozilla.org/show_bug.cgi?id=1325771#c49)。
似乎唯一的正確方法就是,作為構建過程的一部分,從 rustc 中提取出正確的大小和對齊信息,然後再編譯 C++ 代碼,這樣就可以將兩個數字寫入生成的 C++ 頭文件中,供 C++ 代碼參考。更簡單的方法是讓構建系統運行一小段Rust程序,利用 std::mem::size_of和std::mem:align_of 獲取這兩個數值並輸出到 C++ 頭文件中。這個方案假定構建和實際運行發生在同一個目標體系上,所以不能在交叉編譯中使用。這一點可不太好。
我們需要從 rustc 中提取給定的結構體在特定體系下的大小和對齊值,但不能通過執行程序的方式。我們發現(https://blog.mozilla.org/nnethercote/2018/11/09/how-to-get-the-size-of-rust-types-with-zprint-type-sizes/)rustc有個命令行選項,-Zprint-type-sizes,能夠輸出類型的大小和對齊值。不幸的是,這個選項僅存在於每日構建版本上……不過不管怎樣,最正確的方法還是讓一個構架腳本首先用該選項調用 rustc,解析我們關心的大小和對齊,然後將它們作為常量寫入 C++ 頭文件總。
或者,由於「過對齊」(overalign)是允許的,我們可以信任結構體不會包含 SIMD 成員(對於128位向量來說對齊值為 16),因此對齊值永遠為 8。我們還可以檢查在 64 位平台上的對齊值,然後永遠使用該值,希望其結果是正確的(特別是希望在 Rust 中結構體增長時,有人能記得更新給 C++ 看的大小)。但寄希望於有人記得什麼事情,使用 Rust 就失去了意義。
不管怎樣,假設常量 DECODER_SIZE和DECODER_ALIGNMENT 可以在 C++ 中使用,那麼可以這樣做:
其中:
構造器 Decoder() 沒有被標記為 delete,而是標記為 default,但仍然是 private。
Encoding 被定義為 friend,使上面的構造函數能夠訪問。
添加了 public 的默認移動構造函數。
添加了一個 private 欄位,類型為 unsigned char[DECODER_SIZE]。
Decoder 本身定義為 alignas(DECODER_ALIGNMENT)。
operator delete 不再重載。
然後,Encoding 上的 new_decoder() 可以這樣寫(改名為 make_decoder 以避免在 C++ 中不尋常地使用「new」這個詞):
使用方法:
注意在 Encoder 的實現之外試圖定義 Decoder decoder;而不立即初始化會導致編譯錯誤,因為 Decoder() 構造函數是私有的。
我們來分析發生了什麼:
unsigned char 數組提供了 Rust Decoder 的存儲。
C++ Decoder 沒有基類、虛方法等,所以實現沒有提供任何隱藏的成員,Decoder 的地址與它的 storage 成員的地址相同,因此可以簡單地把 Decoder 自身的地址傳遞給 Rust。
unsigned char 的對齊為 1(即不限制),因此 Decoder 上的 alignas 需要確定對齊。
默認的移動構造函數會 memmove Decoder 中的位元組,而 Rust 的 Decoder 是可以移動的。
私有的默認、無參數的構造函數,使得任何在 Encoder 之外對 C++ Decoder 只作定義而不立即初始化的行為導致編譯錯誤。
但是,Encoder 能實例化一個未初始化的 Decoder 並傳遞其指針給 Rust,這樣 Rust 代碼就能將 Rust 的 Decoder 示例寫入 C++ 通過指針提供的內存中。
原文:https://hsivonen.fi/modern-cpp-in-rust/
作者:Henri Sivonen,Mozilla 的軟體開發者,致力於網路層和底層,如HTML解析器、字元編碼轉換器等。
譯者:彎月,責編:屠敏
※如何使用 AOP 和自定義註解?
※Python 爬取貓眼數據分析《無名之輩》為何能逆襲成黑馬?
TAG:CSDN |