billythedummy's silly diary


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:
  1. Iterating over Results or Options
  2. Using Arc<[T]> instead of Vec<T> for readonly data
  3. Using newtypes to implement cheap checked operations

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 Results or Options

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 i64s 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!