C++ 与 Rust 编程理念的融合

发表时间: 2024-05-02 10:35

【编者按】自从美国白宫对开发者呼吁,“停止使用 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 call59 | | 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.rsindex 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/