Skip to main content

1. Always Use #[repr(C)]

// ✅ Good: C-compatible layout
#[derive(Copy, Clone, Debug)]
#[repr(C)]
struct MyData {
    value: f32,
}

// ❌ Bad: Rust layout (may have padding)
#[derive(Copy, Clone, Debug)]
struct MyData {
    value: f32,
}
The #[repr(C)] attribute ensures the struct has a C-compatible memory layout, which is required for:
  • Zero-copy serialization (local transport)
  • Cross-language compatibility (future Python/C++ support)
  • Predictable binary format

2. Use Fixed-Size Arrays

// ✅ Good: Fixed-size array
#[derive(Copy, Clone)]
#[repr(C)]
struct Data {
    values: [f32; 10],
}

// ❌ Bad: Heap-allocated vector
struct Data {
    values: Vec<f32>,  // Doesn't implement Copy!
}
Fixed-size arrays are Copy and work automatically. Vectors and other heap-allocated collections don’t implement Copy and cannot be used.

3. Use Fixed-Size Byte Arrays for Strings

// ✅ Good: Fixed-size byte array
#[derive(Copy, Clone)]
#[repr(C)]
struct User {
    name: [u8; 32],
}

// ❌ Bad: Heap-allocated string
struct User {
    name: String,  // Doesn't implement Copy!
}
Use fixed-size byte arrays ([u8; N]) for strings. The String type is heap-allocated and doesn’t implement Copy.

Working with Fixed-Size Strings

let mut user = User {
    name: [0; 32],
};

// Copy string bytes (truncate if too long)
let name_bytes = b"john_doe";
user.name[..name_bytes.len().min(32)].copy_from_slice(name_bytes);

4. Keep Types Small

Large structs increase serialization time and memory usage:
// ✅ Good: Small, focused struct
#[derive(Copy, Clone)]
#[repr(C)]
struct SensorData {
    temperature: f32,
    timestamp: u64,
}

// ⚠️ Consider: Is 1MB really needed?
#[derive(Copy, Clone)]
#[repr(C)]
struct LargeData {
    buffer: [u8; 1_000_000],  // 1MB per message!
}
Large messages increase memory usage and serialization overhead. Consider splitting large data into multiple smaller messages or using a different transport mechanism for very large payloads.

5. Choose Copy Types vs Protobuf Appropriately

Use Copy Types When:

  • ✅ Same-language communication (Rust only)
  • ✅ Simple, fixed-size message types
  • ✅ Maximum performance is needed
  • ✅ Predictable message sizes
#[derive(Copy, Clone)]
#[repr(C)]
struct SimpleData {
    value: f32,
    timestamp: u64,
}

Use Protobuf When:

  • ✅ Cross-language communication (Rust ↔ Python ↔ C++)
  • ✅ Schema evolution needed (adding/removing fields)
  • ✅ Complex nested structures
  • ✅ Guaranteed message format compatibility
#[derive(Clone, PartialEq, ProstMessage)]
pub struct CrossLangData {
    #[prost(uint64, tag = "1")]
    pub timestamp: u64,
    #[prost(float, tag = "2")]
    pub temperature: f32,
}

6. Validate Type Sizes

Check the size of your message types to ensure they’re reasonable:
use std::mem;

#[derive(Copy, Clone)]
#[repr(C)]
struct MyData {
    // ... fields
}

println!("Size: {} bytes", mem::size_of::<MyData>());
Large types (>1KB) may impact performance. Consider if the data can be split into smaller messages or if a different approach is needed.

7. Handle Relocatable Messages Transparently

The from_bytes() method automatically handles relocatable-wrapped messages:
// ✅ Good: from_bytes() handles both wrapped and unwrapped messages
match SensorData::from_bytes(&bytes) {
    Ok(data) => println!("Received: {:?}", data),
    Err(e) => eprintln!("Error: {}", e),
}

// ❌ Bad: Don't manually unwrap relocatable messages
// The system handles this automatically
You don’t need to manually check for relocatable messages. The from_bytes() implementation automatically extracts payloads from relocatable-wrapped messages if present, or uses the bytes directly if not wrapped.

8. Use Descriptive Field Names

// ✅ Good: Clear, descriptive names
#[derive(Copy, Clone)]
#[repr(C)]
struct SensorReading {
    temperature_celsius: f32,
    pressure_hpa: f32,
    timestamp_ns: u64,
}

// ❌ Bad: Unclear abbreviations
#[derive(Copy, Clone)]
#[repr(C)]
struct SR {
    t: f32,
    p: f32,
    ts: u64,
}

9. Document Message Types

Add documentation to your message types:
/// Sensor data reading with temperature, pressure, and timestamp
#[derive(Copy, Clone, Debug)]
#[repr(C)]
pub struct SensorData {
    /// Temperature in degrees Celsius
    pub temperature: f32,
    /// Pressure in hectopascals
    pub pressure: f32,
    /// Timestamp in nanoseconds since epoch
    pub timestamp: u64,
}

10. Test Serialization Round-Trip

Always test that your types serialize and deserialize correctly:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_serialization_round_trip() {
        let original = SensorData {
            temperature: 23.5,
            pressure: 1013.25,
            timestamp: 1234567890,
        };

        let bytes = original.to_bytes().unwrap();
        let restored = SensorData::from_bytes(&bytes).unwrap();

        assert_eq!(original.temperature, restored.temperature);
        assert_eq!(original.pressure, restored.pressure);
        assert_eq!(original.timestamp, restored.timestamp);
    }
}

Common Pitfalls to Avoid

Pitfall 1: Forgetting #[repr(C)]

// ❌ Bad: May have padding, incompatible layout
#[derive(Copy, Clone)]
struct Data {
    value: f32,
}

// ✅ Good: C-compatible layout
#[derive(Copy, Clone)]
#[repr(C)]
struct Data {
    value: f32,
}

Pitfall 2: Using Heap-Allocated Types

// ❌ Bad: Doesn't implement Copy
struct Data {
    name: String,
    values: Vec<f32>,
}

// ✅ Good: Fixed-size types
#[derive(Copy, Clone)]
#[repr(C)]
struct Data {
    name: [u8; 32],
    values: [f32; 10],
}

Pitfall 3: Not Checking Type Size

// ⚠️ May be too large
#[derive(Copy, Clone)]
#[repr(C)]
struct LargeData {
    buffer: [u8; 10_000_000],  // 10MB!
}

// ✅ Check size first
println!("Size: {} bytes", mem::size_of::<LargeData>());

Next Steps