上一篇文章-改进rust代码的35种具体方法-类型(十九)-避免使用反射
“仅仅因为Rust允许您安全地编写超酷的非分配零复制算法,并不意味着您编写的每个算法都应该是超级酷的、零复制和非分配的。”-trentj
这本书中的大多数项目都旨在帮助现有程序员熟悉Rust及其成语。然而,这个项目是关于一个问题,当程序员偏离另一个方向太远,痴迷于利用Rust的效率潜力时,可能会出现这个问题——以牺牲可用性和可维护性为代价。
数据结构和分配
与其他语言中的指针一样,Rust的引用允许您在不复制的情况下重用数据。与其他语言不同,Rust关于引用生命周期和借阅的规则允许您安全地重用数据。然而,遵守使这成为可能的借款检查规则可能会导致代码更难使用。
这与数据结构特别相关,您可以选择分配存储在数据结构中的新副本或包含对现有副本的引用。
例如,考虑一些解析字节数据流的代码,提取编码为类型长度值(TLV)结构的数据,其中数据以以下格式传输:
- 描述值类型的一个字节(存储在这里的
type_code
字段中)1 - 一个字节描述以字节为单位的值长度(此处用于创建指定长度的切片)
- 后跟值的指定字节数(存储在
value
字段中):
/// A type-length-value (TLV) from a data stream.
#[derive(Clone, Debug)]
pub struct Tlv<'a> {
pub type_code: u8,
pub value: &'a [u8],
}
pub type Error = &'static str; // Some local error type.
/// Extract the next TLV from the `input`, also returning the remaining
/// unprocessed data.
pub fn get_next_tlv(input: &[u8]) -> Result<(Tlv, &[u8]), Error> {
if input.len() < 2 {
return Err("too short for a TLV");
}
// The TL parts of the TLV are one byte each.
let type_code = input[0];
let len = input[1] as usize;
if 2 + len > input.len() {
return Err("TLV longer than remaining data");
}
let tlv = Tlv {
type_code,
// Reference the relevant chunk of input data
value: &input[2..2 + len],
};
Ok((tlv, &input[2 + len..]))
}
这种Tlv
数据结构是高效的,因为它持有对输入数据相关块的引用,而不会复制任何数据,并且Rust的内存安全确保了引用始终有效。这对于某些场景来说是完美的,但如果某些东西需要挂在数据结构的实例上,事情会变得更加尴尬。
例如,考虑一个以TLV形式接收消息的网络服务器。接收的数据可以解析为Tlv
实例,但这些实例的生命周期将与传入消息的生命周期相匹配——该消息可能是堆上的瞬态Vec<u8>
,也可能是重复用于多个消息的缓冲区。
如果服务器代码想要存储传入消息,以便稍后查阅,这会导致问题:
pub struct NetworkServer<'a> {
// ...
/// Most recent max-size message.
max_size: Option<Tlv<'a>>,
}
/// Message type code for a set-maximum-size message.
const SET_MAX_SIZE: u8 = 0x01;
impl<'a> NetworkServer<'a> {
pub fn process(&mut self, mut data: &'a [u8]) -> Result<(), Error> {
while !data.is_empty() {
let (tlv, rest) = get_next_tlv(data)?;
match tlv.type_code {
SET_MAX_SIZE => {
// Save off the most recent `SET_MAX_SIZE` message.
self.max_size = Some(tlv);
}
// (Deal with other message types)
// ...
_ => return Err("unknown message type"),
}
data = rest; // Process remaining data on next iteration.
}
Ok(())
}
}
此代码按原样编译,但实际上无法使用:NetworkServer
的生命周期必须小于输入其process()
方法的任何数据的生命周期。这意味着一个简单的处理循环:
let mut server = NetworkServer::default();
while !server.done() {
// Read data into a fresh vector.
let data: Vec<u8> = read_data_from_socket();
if let Err(e) = server.process(&data) {
log::error!("Failed to process data: {:?}", e);
}
}
无法编译,因为临时数据的生命周期被附加到寿命较长的服务器上:
error[E0597]: `data` does not live long enough
--> src/main.rs:375:40
|
372 | while !server.done() {
| ------------- borrow later used here
373 | // Read data into a fresh vector.
374 | let data: Vec<u8> = read_data_from_socket();
| ---- binding `data` declared here
375 | if let Err(e) = server.process(&data) {
| ^^^^^ borrowed value does not live
| long enough
...
378 | }
| - `data` dropped here while still borrowed
切换代码以便重用寿命更长的缓冲区也无济于事:
let mut perma_buffer = [0u8; 256];
let mut server = NetworkServer::default(); // lifetime within `perma_buffer`
while !server.done() {
// Reuse the same buffer for the next load of data.
read_data_into_buffer(&mut perma_buffer);
if let Err(e) = server.process(&perma_buffer) {
log::error!("Failed to process data: {:?}", e);
}
}
这一次,编译器抱怨代码试图挂在引用的同时,同时向同一缓冲区分发可变引用:
error[E0502]: cannot borrow `perma_buffer` as mutable because it is also
borrowed as immutable
--> src/main.rs:353:31
|
353 | read_data_into_buffer(&mut perma_buffer);
| ^^^^^^^^^^^^^^^^^ mutable borrow occurs here
354 | if let Err(e) = server.process(&perma_buffer) {
| -----------------------------
| | |
| | immutable borrow occurs here
| immutable borrow later used here
核心问题是Tlv
结构引用瞬态数据——这对于瞬态处理很好,但从根本上与以后的存储状态不兼容。但是,如果Tlv
数据结构被转换为拥有其内容:
#[derive(Clone, Debug)]
pub struct Tlv {
pub type_code: u8,
pub value: Vec<u8>, // owned heap data
}
那么服务器代码的工作要容易得多。拥有数据的Tlv
结构没有生命周期参数,因此服务器数据结构也不需要一个参数,并且处理循环的两个变体都可以正常工作。
谁害怕大复制?
程序员可能过于痴迷于减少副本的一个原因是,Rust通常会明确复制和分配。对.to_vec()
或.clone()
等方法或Box::new()
等函数的可见调用,清楚地表明正在发生复制和分配。这与C++形成鲜明对比,在C++中很容易无意中编写代码,在封面下轻松执行分配,特别是在复制构造函数或赋值运算符中。
使分配或复制操作可见而不是隐藏不是优化它的好理由,特别是如果这种情况以牺牲可用性为代价。在许多情况下,如果性能确实令人担忧,并且基准测试表明减少副本将产生重大影响时,首先关注可用性,并进行微调以获得最佳效率,这更有意义。
此外,只有当代码需要扩展以供广泛使用时,代码的效率通常才重要。如果事实证明代码中的权衡是错误的,并且当数百万用户开始使用它时,它无法很好地应对——好吧,这是一个好问题。
然而,有几个具体要点需要记住。当指出副本通常可见时,第一个通常隐藏在錍��豆豆字后面。最大的例外是Copy
类型,编译器会随意无声地复制,从移动语义转向复制语义。因此,在第十篇中的建议值得在这里重复:除非按位副本有效且快速,否则不要实现Copy
。但反过来也是这样:如果按位的副本有效且快速,请考虑实现Copy
。例如,如果派生Copy
,不携带额外数据的enum
类型通常更容易使用。
可能相关的第二点是使用no_std
的潜在权衡。通常只需稍加修改即可编写与no_std
兼容的代码,而完全避免分配的代码则使其更加简单。然而,针对支持堆分配的anono_std
环境(通过alloc
库),可能会提供可用性和no_std
支持的最佳平衡。
参考和智能指针
“所以最近,我有意识地尝试了不担心假设的完美代码的实验。相反,我在需要时调用.clone(),并使用Arc更顺利地将本地对象放入线程和未来。
感觉很光荣。”-乔什·特里普利特
设计一个数据结构,使其拥有其内容当然可以更好地实现人体工程学,但如果多个数据结构需要使用相同的信息,仍然存在潜在问题。如果数据是不可变的,那么每个拥有自己副本的地方都可以正常工作,但如果信息可能会发生变化(这种情况很常见),那么多个副本意味着需要更新的多个地方,彼此同步。
使用Rust的智能指针类型有助于解决这个问题,允许设计从单所有者模型转移到共享所有者模型。Rc
(用于单线程代码)和Arc
(用于多线程代码)智能指针提供支持这种共享所有权模型的参考计数。继续假设需要可变性,它们通常与允许内部可变性的内部类型配对,独立于Rust的借入检查规则:
借用检查器的GuestRegister
示例更详细地介绍了这一过渡,但这里的重点是,您不必将Rust的智能指针视为最后手段。如果您的设计使用智能指针而不是相互连接的参考寿命的复杂网络,这并不是失败的承认——智能指针可以导致更简单、更易于维护、更可用的设计。
1该字段不能被命名为type
,因为这是Rust中的保留关键字。可以使用原始标识符前缀r#
(给一个字段r#type: u8
)来绕过此限制,但通常只需重命名字段就更容易了。