【編者按】自從美國白宮對開發者呼籲,“停止使用 C 和 C++,改用 Rust 等記憶體安全程式設計語言”後,兩方之間從未停止的争論就被推到了一個新高度。而在這之中,也有部分 C++ 開發者提議:或許 Rust 中的一些概念,可以試着運用到 C++ 程式設計中?
整理 | 鄭麗媛
近日,一位開發者(ID:delta242)在 Reddit 上發了一篇長文《在 C++ 中應用 Rust 的概念》,裡面提到了一些可用于改善 C++ 代碼的 Rust 概念,引來了諸多關注和讨論。
根據他在開篇的介紹,“雖然我不是 Rust 專家,但我很喜歡這門語言的許多概念。在日常程式設計中,我主要用 C++,而現在我經常會運用一些 Rust 的概念來改善我的 C++ 代碼”,可以看出,下面是他親身實踐過的、可用于優化 C++ 代碼的 Rust 概念。
在 C++ 中,如何應用 Rust 的概念?
(以下為他的長文翻譯:)
(1)帶值的枚舉
我很喜歡 Rust 的枚舉,因為你可以給枚舉常量指派(例如,Option 枚舉中有一個沒有值的 None 和一個有值的 Some)。在類型理論中,這通常被稱為代數資料類型,而在 C++ 中,我們有 variants,可以定義輔助結構體來實作類似的功能:
struct Some { T value; }; struct None { }; using Optional = std::variant<Some, None>;
(注:這個例子可能有點蠢,因為 std::optional 要好得多。但對于更複雜的類型來說,這具有一定參考意義。)
(2)CRTP 和 Traits
在 Rust 中,Traits 用于定義類型的共享功能。而在 C++ 中,我們可以用 CRTP 在編譯時強制類實作特定的函數來實作靜态多态性。CRTP 還允許在基類中實作預設功能,我以前曾用這種方法來定義疊代器類型,隻要基類實作了 operator[],就可以減少大量模闆代碼的編寫。
(3)字元串格式化
在 C++ 中,如果向 std::format 傳遞的參數數量多于格式字元串中的占位符,并不會導緻編譯時錯誤。我曾經遇到過這樣的 bug,例如由于缺少占位符,日志消息中缺少了某些資訊,導緻與代碼中不一緻。
而這個情況如果放在 Rust 中,就會産生編譯時錯誤。是以這對于 C++ 來說,将是一個簡單而實用的改進,有助于提高代碼品質和開發效率。
(4)擁有 Mutex
在 Rust 中,Mutex 類型擁有受保護的值。我非常喜歡這個概念,這樣不擷取 Mutex 就無法通路受保護的值(這在 C++ 中經常發生)。有一個簡單的技巧來實作類似效果,那就是在 C++ 中寫一個具有 lock 函數的封裝 Mutex 類,該函數将接受一個帶有對受保護值的引用的 lambda 表達式作為參數。由于 Rust 中有借用檢查器,這樣的操作總是安全的,而在 C++ 中,誤用很容易再次導緻競争條件,但至少通過這樣的封裝器,這種情況就不那麼容易發生了。
(5)内部可變性
Rust 在安全的情況下會使用内部可變性(即使變量是 const),例如當一個值受 Mutex 保護時。在 C++ 中,我們也可以采用類似的想法,例如“const 表示線程安全”。
(6)IIFE
在 Rust 中,每個作用域都是一個表達式,這樣可以很好地将變量限制在更小的作用域中。而在 C++ 中,我們可以用 lamdas 表達式來使用立即調用的函數表達式(IIFE)來達到同樣的效果:
auto value = [] { // Complex initializer return result; }(); // notice the invocation
以上,就是我現在能想到的。
“Rust 讓我成為了一名更好的 C++ 開發者”
在這篇長文下,不少開發者也分享了自己在 C++ 程式設計中借鑒 Rust 概念的心得,甚至直言“Rust 讓我成為了一名更好的 C++ 開發者”。
(1)“最近,我養成了在 C++ 中使用“match”宏的這個習慣,我很喜歡。”
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template <typename Val, typename... Ts> auto match(Val val, Ts... ts) { return std::visit(overloaded{ts...}, val); }
(2)“重載非常好,我覺得它可以成為 STL 的一部分。此外,有了 C++20 模闆化的 lambdas,還可以編寫一些非常花哨的代碼。”
visit( overloaded { []<one_of<string, int> T>(T value) {} [](auto other) {} }, value)
對此,一位開發者感慨:“這正是我希望看到的,雖然我不喜歡 Rust,但它确實有一些 C++ 可以借鑒的做法,更安全總歸是好的。”
在 C++ 中應用 Rust 概念的一些失敗案例
不過與此同時,也有開發者提醒“必須小心”:以 Rust 的 Mutex 為例,當你通路 Mutex 中的資料時,不可能将該指針存儲下來,然後在解鎖 Mutex 後再通路資料(忽略特殊情況)。你可以在 C++ 中實作一個擁有 Mutex 的類,但編譯器不會在意你是否在鎖的作用域之外持有一個指向受保護資料的指針,并在未受保護的情況下通路它。
針對這個話題,開源搜尋引擎 Meilisearch 的進階工程師 Louis Dureuil 曾寫過一篇相關文章《這對 C++ 來說太危險了》:“一些設計模式之是以實用,歸功于 Rust 的記憶體安全性,而在 C++ 中使用則過于危險。”
在文中,Louis Dureuil 分享了他在 C++ 中應用 Rust 概念的失敗案例。
當時,他正在用 Rust 編寫一個内部庫,其中有一個他希望能克隆、而不會複制其中資料的錯誤類型。在 Rust 中,這需要使用引用計數指針,比如 Rc。他編寫了一個錯誤類型,将其用作可能發生錯誤的函數的錯誤變體,繼續了他的工作。
struct Error { data: Rc<ExpensiveToCloneDirectly>, } pub type Response = Result<Output, Error>; fn parse(input: Input) -> Response { todo!() }
後來他發現,對某些輸入進行解析需要很長時間,于是決定通過通道将輸入發送到另一個線程,并通過另一個通道擷取響應,這樣長時間的解析就不會阻塞主線程。
enum Command { Input(Input), Exit, } pub enum RequestStatus { Completed(Response), Running, } pub struct Parser { command_sender: Sender<Command>, response_receiver: Receiver<(Input, Response)>, cached_result: HashMap<Input, RequestStatus>, } impl Parser { pub fn new() -> Self { let (command_sender, command_receiver) = channel::<Command>(); let (response_sender, response_receiver) = channel::<(Input, Response)>(); std::thread::spawn(move || loop { match command_receiver.recv() { Ok(Command::Input(input)) => { let response = parse(input); let _ = response_sender.send((input, response)); } Ok(Command::Exit) => break, Err(_) => break, } }); Self { command_sender, response_receiver, cached_result: HashMap::default(), } } pub fn request_parsing(&mut self, input: Input) -> RequestStatus { // pump previously received responses while let Ok((input, response)) = self.response.receiver.try_recv() { self.cached_result .insert(input, RequestStatus::Completed(response)); } let response = match self.cached_result.entry(input) { Entry::Vacant(entry) => { self.command_sender .send(Command::Input(entry.key())) .unwrap(); entry.insert(RequestStatus::Running) } Entry::Occupied(entry) => entry.into_mut(), }; response.clone() } }
然而,在進行這一更改時,Louis Dureuil 收到了以下錯誤資訊:
error[E0277]: `Rc<String>` cannot be sent between threads safely --> src/main.rs:58:32 | 58 | std::thread::spawn(move || loop { | _____________------------------_^ | | | | | required by a bound introduced by this call 59 | | match command_receiver.recv() { 60 | | Ok(Command::Input(input)) => { 61 | | let response = maybe_make(input); ... | 68 | | } 69 | | }); | |_____________^ `Rc<String>` cannot be sent between threads safely | = help: within `(&'static str, Result<worker::Output, worker::Error>)`, the trait `Send` is not implemented for `Rc<String>` note: required because it appears within the type `Error` --> src/main.rs:17:16 | 17 | pub struct Error { | ^^^^^ note: required because it appears within the type `Result<Output, Error>` --> /home/dureuill/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:502:10 | 502 | pub enum Result<T, E> { | ^^^^^^ = note: required because it appears within the type `(&str, Result<Output, Error>)` = note: required for `Sender<(&'static str, Result<worker::Output, worker::Error>)>` to implement `Send` note: required because it's used within this closure --> src/main.rs:58:32 | 58 | std::thread::spawn(move || loop { | ^^^^^^^ note: required by a bound in `spawn` --> /home/dureuill/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/thread/mod.rs:683:8 | 680 | pub fn spawn<F, T>(f: F) -> JoinHandle<T> | ----- required by a bound in this function ... 683 | F: Send + 'static, | ^^^^ required by this bound in `spawn`
正如編譯器所解釋的那樣,這是因為 Rc 類型不支援線上程之間發送,因為這樣會導緻資料競争。實際上,Rc 中的引用計數并不以原子方式進行操作,而是使用正常的整數操作。
為了實作線程安全的引用計數,Rust 提供了另一種類型 Arc,它使用原子引用計數,而将代碼修改為使用 Arc 非常簡單:
diff --git a/src/main.rs b/src/main.rs index 04ec0d0..fd4b447 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,9 @@ use std::{io::Write, time::Duration}; mod parse { use std::{ collections::{hash_map::Entry, HashMap}, - rc::Rc, sync::{ mpsc::{channel, Receiver, Sender}, + Arc, }, time::Duration, }; @@ -15,13 +15,13 @@ mod parse { #[derive(Clone, Debug)] pub struct Error { - data: Rc<ExpensiveToCloneDirectly>, + data: Arc<ExpensiveToCloneDirectly>, } impl Error { fn new(data: ExpensiveToCloneDirectly) -> Self { Self { - data: Rc::new(data), + data: Arc::new(data), } } }
也就是說,隻要不需要引用原子操作的計數,就可以使用 Rc。但當需要線程安全時,編譯器會強制 Louis Dureuil 切換到 Arc,并帶來了原子引用計數的開銷。
Louis Dureuil 指出,這個原則也深受 C++ 開發者的喜愛。但與 Rust 完全不同的是,在 C++ 中,标準庫中隻有帶有原子引用計數的 shared_ptr,它相當于 Arc,而不是 Rc——是以,即使你不使用原子操作,也仍要為原子引用計數付出代價。
最後,一句話總結:在 C++ 中适當應用 Rust 概念固然不錯,但切記不要根據在 Rust 中會發生的情況,對 C++ 也做出相同的假設。
參考連結:
https://www.reddit.com/r/cpp/comments/1bx7wjm/applying_concepts_from_rust_in_c/
https://blog.dureuill.net/articles/too-dangerous-cpp/