2024/01/13
Less-well-known Rust Tricks For The Advanced Beginner Part 1
TLDR: this post goes through the following 3 less-well-known (in my
opinion) tips/tricks in rust:
- Iterating over
Result
s or Option
s
-
Using
Arc<[T]>
instead of
Vec<T>
for readonly data
- Using newtypes to implement cheap checked operations
Result
s or Option
sArc<[T]>
instead of
Vec<T>
for readonly data
P.S. A part 2 is not confirmed, I intend to add continuations as and when I feel like there are other noteworthy ones.
1. Iterating over Result
s or Option
s
I originally had written a section about this but I realized the Rust By Example book explains this in a much more concise manner, provides detailed examples, and goes through several other cases beyond what I had considered, so I'm just gonna be lazy and link to it instead here.
Other noteworthy mentions not in the link are
try_fold()
and
try_for_each()
. In case the examples on the doc weren't clear, here's another toy
example that uses try_fold()
to sum a sequence of
i64
s if they're all non-negative, or return the first
encountered conversion error otherwise:
pub fn convert_and_sum(inputs: &[i64]) -> Result<u64, TryFromIntError> {
inputs.iter().try_fold(0u64, |acc, x| {
let x_unsigned: u64 = (*x).try_into()?;
Ok(acc + x_unsigned)
})
}
2. Using Arc<[T]>
instead of
Vec<T>
for readonly data
If you have a readonly array of values that needs to be shared across
multiple threads, consider using an
Arc<[T]>
instead of a Vec
. This way,
when you need to send the data to another thread by cloning, you're only
performing an atomic reference count increment to clone the
Arc
, as opposed to cloning a Vec
which would
involve allocating memory for the new Vec
then cloning
element-wise from the original Vec
to the new
Vec
.
Arc<[T]>
implements
From<Vec<T>>
so existing code that uses
Vec
can be easily converted with a simple
into()
. However, the implementation needs to allocate new
memory for the Arc
since an Arc
's heap
allocation also contains the atomic reference counts, so it uses a
different amount of space from a Vec
's heap alllocation.
Apart from using a const array for static initializations, I'm not aware
of any other mitigations of this additional allocation. Another point
about this is that once
TrustedLenIterator
is stabilized in the future,
Arc::from_iter()
will no longer use an intermediate Vec
, making it efficient
to construct Arc<[T]>
from not just
Vec
but other trusted length iterators as well.
If you need to mutate the owned values behind the Arc
(not
the same value shared across the other threads as you would with a
Arc<Mutex>
or Arc<RwLock>
), you
can always use
Arc::make_mut()
to make it clone on write.
Note that the cost of an atomic reference count increment/decrement may
actually be more expensive than just straight up cloning the
Vec
in some cases e.g. for a small number of elements or
because the cost of allocating new memory during
vec.into()
is too great. As with all things perf, it's best
to benchmark to check your specific use-case.
3. Using newtypes to implement cheap checked operations
Sometimes you're dealing with data that you need to perform some common checks on before some operations. Here's a simple example: let's say I'm receiving a packet of bytes over the network that's supposed to be cast into the following struct:
pub struct DetailedPoint {
pub magic: u8,
pub x: f64,
pub y: f64,
...many other fields
}
impl DetailedPoint {
pub const SERIALIZED_LEN: usize = ...some fixed length...;
}
Where magic
is the first byte of the packet and indicates
that the packet represents a DetailedPoint
only when it's
of the specific value 0xAF
. Lets say that my current
use-case is to calculate the magnitude of the point, which I only need
the x
and y
fields for.
pub fn magnitude(packet: &[u8]) -> Result<f64, CustomError> {
// return sqrt(x^2 + y^2) after checking
// packet is a valid DetailedPoint
}
Let's also say that the struct is serialized little-endian packed into
the network packet, so
magic
is at offset 0, x
is at offset 1 and
y
is at offset 9.
Since the struct is big and has many other unneeded fields, I don't want
to deserialize the entire thing. However, I do need to check that the
magic
byte is indeed 0xAF
and that the slice
length is correct before attempting to read x
and
y
. It might be obvious to write something like this as a
first-pass:
pub fn is_valid_detailed_point(packet: &[u8]) -> bool {
packet.len() == DetailedPoint::SERIALIZED_LEN && packet[0] == 0xAF
}
fn deser_le_f64_unchecked(packet: &[u8], offset: usize) -> f64 {
let subslice: &[u8; 8] = &packet[offset..offset + 8].try_into().unwrap();
f64::from_le_bytes(*subslice)
}
pub fn x_checked(packet: &[u8]) -> Resul<f64, CustomError> {
if !is_valid_detailed_point(packet) {
return Err(CustomError);
}
Ok(deser_le_f64_unchecked(packet, 1))
}
pub fn y_checked(packet: &[u8]) -> Resul<f64, CustomError> {
if !is_valid_detailed_point(packet) {
return Err(CustomError);
}
Ok(deser_le_f64_unchecked(packet, 9))
}
and the completed consumer function:
pub fn magnitude(packet: &[u8]) -> Result<f64, CustomError> {
let x = x_checked(packet)?;
let y = y_checked(packet)?;
Ok((x.powf(2.0) + y.powf(2.0)).sqrt())
}
However, this means that you perform a redundant
is_valid_detailed_point()
check whenever you need to call
x_checked()
or y_checked()
more than once in
the same context, just as in the completed consumer function above.
What you can do instead is define a newtype that implements these unsafe-without-checking operations. Then, make performing the check the only way to create this newtype.
pub fn is_valid_detailed_point(packet: &[u8]) -> bool {
packet.len() == DetailedPoint::SERIALIZED_LEN && packet[0] == 0xAF
}
fn deser_le_f64_unchecked(packet: &[u8], offset: usize) -> f64 {
let subslice: &[u8; 8] = &packet[offset..offset + 8].try_into().unwrap();
f64::from_le_bytes(*subslice)
}
pub struct CheckedDetailedPoint<'a>(&'a [u8]);
impl<'a> CheckedDetailedPoint<'a> {
pub fn try_from_packet(packet: &'a [u8]) -> Result<Self, CustomError> {
if !is_valid_detailed_point(packet) {
return Err(CustomError);
}
Ok(Self(packet))
}
pub fn x(&self) -> f64 {
deser_le_f64_unchecked(self.0, 1)
}
pub fn y(&self) -> f64 {
deser_le_f64_unchecked(self.0, 9)
}
}
and the completed consumer function:
pub fn magnitude(packet: &[u8]) -> Result<f64, CustomError> {
let checked = CheckedDetailedPoint::try_from_packet(packet)?;
Ok((checked.x().powf(2.0) + checked.y().powf(2.0)).sqrt())
}
This new version has no redundant checks. At compile-time, the type
system ensures that every call to x()
and
y()
must have been preceded by the required check in
CheckedDetailedPoint::try_from_packet()
in the same
context. The newtype also incurs no additional memory cost as the binary
representation of CheckedDetailedPoint
should be the same
as &[u8]
. This can be further ensured with a
#[repr(transparent)]
.
The essence of this little tip can be seen throughout many rust
libraries in use-cases like adapters. An example most similar to the
simple example above is probably the
bytemuck
crate, where
try_from_bytes()
transmutes a raw byte slice reference to a typed reference after
performing the required checks. While the application of this pattern
seems obvious in bytemuck
's case, it might not be so for
your use-cases. As a rule-of-thumb, it might be time to consider a
newtype when you find yourself calling the same checking code in
multiple places.
That's all I have to share for now. Hope this helps!